From ff7e2e08a4153ab4f20a039298eb99fed2023c8a Mon Sep 17 00:00:00 2001 From: CueCrux-Myles Date: Thu, 30 Jul 2026 11:31:12 +0100 Subject: [PATCH 1/7] fix(http): enforce mutation route scopes Require admin:write for structural governance mutations, derive secure route-auth defaults from listener and auth posture, preserve semantic read/capability reachability, and pin enforce mode across shipped packaging.\n\nagent:codex-work --- .github/workflows/ci.yml | 2 +- README.md | 1 + config.example.env | 4 + crates/corecruxd/src/http/mod.rs | 3 +- crates/corecruxd/src/http/openapi.rs | 4 +- crates/corecruxd/src/http/passports.rs | 4 +- crates/corecruxd/src/http/planes.rs | 16 +- crates/corecruxd/src/http/projects.rs | 14 +- crates/corecruxd/src/http/route_auth.rs | 288 ++++++++++++++++++++++-- crates/corecruxd/src/http/tests.rs | 172 ++++++++++++-- crates/corecruxd/src/http/workspace.rs | 2 +- docker-compose.yml | 1 + docs/api-reference.md | 8 +- docs/developer-guide/01-architecture.md | 26 ++- docs/ops-guide.md | 14 +- examples/quickstart/docker-compose.yml | 1 + helm/corecrux/README.md | 5 +- helm/corecrux/templates/deployment.yaml | 2 + helm/corecrux/values.yaml | 1 + llms-full.txt | 9 +- packaging/homebrew/crux.rb | 1 + packaging/install.sh | 4 +- packaging/systemd/crux.service | 1 + packaging/tests/install-smoke.sh | 1 + 24 files changed, 499 insertions(+), 85 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd6aa49d..e62f2995 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -272,7 +272,7 @@ jobs: # as an unrelated red on whatever PR happened to be second. # Own range, above HTTP/MCP's 15000-23999 band. GRPC_PORT=$((24000 + (${{ github.run_id }} % 4500))) - CORECRUXD_AUTH_MODE=dev_scopes CORECRUXD_DATA_DIR="$DATA_DIR" \ + CORECRUXD_AUTH_MODE=dev_scopes CORECRUXD_ROUTE_AUTH=enforce CORECRUXD_DATA_DIR="$DATA_DIR" \ CORECRUXD_HTTP_PORT="$HTTP_PORT" CORECRUXD_MCP_PORT="$MCP_PORT" \ CORECRUXD_GRPC_PORT="$GRPC_PORT" \ ./target/debug/corecruxd & diff --git a/README.md b/README.md index 7a61704e..ec59c3f2 100644 --- a/README.md +++ b/README.md @@ -409,6 +409,7 @@ Config via environment variables or YAML (`config.example.env`, `config.example. | Variable | Default | Description | |---|---|---| | `CORECRUXD_AUTH_MODE` | required | `off`, `dev_scopes`, `jwt_hs256`, or `jwt_jwks`. | +| `CORECRUXD_ROUTE_AUTH` | derived | `enforce` when auth is enabled or the listener is non-loopback; otherwise `shadow`. Shipped packaging pins `enforce`. | | `CORECRUXD_DATA_DIR` | `../CoreCruxData/v1` | Data directory. | | `CORECRUXD_HTTP_PORT` | `14800` | HTTP API port. | | `CORECRUXD_GRPC_PORT` | `4007` | gRPC API port. | diff --git a/config.example.env b/config.example.env index 05ee1c9e..2ef577c1 100644 --- a/config.example.env +++ b/config.example.env @@ -24,6 +24,10 @@ # jwt_hs256 — HS256 JWT verification (small deployments) # jwt_jwks — JWKS/OIDC JWT verification (production) CORECRUXD_AUTH_MODE=off +# Route contracts are enforced in shipped configurations. Unset derives +# enforce whenever auth is enabled or the listener is non-loopback; use +# `shadow` only as an explicit migration diagnostic. +CORECRUXD_ROUTE_AUTH=enforce # HS256 JWT mode requires at least 32 bytes of secret material. Prefer JWKS/OIDC # for production; use HS256 only for small controlled deployments. diff --git a/crates/corecruxd/src/http/mod.rs b/crates/corecruxd/src/http/mod.rs index f999b730..aecdf1c7 100644 --- a/crates/corecruxd/src/http/mod.rs +++ b/crates/corecruxd/src/http/mod.rs @@ -480,7 +480,8 @@ pub fn router(state: AppState, case_store: self::cases::SharedCaseStore) -> Rout // Route-authorization posture is read ONCE here, at router build time, not // per request. Tests build the router via `router_with_route_auth` to pin an // explicit mode without touching the process-global env. - router_with_route_auth(state, case_store, self::route_auth::RouteAuthMode::from_env()) + let route_auth_mode = self::route_auth::RouteAuthMode::from_env(state.auth.mode(), state.http_bind_loopback); + router_with_route_auth(state, case_store, route_auth_mode) } pub(crate) fn router_with_route_auth( diff --git a/crates/corecruxd/src/http/openapi.rs b/crates/corecruxd/src/http/openapi.rs index 6669f9c4..e6f802f6 100644 --- a/crates/corecruxd/src/http/openapi.rs +++ b/crates/corecruxd/src/http/openapi.rs @@ -356,11 +356,11 @@ const ROUTES: &[RouteEntry] = &[ RouteEntry { path: "/v1/passports/{passportId}", methods: &["GET", "PATCH", "DELETE"], tag: "Passports", auth: "read-write", summary: "Passports {passportId}" }, RouteEntry { path: "/v1/policy/capabilities", methods: &["GET"], tag: "Policy", auth: "read", summary: "Policy capabilities" }, RouteEntry { path: "/v1/principal/resolve", methods: &["GET"], tag: "Principal", auth: "read", summary: "Principal resolve" }, - RouteEntry { path: "/v1/projections/batch_lookup", methods: &["POST"], tag: "Projections", auth: "admin-write", summary: "Projections batch lookup" }, + RouteEntry { path: "/v1/projections/batch_lookup", methods: &["POST"], tag: "Projections", auth: "admin-read", summary: "Projections batch lookup" }, RouteEntry { path: "/v1/projections/entity/count", methods: &["GET"], tag: "Projections", auth: "read", summary: "Projections entity count" }, RouteEntry { path: "/v1/projections/entity/current-state", methods: &["GET"], tag: "Projections", auth: "read", summary: "Projections entity current state" }, RouteEntry { path: "/v1/projections/entity/timeline", methods: &["GET"], tag: "Projections", auth: "read", summary: "Projections entity timeline" }, - RouteEntry { path: "/v1/projections/lookup", methods: &["POST"], tag: "Projections", auth: "admin-write", summary: "Projections lookup" }, + RouteEntry { path: "/v1/projections/lookup", methods: &["POST"], tag: "Projections", auth: "admin-read", summary: "Projections lookup" }, RouteEntry { path: "/v1/projects", methods: &["GET", "POST"], tag: "Projects", auth: "read-write", summary: "Projects" }, RouteEntry { path: "/v1/projects/{id}", methods: &["GET", "PATCH", "DELETE"], tag: "Projects", auth: "read-write", summary: "Projects {id}" }, RouteEntry { path: "/v1/projects/{id}/context-graph", methods: &["GET"], tag: "Projects", auth: "read", summary: "Projects {id} context graph" }, diff --git a/crates/corecruxd/src/http/passports.rs b/crates/corecruxd/src/http/passports.rs index 407b62a8..34e90012 100644 --- a/crates/corecruxd/src/http/passports.rs +++ b/crates/corecruxd/src/http/passports.rs @@ -541,7 +541,7 @@ pub(super) async fn patch_passport( headers: HeaderMap, Json(body): Json, ) -> impl IntoResponse { - if let Err(problem) = require_http_scopes(&state.auth, &headers, &["admin:read"]) { + if let Err(problem) = require_http_scopes(&state.auth, &headers, &["admin:write"]) { return problem.into_response(); } let mut store = state.fact_store.write().await; @@ -578,7 +578,7 @@ pub(super) async fn delete_passport( Path(id): Path, headers: HeaderMap, ) -> impl IntoResponse { - if let Err(problem) = require_http_scopes(&state.auth, &headers, &["admin:read"]) { + if let Err(problem) = require_http_scopes(&state.auth, &headers, &["admin:write"]) { return problem.into_response(); } let mut store = state.fact_store.write().await; diff --git a/crates/corecruxd/src/http/planes.rs b/crates/corecruxd/src/http/planes.rs index 065388a6..0e167a4e 100644 --- a/crates/corecruxd/src/http/planes.rs +++ b/crates/corecruxd/src/http/planes.rs @@ -7,8 +7,8 @@ //! tenants, and layers. //! //! All routes scope under `/v1/projects/{id}/planes/...`. Reads need -//! `admin:read`; mutations need `admin:read` for member/tenant changes (same -//! posture as the parent project routes) and `facts:write` for layer writes. +//! `admin:read`; structural mutations need `admin:write`, while layer writes +//! use their dedicated `facts:write` contract. use super::{problem_response, require_http_scopes, AppState, HeaderMap, IntoResponse, Json, Path, State, StatusCode}; @@ -106,7 +106,7 @@ pub(super) async fn post_plane( headers: HeaderMap, Json(body): Json, ) -> impl IntoResponse { - if let Err(problem) = require_http_scopes(&state.auth, &headers, &["admin:read"]) { + if let Err(problem) = require_http_scopes(&state.auth, &headers, &["admin:write"]) { return problem.into_response(); } let mut store = state.fact_store.write().await; @@ -138,7 +138,7 @@ pub(super) async fn delete_plane( Path((project_id, plane_id)): Path<(String, String)>, headers: HeaderMap, ) -> impl IntoResponse { - if let Err(problem) = require_http_scopes(&state.auth, &headers, &["admin:read"]) { + if let Err(problem) = require_http_scopes(&state.auth, &headers, &["admin:write"]) { return problem.into_response(); } let mut store = state.fact_store.write().await; @@ -158,7 +158,7 @@ pub(super) async fn post_plane_member( headers: HeaderMap, Json(body): Json, ) -> impl IntoResponse { - if let Err(problem) = require_http_scopes(&state.auth, &headers, &["admin:read"]) { + if let Err(problem) = require_http_scopes(&state.auth, &headers, &["admin:write"]) { return problem.into_response(); } let mut store = state.fact_store.write().await; @@ -184,7 +184,7 @@ pub(super) async fn delete_plane_member( Path((project_id, plane_id, passport_id)): Path<(String, String, String)>, headers: HeaderMap, ) -> impl IntoResponse { - if let Err(problem) = require_http_scopes(&state.auth, &headers, &["admin:read"]) { + if let Err(problem) = require_http_scopes(&state.auth, &headers, &["admin:write"]) { return problem.into_response(); } let mut store = state.fact_store.write().await; @@ -203,7 +203,7 @@ pub(super) async fn post_plane_tenant( headers: HeaderMap, Json(body): Json, ) -> impl IntoResponse { - if let Err(problem) = require_http_scopes(&state.auth, &headers, &["admin:read"]) { + if let Err(problem) = require_http_scopes(&state.auth, &headers, &["admin:write"]) { return problem.into_response(); } let mut store = state.fact_store.write().await; @@ -229,7 +229,7 @@ pub(super) async fn delete_plane_tenant( Path((project_id, plane_id, tenant_id)): Path<(String, String, String)>, headers: HeaderMap, ) -> impl IntoResponse { - if let Err(problem) = require_http_scopes(&state.auth, &headers, &["admin:read"]) { + if let Err(problem) = require_http_scopes(&state.auth, &headers, &["admin:write"]) { return problem.into_response(); } let mut store = state.fact_store.write().await; diff --git a/crates/corecruxd/src/http/projects.rs b/crates/corecruxd/src/http/projects.rs index 9236e798..6ea0955e 100644 --- a/crates/corecruxd/src/http/projects.rs +++ b/crates/corecruxd/src/http/projects.rs @@ -82,7 +82,7 @@ pub(super) async fn post_project( headers: HeaderMap, Json(body): Json, ) -> impl IntoResponse { - if let Err(problem) = require_http_scopes(&state.auth, &headers, &["admin:read"]) { + if let Err(problem) = require_http_scopes(&state.auth, &headers, &["admin:write"]) { return problem.into_response(); } let mut store = state.fact_store.write().await; @@ -141,7 +141,7 @@ pub(super) async fn patch_project( headers: HeaderMap, Json(body): Json, ) -> impl IntoResponse { - if let Err(problem) = require_http_scopes(&state.auth, &headers, &["admin:read"]) { + if let Err(problem) = require_http_scopes(&state.auth, &headers, &["admin:write"]) { return problem.into_response(); } let mut store = state.fact_store.write().await; @@ -176,7 +176,7 @@ pub(super) async fn delete_project( Path(id): Path, headers: HeaderMap, ) -> impl IntoResponse { - if let Err(problem) = require_http_scopes(&state.auth, &headers, &["admin:read"]) { + if let Err(problem) = require_http_scopes(&state.auth, &headers, &["admin:write"]) { return problem.into_response(); } let mut store = state.fact_store.write().await; @@ -198,7 +198,7 @@ pub(super) async fn post_project_member( headers: HeaderMap, Json(body): Json, ) -> impl IntoResponse { - if let Err(problem) = require_http_scopes(&state.auth, &headers, &["admin:read"]) { + if let Err(problem) = require_http_scopes(&state.auth, &headers, &["admin:write"]) { return problem.into_response(); } let mut store = state.fact_store.write().await; @@ -222,7 +222,7 @@ pub(super) async fn delete_project_member( Path((id, passport_id)): Path<(String, String)>, headers: HeaderMap, ) -> impl IntoResponse { - if let Err(problem) = require_http_scopes(&state.auth, &headers, &["admin:read"]) { + if let Err(problem) = require_http_scopes(&state.auth, &headers, &["admin:write"]) { return problem.into_response(); } let mut store = state.fact_store.write().await; @@ -241,7 +241,7 @@ pub(super) async fn post_project_tenant( headers: HeaderMap, Json(body): Json, ) -> impl IntoResponse { - if let Err(problem) = require_http_scopes(&state.auth, &headers, &["admin:read"]) { + if let Err(problem) = require_http_scopes(&state.auth, &headers, &["admin:write"]) { return problem.into_response(); } let mut store = state.fact_store.write().await; @@ -268,7 +268,7 @@ pub(super) async fn delete_project_tenant( Path((id, tenant_id)): Path<(String, String)>, headers: HeaderMap, ) -> impl IntoResponse { - if let Err(problem) = require_http_scopes(&state.auth, &headers, &["admin:read"]) { + if let Err(problem) = require_http_scopes(&state.auth, &headers, &["admin:write"]) { return problem.into_response(); } let mut store = state.fact_store.write().await; diff --git a/crates/corecruxd/src/http/route_auth.rs b/crates/corecruxd/src/http/route_auth.rs index ce90871b..7394837e 100644 --- a/crates/corecruxd/src/http/route_auth.rs +++ b/crates/corecruxd/src/http/route_auth.rs @@ -14,11 +14,13 @@ //! //! * `off` — pass-through. //! * `shadow` — evaluate the contract; on a would-deny, emit a structured -//! `route_auth_shadow_mismatch` warning and continue (DEFAULT). +//! `route_auth_shadow_mismatch` warning and continue. This is the derived +//! default only for an auth-off, loopback-only daemon. //! * `enforce` — Public routes pass with no auth; classified routes require the //! contract's scopes via the same `auth.rs` primitive handlers use; an //! unclassified route (or a request with no matched path) fails closed with -//! `403`. +//! `403`. This is the derived default whenever authentication is enabled or +//! the listener is non-loopback. //! //! Handler-level scope checks stay in place as defence in depth — this layer is //! a coarse deny-by-default gate in front of them, never a replacement. @@ -29,7 +31,7 @@ use axum::middleware::Next; use axum::response::{IntoResponse, Response}; use super::{problem_response, AppState}; -use crate::auth::require_http_any_scope; +use crate::auth::{require_http_any_scope, AuthMode}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum RouteAuthClass { @@ -136,6 +138,34 @@ pub(crate) fn classify_route(method: &str, path: &str) -> Option Option Option Option Option Option bool { pub(crate) enum RouteAuthMode { /// Pass-through: the middleware does nothing. Off, - /// Evaluate the contract and log would-denies, but never block. DEFAULT. + /// Evaluate the contract and log would-denies, but never block. Shadow, /// Deny by default: classified routes require their scopes; unclassified /// routes and requests with no matched route template fail closed. @@ -606,18 +717,39 @@ pub(crate) enum RouteAuthMode { } impl RouteAuthMode { - /// Read `CORECRUXD_ROUTE_AUTH` once. `off` / `enforce` are explicit; - /// anything else (including unset) is the safe `shadow` default. - pub(crate) fn from_env() -> Self { - match std::env::var("CORECRUXD_ROUTE_AUTH") - .ok() - .map(|v| v.trim().to_ascii_lowercase()) - .as_deref() - { + /// Resolve an explicit value or derive the secure default from daemon + /// exposure. Unknown and empty explicit values fail safe to `enforce`. + fn resolve(raw: Option<&str>, auth_mode: AuthMode, bind_loopback: bool) -> Self { + match raw.map(|value| value.trim().to_ascii_lowercase()).as_deref() { Some("off") => Self::Off, + Some("shadow") => Self::Shadow, Some("enforce") => Self::Enforce, - // "shadow", the empty string, an unknown value, or unset. - _ => Self::Shadow, + Some(_) => Self::Enforce, + None if auth_mode == AuthMode::Off && bind_loopback => Self::Shadow, + None => Self::Enforce, + } + } + + /// Read `CORECRUXD_ROUTE_AUTH` once. Unset derives from auth/listener + /// posture; an invalid explicit value is logged and fails safe to enforce. + pub(crate) fn from_env(auth_mode: AuthMode, bind_loopback: bool) -> Self { + match std::env::var("CORECRUXD_ROUTE_AUTH") { + Ok(raw) => { + let mode = Self::resolve(Some(&raw), auth_mode, bind_loopback); + if !matches!(raw.trim().to_ascii_lowercase().as_str(), "off" | "shadow" | "enforce") { + tracing::warn!( + configured = %raw, + fallback = "enforce", + "invalid CORECRUXD_ROUTE_AUTH; failing safe" + ); + } + mode + } + Err(std::env::VarError::NotPresent) => Self::resolve(None, auth_mode, bind_loopback), + Err(std::env::VarError::NotUnicode(_)) => { + tracing::warn!(fallback = "enforce", "non-Unicode CORECRUXD_ROUTE_AUTH; failing safe"); + Self::Enforce + } } } @@ -843,6 +975,59 @@ mod tests { routes } + #[test] + fn route_auth_unset_derives_from_auth_and_listener_posture() { + let cases = [ + (AuthMode::Off, true, RouteAuthMode::Shadow), + (AuthMode::Off, false, RouteAuthMode::Enforce), + (AuthMode::DevScopes, true, RouteAuthMode::Enforce), + (AuthMode::DevScopes, false, RouteAuthMode::Enforce), + (AuthMode::JwtHs256, true, RouteAuthMode::Enforce), + (AuthMode::JwtJwks, true, RouteAuthMode::Enforce), + ]; + for (auth_mode, bind_loopback, expected) in cases { + assert_eq!( + RouteAuthMode::resolve(None, auth_mode, bind_loopback), + expected, + "auth={} loopback={bind_loopback}", + auth_mode.as_str() + ); + } + } + + #[test] + fn route_auth_explicit_modes_and_typos_are_deterministic() { + for auth_mode in [ + AuthMode::Off, + AuthMode::DevScopes, + AuthMode::JwtHs256, + AuthMode::JwtJwks, + ] { + for bind_loopback in [true, false] { + assert_eq!( + RouteAuthMode::resolve(Some("off"), auth_mode, bind_loopback), + RouteAuthMode::Off + ); + assert_eq!( + RouteAuthMode::resolve(Some(" SHADOW "), auth_mode, bind_loopback), + RouteAuthMode::Shadow + ); + assert_eq!( + RouteAuthMode::resolve(Some("Enforce"), auth_mode, bind_loopback), + RouteAuthMode::Enforce + ); + assert_eq!( + RouteAuthMode::resolve(Some(""), auth_mode, bind_loopback), + RouteAuthMode::Enforce + ); + assert_eq!( + RouteAuthMode::resolve(Some("enfore"), auth_mode, bind_loopback), + RouteAuthMode::Enforce + ); + } + } + } + #[test] fn route_auth_matrix_is_complete() { let missing: Vec = router_routes() @@ -891,7 +1076,7 @@ mod tests { "POST", "/v1/gpu1/answer", RouteAuthClass::FeatureGated, - &["query:read", "admin:read"][..], + &["gpu1:answer", "admin:write"][..], ), ( "POST", @@ -962,6 +1147,73 @@ mod tests { } } + #[test] + fn structural_mutation_contracts_require_exact_admin_write() { + let mutations = [ + ("PATCH", "/v1/passports/{passportId}"), + ("DELETE", "/v1/passports/{passportId}"), + ("POST", "/v1/projects"), + ("PATCH", "/v1/projects/{id}"), + ("DELETE", "/v1/projects/{id}"), + ("POST", "/v1/projects/{id}/passports"), + ("DELETE", "/v1/projects/{id}/passports/{passportId}"), + ("POST", "/v1/projects/{id}/tenants"), + ("DELETE", "/v1/projects/{id}/tenants/{tenantId}"), + ("POST", "/v1/projects/{id}/planes"), + ("DELETE", "/v1/projects/{id}/planes/{planeId}"), + ("POST", "/v1/projects/{id}/planes/{planeId}/passports"), + ("DELETE", "/v1/projects/{id}/planes/{planeId}/passports/{passportId}"), + ("POST", "/v1/projects/{id}/planes/{planeId}/tenants"), + ("DELETE", "/v1/projects/{id}/planes/{planeId}/tenants/{tenantId}"), + ("POST", "/v1/workspace/scan"), + ]; + for (method, path) in mutations { + let contract = classify_route(method, path).expect("structural mutation contract"); + assert_eq!(contract.class, RouteAuthClass::AdminWrite, "{method} {path}"); + assert_eq!(contract.scopes, &["admin:write"], "{method} {path}"); + } + } + + #[test] + fn semantic_post_and_capability_contracts_remain_reachable_in_enforce() { + let cases: &[(&str, &str, &[&str])] = &[ + ("POST", "/v1/projections/lookup", &["admin:read"]), + ("POST", "/v1/projections/batch_lookup", &["admin:read"]), + ("POST", "/v1/relations/expand", &["admin:read"]), + ("POST", "/v1/rcx/publish/projects/{projectId}/preview", &["admin:read"]), + ( + "POST", + "/v1/rcx/publish/passports/{passportId}/preview", + &["admin:read"], + ), + ("POST", "/v1/console/engine/search", &["admin:read"]), + ("POST", "/v1/actions/enrich", &["query:read", "enrichers:first_party"]), + ( + "POST", + "/v1/openai/invoke", + &["query:read", "facts:write", "sessions:write"], + ), + ("POST", "/v1/gpu1/rerank", &["gpu1:rerank", "admin:write"]), + ( + "POST", + "/v1/workbench/context-pack", + &["context_pack:budgeted", "admin:write"], + ), + ("GET", "/v1/workbench/contract", &["query:read", "admin:read"]), + ("GET", "/v1/workbench/brief", &["agent_brief:pro", "admin:read"]), + ]; + for (method, path, expected_scopes) in cases { + let contract = classify_route(method, path).expect("route contract"); + for scope in *expected_scopes { + assert!( + contract.scopes.contains(scope), + "{method} {path} missing handler scope {scope}; got {:?}", + contract.scopes + ); + } + } + } + #[test] fn passport_mint_routes_record_the_feature_gate() { for (method, path, scopes) in [ diff --git a/crates/corecruxd/src/http/tests.rs b/crates/corecruxd/src/http/tests.rs index b0067896..e7730713 100644 --- a/crates/corecruxd/src/http/tests.rs +++ b/crates/corecruxd/src/http/tests.rs @@ -13130,7 +13130,7 @@ async fn passports_patch_updates_gate_and_default_flag() { let resp = super::passports::patch_passport( State(state), Path("alice".to_string()), - dev_scope_headers("admin:read"), + dev_scope_headers("admin:write"), Json(super::passports::UpdatePassportBody { category: Some("work".to_string()), agent_work_gate: Some(true), @@ -13178,7 +13178,7 @@ async fn passports_delete_removes_record() { let del_resp = super::passports::delete_passport( State(state.clone()), Path("alice".to_string()), - dev_scope_headers("admin:read"), + dev_scope_headers("admin:write"), ) .await .into_response(); @@ -13748,7 +13748,7 @@ async fn projects_create_then_list_then_get() { let create_resp = super::projects::post_project( State(state.clone()), - dev_scope_headers("admin:read"), + dev_scope_headers("admin:write"), Json(super::projects::CreateProjectBody { id: "alpha".to_string(), name: "Alpha".to_string(), @@ -13787,7 +13787,7 @@ async fn projects_invalid_planning_target_returns_400() { } let resp = super::projects::post_project( State(state), - dev_scope_headers("admin:read"), + dev_scope_headers("admin:write"), Json(super::projects::CreateProjectBody { id: "alpha".to_string(), name: "Alpha".to_string(), @@ -13810,7 +13810,7 @@ async fn projects_add_unknown_passport_returns_404() { } let _ = super::projects::post_project( State(state.clone()), - dev_scope_headers("admin:read"), + dev_scope_headers("admin:write"), Json(super::projects::CreateProjectBody { id: "alpha".to_string(), name: "Alpha".to_string(), @@ -13825,7 +13825,7 @@ async fn projects_add_unknown_passport_returns_404() { let resp = super::projects::post_project_member( State(state), Path("alpha".to_string()), - dev_scope_headers("admin:read"), + dev_scope_headers("admin:write"), Json(super::projects::AddMemberBody { passport_id: "ghost".to_string(), role: "contributor".to_string(), @@ -13848,7 +13848,7 @@ async fn projects_delete_removes_subentities() { } let _ = super::projects::post_project( State(state.clone()), - dev_scope_headers("admin:read"), + dev_scope_headers("admin:write"), Json(super::projects::CreateProjectBody { id: "alpha".to_string(), name: "Alpha".to_string(), @@ -13862,7 +13862,7 @@ async fn projects_delete_removes_subentities() { let del = super::projects::delete_project( State(state.clone()), Path("alpha".to_string()), - dev_scope_headers("admin:read"), + dev_scope_headers("admin:write"), ) .await .into_response(); @@ -13883,7 +13883,7 @@ async fn projects_members_tenants_layers_repos_and_graph_round_trip() { let create_resp = super::projects::post_project( State(state.clone()), - dev_scope_headers("admin:read facts:write"), + dev_scope_headers("admin:write facts:write"), Json(super::projects::CreateProjectBody { id: "alpha".to_string(), name: "Alpha".to_string(), @@ -13899,7 +13899,7 @@ async fn projects_members_tenants_layers_repos_and_graph_round_trip() { let patch_resp = super::projects::patch_project( State(state.clone()), Path("alpha".to_string()), - dev_scope_headers("admin:read"), + dev_scope_headers("admin:write"), Json(super::projects::UpdateProjectBody { name: Some("Alpha Updated".to_string()), planning_target: Some(None), @@ -13917,7 +13917,7 @@ async fn projects_members_tenants_layers_repos_and_graph_round_trip() { let member_resp = super::projects::post_project_member( State(state.clone()), Path("alpha".to_string()), - dev_scope_headers("admin:read"), + dev_scope_headers("admin:write"), Json(super::projects::AddMemberBody { passport_id: "work-default".to_string(), role: "reviewer".to_string(), @@ -13930,7 +13930,7 @@ async fn projects_members_tenants_layers_repos_and_graph_round_trip() { let tenant_resp = super::projects::post_project_tenant( State(state.clone()), Path("alpha".to_string()), - dev_scope_headers("admin:read"), + dev_scope_headers("admin:write"), Json(super::projects::AddTenantBody { tenant_id: "tenant-b".to_string(), default_passport_id: Some("public-default".to_string()), @@ -14031,7 +14031,7 @@ async fn projects_members_tenants_layers_repos_and_graph_round_trip() { let delete_tenant = super::projects::delete_project_tenant( State(state.clone()), Path(("alpha".to_string(), "tenant-b".to_string())), - dev_scope_headers("admin:read"), + dev_scope_headers("admin:write"), ) .await .into_response(); @@ -14040,7 +14040,7 @@ async fn projects_members_tenants_layers_repos_and_graph_round_trip() { let delete_member = super::projects::delete_project_member( State(state), Path(("alpha".to_string(), "work-default".to_string())), - dev_scope_headers("admin:read"), + dev_scope_headers("admin:write"), ) .await .into_response(); @@ -15699,7 +15699,7 @@ async fn workspace_routes_report_catalog_and_missing_scan_states() { assert_eq!(missing_storyline.status(), StatusCode::NOT_FOUND); std::env::remove_var("CORECRUXD_WORKSPACE_PATH"); - let unconfigured = super::workspace::post_scan(State(state), dev_scope_headers("admin:read")) + let unconfigured = super::workspace::post_scan(State(state), dev_scope_headers("admin:write")) .await .into_response(); assert_eq!(unconfigured.status(), StatusCode::PRECONDITION_FAILED); @@ -16720,7 +16720,7 @@ async fn seed_project_for_planes_tests(state: &AppState) { } let resp = super::projects::post_project( State(state.clone()), - dev_scope_headers("admin:read facts:write"), + dev_scope_headers("admin:write facts:write"), Json(super::projects::CreateProjectBody { id: "alpha".to_string(), name: "Alpha".to_string(), @@ -16743,7 +16743,7 @@ async fn planes_create_then_list_then_get() { let create_resp = super::planes::post_plane( State(state.clone()), Path("alpha".to_string()), - dev_scope_headers("admin:read facts:write"), + dev_scope_headers("admin:write facts:write"), Json(super::planes::CreatePlaneBody { id: "daemon".to_string(), name: "Crux Daemon".to_string(), @@ -16796,7 +16796,7 @@ async fn planes_member_round_trip() { let _ = super::planes::post_plane( State(state.clone()), Path("alpha".to_string()), - dev_scope_headers("admin:read facts:write"), + dev_scope_headers("admin:write facts:write"), Json(super::planes::CreatePlaneBody { id: "daemon".to_string(), name: "Daemon".to_string(), @@ -16811,7 +16811,7 @@ async fn planes_member_round_trip() { let add_resp = super::planes::post_plane_member( State(state.clone()), Path(("alpha".to_string(), "daemon".to_string())), - dev_scope_headers("admin:read facts:write"), + dev_scope_headers("admin:write facts:write"), Json(super::planes::PlaneMemberBody { passport_id: "work-default".to_string(), role: "contributor".to_string(), @@ -16824,7 +16824,7 @@ async fn planes_member_round_trip() { let rm_resp = super::planes::delete_plane_member( State(state), Path(("alpha".to_string(), "daemon".to_string(), "work-default".to_string())), - dev_scope_headers("admin:read facts:write"), + dev_scope_headers("admin:write facts:write"), ) .await .into_response(); @@ -16838,7 +16838,7 @@ async fn planes_layer_put_then_get_then_delete() { let _ = super::planes::post_plane( State(state.clone()), Path("alpha".to_string()), - dev_scope_headers("admin:read facts:write"), + dev_scope_headers("admin:write facts:write"), Json(super::planes::CreatePlaneBody { id: "daemon".to_string(), name: "Daemon".to_string(), @@ -16892,7 +16892,7 @@ async fn planes_delete_removes_record() { let _ = super::planes::post_plane( State(state.clone()), Path("alpha".to_string()), - dev_scope_headers("admin:read facts:write"), + dev_scope_headers("admin:write facts:write"), Json(super::planes::CreatePlaneBody { id: "daemon".to_string(), name: "Daemon".to_string(), @@ -16906,7 +16906,7 @@ async fn planes_delete_removes_record() { let resp = super::planes::delete_plane( State(state.clone()), Path(("alpha".to_string(), "daemon".to_string())), - dev_scope_headers("admin:read facts:write"), + dev_scope_headers("admin:write facts:write"), ) .await .into_response(); @@ -19767,6 +19767,21 @@ fn route_auth_request(method: &str, uri: &str, scopes: Option<&str>) -> axum::ht builder.body(axum::body::Body::empty()).expect("build request") } +fn route_auth_json_request( + method: &str, + uri: &str, + scopes: &str, + body: &serde_json::Value, +) -> axum::http::Request { + axum::http::Request::builder() + .method(method) + .uri(uri) + .header("x-corecrux-scopes", scopes) + .header(axum::http::header::CONTENT_TYPE, "application/json") + .body(axum::body::Body::from(body.to_string())) + .expect("build JSON request") +} + /// (b) Enforce mode fails closed on a route with no contract entry, even when /// the caller presents ample scopes. #[tokio::test] @@ -19941,6 +19956,117 @@ async fn route_auth_enforce_contract_matrix() { } } +/// Handler/contract drift gate for every structural mutation tightened by +/// H-02. Shadow mode deliberately lets the request reach the real handler: +/// `admin:read` must be rejected there, while `admin:write` must clear both +/// middleware and handler authorization (the domain operation may still +/// return a non-auth 4xx because this compact matrix does not seed every +/// referenced object). +#[tokio::test] +async fn structural_mutation_handlers_reject_admin_read_and_accept_admin_write() { + use tower::ServiceExt; + + let cases = vec![ + ( + "PATCH", + "/v1/passports/scope-passport", + serde_json::json!({"name": "Scope"}), + ), + ("DELETE", "/v1/passports/scope-passport", serde_json::json!({})), + ( + "POST", + "/v1/projects", + serde_json::json!({ + "id": "scope-project", + "default_passport_id": "personal-default" + }), + ), + ( + "PATCH", + "/v1/projects/scope-project", + serde_json::json!({"name": "Scope project"}), + ), + ("DELETE", "/v1/projects/scope-project", serde_json::json!({})), + ( + "POST", + "/v1/projects/scope-project/passports", + serde_json::json!({"passport_id": "work-default"}), + ), + ( + "DELETE", + "/v1/projects/scope-project/passports/work-default", + serde_json::json!({}), + ), + ( + "POST", + "/v1/projects/scope-project/tenants", + serde_json::json!({"tenant_id": "tenant-a"}), + ), + ( + "DELETE", + "/v1/projects/scope-project/tenants/tenant-a", + serde_json::json!({}), + ), + ( + "POST", + "/v1/projects/scope-project/planes", + serde_json::json!({"id": "scope-plane"}), + ), + ( + "DELETE", + "/v1/projects/scope-project/planes/scope-plane", + serde_json::json!({}), + ), + ( + "POST", + "/v1/projects/scope-project/planes/scope-plane/passports", + serde_json::json!({"passport_id": "work-default"}), + ), + ( + "DELETE", + "/v1/projects/scope-project/planes/scope-plane/passports/work-default", + serde_json::json!({}), + ), + ( + "POST", + "/v1/projects/scope-project/planes/scope-plane/tenants", + serde_json::json!({"tenant_id": "tenant-a"}), + ), + ( + "DELETE", + "/v1/projects/scope-project/planes/scope-plane/tenants/tenant-a", + serde_json::json!({}), + ), + ("POST", "/v1/workspace/scan", serde_json::json!({})), + ]; + + let state = test_app_state_with_auth(16, AuthMode::DevScopes); + let app = router_with_route_auth(state, test_case_store(), RouteAuthMode::Shadow); + for (method, uri, body) in cases { + let denied = app + .clone() + .oneshot(route_auth_json_request(method, uri, "admin:read", &body)) + .await + .expect("admin:read response"); + assert_eq!( + denied.status(), + StatusCode::FORBIDDEN, + "{method} {uri} must reject admin:read in the handler" + ); + + let admitted = app + .clone() + .oneshot(route_auth_json_request(method, uri, "admin:write", &body)) + .await + .expect("admin:write response"); + assert!( + admitted.status() != StatusCode::UNAUTHORIZED && admitted.status() != StatusCode::FORBIDDEN, + "{method} {uri} must admit admin:write to the domain handler, got {}", + admitted.status() + ); + } +} + // ── Central Studio template library (crux-integrations-and-template-library L2) ─ // // GET /v1/studio/library — verified cached catalog + installed join diff --git a/crates/corecruxd/src/http/workspace.rs b/crates/corecruxd/src/http/workspace.rs index 31b0fadb..4c5ea6ef 100644 --- a/crates/corecruxd/src/http/workspace.rs +++ b/crates/corecruxd/src/http/workspace.rs @@ -20,7 +20,7 @@ use crate::workspace_scan::{LATEST_SCAN_ENTITY, SCAN_KEY}; /// persisted as a fact. #[tracing::instrument(level = "info", skip_all)] pub(super) async fn post_scan(State(state): State, headers: HeaderMap) -> impl IntoResponse { - if let Err(problem) = require_http_scopes(&state.auth, &headers, &["admin:read"]) { + if let Err(problem) = require_http_scopes(&state.auth, &headers, &["admin:write"]) { return problem.into_response(); } let scan_result = tokio::task::spawn_blocking(crate::workspace_scan::run_scan).await; diff --git a/docker-compose.yml b/docker-compose.yml index 23c3216d..b8d98f26 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,6 +26,7 @@ services: environment: - CORECRUXD_DATA_DIR=/data - CORECRUXD_AUTH_MODE=dev_scopes + - CORECRUXD_ROUTE_AUTH=enforce - CORECRUXD_ALLOW_INSECURE_DEV_AUTH_BIND=1 - CORECRUXD_HTTP_HOST=0.0.0.0 - CORECRUXD_GRPC_HOST=0.0.0.0 diff --git a/docs/api-reference.md b/docs/api-reference.md index 38ab11df..ab18b876 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -181,7 +181,7 @@ understanding back to agents. | GET | `/v1/repos/{repoId}?tenant_id=…` | One registration | `admin:read` | | DELETE | `/v1/repos/{repoId}?tenant_id=…` | Unregister (stops watch) | `admin:write` | | GET | `/v1/repos/{repoId}/codemap?tenant_id=…&format=summary\|full` | AST code map: `summary` = stats + per-crate rollup; `full` = files, symbols, deps, routes | `admin:read` | -| POST | `/v1/workspace/scan` | Scan the daemon's own workspace (`CORECRUXD_WORKSPACE_PATH`) | `admin:read` | +| POST | `/v1/workspace/scan` | Scan the daemon's own workspace (`CORECRUXD_WORKSPACE_PATH`) | `admin:write` | | GET | `/v1/workspace/scan` | Latest self-scan in full | `admin:read` | | GET | `/v1/workspace/storyline?format=tree\|json` | Per-route call trees from the self-scan | `admin:read` | @@ -350,9 +350,13 @@ controlled by `CORECRUXD_ROUTE_AUTH` (read once at startup): | Value | Behaviour | |-------|-----------| | `off` | Pass-through; the middleware does nothing. | -| `shadow` (default) | Evaluates the contract and logs a structured `route_auth_shadow_mismatch` warning on any would-deny, but never blocks. Use it to observe coverage before switching to `enforce`. | +| `shadow` | Evaluates the contract and logs a structured `route_auth_shadow_mismatch` warning on any would-deny, but never blocks. It is the derived default only for auth-off, loopback-only operation; otherwise it is an explicit migration override. | | `enforce` | Public routes (`/healthz`, `/readyz`, `/metrics`, `/session`, `/invocation/verify`, `/v1/openapi.json`, `/v1/version`, `/v1/witness/smoke`, and the `/v1/auth/*` bootstrap rails) pass with no auth headers. Every other route requires one of its contract scopes via the same primitive the handlers use. A route with **no** contract entry — or a request axum could not match to a route template — **fails closed with `403`**. | +With the variable unset, authentication enabled or a non-loopback listener +selects `enforce`; only auth-off plus loopback derives `shadow`. An empty or +unknown explicit value also selects `enforce` and emits a startup warning. + The gate authorizes scopes only; feature-flag gating for optional surfaces stays in the handler. When `CORECRUXD_AUTH_MODE=off`, the scope check is a no-op (there is nothing to enforce), but `enforce` still fails closed on uncontracted routes. diff --git a/docs/developer-guide/01-architecture.md b/docs/developer-guide/01-architecture.md index 7207822c..4fe2b685 100644 --- a/docs/developer-guide/01-architecture.md +++ b/docs/developer-guide/01-architecture.md @@ -128,25 +128,29 @@ namespace that overlaps these strings. Do not conflate them — see chapter 3. ### The route-auth middleware Independent of the per-handler `require_http_scopes` calls, a middleware -classifies routes by prefix. `CORECRUXD_ROUTE_AUTH` selects `off`, `shadow` -(the default) or `enforce` -([route_auth.rs:576](../../crates/corecruxd/src/http/route_auth.rs#L576)). +classifies routes by method and matched route template. +`CORECRUXD_ROUTE_AUTH` selects `off`, `shadow`, or `enforce` +([route_auth.rs:692](../../crates/corecruxd/src/http/route_auth.rs#L692)). +When unset it derives `enforce` for authenticated or non-loopback operation and +`shadow` only for auth-off loopback development; malformed explicit values fail +safe to `enforce`. | Prefix | Class | Any-of scopes | |---|---|---| -| `/v1/studio/library/` POST | **write** | `facts:write`, `admin:write` ([route_auth.rs:144](../../crates/corecruxd/src/http/route_auth.rs#L144)) | -| `/v1/studio/` (everything else) | read | `query:read`, `admin:read` ([route_auth.rs:157](../../crates/corecruxd/src/http/route_auth.rs#L157)) | -| `/v1/extensions` GET | read | `admin:read`, `facts:read`, `query:read`, `sessions:read` ([route_auth.rs:386](../../crates/corecruxd/src/http/route_auth.rs#L386)) | +| `/v1/studio/library/` POST | **write** | `facts:write`, `admin:write` ([route_auth.rs:169](../../crates/corecruxd/src/http/route_auth.rs#L169)) | +| `/v1/studio/` (everything else) | read | `query:read`, `admin:read` ([route_auth.rs:181](../../crates/corecruxd/src/http/route_auth.rs#L181)) | +| `/v1/extensions` GET | read | `admin:read`, `facts:read`, `query:read`, `sessions:read` ([route_auth.rs:492](../../crates/corecruxd/src/http/route_auth.rs#L492)) | | `/v1/extensions` non-GET | write | `admin:write`, `facts:write`, `integrations:install` | -| `/v1/integrations/` | write | `integrations:install`, `integrations:disable` ([route_auth.rs:326](../../crates/corecruxd/src/http/route_auth.rs#L326)) | +| `/v1/integrations/` | write | `integrations:install`, `integrations:disable` ([route_auth.rs:356](../../crates/corecruxd/src/http/route_auth.rs#L356)) | -In `shadow` the middleware logs; in `enforce` it rejects. The handler's own +In `shadow` the middleware logs; in `enforce` it rejects. Shipped packaging +sets `enforce` explicitly. The handler's own `require_http_scopes` always applies. The Studio carve-out is worth noting as a pattern: the install route is the one `/v1/studio/` route that mutates, so it is classified ahead of the read-class prefix rule to guarantee a read token can never authorise an install -([route_auth.rs:144](../../crates/corecruxd/src/http/route_auth.rs#L144)). If you add a +([route_auth.rs:169](../../crates/corecruxd/src/http/route_auth.rs#L169)). If you add a mutating route under a read-class prefix, do the same. ## 1.5 Errors are RFC 7807 @@ -214,8 +218,8 @@ without failing CI. - [crates/corecruxd/src/auth.rs:24](../../crates/corecruxd/src/auth.rs#L24) — `AuthMode` - [crates/corecruxd/src/auth.rs:1320](../../crates/corecruxd/src/auth.rs#L1320) — `require_http_scopes` - [crates/corecruxd/src/main.rs:307](../../crates/corecruxd/src/main.rs#L307) — auth-mode-required abort -- [crates/corecruxd/src/http/route_auth.rs:157](../../crates/corecruxd/src/http/route_auth.rs#L157) — studio route class -- [crates/corecruxd/src/http/route_auth.rs:386](../../crates/corecruxd/src/http/route_auth.rs#L386) — extensions route class +- [crates/corecruxd/src/http/route_auth.rs:181](../../crates/corecruxd/src/http/route_auth.rs#L181) — studio route class +- [crates/corecruxd/src/http/route_auth.rs:492](../../crates/corecruxd/src/http/route_auth.rs#L492) — extensions route class - [crates/corecruxd/src/http/mod.rs:1788](../../crates/corecruxd/src/http/mod.rs#L1788) — `problem_response` - [crates/corecrux-types/src/lib.rs:783](../../crates/corecrux-types/src/lib.rs#L783) — `ProblemDetails` - [crates/corecruxd/src/extension_registry.rs:29](../../crates/corecruxd/src/extension_registry.rs#L29) — extension fact prefix diff --git a/docs/ops-guide.md b/docs/ops-guide.md index 2f9edb57..6bce0f1c 100644 --- a/docs/ops-guide.md +++ b/docs/ops-guide.md @@ -48,14 +48,20 @@ route template + method) and is controlled by `CORECRUXD_ROUTE_AUTH`, read once at startup: - `off` — pass-through. -- `shadow` (default) — evaluate the contract and log a structured - `route_auth_shadow_mismatch` warning on any would-deny, but never block. Grep - the daemon logs for that marker to find routes that would be denied before you - flip to `enforce`. +- `shadow` — evaluate the contract and log a structured + `route_auth_shadow_mismatch` warning on any would-deny, but never block. This + is the derived default only when authentication is off **and** the listener + is loopback-only. Set it explicitly as a temporary migration diagnostic. - `enforce` — public probes and the `/v1/auth/*` bootstrap rails pass without auth; every other route requires one of its contract scopes; a route with no contract entry fails closed with `403`. +When the variable is unset, the daemon derives `enforce` whenever authentication +is configured or the listener is non-loopback. Empty, misspelled, and +non-Unicode explicit values fail safe to `enforce`. Shipped Compose, +systemd/Homebrew/install-script, and Helm configurations set `enforce` +explicitly. + Handler-level scope checks remain in place as defence in depth. The gate authorizes scopes only — feature-flag gating for optional surfaces stays in the handler. Roll it out `shadow` → (triage warnings) → `enforce`. diff --git a/examples/quickstart/docker-compose.yml b/examples/quickstart/docker-compose.yml index ccbfcb6d..8e4476e7 100644 --- a/examples/quickstart/docker-compose.yml +++ b/examples/quickstart/docker-compose.yml @@ -41,6 +41,7 @@ services: # dev_scopes is the local-development posture. For anything shared, # switch to jwt_hs256 / jwt_jwks — see docs/getting-started.md. - CORECRUXD_AUTH_MODE=dev_scopes + - CORECRUXD_ROUTE_AUTH=enforce - CORECRUXD_ALLOW_INSECURE_DEV_AUTH_BIND=1 - CORECRUXD_HTTP_HOST=0.0.0.0 - CORECRUXD_GRPC_HOST=0.0.0.0 diff --git a/helm/corecrux/README.md b/helm/corecrux/README.md index ed7e6107..5fb5384b 100644 --- a/helm/corecrux/README.md +++ b/helm/corecrux/README.md @@ -5,10 +5,11 @@ This chart is **not launch-supported** for the public Crux Daemon release line. It remains in-tree as historical packaging work, but it is pinned to the old `0.1.0` surface and does not expose the current daemon's MCP/gRPC ports, release verification flow, or launch-default configuration. Do not use it as a -fresh-install path for the production cutover. +fresh-install path for the production cutover. Its retained defaults still set +`CORECRUXD_ROUTE_AUTH=enforce` so an accidental evaluation install does not +silently use shadow route authorization. Supported launch install paths are documented in: - [`../../docs/getting-started.md`](../../docs/getting-started.md) - [`../../packaging/README.md`](../../packaging/README.md) - diff --git a/helm/corecrux/templates/deployment.yaml b/helm/corecrux/templates/deployment.yaml index c97fc828..70fa1bc1 100644 --- a/helm/corecrux/templates/deployment.yaml +++ b/helm/corecrux/templates/deployment.yaml @@ -41,6 +41,8 @@ spec: value: "0.0.0.0" - name: CORECRUXD_AUTH_MODE value: {{ .Values.auth.mode | quote }} + - name: CORECRUXD_ROUTE_AUTH + value: {{ .Values.config.routeAuthMode | quote }} - name: CORECRUX_LOG_FORMAT value: {{ .Values.config.logFormat | quote }} - name: CORECRUXD_BUILD_CCXI diff --git a/helm/corecrux/values.yaml b/helm/corecrux/values.yaml index e4b2cb36..baa420ea 100644 --- a/helm/corecrux/values.yaml +++ b/helm/corecrux/values.yaml @@ -37,6 +37,7 @@ auth: config: logFormat: "json" buildCcxi: "1" + routeAuthMode: "enforce" # -- Extra environment variables injected into the container # Example: diff --git a/llms-full.txt b/llms-full.txt index 0c3ff04b..54b4c2b8 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -1964,7 +1964,7 @@ understanding back to agents. | GET | `/v1/repos/{repoId}?tenant_id=…` | One registration | `admin:read` | | DELETE | `/v1/repos/{repoId}?tenant_id=…` | Unregister (stops watch) | `admin:write` | | GET | `/v1/repos/{repoId}/codemap?tenant_id=…&format=summary\|full` | AST code map: `summary` = stats + per-crate rollup; `full` = files, symbols, deps, routes | `admin:read` | -| POST | `/v1/workspace/scan` | Scan the daemon's own workspace (`CORECRUXD_WORKSPACE_PATH`) | `admin:read` | +| POST | `/v1/workspace/scan` | Scan the daemon's own workspace (`CORECRUXD_WORKSPACE_PATH`) | `admin:write` | | GET | `/v1/workspace/scan` | Latest self-scan in full | `admin:read` | | GET | `/v1/workspace/storyline?format=tree\|json` | Per-route call trees from the self-scan | `admin:read` | @@ -2133,9 +2133,13 @@ controlled by `CORECRUXD_ROUTE_AUTH` (read once at startup): | Value | Behaviour | |-------|-----------| | `off` | Pass-through; the middleware does nothing. | -| `shadow` (default) | Evaluates the contract and logs a structured `route_auth_shadow_mismatch` warning on any would-deny, but never blocks. Use it to observe coverage before switching to `enforce`. | +| `shadow` | Evaluates the contract and logs a structured `route_auth_shadow_mismatch` warning on any would-deny, but never blocks. It is the derived default only for auth-off, loopback-only operation; otherwise it is an explicit migration override. | | `enforce` | Public routes (`/healthz`, `/readyz`, `/metrics`, `/session`, `/invocation/verify`, `/v1/openapi.json`, `/v1/version`, `/v1/witness/smoke`, and the `/v1/auth/*` bootstrap rails) pass with no auth headers. Every other route requires one of its contract scopes via the same primitive the handlers use. A route with **no** contract entry — or a request axum could not match to a route template — **fails closed with `403`**. | +With the variable unset, authentication enabled or a non-loopback listener +selects `enforce`; only auth-off plus loopback derives `shadow`. An empty or +unknown explicit value also selects `enforce` and emits a startup warning. + The gate authorizes scopes only; feature-flag gating for optional surfaces stays in the handler. When `CORECRUXD_AUTH_MODE=off`, the scope check is a no-op (there is nothing to enforce), but `enforce` still fails closed on uncontracted routes. @@ -4074,6 +4078,7 @@ Config via environment variables or YAML (`config.example.env`, `config.example. | Variable | Default | Description | |---|---|---| | `CORECRUXD_AUTH_MODE` | required | `off`, `dev_scopes`, `jwt_hs256`, or `jwt_jwks`. | +| `CORECRUXD_ROUTE_AUTH` | derived | `enforce` when auth is enabled or the listener is non-loopback; otherwise `shadow`. Shipped packaging pins `enforce`. | | `CORECRUXD_DATA_DIR` | `../CoreCruxData/v1` | Data directory. | | `CORECRUXD_HTTP_PORT` | `14800` | HTTP API port. | | `CORECRUXD_GRPC_PORT` | `4007` | gRPC API port. | diff --git a/packaging/homebrew/crux.rb b/packaging/homebrew/crux.rb index 57ba7c11..bf94174a 100644 --- a/packaging/homebrew/crux.rb +++ b/packaging/homebrew/crux.rb @@ -90,6 +90,7 @@ def install keep_alive successful_exit: false environment_variables CORECRUXD_DATA_DIR: var/"crux", CORECRUXD_AUTH_MODE: "dev_scopes", + CORECRUXD_ROUTE_AUTH: "enforce", CORECRUXD_UPDATE_CHECK_ENABLED: "0" working_dir var/"crux" end diff --git a/packaging/install.sh b/packaging/install.sh index e77a6e9b..30da8d97 100755 --- a/packaging/install.sh +++ b/packaging/install.sh @@ -205,6 +205,7 @@ After=network.target ExecStart=${BIN_DIR}/corecruxd Environment=CORECRUXD_DATA_DIR=${DATA_DIR} Environment=CORECRUXD_AUTH_MODE=dev_scopes +Environment=CORECRUXD_ROUTE_AUTH=enforce # Binary installs have no git checkout to compare against; keep the # no-phone-home posture explicit. Environment=CORECRUXD_UPDATE_CHECK_ENABLED=0 @@ -231,6 +232,7 @@ EOF CORECRUXD_DATA_DIR${DATA_DIR} CORECRUXD_AUTH_MODEdev_scopes + CORECRUXD_ROUTE_AUTHenforce CORECRUXD_UPDATE_CHECK_ENABLED0 KeepAliveSuccessfulExit @@ -261,7 +263,7 @@ if [ "$WITH_SERVICE" -eq 1 ]; then echo " ${SERVICE_HINT}" else echo " 1. Start the daemon:" - echo " CORECRUXD_AUTH_MODE=dev_scopes CORECRUXD_DATA_DIR='${DATA_DIR}' '${BIN_DIR}/crux'" + echo " CORECRUXD_AUTH_MODE=dev_scopes CORECRUXD_ROUTE_AUTH=enforce CORECRUXD_DATA_DIR='${DATA_DIR}' '${BIN_DIR}/crux'" fi echo " 2. Open the console: http://127.0.0.1:14800" echo " 3. Guided first fact: '${BIN_DIR}/corecruxctl' quickstart" diff --git a/packaging/systemd/crux.service b/packaging/systemd/crux.service index 58cbbdb7..ee82401a 100644 --- a/packaging/systemd/crux.service +++ b/packaging/systemd/crux.service @@ -20,6 +20,7 @@ StateDirectory=crux StateDirectoryMode=0700 Environment=CORECRUXD_DATA_DIR=/var/lib/crux Environment=CORECRUXD_AUTH_MODE=dev_scopes +Environment=CORECRUXD_ROUTE_AUTH=enforce # Binary installs have no git checkout to compare against; keep the # no-phone-home posture explicit (see scripts/assert-no-phone-home.sh). Environment=CORECRUXD_UPDATE_CHECK_ENABLED=0 diff --git a/packaging/tests/install-smoke.sh b/packaging/tests/install-smoke.sh index f71c863e..a372d6ce 100755 --- a/packaging/tests/install-smoke.sh +++ b/packaging/tests/install-smoke.sh @@ -77,6 +77,7 @@ EXPECTED_HOOK_VERSION="crux-hook ${TAG#v}" step "5/9 boot daemon" CORECRUXD_AUTH_MODE=dev_scopes \ +CORECRUXD_ROUTE_AUTH=enforce \ CORECRUXD_DATA_DIR="${DATA_DIR}" \ CORECRUXD_HTTP_PORT=14800 \ "${PREFIX}/bin/crux" >"${PREFIX}/daemon.log" 2>&1 & From a413ce6ff1dbebbccd03c4714dcbec56e6536854 Mon Sep 17 00:00:00 2001 From: CueCrux Date: Mon, 10 Aug 2026 22:58:15 +0100 Subject: [PATCH 2/7] fix(work): bind actor and tenant authority MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-picked from red-steel `bf308b51`, resolved against current `main`. Work reads and mutations now answer for the *authenticated* tenant rather than whatever `?tenant_id=` the caller supplied. **Extended beyond the original commit, deliberately.** Since red-steel was written, `main` extracted the kanban lookup into `work::kanban_items_for_query`, now called from two places. A literal cherry-pick fixes `GET /v1/work` and silently leaves the second caller — `GET /v1/attention/summary` — passing a default `ListWorkQuery`, so its `tenant_id` is `None` and `list_work` counts every tenant's items into one roll-up. Holding `admin:read` says you may read a summary, not whose. Taking a side of the diff here would have dropped the fix on one of the two surfaces while looking complete — the same failure mode the WIP branch flagged for workspace-scan. So the shared helper now *requires* an authenticated tenant as a parameter instead of reading `q.tenant_id`, which makes the omission a compile error rather than a silent leak, and `attention.rs` resolves one through the same `work_scope_context` + `resolve_authorized_tenant` path `/v1/work` uses. Its `execplan_items_for_query` and `list_pending_gates` calls are tenant-scoped for the same reason. Verified: `cargo check --workspace --all-targets --locked` clean. Refs #630. Ordered replay of redsteel-remediation-replay-2026-08-07. Co-Authored-By: Claude Opus 5 (1M context) --- crates/corecruxd/src/auth.rs | 160 ++- crates/corecruxd/src/http/attention.rs | 24 +- crates/corecruxd/src/http/auth_device.rs | 2 + crates/corecruxd/src/http/auth_rails.rs | 1 + crates/corecruxd/src/http/coord.rs | 39 +- crates/corecruxd/src/http/entities.rs | 49 +- crates/corecruxd/src/http/orchestrators.rs | 490 ++++++-- crates/corecruxd/src/http/result_envelope.rs | 58 +- crates/corecruxd/src/http/tests.rs | 1079 ++++++++++++++++-- crates/corecruxd/src/http/work.rs | 372 ++++-- crates/corecruxd/src/http/workbench.rs | 11 +- crates/corecruxd/src/work.rs | 104 +- crates/crux-mcp/src/dispatch.rs | 65 ++ crates/crux-mcp/src/tools/coordination.rs | 226 +++- crates/crux-mcp/src/tools/entities.rs | 105 +- crates/crux-mcp/src/tools/loopback_auth.rs | 30 + crates/crux-mcp/src/tools/orchestrators.rs | 104 +- crates/crux-mcp/src/tools/punchcards.rs | 18 +- docs/agent-guide.md | 5 +- docs/api-reference.md | 23 + docs/developer-guide/01-architecture.md | 2 +- llms-full.txt | 28 +- 22 files changed, 2624 insertions(+), 371 deletions(-) diff --git a/crates/corecruxd/src/auth.rs b/crates/corecruxd/src/auth.rs index 8894ea09..71b99358 100644 --- a/crates/corecruxd/src/auth.rs +++ b/crates/corecruxd/src/auth.rs @@ -138,6 +138,7 @@ struct AgentTokenHttpConfig { registry: crux_mcp::agent::AgentRegistry, scopes: BTreeSet, tenants: TenantAllow, + passport_map: Option, } /// Env flag enabling HTTP acceptance of MCP agent tokens. Default off. @@ -170,10 +171,13 @@ fn build_agent_http_config() -> Option { .ok() .unwrap_or_else(|| "*".to_string()); let tenants = tenant_allow_from_str(&tenant_raw); + let passport_map = + env_truthy("CORECRUXD_AGENT_PASSPORTS").then(crux_mcp::agent_passport::AgentPassportMap::from_env_or_default); Some(AgentTokenHttpConfig { registry, scopes, tenants, + passport_map, }) } @@ -213,13 +217,40 @@ impl AgentTokenHttpConfig { /// If `token` is a registered agent token, return an `AuthContext` carrying /// the configured scopes + tenant binding, attributed to the agent name. fn try_auth(&self, token: &str) -> Option { - self.registry.lookup(token).map(|agent| AuthContext { - subject: Some(format!("agent:{}", agent.name)), - passport_id: Some(format!("agent:{}", agent.name)), - scopes: self.scopes.clone(), - tenants: self.tenants.clone(), - canonical_passport_claim_verified: false, - credential_is_agent_token: true, + self.registry.lookup(token).and_then(|agent| { + // An opaque agent-token name is an automation principal, not a + // passport. Namespace it unless the operator explicitly supplied + // an agent→passport mapping; otherwise a token named like a real + // passport could inherit that passport's human/ungated policy. + let automation_id = format!("agent:{}", agent.name); + let (passport_id, tenants) = if let Some(passport_map) = &self.passport_map { + if let Some(group) = passport_map.get_group(&agent.name) { + require_tenant_allowed(&self.tenants, &group.tenant).ok()?; + let mut tenant = BTreeSet::new(); + tenant.insert(group.tenant.clone()); + (group.passport.clone(), TenantAllow::Only(tenant)) + } else { + // Flag-on but unmapped agents remain automation principals + // and are confined to default, matching MCP authority. + require_tenant_allowed(&self.tenants, "default").ok()?; + let mut tenant = BTreeSet::new(); + tenant.insert("default".to_string()); + (automation_id.clone(), TenantAllow::Only(tenant)) + } + } else { + // The token registry cryptographically verifies this name. It + // is a valid *automation* principal even when passport mapping + // is disabled, but it is never a human passport by name alone. + (automation_id, self.tenants.clone()) + }; + Some(AuthContext { + subject: Some(format!("agent:{}", agent.name)), + passport_id: Some(passport_id), + scopes: self.scopes.clone(), + tenants, + canonical_passport_claim_verified: false, + credential_is_agent_token: true, + }) }) } } @@ -958,6 +989,10 @@ pub struct HttpScopeContext { pub scopes: Vec, pub passport_id: Option, auth_enforced: bool, + /// Auth-off and DevScopes are explicit local-development modes. They can + /// carry caller assertions, but those assertions are never verified + /// principals and must be durably labelled as such. + local_unverified_identity: bool, passport_override_used: bool, canonical_passport_claim_verified: bool, credential_is_agent_token: bool, @@ -1125,6 +1160,12 @@ impl HttpScopeContext { self.auth_enforced } + /// Whether caller-supplied identity is permitted only as an explicitly + /// unverified local-development assertion. + pub(crate) fn local_unverified_identity(&self) -> bool { + self.local_unverified_identity + } + /// Whether the verified JWT carried a canonical, non-empty `passport_id` /// claim. This is stricter than the ordinary identity fallback to `sub`. pub(crate) fn canonical_passport_claim_verified(&self) -> bool { @@ -1152,6 +1193,57 @@ impl HttpScopeContext { pub(crate) fn resolve_read_tenant(&self) -> Option { resolve_read_tenant_flagged(&self.tenants, TenantStampMode::from_env()) } + + /// Resolve one concrete tenant for an authority-sensitive surface, + /// independent of the legacy tenant-stamping rollout flag. + /// + /// `requested` is the route/body/query target. The optional + /// `x-corecrux-tenant-id` selector is treated as an additional constraint: + /// when both are present they must agree. Tokens with no tenant claim are + /// confined to `default`; multi-tenant tokens must select one tenant; and a + /// wildcard/admin token may explicitly select any tenant. Authenticated + /// tokens without a tenant claim are denied; this authority-sensitive + /// surface does not inherit the legacy shared-default fallback. + #[allow(clippy::result_large_err)] + pub(crate) fn resolve_authorized_tenant(&self, requested: Option<&str>) -> Result { + let requested = requested.map(str::trim).filter(|value| !value.is_empty()); + let header = self + .write_tenant_selector + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + if let (Some(requested), Some(header)) = (requested, header) { + if requested != header { + return Err(ProblemResponse( + ProblemDetails::forbidden("route tenant does not match x-corecrux-tenant-id").with_extensions( + serde_json::json!({ + "code": "TENANT_SELECTOR_MISMATCH", + "routeTenantId": requested, + "headerTenantId": header, + }), + ), + )); + } + } + let selector = requested.or(header); + if matches!(self.tenants, TenantAllow::Missing) && self.auth_enforced { + return Err(ProblemResponse( + ProblemDetails::forbidden("token is missing a tenant claim").with_extensions(serde_json::json!({ + "code": "TENANT_CLAIM_MISSING", + })), + )); + } + if matches!(self.tenants, TenantAllow::Missing) && selector.is_some_and(|tenant| tenant != "default") { + return Err(ProblemResponse( + ProblemDetails::forbidden("token without a tenant claim is confined to the default tenant") + .with_extensions(serde_json::json!({ + "code": "TENANT_FORBIDDEN", + "tenantId": selector, + })), + )); + } + Ok(resolve_write_tenant_on(&self.tenants, selector)?.unwrap_or_else(|| "default".to_string())) + } } pub fn http_passport_id(headers: &HeaderMap) -> Option { @@ -1184,6 +1276,7 @@ pub fn passport_bound_context(auth: &Authz, headers: &HeaderMap) -> Result u64 { std::time::SystemTime::now() @@ -48,9 +48,19 @@ pub(super) async fn get_attention_summary( Query(q): Query, headers: HeaderMap, ) -> impl IntoResponse { - if let Err(problem) = require_http_scopes(&state.auth, &headers, &["admin:read"]) { - return problem.into_response(); - } + // Resolved through the same context `GET /v1/work` uses, not + // `require_http_scopes`: the counts below are derived from work items, and + // those are tenant-owned. Holding `admin:read` says you may read a summary, + // not whose summary — without a tenant to answer for, this endpoint counted + // every tenant's items into one roll-up. + let context = match super::work::work_scope_context(&state, &headers, "admin:read") { + Ok(context) => context, + Err(response) => return response, + }; + let tenant_id = match context.resolve_authorized_tenant(None) { + Ok(tenant_id) => tenant_id, + Err(problem) => return problem.into_response(), + }; let now = now_unix_ms(); let work_query = super::work::ListWorkQuery { @@ -59,9 +69,9 @@ pub(super) async fn get_attention_summary( }; let store = state.fact_store.read().await; - let kanban = super::work::kanban_items_for_query(&store, &work_query); - let execplans = super::work::execplan_items_for_query(&store, &work_query); - let gates = crate::work::list_pending_gates(&store, None); + let kanban = super::work::kanban_items_for_query(&store, &work_query, &tenant_id); + let execplans = super::work::execplan_items_for_query(&store, &work_query, &tenant_id); + let gates = crate::work::list_pending_gates(&store, Some(&tenant_id), None); let bindings = if state.coord_enabled { crate::session_bindings::list_bindings(&store) } else { diff --git a/crates/corecruxd/src/http/auth_device.rs b/crates/corecruxd/src/http/auth_device.rs index a19e01ed..66fe52a7 100644 --- a/crates/corecruxd/src/http/auth_device.rs +++ b/crates/corecruxd/src/http/auth_device.rs @@ -627,6 +627,7 @@ async fn issue_device_tokens(state: &AppState, tenant_id: &str, scopes: &[String let sub = format!("device:{cred_id}"); let claims = ScopedClaims { sub: &sub, + passport_id: None, scopes: &scope_refs, tenant_id, ttl_secs: ISSUED_TOKEN_TTL_SECS, @@ -717,6 +718,7 @@ pub(super) async fn post_device_refresh(State(_state): State, Json(req let sub = format!("device:{cred_id}"); let claims = ScopedClaims { sub: &sub, + passport_id: None, scopes: &scope_refs, tenant_id: &tenant_id, ttl_secs: ISSUED_TOKEN_TTL_SECS, diff --git a/crates/corecruxd/src/http/auth_rails.rs b/crates/corecruxd/src/http/auth_rails.rs index 8b03b1cb..e7c071ca 100644 --- a/crates/corecruxd/src/http/auth_rails.rs +++ b/crates/corecruxd/src/http/auth_rails.rs @@ -241,6 +241,7 @@ pub(super) async fn post_tailscale_token( let sub = format!("ts:{login}"); let claims = ScopedClaims { sub: &sub, + passport_id: None, scopes: &scope_refs, tenant_id: &principal.tenant_id, ttl_secs: ISSUED_TOKEN_TTL_SECS, diff --git a/crates/corecruxd/src/http/coord.rs b/crates/corecruxd/src/http/coord.rs index bd4b1d91..ad6c3dc6 100644 --- a/crates/corecruxd/src/http/coord.rs +++ b/crates/corecruxd/src/http/coord.rs @@ -17,8 +17,7 @@ use serde_json::Value; use super::{ - problem_response, require_http_any_scope, require_http_scopes, AppState, HeaderMap, IntoResponse, Json, Query, - State, StatusCode, + problem_response, require_http_any_scope, AppState, HeaderMap, IntoResponse, Json, Query, State, StatusCode, }; use crate::agentgraph_kinds::PUNCHCARD_KIND; use crate::coord::{CoordIntent, LeaseSummary}; @@ -26,6 +25,7 @@ use crate::coord::{CoordIntent, LeaseSummary}; #[derive(Debug, serde::Deserialize)] pub(super) struct ActiveQuery { pub project_id: Option, + pub tenant_id: Option, } #[derive(Debug, serde::Deserialize)] @@ -128,19 +128,36 @@ pub(super) async fn get_coord_active( if !state.coord_enabled { return coord_disabled_response(); } - if let Err(problem) = require_http_scopes(&state.auth, &headers, &["admin:read"]) { - return problem.into_response(); + let context = match crate::auth::passport_bound_context(&state.auth, &headers) { + Ok(context) => context, + Err(problem) => return problem.into_response(), + }; + if !context.has_scope("admin:read") { + return problem_response( + StatusCode::FORBIDDEN, + "admin:read scope required for coordination status", + ); } + let tenant_id = match context.resolve_authorized_tenant(q.tenant_id.as_deref()) { + Ok(tenant_id) => tenant_id, + Err(problem) => return problem.into_response(), + }; let now = now_unix_ms(); let store = state.fact_store.read().await; let bindings = crate::session_bindings::list_bindings(&store); let intents = crate::coord::list_intents(&store, q.project_id.as_deref()); - let mut work_in_flight = crate::work::list_work(&store, q.project_id.as_deref(), Some("in_progress"), None, None); + let mut work_in_flight = crate::work::list_work( + &store, + q.project_id.as_deref(), + Some("in_progress"), + Some(&tenant_id), + None, + ); work_in_flight.extend(crate::work::list_work( &store, q.project_id.as_deref(), Some("blocked"), - None, + Some(&tenant_id), None, )); drop(store); @@ -331,7 +348,10 @@ mod tests { state.coord_enabled = false; let resp = get_coord_active( StateExtract(state.clone()), - QueryExtract(ActiveQuery { project_id: None }), + QueryExtract(ActiveQuery { + project_id: None, + tenant_id: None, + }), HeaderMap::new(), ) .await @@ -371,6 +391,7 @@ mod tests { StateExtract(state.clone()), Query(ActiveQuery { project_id: Some("proj".to_string()), + tenant_id: None, }), HeaderMap::new(), ) @@ -471,6 +492,7 @@ mod tests { StateExtract(state.clone()), QueryExtract(ActiveQuery { project_id: Some("proj".to_string()), + tenant_id: None, }), HeaderMap::new(), ) @@ -492,6 +514,7 @@ mod tests { StateExtract(state), QueryExtract(ActiveQuery { project_id: Some("other".to_string()), + tenant_id: None, }), HeaderMap::new(), ) @@ -518,6 +541,7 @@ mod tests { StateExtract(state), QueryExtract(ActiveQuery { project_id: Some("proj".to_string()), + tenant_id: None, }), HeaderMap::new(), ) @@ -565,6 +589,7 @@ mod tests { StateExtract(state), QueryExtract(ActiveQuery { project_id: Some("proj".to_string()), + tenant_id: None, }), HeaderMap::new(), ) diff --git a/crates/corecruxd/src/http/entities.rs b/crates/corecruxd/src/http/entities.rs index f99e8499..0d8d8fd1 100644 --- a/crates/corecruxd/src/http/entities.rs +++ b/crates/corecruxd/src/http/entities.rs @@ -49,6 +49,12 @@ pub(super) async fn get_entity( if let Err(p) = require_http_any_scope(&state.auth, &headers, &["facts:read", "admin:read"]) { return p.into_response(); } + if crux_mcp::tools::entities::is_governed_entity_kind(&kind) { + return problem_response( + StatusCode::FORBIDDEN, + format!("entity kind '{kind}' is governed by its typed API"), + ); + } let store = state.entity_store.read().await; match store.get(&kind, &id) { Some(rec) => (StatusCode::OK, Json(json!({"entity": rec}))).into_response(), @@ -65,13 +71,32 @@ pub(super) async fn list_entities( if let Err(p) = require_http_any_scope(&state.auth, &headers, &["facts:read", "admin:read"]) { return p.into_response(); } + if q.kind + .as_deref() + .is_some_and(crux_mcp::tools::entities::is_governed_entity_kind) + { + return problem_response(StatusCode::FORBIDDEN, "entity kind is governed by its typed API"); + } + let requested_kind = q.kind; + let requested_limit = q.limit; let query = EntityQuery { - kind: q.kind, - limit: q.limit, + kind: requested_kind.clone(), + // An unfiltered listing must hide governed rows before truncation. + limit: requested_kind.as_ref().and(requested_limit), include_deleted: q.include_deleted, }; let store = state.entity_store.read().await; - let entities: Vec<_> = store.list(&query).into_iter().cloned().collect(); + let mut entities: Vec<_> = store + .list(&query) + .into_iter() + .filter(|record| !crux_mcp::tools::entities::is_governed_entity_kind(&record.kind)) + .cloned() + .collect(); + if requested_kind.is_none() { + if let Some(limit) = requested_limit { + entities.truncate(limit); + } + } let count = entities.len(); (StatusCode::OK, Json(json!({"entities": entities, "count": count}))).into_response() } @@ -86,6 +111,12 @@ pub(super) async fn put_entity( if let Err(p) = require_http_any_scope(&state.auth, &headers, &["facts:write", "admin:write"]) { return p.into_response(); } + if crux_mcp::tools::entities::is_governed_entity_kind(&kind) { + return problem_response( + StatusCode::FORBIDDEN, + format!("entity kind '{kind}' is governed by its typed API"), + ); + } let actor = actor_from_headers(&state, &headers); let registry = state.kind_registry.read().await; let registry_opt = if registry.is_registered(&kind) { @@ -109,6 +140,12 @@ pub(super) async fn get_entity_history( if let Err(p) = require_http_any_scope(&state.auth, &headers, &["facts:read", "admin:read"]) { return p.into_response(); } + if crux_mcp::tools::entities::is_governed_entity_kind(&kind) { + return problem_response( + StatusCode::FORBIDDEN, + format!("entity kind '{kind}' is governed by its typed API"), + ); + } let store = state.entity_store.read().await; let versions: Vec<_> = store.history(&kind, &id).into_iter().cloned().collect(); let count = versions.len(); @@ -124,6 +161,12 @@ pub(super) async fn delete_entity( if let Err(p) = require_http_any_scope(&state.auth, &headers, &["facts:write", "admin:write"]) { return p.into_response(); } + if crux_mcp::tools::entities::is_governed_entity_kind(&kind) { + return problem_response( + StatusCode::FORBIDDEN, + format!("entity kind '{kind}' is governed by its typed API"), + ); + } let actor = actor_from_headers(&state, &headers); let mut store = state.entity_store.write().await; match store.delete(&kind, &id, &actor) { diff --git a/crates/corecruxd/src/http/orchestrators.rs b/crates/corecruxd/src/http/orchestrators.rs index 23ff5b0d..9b8baf7d 100644 --- a/crates/corecruxd/src/http/orchestrators.rs +++ b/crates/corecruxd/src/http/orchestrators.rs @@ -18,19 +18,18 @@ //! reference surfaces as `missing: true` rather than failing the whole call. //! //! Storage: orchestrators persist as `orchestrator`-kind entities in the -//! substrate entity store (`PUT/GET/DELETE /v1/entities/orchestrator/{id}` -//! under the hood), so they inherit the journal + restart-survival that -//! Package S wired. Each mutation emits a `CruxEvent::OrchestratorChanged` -//! and writes a receipt fact. +//! substrate entity store, so they inherit the journal + restart-survival that +//! Package S wired. Generic HTTP/MCP entity CRUD reserves this governed kind; +//! callers must use this typed surface so tenant and actor checks cannot be +//! bypassed. Each mutation emits a `CruxEvent::OrchestratorChanged` and writes +//! a receipt fact. use axum::routing::{delete, get, post}; use axum::Router; use serde::Deserialize; use serde_json::{json, Value}; -use super::{ - problem_response, require_http_any_scope, AppState, HeaderMap, IntoResponse, Json, Path, Query, State, StatusCode, -}; +use super::{problem_response, AppState, HeaderMap, IntoResponse, Json, Path, Query, Response, State, StatusCode}; use crate::agentgraph_kinds::{orchestrators_enabled, ORCHESTRATOR_KIND}; /// Member reference types accepted by `POST …/{id}/members`. @@ -83,14 +82,80 @@ fn gate_check() -> Option { } } -/// Resolve the calling passport from the request headers. Copied from -/// `entities.rs::actor_from_headers` — orchestrators stamp this as the -/// `created_by_passport` and as the entity-store `actor`. -fn actor_from_headers(state: &AppState, headers: &HeaderMap) -> String { - crate::auth::http_scope_context(&state.auth, headers) - .ok() - .and_then(|ctx| ctx.passport_id) - .unwrap_or_else(|| "anonymous".into()) +#[allow(clippy::result_large_err)] +fn scoped_context( + state: &AppState, + headers: &HeaderMap, + accepted_scopes: &[&str], +) -> Result { + let context = crate::auth::passport_bound_context(&state.auth, headers) + .map_err(axum::response::IntoResponse::into_response)?; + if !accepted_scopes.iter().any(|scope| context.has_scope(scope)) { + return Err(problem_response( + StatusCode::FORBIDDEN, + format!("one of {} is required", accepted_scopes.join(", ")), + )); + } + Ok(context) +} + +struct MutationAuthority { + context: crate::auth::HttpScopeContext, + actor: String, +} + +#[allow(clippy::result_large_err)] +fn mutation_authority( + state: &AppState, + headers: &HeaderMap, + identity_hint: Option<&str>, +) -> Result { + let context = scoped_context(state, headers, &["facts:write", "admin:write"])?; + let body_hint = identity_hint.map(str::trim).filter(|value| !value.is_empty()); + if !context.local_unverified_identity() { + if context.passport_override_used() { + return Err(problem_response( + StatusCode::FORBIDDEN, + "passport impersonation is not permitted for orchestrator mutations", + )); + } + let Some(passport_id) = context.passport_id.as_deref() else { + return Err(problem_response( + StatusCode::FORBIDDEN, + "an authenticated passport is required for orchestrator mutations", + )); + }; + if body_hint.is_some_and(|hint| hint != passport_id) { + return Err(problem_response( + StatusCode::FORBIDDEN, + "body passport does not match the authenticated passport", + )); + } + Ok(MutationAuthority { + actor: passport_id.to_string(), + context, + }) + } else { + let header_hint = context.passport_id.as_deref(); + if let (Some(body_hint), Some(header_hint)) = (body_hint, header_hint) { + if body_hint != header_hint { + return Err(problem_response( + StatusCode::FORBIDDEN, + "body passport does not match the local identity assertion header", + )); + } + } + let Some(asserted) = body_hint.or(header_hint) else { + return Err(problem_response( + StatusCode::BAD_REQUEST, + "an explicit passport identity assertion is required in local unverified mode", + )); + }; + Ok(MutationAuthority { + actor: format!("{}{asserted}", super::approval_receipts::UNVERIFIED_APPROVER_PREFIX), + context, + }) + } } fn now_unix_ms() -> u64 { @@ -146,12 +211,15 @@ fn members_of(payload: &Value) -> Vec { .unwrap_or_default() } -/// `tenant_id` of a stored orchestrator payload (empty string if absent). +/// Concrete tenant of a stored orchestrator payload. Legacy records without a +/// tenant belong to `default`; there are no tenant-wildcard orchestrators. fn tenant_of(payload: &Value) -> String { payload .get("tenant_id") .and_then(Value::as_str) - .unwrap_or_default() + .map(str::trim) + .filter(|tenant| !tenant.is_empty()) + .unwrap_or("default") .to_string() } @@ -182,7 +250,7 @@ async fn after_mutation(state: &AppState, id: &str, actor: &str) { confidence: 1.0, private: true, horizon_class: None, - actor: None, + actor: Some(actor.to_string()), }; crate::fact_privacy::enforce(&state.privacy_policy, &mut fact); state.fact_store.write().await.store(fact); @@ -217,16 +285,20 @@ pub(super) async fn create_orchestrator( if let Some(resp) = gate_check() { return resp; } - if let Err(p) = require_http_any_scope(&state.auth, &headers, &["facts:write", "admin:write"]) { - return p.into_response(); - } + let authority = match mutation_authority(&state, &headers, body.created_by_passport.as_deref()) { + Ok(authority) => authority, + Err(response) => return response, + }; + let tenant = match authority.context.resolve_authorized_tenant(body.tenant_id.as_deref()) { + Ok(tenant) => tenant, + Err(problem) => return problem.into_response(), + }; if body.name.trim().is_empty() { return problem_response(StatusCode::BAD_REQUEST, "name must not be empty"); } - let actor = actor_from_headers(&state, &headers); - let created_by = body.created_by_passport.unwrap_or_else(|| actor.clone()); + let actor = authority.actor; + let created_by = actor.clone(); let assignee = body.assignee_passport.unwrap_or_else(|| created_by.clone()); - let tenant = body.tenant_id.unwrap_or_default(); let st = body.state.unwrap_or_else(|| DEFAULT_STATE.to_string()); if !ORCHESTRATOR_STATES.contains(&st.as_str()) { return problem_response( @@ -275,9 +347,14 @@ pub(super) async fn list_orchestrators( if let Some(resp) = gate_check() { return resp; } - if let Err(p) = require_http_any_scope(&state.auth, &headers, &["facts:read", "admin:read"]) { - return p.into_response(); - } + let context = match scoped_context(&state, &headers, &["facts:read", "admin:read"]) { + Ok(context) => context, + Err(response) => return response, + }; + let tenant = match context.resolve_authorized_tenant(q.tenant_id.as_deref()) { + Ok(tenant) => tenant, + Err(problem) => return problem.into_response(), + }; let query = corecrux_memory::EntityQuery { kind: Some(ORCHESTRATOR_KIND.to_string()), limit: None, @@ -292,9 +369,7 @@ pub(super) async fn list_orchestrators( q.assignee .as_deref() .is_none_or(|a| p.get("assignee_passport").and_then(Value::as_str) == Some(a)) - && q.tenant_id - .as_deref() - .is_none_or(|t| p.get("tenant_id").and_then(Value::as_str) == Some(t)) + && tenant_of(p) == tenant && q.state .as_deref() .is_none_or(|s| p.get("state").and_then(Value::as_str) == Some(s)) @@ -322,12 +397,18 @@ pub(super) async fn get_orchestrator( if let Some(resp) = gate_check() { return resp; } - if let Err(p) = require_http_any_scope(&state.auth, &headers, &["facts:read", "admin:read"]) { - return p.into_response(); - } + let context = match scoped_context(&state, &headers, &["facts:read", "admin:read"]) { + Ok(context) => context, + Err(response) => return response, + }; let store = state.entity_store.read().await; match store.get(ORCHESTRATOR_KIND, &id) { - Some(rec) => (StatusCode::OK, Json(json!({ "orchestrator": rec }))).into_response(), + Some(rec) => { + if let Err(problem) = context.resolve_authorized_tenant(Some(&tenant_of(&rec.payload))) { + return problem.into_response(); + } + (StatusCode::OK, Json(json!({ "orchestrator": rec }))).into_response() + } None => problem_response(StatusCode::NOT_FOUND, format!("orchestrator {id} not found")), } } @@ -352,9 +433,10 @@ pub(super) async fn patch_orchestrator( if let Some(resp) = gate_check() { return resp; } - if let Err(p) = require_http_any_scope(&state.auth, &headers, &["facts:write", "admin:write"]) { - return p.into_response(); - } + let authority = match mutation_authority(&state, &headers, None) { + Ok(authority) => authority, + Err(response) => return response, + }; if let Some(s) = &body.state { if !ORCHESTRATOR_STATES.contains(&s.as_str()) { return problem_response( @@ -363,7 +445,7 @@ pub(super) async fn patch_orchestrator( ); } } - let actor = actor_from_headers(&state, &headers); + let actor = authority.actor; let mut store = state.entity_store.write().await; let mut payload = match store.get(ORCHESTRATOR_KIND, &id) { @@ -373,6 +455,9 @@ pub(super) async fn patch_orchestrator( return problem_response(StatusCode::NOT_FOUND, format!("orchestrator {id} not found")); } }; + if let Err(problem) = authority.context.resolve_authorized_tenant(Some(&tenant_of(&payload))) { + return problem.into_response(); + } if let Some(name) = &body.name { if name.trim().is_empty() { drop(store); @@ -419,12 +504,15 @@ pub(super) struct AddMemberBody { /// True when a member's tenant conflicts with the orchestrator's tenant (T.1). /// -/// An empty orchestrator tenant is a wildcard — it accepts members from any -/// tenant (orchestrators created without a tenant are workspace-global). A -/// member with no tenant (`None`) never conflicts. Conflict arises only when -/// both sides are populated and differ. +/// Legacy missing/empty values on either side mean `default`; orchestrators +/// never act as tenant wildcards. fn tenant_conflict(orchestrator_tenant: &str, member_tenant: Option<&str>) -> bool { - !orchestrator_tenant.is_empty() && member_tenant.is_some_and(|t| t != orchestrator_tenant) + let orchestrator_tenant = if orchestrator_tenant.trim().is_empty() { + "default" + } else { + orchestrator_tenant + }; + member_tenant.unwrap_or("default") != orchestrator_tenant } /// Infer the member type from an id prefix when the caller omitted `type`. @@ -459,12 +547,12 @@ async fn validate_member( return Err((StatusCode::NOT_FOUND, format!("work item '{member_ref}' not found"))); }; // T.1: cross-tenant reject. - if tenant_conflict(orchestrator_tenant, item.tenant_id.as_deref()) { + if tenant_conflict(orchestrator_tenant, Some(crate::work::work_tenant_id(&item))) { return Err(( StatusCode::CONFLICT, format!( "cross-tenant membership rejected: work '{member_ref}' tenant '{}' != orchestrator tenant '{orchestrator_tenant}'", - item.tenant_id.as_deref().unwrap_or("") + crate::work::work_tenant_id(&item) ), )); } @@ -478,8 +566,17 @@ async fn validate_member( let store = state.fact_store.read().await; let items = crate::work_execplans::list_execplans(&store, &root, now_unix_ms()).unwrap_or_default(); drop(store); - if !items.iter().any(|w| w.id == member_ref) { + let Some(item) = items.iter().find(|work| work.id == member_ref) else { return Err((StatusCode::NOT_FOUND, format!("execplan '{member_ref}' not found"))); + }; + if tenant_conflict(orchestrator_tenant, Some(crate::work::work_tenant_id(item))) { + return Err(( + StatusCode::CONFLICT, + format!( + "cross-tenant membership rejected: execplan '{member_ref}' tenant '{}' != orchestrator tenant '{orchestrator_tenant}'", + crate::work::work_tenant_id(item) + ), + )); } } } @@ -520,9 +617,10 @@ pub(super) async fn add_member( if let Some(resp) = gate_check() { return resp; } - if let Err(p) = require_http_any_scope(&state.auth, &headers, &["facts:write", "admin:write"]) { - return p.into_response(); - } + let authority = match mutation_authority(&state, &headers, None) { + Ok(authority) => authority, + Err(response) => return response, + }; let Some(member_ref) = body.r#ref.filter(|s| !s.trim().is_empty()) else { return problem_response(StatusCode::BAD_REQUEST, "ref (or member_ref) is required"); }; @@ -553,7 +651,7 @@ pub(super) async fn add_member( }, }; - let actor = actor_from_headers(&state, &headers); + let actor = authority.actor; // Load the orchestrator first (for tenant + existing members). let (mut payload, orchestrator_tenant) = { @@ -566,6 +664,9 @@ pub(super) async fn add_member( } } }; + if let Err(problem) = authority.context.resolve_authorized_tenant(Some(&orchestrator_tenant)) { + return problem.into_response(); + } let new_member = match validate_member(&state, &member_type, &member_ref, &orchestrator_tenant).await { Ok(m) => m, @@ -593,7 +694,9 @@ pub(super) async fn add_member( // For work members, stamp orchestrator_id on the WorkItem. if new_member.member_type == MEMBER_TYPE_WORK { - if let Err(e) = stamp_work_orchestrator(&state, &new_member.member_ref, Some(&id), &actor).await { + if let Err(e) = + stamp_work_orchestrator(&state, &new_member.member_ref, Some(&id), &orchestrator_tenant, &actor).await + { tracing::warn!(error = %e, work = %new_member.member_ref, "failed to stamp orchestrator_id on work item"); } } @@ -608,15 +711,22 @@ async fn stamp_work_orchestrator( state: &AppState, work_ref: &str, orchestrator_id: Option<&str>, - _actor: &str, + expected_tenant: &str, + actor: &str, ) -> Result<(), String> { let mut store = state.fact_store.write().await; let Some(mut item) = crate::work::get_work(&store, work_ref) else { return Ok(()); }; + if crate::work::work_tenant_id(&item) != expected_tenant { + return Err(format!( + "work item tenant '{}' does not match authorized orchestrator tenant '{expected_tenant}'", + crate::work::work_tenant_id(&item) + )); + } item.orchestrator_id = orchestrator_id.map(str::to_string); item.updated_at_unix_ms = now_unix_ms(); - crate::work::write_work_record(&mut store, &item).map_err(|e| e.to_string())?; + crate::work::write_work_record_with_actor(&mut store, &item, actor).map_err(|e| e.to_string())?; Ok(()) } @@ -629,21 +739,25 @@ pub(super) async fn remove_member( if let Some(resp) = gate_check() { return resp; } - if let Err(p) = require_http_any_scope(&state.auth, &headers, &["facts:write", "admin:write"]) { - return p.into_response(); - } - let actor = actor_from_headers(&state, &headers); + let authority = match mutation_authority(&state, &headers, None) { + Ok(authority) => authority, + Err(response) => return response, + }; + let actor = authority.actor; - let mut payload = { + let (mut payload, orchestrator_tenant) = { let store = state.entity_store.read().await; match store.get(ORCHESTRATOR_KIND, &id) { - Some(rec) => rec.payload.clone(), + Some(rec) => (rec.payload.clone(), tenant_of(&rec.payload)), None => { drop(store); return problem_response(StatusCode::NOT_FOUND, format!("orchestrator {id} not found")); } } }; + if let Err(problem) = authority.context.resolve_authorized_tenant(Some(&orchestrator_tenant)) { + return problem.into_response(); + } let mut members = members_of(&payload); let before = members.len(); @@ -672,7 +786,7 @@ pub(super) async fn remove_member( // Unstamp orchestrator_id on any removed work members. for m in &removed { if m.member_type == MEMBER_TYPE_WORK { - if let Err(e) = stamp_work_orchestrator(&state, &m.member_ref, None, &actor).await { + if let Err(e) = stamp_work_orchestrator(&state, &m.member_ref, None, &orchestrator_tenant, &actor).await { tracing::warn!(error = %e, work = %m.member_ref, "failed to clear orchestrator_id on work item"); } } @@ -693,23 +807,27 @@ pub(super) async fn list_orchestrator_work( if let Some(resp) = gate_check() { return resp; } - if let Err(p) = require_http_any_scope(&state.auth, &headers, &["facts:read", "admin:read"]) { - return p.into_response(); - } + let context = match scoped_context(&state, &headers, &["facts:read", "admin:read"]) { + Ok(context) => context, + Err(response) => return response, + }; - let payload = { + let (payload, orchestrator_tenant) = { let store = state.entity_store.read().await; match store.get(ORCHESTRATOR_KIND, &id) { - Some(rec) => rec.payload.clone(), + Some(rec) => (rec.payload.clone(), tenant_of(&rec.payload)), None => { drop(store); return problem_response(StatusCode::NOT_FOUND, format!("orchestrator {id} not found")); } } }; + if let Err(problem) = context.resolve_authorized_tenant(Some(&orchestrator_tenant)) { + return problem.into_response(); + } let members = members_of(&payload); - let resolved = resolve_members(&state, &members).await; + let resolved = resolve_members(&state, &members, &orchestrator_tenant).await; ( StatusCode::OK, Json(json!({ @@ -723,13 +841,14 @@ pub(super) async fn list_orchestrator_work( /// Resolve every member reference to a live record. Dangling references /// surface as `{type, ref, missing: true}` rather than failing the call. -async fn resolve_members(state: &AppState, members: &[MemberRef]) -> Vec { +async fn resolve_members(state: &AppState, members: &[MemberRef], tenant_id: &str) -> Vec { // Load the kanban + execplan universes once, then resolve each member. let store = state.fact_store.read().await; - let kanban = crate::work::list_work(&store, None, None, None, None); - let execplans = crate::work_execplans::execplans_root_from_env() + let kanban = crate::work::list_work(&store, None, None, Some(tenant_id), None); + let mut execplans = crate::work_execplans::execplans_root_from_env() .and_then(|root| crate::work_execplans::list_execplans(&store, &root, now_unix_ms()).ok()) .unwrap_or_default(); + execplans.retain(|work| crate::work::work_tenant_id(work) == tenant_id); drop(store); resolve_members_against(members, &kanban, &execplans) } @@ -771,9 +890,11 @@ fn resolve_members_against( pub(crate) fn orchestrator_member_refs( entity_store: &corecrux_memory::EntityStore, orchestrator_id: &str, + tenant_id: &str, ) -> std::collections::HashSet { entity_store .get(ORCHESTRATOR_KIND, orchestrator_id) + .filter(|rec| tenant_of(&rec.payload) == tenant_id) .map(|rec| members_of(&rec.payload).into_iter().map(|m| m.member_ref).collect()) .unwrap_or_default() } @@ -816,7 +937,7 @@ mod tests { fn members_of_tolerates_missing_field() { let payload = json!({ "id": "orc_1", "name": "x" }); assert!(members_of(&payload).is_empty()); - assert_eq!(tenant_of(&payload), ""); + assert_eq!(tenant_of(&payload), "default"); } #[test] @@ -834,13 +955,13 @@ mod tests { #[test] fn tenant_conflict_rules() { - // Empty orchestrator tenant is a wildcard. - assert!(!tenant_conflict("", Some("tenant-a"))); + // Missing/empty values are the legacy spelling of default. + assert!(tenant_conflict("", Some("tenant-a"))); assert!(!tenant_conflict("", None)); // Matching tenants are fine. assert!(!tenant_conflict("tenant-a", Some("tenant-a"))); - // Member with no tenant never conflicts. - assert!(!tenant_conflict("tenant-a", None)); + // A legacy default member conflicts with a non-default orchestrator. + assert!(tenant_conflict("tenant-a", None)); // Populated + differing = conflict. assert!(tenant_conflict("tenant-a", Some("tenant-b"))); } @@ -906,11 +1027,12 @@ mod tests { .upsert(ORCHESTRATOR_KIND, "orc_1", payload, "p1", Some(®)) .unwrap(); - let refs = orchestrator_member_refs(&store, "orc_1"); + let refs = orchestrator_member_refs(&store, "orc_1", "tenant-a"); assert_eq!(refs.len(), 2, "duplicate w_1 collapsed"); assert!(refs.contains("w_1")); assert!(refs.contains("execplan:p")); - assert!(orchestrator_member_refs(&store, "orc_unknown").is_empty()); + assert!(orchestrator_member_refs(&store, "orc_1", "tenant-b").is_empty()); + assert!(orchestrator_member_refs(&store, "orc_unknown", "tenant-a").is_empty()); } // ── resolution (M3) ───────────────────────────────────────────── @@ -1056,7 +1178,7 @@ mod tests { async fn seed_work(st: &AppState, id: &str) { let mut store = st.fact_store.write().await; - crate::work::write_work_record(&mut store, &work_item(id, None)).unwrap(); + crate::work::write_work_record_with_actor(&mut store, &work_item(id, None), "test:orchestrator").unwrap(); } async fn seed_passport(st: &AppState, id: &str, principal: &str) { @@ -1081,16 +1203,57 @@ mod tests { }); } - async fn create(st: &AppState, body: Value) -> (StatusCode, Value) { + fn local_headers() -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert("x-corecrux-passport-id", axum::http::HeaderValue::from_static("p1")); + headers + } + + fn verified_headers(tenant: &str, passport: &str) -> HeaderMap { + const SECRET: &str = "orchestrator-auth-test-secret-32-bytes"; + const ISSUER: &str = "corecrux-orchestrator-test"; + const AUDIENCE: &str = "corecrux"; + let exp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system time after epoch") + .as_secs() + .saturating_add(3_600) as usize; + let claims = json!({ + "exp": exp, + "iss": ISSUER, + "aud": AUDIENCE, + "scope": "admin:read facts:write", + "tenant_id": tenant, + "passport_id": passport, + }); + let token = jsonwebtoken::encode( + &jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256), + &claims, + &jsonwebtoken::EncodingKey::from_secret(SECRET.as_bytes()), + ) + .expect("orchestrator auth test JWT"); + let mut headers = HeaderMap::new(); + headers.insert( + axum::http::header::AUTHORIZATION, + axum::http::HeaderValue::from_str(&format!("Bearer {token}")).expect("bearer header"), + ); + headers + } + + async fn create_with_headers(st: &AppState, headers: HeaderMap, body: Value) -> (StatusCode, Value) { let body: CreateOrchestratorBody = serde_json::from_value(body).unwrap(); parts( - create_orchestrator(State(st.clone()), HeaderMap::new(), Json(body)) + create_orchestrator(State(st.clone()), headers, Json(body)) .await .into_response(), ) .await } + async fn create(st: &AppState, body: Value) -> (StatusCode, Value) { + create_with_headers(st, local_headers(), body).await + } + #[tokio::test] #[serial_test::serial] async fn gate_off_returns_501() { @@ -1141,7 +1304,7 @@ mod tests { let st = st.clone(); async move { parts( - list_orchestrators(State(st), Query(q), HeaderMap::new()) + list_orchestrators(State(st), Query(q), local_headers()) .await .into_response(), ) @@ -1155,7 +1318,7 @@ mod tests { limit: None, }) .await; - assert!(body["count"].as_u64().unwrap() >= 1); + assert_eq!(body["count"], 0, "default-tenant list must not leak tenant-a"); let (_, body) = list(ListOrchestratorsQuery { assignee: None, tenant_id: Some("tenant-a".into()), @@ -1178,7 +1341,7 @@ mod tests { patch_orchestrator( State(st.clone()), Path(id.clone()), - HeaderMap::new(), + local_headers(), Json( serde_json::from_value(json!({ "name": "Coord2", "state": "active", "assignee_passport": "p9" })) .unwrap(), @@ -1200,7 +1363,7 @@ mod tests { patch_orchestrator( State(st), Path(id), - HeaderMap::new(), + local_headers(), Json(serde_json::from_value(b).unwrap()), ) .await @@ -1224,12 +1387,159 @@ mod tests { std::env::remove_var("CORECRUXD_ORCHESTRATORS"); } + #[tokio::test] + #[serial_test::serial] + async fn verified_tenant_cannot_read_or_mutate_foreign_orchestrator() { + const SECRET: &str = "orchestrator-auth-test-secret-32-bytes"; + const ISSUER: &str = "corecrux-orchestrator-test"; + const AUDIENCE: &str = "corecrux"; + std::env::set_var("CORECRUXD_ORCHESTRATORS", "1"); + let mut st = handler_state().await; + st.auth = crate::auth::Authz::test_hs256(SECRET.as_bytes(), ISSUER, AUDIENCE); + + let (status, a) = create_with_headers( + &st, + verified_headers("tenant-a", "passport-a"), + json!({"name":"A","tenant_id":"tenant-a"}), + ) + .await; + assert_eq!(status, StatusCode::CREATED); + assert_eq!(a["orchestrator"]["payload"]["created_by_passport"], "passport-a"); + let a_id = a["orchestrator"]["id"].as_str().expect("A id").to_string(); + let (status, b) = create_with_headers( + &st, + verified_headers("tenant-b", "passport-b"), + json!({"name":"B","tenant_id":"tenant-b"}), + ) + .await; + assert_eq!(status, StatusCode::CREATED); + let b_id = b["orchestrator"]["id"].as_str().expect("B id").to_string(); + let b_version = b["orchestrator"]["version"].as_u64().expect("B version") as u32; + + let list_a = list_orchestrators( + State(st.clone()), + Query(ListOrchestratorsQuery { + assignee: None, + tenant_id: None, + state: None, + limit: None, + }), + verified_headers("tenant-a", "passport-a"), + ) + .await + .into_response(); + assert_eq!(list_a.status(), StatusCode::OK); + let (_, list_a) = parts(list_a).await; + assert_eq!(list_a["count"], 1); + assert_eq!(list_a["orchestrators"][0]["id"], a_id); + + let cross_list = list_orchestrators( + State(st.clone()), + Query(ListOrchestratorsQuery { + assignee: None, + tenant_id: Some("tenant-b".to_string()), + state: None, + limit: None, + }), + verified_headers("tenant-a", "passport-a"), + ) + .await + .into_response(); + assert_eq!(cross_list.status(), StatusCode::FORBIDDEN); + + let cross_get = get_orchestrator( + State(st.clone()), + Path(b_id.clone()), + verified_headers("tenant-a", "passport-a"), + ) + .await + .into_response(); + assert_eq!(cross_get.status(), StatusCode::FORBIDDEN); + let cross_patch = patch_orchestrator( + State(st.clone()), + Path(b_id.clone()), + verified_headers("tenant-a", "passport-a"), + Json(PatchOrchestratorBody { + name: Some("stolen".to_string()), + assignee_passport: None, + state: None, + }), + ) + .await + .into_response(); + assert_eq!(cross_patch.status(), StatusCode::FORBIDDEN); + let cross_add = add_member( + State(st.clone()), + Path(b_id.clone()), + verified_headers("tenant-a", "passport-a"), + Json(AddMemberBody { + member_type: Some("work".to_string()), + r#ref: Some("w_missing".to_string()), + }), + ) + .await + .into_response(); + assert_eq!(cross_add.status(), StatusCode::FORBIDDEN); + let cross_remove = remove_member( + State(st.clone()), + Path((b_id.clone(), "w_missing".to_string())), + verified_headers("tenant-a", "passport-a"), + ) + .await + .into_response(); + assert_eq!(cross_remove.status(), StatusCode::FORBIDDEN); + let cross_members = list_orchestrator_work( + State(st.clone()), + Path(b_id.clone()), + verified_headers("tenant-a", "passport-a"), + ) + .await + .into_response(); + assert_eq!(cross_members.status(), StatusCode::FORBIDDEN); + + let spoofed_identity = create_with_headers( + &st, + verified_headers("tenant-a", "passport-a"), + json!({ + "name":"spoofed", + "tenant_id":"tenant-a", + "created_by_passport":"passport-b" + }), + ) + .await; + assert_eq!(spoofed_identity.0, StatusCode::FORBIDDEN); + let cross_create = create_with_headers( + &st, + verified_headers("tenant-a", "passport-a"), + json!({"name":"cross","tenant_id":"tenant-b"}), + ) + .await; + assert_eq!(cross_create.0, StatusCode::FORBIDDEN); + + let store = st.entity_store.read().await; + let b_after = store.get(ORCHESTRATOR_KIND, &b_id).expect("B remains"); + assert_eq!(b_after.version, b_version); + assert_eq!(b_after.payload["name"], "B"); + assert_eq!( + store + .list(&corecrux_memory::EntityQuery { + kind: Some(ORCHESTRATOR_KIND.to_string()), + limit: None, + include_deleted: false, + }) + .len(), + 2, + "denied creates must not add orchestrators" + ); + std::env::remove_var("CORECRUXD_ORCHESTRATORS"); + } + #[tokio::test] #[serial_test::serial] async fn membership_add_remove_and_resolution() { std::env::set_var("CORECRUXD_ORCHESTRATORS", "1"); let st = handler_state().await; - let (_, body) = create(&st, json!({ "name": "Coord" })).await; // empty tenant = wildcard + let (_, body) = create(&st, json!({ "name": "Coord" })).await; // absent tenant = default let id = body["orchestrator"]["id"].as_str().unwrap().to_string(); seed_work(&st, "w_1").await; @@ -1242,7 +1552,7 @@ mod tests { add_member( State(st), Path(id), - HeaderMap::new(), + local_headers(), Json(serde_json::from_value(b).unwrap()), ) .await @@ -1295,7 +1605,7 @@ mod tests { // Resolve members → live work resolves, handoff/execplan-missing flagged. let (status, body) = parts( - list_orchestrator_work(State(st.clone()), Path(id.clone()), HeaderMap::new()) + list_orchestrator_work(State(st.clone()), Path(id.clone()), local_headers()) .await .into_response(), ) @@ -1306,7 +1616,7 @@ mod tests { // Remove the work member (also unstamps), then a non-member, then missing orchestrator. assert_eq!( parts( - remove_member(State(st.clone()), Path((id.clone(), "w_1".into())), HeaderMap::new()) + remove_member(State(st.clone()), Path((id.clone(), "w_1".into())), local_headers()) .await .into_response() ) @@ -1318,7 +1628,7 @@ mod tests { remove_member( State(st.clone()), Path((id.clone(), "not-a-member".into())), - HeaderMap::new() + local_headers() ) .await .into_response() @@ -1329,7 +1639,7 @@ mod tests { remove_member( State(st.clone()), Path(("orc_missing".into(), "w_1".into())), - HeaderMap::new() + local_headers() ) .await .into_response() @@ -1339,7 +1649,7 @@ mod tests { // list_orchestrator_work on a missing orchestrator → 404. assert_eq!( - list_orchestrator_work(State(st.clone()), Path("orc_missing".into()), HeaderMap::new()) + list_orchestrator_work(State(st.clone()), Path("orc_missing".into()), local_headers()) .await .into_response() .status(), diff --git a/crates/corecruxd/src/http/result_envelope.rs b/crates/corecruxd/src/http/result_envelope.rs index 46cd7a2d..548a3899 100644 --- a/crates/corecruxd/src/http/result_envelope.rs +++ b/crates/corecruxd/src/http/result_envelope.rs @@ -11,7 +11,8 @@ //! //! - `facts[]` → `FactStore::try_store_bulk` (the `/v1/facts/bulk` path), with //! `source_receipt` stamped `result-envelope:` when absent. -//! - `entities[]` → `EntityStore::upsert` (the `entity_upsert` surface). +//! - `entities[]` → `EntityStore::upsert` (the generic `entity_upsert` +//! surface); typed-governance kinds are rejected before any write. //! - `edges[]` → `EdgeStore::upsert` (the `edge_upsert` surface). //! //! Idempotency: keyed on `job_id`. A prior import receipt for the same job whose @@ -212,6 +213,29 @@ pub(super) async fn post_result_envelope_import( } } + // Validate every caller-selected namespace before applying the first fact. + // Result envelopes are signed by a platform key, but that key authorizes + // extraction output—not bypassing a daemon's typed governance surfaces. + if let Some(entity) = envelope + .payload + .entities + .iter() + .find(|entity| crux_mcp::tools::entities::is_governed_entity_kind(&entity.kind)) + { + return crate::problem::ProblemResponse( + corecrux_types::ProblemDetails::forbidden(format!( + "result envelope entity kind '{}' is governed by its typed API", + entity.kind + )) + .with_extensions(json!({ + "code": "GOVERNED_ENTITY_KIND", + "kind": entity.kind, + "entity_id": entity.id, + })), + ) + .into_response(); + } + // ---- 2a) Apply facts via the bulk store path --------------------------- let facts_in = &envelope.payload.facts; if facts_in.iter().any(|f| f.private) { @@ -584,6 +608,38 @@ mod tests { ); } + #[tokio::test] + async fn signed_envelope_cannot_import_governed_entities_atomically() { + let (signing, _guard) = pin_platform_key(); + let state = test_app_state(8); + let mut envelope = build_envelope(&signing, "job_governed_entity_forgery"); + envelope.payload.entities.push(EnvelopeEntity { + kind: "orchestrator".into(), + id: "orc_forged".into(), + payload: serde_json::json!({ + "tenant_id": "business::acme", + "name": "forged", + "created_by_passport": "platform:extraction", + "members": [], + }), + }); + resign_envelope(&mut envelope, &signing); + + let response = post_result_envelope_import(State(state.clone()), HeaderMap::new(), Json(envelope)).await; + let (status, body) = body_json(response).await; + assert_eq!(status, StatusCode::FORBIDDEN, "body={body}"); + assert_eq!(body["code"], "GOVERNED_ENTITY_KIND"); + assert_eq!(body["kind"], "orchestrator"); + assert_eq!( + state.fact_store.read().await.count(), + 0, + "the safe leading fact and import receipt must not be written" + ); + let entities = state.entity_store.read().await; + assert!(entities.get("person", "p_ada").is_none()); + assert!(entities.get("orchestrator", "orc_forged").is_none()); + } + #[tokio::test] async fn reimport_same_job_is_idempotent() { let (signing, _guard) = pin_platform_key(); diff --git a/crates/corecruxd/src/http/tests.rs b/crates/corecruxd/src/http/tests.rs index e7730713..eb287c48 100644 --- a/crates/corecruxd/src/http/tests.rs +++ b/crates/corecruxd/src/http/tests.rs @@ -14047,10 +14047,89 @@ async fn projects_members_tenants_layers_repos_and_graph_round_trip() { assert_eq!(delete_member.status(), StatusCode::NO_CONTENT); } +const WORK_AUTH_TEST_SECRET: &str = "work-auth-test-secret-at-least-32-bytes"; +const WORK_AUTH_TEST_ISSUER: &str = "corecrux-work-test"; +const WORK_AUTH_TEST_AUDIENCE: &str = "corecrux"; + +fn work_auth_test_state(action_max_pending: usize) -> AppState { + let mut state = test_app_state_with_auth(action_max_pending, AuthMode::Off); + state.auth = crate::auth::Authz::test_hs256( + WORK_AUTH_TEST_SECRET.as_bytes(), + WORK_AUTH_TEST_ISSUER, + WORK_AUTH_TEST_AUDIENCE, + ); + state +} + +fn work_auth_headers(tenant_id: &str, passport_id: Option<&str>, scopes: &str) -> HeaderMap { + let exp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system time after epoch") + .as_secs() + .saturating_add(3_600) as usize; + let mut claims = serde_json::json!({ + "exp": exp, + "iss": WORK_AUTH_TEST_ISSUER, + "aud": WORK_AUTH_TEST_AUDIENCE, + "scope": scopes, + "tenant_id": tenant_id, + }); + if let Some(passport_id) = passport_id { + claims["passport_id"] = serde_json::json!(passport_id); + } + work_auth_headers_from_claims(claims) +} + +fn work_auth_headers_from_claims(claims: serde_json::Value) -> HeaderMap { + let token = jsonwebtoken::encode( + &jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256), + &claims, + &jsonwebtoken::EncodingKey::from_secret(WORK_AUTH_TEST_SECRET.as_bytes()), + ) + .expect("work auth test JWT"); + let mut headers = HeaderMap::new(); + headers.insert( + header::AUTHORIZATION, + HeaderValue::from_str(&format!("Bearer {token}")).expect("bearer header"), + ); + headers +} + +fn work_auth_sub_headers(tenant_id: &str, subject: &str, scopes: &str) -> HeaderMap { + let exp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system time after epoch") + .as_secs() + .saturating_add(3_600) as usize; + work_auth_headers_from_claims(serde_json::json!({ + "exp": exp, + "iss": WORK_AUTH_TEST_ISSUER, + "aud": WORK_AUTH_TEST_AUDIENCE, + "scope": scopes, + "tenant_id": tenant_id, + "sub": subject, + })) +} + +fn work_auth_missing_tenant_headers(passport_id: &str, scopes: &str) -> HeaderMap { + let exp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system time after epoch") + .as_secs() + .saturating_add(3_600) as usize; + work_auth_headers_from_claims(serde_json::json!({ + "exp": exp, + "iss": WORK_AUTH_TEST_ISSUER, + "aud": WORK_AUTH_TEST_AUDIENCE, + "scope": scopes, + "passport_id": passport_id, + })) +} + #[serial_test::serial] #[tokio::test] async fn work_post_then_list_then_patch_state_round_trip() { - let state = test_app_state_with_auth(16, AuthMode::DevScopes); + let state = work_auth_test_state(16); { let mut store = state.fact_store.write().await; crate::passports::seed_defaults_if_missing(&state.data_dir, &mut store, 1).expect("seed"); @@ -14059,7 +14138,7 @@ async fn work_post_then_list_then_patch_state_round_trip() { let create_resp = super::work::post_work( State(state.clone()), - dev_scope_headers("facts:write"), + work_auth_headers("personal", Some("personal-default"), "facts:write"), Json(super::work::CreateWorkBody { project_id: "default".to_string(), title: "fix the thing".to_string(), @@ -14069,7 +14148,7 @@ async fn work_post_then_list_then_patch_state_round_trip() { tenant_id: Some("personal".to_string()), linked_pr: None, linked_issue: None, - created_by_passport: "personal-default".to_string(), + created_by_passport: Some("personal-default".to_string()), }), ) .await @@ -14083,7 +14162,7 @@ async fn work_post_then_list_then_patch_state_round_trip() { Query(super::work::ListWorkQuery { project_id: Some("default".to_string()), state: Some("planned".to_string()), - tenant_id: None, + tenant_id: Some("personal".to_string()), assignee_passport: None, source: super::work::WorkSource::default(), orchestrator: None, @@ -14091,17 +14170,550 @@ async fn work_post_then_list_then_patch_state_round_trip() { limit: None, fields: None, }), - dev_scope_headers("admin:read"), + work_auth_headers("personal", Some("personal-default"), "admin:read"), + ) + .await + .into_response(); + let list_body = json_body(list_resp).await; + assert_eq!(list_body["count"], 1); + + let patch_resp = super::work::patch_work( + State(state.clone()), + Path(work_id.clone()), + work_auth_headers("personal", Some("personal-default"), "facts:write"), + Json(super::work::UpdateWorkBody { + title: None, + body: None, + state: Some("in_progress".to_string()), + assignee_passport: None, + tenant_id: None, + linked_pr: None, + linked_issue: None, + blocker_reason: None, + blocker_kind: None, + by_passport: Some("personal-default".to_string()), + }), + ) + .await + .into_response(); + assert_eq!(patch_resp.status(), StatusCode::OK); + let patched = json_body(patch_resp).await; + assert_eq!(patched["applied"], true); + assert_eq!(patched["work"]["state"], "in_progress"); + + let txn_resp = super::work::get_transitions( + State(state), + Path(work_id), + work_auth_headers("personal", Some("personal-default"), "admin:read"), + ) + .await + .into_response(); + let txn_body = json_body(txn_resp).await; + let txns = txn_body["transitions"].as_array().expect("transitions"); + assert_eq!(txns.len(), 2, "create + transition"); + assert_eq!(txns[0]["from_state"], "(none)"); + assert_eq!(txns[1]["to_state"], "in_progress"); +} + +#[tokio::test] +async fn work_jwt_actor_and_tenant_isolation_matrix() { + let state = work_auth_test_state(16); + let (work_a, work_b) = { + let mut store = state.fact_store.write().await; + crate::passports::seed_defaults_if_missing(&state.data_dir, &mut store, 1).expect("seed passports"); + crate::projects::seed_default_if_missing(&mut store, 1).expect("seed project"); + let create = |tenant: &str, actor: &str, title: &str| crate::work::CreateWorkInput { + project_id: "default".to_string(), + title: title.to_string(), + body: None, + state: None, + assignee_passport: None, + tenant_id: Some(tenant.to_string()), + linked_pr: None, + linked_issue: None, + created_by_passport: actor.to_string(), + }; + let a = crate::work::create_work(&mut store, create("tenant-a", "passport-a", "tenant a"), 1_000) + .expect("create tenant A"); + let b = crate::work::create_work(&mut store, create("tenant-b", "passport-b", "tenant b"), 1_100) + .expect("create tenant B"); + crate::work::add_comment(&mut store, &b.id, "passport-b", "tenant b comment", 1_200).expect("comment tenant B"); + let queued = crate::work::update_work( + &mut store, + &b.id, + crate::work::UpdateWorkInput { + title: None, + body: None, + state: Some("in_progress".to_string()), + assignee_passport: None, + tenant_id: None, + linked_pr: None, + linked_issue: None, + blocker_reason: None, + blocker_kind: None, + }, + crate::work::UpdateWorkContext { + by_passport: "passport-b".to_string(), + passport_gated: true, + now_unix_ms: 1_300, + }, + ) + .expect("queue tenant B transition"); + assert!(matches!(queued, crate::work::UpdateOutcome::Queued(_))); + (a, b) + }; + + let read_a = || work_auth_headers("tenant-a", Some("passport-a"), "admin:read"); + let write_a = || work_auth_headers("tenant-a", Some("passport-a"), "facts:write"); + + let list = super::work::get_work( + State(state.clone()), + Query(super::work::ListWorkQuery { + project_id: None, + state: None, + tenant_id: None, + assignee_passport: None, + source: super::work::WorkSource::Kanban, + orchestrator: None, + ranked: false, + limit: None, + fields: None, + }), + read_a(), + ) + .await + .into_response(); + assert_eq!(list.status(), StatusCode::OK); + let listed = json_body(list).await; + assert_eq!(listed["count"], 1); + assert_eq!(listed["work"][0]["id"], work_a.id); + + let cross_list = super::work::get_work( + State(state.clone()), + Query(super::work::ListWorkQuery { + project_id: None, + state: None, + tenant_id: Some("tenant-b".to_string()), + assignee_passport: None, + source: super::work::WorkSource::Kanban, + orchestrator: None, + ranked: false, + limit: None, + fields: None, + }), + read_a(), + ) + .await + .into_response(); + assert_eq!(cross_list.status(), StatusCode::FORBIDDEN); + + for response in [ + super::work::get_work_item(State(state.clone()), Path(work_b.id.clone()), read_a()) + .await + .into_response(), + super::work::get_comments(State(state.clone()), Path(work_b.id.clone()), read_a()) + .await + .into_response(), + super::work::get_transitions(State(state.clone()), Path(work_b.id.clone()), read_a()) + .await + .into_response(), + ] { + assert_eq!(response.status(), StatusCode::FORBIDDEN); + } + + let pending = super::work::get_pending_gates( + State(state.clone()), + Query(super::work::GateListQuery { + by_passport: None, + tenant_id: None, + }), + read_a(), + ) + .await + .into_response(); + assert_eq!(pending.status(), StatusCode::OK); + assert_eq!(json_body(pending).await["count"], 0); + + let before_denied = { + let store = state.fact_store.read().await; + store.all_facts().count() + }; + let cross_patch = super::work::patch_work( + State(state.clone()), + Path(work_b.id.clone()), + write_a(), + Json(super::work::UpdateWorkBody { + title: Some("stolen".to_string()), + body: None, + state: None, + assignee_passport: None, + tenant_id: None, + linked_pr: None, + linked_issue: None, + blocker_reason: None, + blocker_kind: None, + by_passport: None, + }), + ) + .await + .into_response(); + assert_eq!(cross_patch.status(), StatusCode::FORBIDDEN); + let cross_comment = super::work::post_comment( + State(state.clone()), + Path(work_b.id.clone()), + write_a(), + Json(super::work::CommentBody { + author_passport: None, + body: "stolen".to_string(), + }), + ) + .await + .into_response(); + assert_eq!(cross_comment.status(), StatusCode::FORBIDDEN); + + let spoof_create = super::work::post_work( + State(state.clone()), + write_a(), + Json(super::work::CreateWorkBody { + project_id: "default".to_string(), + title: "spoof".to_string(), + body: None, + state: None, + assignee_passport: None, + tenant_id: None, + linked_pr: None, + linked_issue: None, + created_by_passport: Some("passport-b".to_string()), + }), + ) + .await + .into_response(); + assert_eq!(spoof_create.status(), StatusCode::FORBIDDEN); + let cross_create = super::work::post_work( + State(state.clone()), + write_a(), + Json(super::work::CreateWorkBody { + project_id: "default".to_string(), + title: "cross tenant".to_string(), + body: None, + state: None, + assignee_passport: None, + tenant_id: Some("tenant-b".to_string()), + linked_pr: None, + linked_issue: None, + created_by_passport: None, + }), + ) + .await + .into_response(); + assert_eq!(cross_create.status(), StatusCode::FORBIDDEN); + let spoof_patch = super::work::patch_work( + State(state.clone()), + Path(work_a.id.clone()), + write_a(), + Json(super::work::UpdateWorkBody { + title: Some("spoof".to_string()), + body: None, + state: None, + assignee_passport: None, + tenant_id: None, + linked_pr: None, + linked_issue: None, + blocker_reason: None, + blocker_kind: None, + by_passport: Some("passport-b".to_string()), + }), + ) + .await + .into_response(); + assert_eq!(spoof_patch.status(), StatusCode::FORBIDDEN); + let spoof_comment = super::work::post_comment( + State(state.clone()), + Path(work_a.id.clone()), + write_a(), + Json(super::work::CommentBody { + author_passport: Some("passport-b".to_string()), + body: "spoof".to_string(), + }), + ) + .await + .into_response(); + assert_eq!(spoof_comment.status(), StatusCode::FORBIDDEN); + + let missing_identity = super::work::post_work( + State(state.clone()), + work_auth_headers("tenant-a", None, "facts:write"), + Json(super::work::CreateWorkBody { + project_id: "default".to_string(), + title: "anonymous".to_string(), + body: None, + state: None, + assignee_passport: None, + tenant_id: None, + linked_pr: None, + linked_issue: None, + created_by_passport: None, + }), + ) + .await + .into_response(); + assert_eq!(missing_identity.status(), StatusCode::FORBIDDEN); + let missing_tenant = super::work::post_work( + State(state.clone()), + work_auth_missing_tenant_headers("passport-a", "facts:write"), + Json(super::work::CreateWorkBody { + project_id: "default".to_string(), + title: "tenantless".to_string(), + body: None, + state: None, + assignee_passport: None, + tenant_id: None, + linked_pr: None, + linked_issue: None, + created_by_passport: None, + }), + ) + .await + .into_response(); + assert_eq!(missing_tenant.status(), StatusCode::FORBIDDEN); + + let after_denied = { + let store = state.fact_store.read().await; + store.all_facts().count() + }; + assert_eq!(after_denied, before_denied, "denied work requests must not write facts"); + + for initial_state in ["drafting", "in_progress", "complete", "deployed", "pending_approval"] { + let before = { + let store = state.fact_store.read().await; + store.all_facts().count() + }; + let response = super::work::post_work( + State(state.clone()), + write_a(), + Json(super::work::CreateWorkBody { + project_id: "default".to_string(), + title: format!("bad initial {initial_state}"), + body: None, + state: Some(initial_state.to_string()), + assignee_passport: None, + tenant_id: None, + linked_pr: None, + linked_issue: None, + created_by_passport: None, + }), + ) + .await + .into_response(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let store = state.fact_store.read().await; + assert_eq!(store.all_facts().count(), before); + } + + for tenant_change in [Some(Some("tenant-b".to_string())), Some(None)] { + let response = super::work::patch_work( + State(state.clone()), + Path(work_a.id.clone()), + write_a(), + Json(super::work::UpdateWorkBody { + title: None, + body: None, + state: None, + assignee_passport: None, + tenant_id: tenant_change, + linked_pr: None, + linked_issue: None, + blocker_reason: None, + blocker_kind: None, + by_passport: None, + }), + ) + .await + .into_response(); + assert_eq!(response.status(), StatusCode::CONFLICT); + } + + let queued_unknown = super::work::patch_work( + State(state.clone()), + Path(work_a.id.clone()), + work_auth_headers("tenant-a", Some("unknown-passport"), "facts:write"), + Json(super::work::UpdateWorkBody { + title: None, + body: None, + state: Some("in_progress".to_string()), + assignee_passport: None, + tenant_id: None, + linked_pr: None, + linked_issue: None, + blocker_reason: None, + blocker_kind: None, + by_passport: None, + }), + ) + .await + .into_response(); + assert_eq!(queued_unknown.status(), StatusCode::ACCEPTED); + let queued = json_body(queued_unknown).await; + assert_eq!(queued["queued"]["requested_by_passport"], "unknown-passport"); +} + +#[tokio::test] +async fn work_local_assertions_are_tagged_and_known_ungated_passports_still_queue() { + let state = test_app_state_with_auth(16, AuthMode::DevScopes); + { + let mut store = state.fact_store.write().await; + crate::passports::seed_defaults_if_missing(&state.data_dir, &mut store, 1).expect("seed passports"); + crate::projects::seed_default_if_missing(&mut store, 1).expect("seed project"); + let passport = crate::passports::get_passport(&store, "personal-default").expect("personal passport"); + assert!( + !passport.agent_work_gate, + "fixture must be an ordinarily ungated passport" + ); + } + let expected_actor = "operator:unverified:personal-default"; + let headers = || dev_scope_passport_headers("facts:write", "personal-default"); + + let created = super::work::post_work( + State(state.clone()), + headers(), + Json(super::work::CreateWorkBody { + project_id: "default".to_string(), + title: "local assertion attribution".to_string(), + body: None, + state: None, + assignee_passport: None, + tenant_id: Some("tenant-a".to_string()), + linked_pr: None, + linked_issue: None, + created_by_passport: Some("personal-default".to_string()), + }), + ) + .await + .into_response(); + assert_eq!(created.status(), StatusCode::CREATED); + let created = json_body(created).await; + assert_eq!(created["created_by_passport"], expected_actor); + let work_id = created["id"].as_str().expect("created work id").to_string(); + + let commented = super::work::post_comment( + State(state.clone()), + Path(work_id.clone()), + headers(), + Json(super::work::CommentBody { + author_passport: Some("personal-default".to_string()), + body: "locally asserted comment".to_string(), + }), + ) + .await + .into_response(); + assert_eq!(commented.status(), StatusCode::CREATED); + assert_eq!(json_body(commented).await["author_passport"], expected_actor); + + let transition = super::work::patch_work( + State(state.clone()), + Path(work_id.clone()), + headers(), + Json(super::work::UpdateWorkBody { + title: None, + body: None, + state: Some("in_progress".to_string()), + assignee_passport: None, + tenant_id: None, + linked_pr: None, + linked_issue: None, + blocker_reason: None, + blocker_kind: None, + by_passport: Some("personal-default".to_string()), + }), + ) + .await + .into_response(); + assert_eq!(transition.status(), StatusCode::ACCEPTED); + let transition = json_body(transition).await; + assert_eq!(transition["applied"], false); + assert_eq!(transition["queued"]["requested_by_passport"], expected_actor); + + let store = state.fact_store.read().await; + let persisted = crate::work::get_work(&store, &work_id).expect("persisted work"); + assert_eq!(persisted.created_by_passport, expected_actor); + assert_eq!( + persisted.state, "planned", + "unverified state transition must remain gated" + ); + assert_eq!( + crate::work::list_comments(&store, &work_id)[0].author_passport, + expected_actor + ); + let pending = crate::work::list_pending_gates(&store, Some("tenant-a"), Some(expected_actor)); + assert_eq!(pending.len(), 1); + assert!( + store + .all_facts() + .filter(|fact| { fact.entity.contains(&work_id) || fact.entity.contains(pending[0].action_id.as_str()) }) + .all(|fact| fact.actor.as_deref() == Some(expected_actor)), + "all local work mutations must retain the durable unverified actor tag" + ); +} + +#[tokio::test] +#[serial_test::serial] +async fn unmapped_agent_token_cannot_inherit_colliding_passport_policy() -> Result<(), Box> { + const SECRET: &str = "0123456789abcdef0123456789abcdef"; + const AGENT_TOKEN: &str = "abcdef0123456789abcdef0123456789abcdef0123456789"; + let _secret = EnvVarGuard::set("CORECRUXD_JWT_HS256_SECRET", SECRET); + let _issuer = EnvVarGuard::unset("CORECRUXD_JWT_ISS"); + let _audience = EnvVarGuard::unset("CORECRUXD_JWT_AUD"); + let _accept = EnvVarGuard::set("CORECRUXD_HTTP_ACCEPT_AGENT_TOKENS", "1"); + let _tokens = EnvVarGuard::set("CRUX_AGENT_TOKENS", &format!("personal-default:{AGENT_TOKEN}")); + let _scopes = EnvVarGuard::set("CORECRUXD_AGENT_TOKEN_HTTP_SCOPES", "facts:write"); + let _tenant = EnvVarGuard::set("CORECRUXD_AGENT_TOKEN_HTTP_TENANT", "tenant-a"); + let _passport_flag = EnvVarGuard::unset("CORECRUXD_AGENT_PASSPORTS"); + let _passport_map = EnvVarGuard::unset("CRUX_AGENT_PASSPORTS"); + + let mut state = test_app_state_with_auth(16, AuthMode::Off); + state.auth = crate::auth::Authz::from_env(AuthMode::JwtHs256).expect("agent-token auth config"); + { + let mut store = state.fact_store.write().await; + crate::passports::seed_defaults_if_missing(&state.data_dir, &mut store, 1).expect("seed passports"); + crate::projects::seed_default_if_missing(&mut store, 1).expect("seed project"); + let colliding = crate::passports::get_passport(&store, "personal-default").expect("colliding passport"); + assert!( + !colliding.agent_work_gate, + "fixture must prove the human passport would ordinarily be ungated" + ); + } + let mut headers = HeaderMap::new(); + headers.insert( + header::AUTHORIZATION, + HeaderValue::from_str(&format!("Bearer {AGENT_TOKEN}"))?, + ); + + let created = super::work::post_work( + State(state.clone()), + headers.clone(), + Json(super::work::CreateWorkBody { + project_id: "default".to_string(), + title: "agent-token collision regression".to_string(), + body: None, + state: None, + assignee_passport: None, + tenant_id: Some("tenant-a".to_string()), + linked_pr: None, + linked_issue: None, + created_by_passport: None, + }), ) .await .into_response(); - let list_body = json_body(list_resp).await; - assert_eq!(list_body["count"], 1); + assert_eq!(created.status(), StatusCode::CREATED); + let created = json_body(created).await; + assert_eq!(created["created_by_passport"], "agent:personal-default"); + let work_id = created["id"].as_str().expect("created work id").to_string(); - let patch_resp = super::work::patch_work( + let transitioned = super::work::patch_work( State(state.clone()), Path(work_id.clone()), - dev_scope_headers("facts:write"), + headers, Json(super::work::UpdateWorkBody { title: None, body: None, @@ -14112,24 +14724,207 @@ async fn work_post_then_list_then_patch_state_round_trip() { linked_issue: None, blocker_reason: None, blocker_kind: None, - by_passport: "personal-default".to_string(), + by_passport: None, }), ) .await .into_response(); - assert_eq!(patch_resp.status(), StatusCode::OK); - let patched = json_body(patch_resp).await; - assert_eq!(patched["applied"], true); - assert_eq!(patched["work"]["state"], "in_progress"); + assert_eq!(transitioned.status(), StatusCode::ACCEPTED); + let transitioned = json_body(transitioned).await; + assert_eq!(transitioned["applied"], false); + assert_eq!( + transitioned["queued"]["requested_by_passport"], + "agent:personal-default" + ); - let txn_resp = super::work::get_transitions(State(state), Path(work_id), dev_scope_headers("admin:read")) - .await - .into_response(); - let txn_body = json_body(txn_resp).await; - let txns = txn_body["transitions"].as_array().expect("transitions"); - assert_eq!(txns.len(), 2, "create + transition"); - assert_eq!(txns[0]["from_state"], "(none)"); - assert_eq!(txns[1]["to_state"], "in_progress"); + let store = state.fact_store.read().await; + let persisted = crate::work::get_work(&store, &work_id).expect("persisted work"); + assert_eq!(persisted.state, "planned"); + assert_eq!(persisted.created_by_passport, "agent:personal-default"); + Ok(()) +} + +#[tokio::test] +async fn coord_active_filters_embedded_work_by_verified_tenant() { + let mut state = work_auth_test_state(16); + state.coord_enabled = true; + let (work_a, work_b) = { + let mut store = state.fact_store.write().await; + crate::passports::seed_defaults_if_missing(&state.data_dir, &mut store, 1).expect("seed passports"); + crate::projects::seed_default_if_missing(&mut store, 1).expect("seed project"); + let mut create = |tenant: &str, actor: &str, now| { + let work = crate::work::create_work( + &mut store, + crate::work::CreateWorkInput { + project_id: "default".to_string(), + title: format!("{tenant} work"), + body: None, + state: None, + assignee_passport: None, + tenant_id: Some(tenant.to_string()), + linked_pr: None, + linked_issue: None, + created_by_passport: actor.to_string(), + }, + now, + ) + .expect("create work"); + let outcome = crate::work::update_work( + &mut store, + &work.id, + crate::work::UpdateWorkInput { + title: None, + body: None, + state: Some("in_progress".to_string()), + assignee_passport: None, + tenant_id: None, + linked_pr: None, + linked_issue: None, + blocker_reason: None, + blocker_kind: None, + }, + crate::work::UpdateWorkContext { + by_passport: actor.to_string(), + passport_gated: false, + now_unix_ms: now + 1, + }, + ) + .expect("transition work"); + assert!(matches!(outcome, crate::work::UpdateOutcome::Applied(_))); + work.id + }; + ( + create("tenant-a", "passport-a", 1_000), + create("tenant-b", "passport-b", 2_000), + ) + }; + + let response = super::coord::get_coord_active( + State(state.clone()), + Query(super::coord::ActiveQuery { + project_id: None, + tenant_id: None, + }), + work_auth_headers("tenant-a", Some("passport-a"), "admin:read"), + ) + .await + .into_response(); + assert_eq!(response.status(), StatusCode::OK); + let body = json_body(response).await; + let work = body["work_in_flight"].as_array().expect("work in flight"); + assert_eq!(work.len(), 1); + assert_eq!(work[0]["id"], work_a); + assert!(work.iter().all(|item| item["id"] != work_b)); + + let cross_tenant = super::coord::get_coord_active( + State(state), + Query(super::coord::ActiveQuery { + project_id: None, + tenant_id: Some("tenant-b".to_string()), + }), + work_auth_headers("tenant-a", Some("passport-a"), "admin:read"), + ) + .await + .into_response(); + assert_eq!(cross_tenant.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn generic_http_entities_hide_and_reject_governed_kinds() { + let state = test_app_state_with_auth(16, AuthMode::DevScopes); + { + let mut store = state.entity_store.write().await; + store + .upsert( + "orchestrator", + "orc_secret", + serde_json::json!({"tenant_id":"tenant-b","name":"secret"}), + "seed", + None, + ) + .expect("seed governed entity"); + store + .upsert( + "capability", + "visible", + serde_json::json!({"name":"visible"}), + "seed", + None, + ) + .expect("seed visible entity"); + } + + let denied_get = super::entities::get_entity( + State(state.clone()), + Path(("orchestrator".to_string(), "orc_secret".to_string())), + dev_scope_headers("facts:read"), + ) + .await + .into_response(); + assert_eq!(denied_get.status(), StatusCode::FORBIDDEN); + let denied_list = super::entities::list_entities( + State(state.clone()), + Query(super::entities::ListEntitiesQuery { + kind: Some("orchestrator".to_string()), + limit: None, + include_deleted: false, + }), + dev_scope_headers("facts:read"), + ) + .await + .into_response(); + assert_eq!(denied_list.status(), StatusCode::FORBIDDEN); + let denied_put = super::entities::put_entity( + State(state.clone()), + Path(("orchestrator".to_string(), "orc_secret".to_string())), + dev_scope_headers("facts:write"), + Json(super::entities::UpsertEntityBody { + payload: serde_json::json!({"tenant_id":"tenant-a","name":"stolen"}), + }), + ) + .await + .into_response(); + assert_eq!(denied_put.status(), StatusCode::FORBIDDEN); + let denied_history = super::entities::get_entity_history( + State(state.clone()), + Path(("orchestrator".to_string(), "orc_secret".to_string())), + dev_scope_headers("facts:read"), + ) + .await + .into_response(); + assert_eq!(denied_history.status(), StatusCode::FORBIDDEN); + let denied_delete = super::entities::delete_entity( + State(state.clone()), + Path(("orchestrator".to_string(), "orc_secret".to_string())), + dev_scope_headers("facts:write"), + ) + .await + .into_response(); + assert_eq!(denied_delete.status(), StatusCode::FORBIDDEN); + + let unfiltered = super::entities::list_entities( + State(state.clone()), + Query(super::entities::ListEntitiesQuery { + kind: None, + limit: Some(1), + include_deleted: false, + }), + dev_scope_headers("facts:read"), + ) + .await + .into_response(); + assert_eq!(unfiltered.status(), StatusCode::OK); + let body = json_body(unfiltered).await; + assert_eq!(body["count"], 1); + assert_eq!(body["entities"][0]["kind"], "capability"); + + let store = state.entity_store.read().await; + let governed = store + .get("orchestrator", "orc_secret") + .expect("governed entity remains"); + assert_eq!(governed.version, 1); + assert!(!governed.deleted); + assert_eq!(governed.payload["tenant_id"], "tenant-b"); } #[serial_test::serial] @@ -14185,6 +14980,7 @@ async fn status_feed_disabled_returns_notice_not_error() { State(state), axum::extract::Query(super::work::StatusFeedQuery { work_id: None, + tenant_id: None, limit: None, }), dev_scope_headers("admin:read"), @@ -14301,7 +15097,8 @@ fn gate_test_observation_count(state: &AppState) -> Result Result<(), Box> { +async fn work_gate_spoofed_local_assertion_is_denied_and_accepted_actor_is_tagged( +) -> Result<(), Box> { for approve in [true, false] { let (state, _work_id, action_id) = gate_test_state(AuthMode::DevScopes, Some("tenant-a")).await?; let spoofed = gate_test_resolve( @@ -14340,7 +15137,10 @@ async fn work_gate_spoofed_body_is_denied_and_bound_passport_is_stored() -> Resu return Err(std::io::Error::other("resolved gate fact missing").into()); }; let gate: crate::work::PendingGateAction = serde_json::from_str(&fact.value)?; - assert_eq!(gate.resolved_by_passport.as_deref(), Some("approver-a")); + assert_eq!( + gate.resolved_by_passport.as_deref(), + Some("operator:unverified:approver-a") + ); assert_ne!(gate.resolved_by_passport.as_deref(), Some("approver-b")); } Ok(()) @@ -14383,23 +15183,95 @@ async fn work_gate_read_only_token_is_denied_in_route_auth_shadow() -> Result<() async fn work_gate_missing_passport_is_denied_closed() -> Result<(), Box> { for approve in [true, false] { let (state, _work_id, action_id) = gate_test_state(AuthMode::DevScopes, Some("tenant-a")).await?; - let response = gate_test_resolve( + let response = gate_test_resolve(&state, &action_id, dev_scope_headers("facts:write"), None, approve).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + let store = state.fact_store.read().await; + let entity = format!("{}::{action_id}", crate::work::WORK_GATE_ENTITY_PREFIX); + let Some(fact) = gate_test_latest_fact(&store, &entity) else { + return Err(std::io::Error::other("pending gate fact missing after anonymous attempt").into()); + }; + let gate: crate::work::PendingGateAction = serde_json::from_str(&fact.value)?; + assert_eq!(gate.status, "pending"); + } + Ok(()) +} + +#[tokio::test] +async fn work_gate_requires_canonical_passport_and_tenant_claims() -> Result<(), Box> { + for approve in [true, false] { + let (mut state, _work_id, action_id) = gate_test_state(AuthMode::Off, Some("tenant-a")).await?; + state.auth = crate::auth::Authz::test_hs256( + WORK_AUTH_TEST_SECRET.as_bytes(), + WORK_AUTH_TEST_ISSUER, + WORK_AUTH_TEST_AUDIENCE, + ); + + let sub_only = gate_test_resolve( &state, &action_id, - dev_scope_headers("facts:write"), - Some("approver-a"), + work_auth_sub_headers("tenant-a", "approver-a", "facts:write"), + None, + approve, + ) + .await; + assert_eq!(sub_only.status(), StatusCode::FORBIDDEN); + + let missing_tenant = gate_test_resolve( + &state, + &action_id, + work_auth_missing_tenant_headers("approver-a", "facts:write"), + None, approve, ) .await; + assert_eq!(missing_tenant.status(), StatusCode::FORBIDDEN); + + let store = state.fact_store.read().await; + let entity = format!("{}::{action_id}", crate::work::WORK_GATE_ENTITY_PREFIX); + let fact = gate_test_latest_fact(&store, &entity) + .ok_or_else(|| std::io::Error::other("pending gate missing after denied identity attempts"))?; + let gate: crate::work::PendingGateAction = serde_json::from_str(&fact.value)?; + assert_eq!(gate.status, "pending"); + assert_eq!(gate_test_observation_count(&state)?, 0); + } + Ok(()) +} + +#[tokio::test] +#[serial_test::serial] +async fn work_gate_rejects_registered_agent_token_as_human_decision() -> Result<(), Box> { + const SECRET: &str = "0123456789abcdef0123456789abcdef"; + const AGENT_TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef"; + let _secret = EnvVarGuard::set("CORECRUXD_JWT_HS256_SECRET", SECRET); + let _issuer = EnvVarGuard::unset("CORECRUXD_JWT_ISS"); + let _audience = EnvVarGuard::unset("CORECRUXD_JWT_AUD"); + let _accept = EnvVarGuard::set("CORECRUXD_HTTP_ACCEPT_AGENT_TOKENS", "1"); + let _tokens = EnvVarGuard::set("CRUX_AGENT_TOKENS", &format!("approver-agent:{AGENT_TOKEN}")); + let _scopes = EnvVarGuard::set("CORECRUXD_AGENT_TOKEN_HTTP_SCOPES", "facts:write"); + let _tenant = EnvVarGuard::set("CORECRUXD_AGENT_TOKEN_HTTP_TENANT", "tenant-a"); + let _passport_flag = EnvVarGuard::unset("CORECRUXD_AGENT_PASSPORTS"); + let _passport_map = EnvVarGuard::unset("CRUX_AGENT_PASSPORTS"); + + for approve in [true, false] { + let (state, _work_id, action_id) = gate_test_state(AuthMode::JwtHs256, Some("tenant-a")).await?; + let mut headers = HeaderMap::new(); + headers.insert( + header::AUTHORIZATION, + HeaderValue::from_str(&format!("Bearer {AGENT_TOKEN}"))?, + ); + let response = gate_test_resolve(&state, &action_id, headers, None, approve).await; assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!(gate_test_observation_count(&state)?, 0); let store = state.fact_store.read().await; let entity = format!("{}::{action_id}", crate::work::WORK_GATE_ENTITY_PREFIX); - let Some(fact) = gate_test_latest_fact(&store, &entity) else { - return Err(std::io::Error::other("pending gate fact missing after anonymous attempt").into()); - }; + let fact = gate_test_latest_fact(&store, &entity) + .ok_or_else(|| std::io::Error::other("agent-token pending gate missing"))?; let gate: crate::work::PendingGateAction = serde_json::from_str(&fact.value)?; assert_eq!(gate.status, "pending"); + assert_eq!(gate.resolved_by_passport, None); + assert_eq!(gate.receipt_id, None); } Ok(()) } @@ -14627,7 +15499,7 @@ async fn work_gate_jwt_binding_and_cross_tenant_enforcement() -> Result<(), Box< gate_test_state(AuthMode::JwtHs256, Some("tenant-y")).await?; { let mut store = drift_state.fact_store.write().await; - let _ = crate::work::update_work( + let immutable = crate::work::update_work( &mut store, &drift_work_id, crate::work::UpdateWorkInput { @@ -14646,7 +15518,32 @@ async fn work_gate_jwt_binding_and_cross_tenant_enforcement() -> Result<(), Box< passport_gated: false, now_unix_ms: 2_500, }, - )?; + ) + .expect_err("ordinary work updates must not move a tenant"); + assert!(matches!(immutable, crate::work::WorkError::TenantImmutable)); + + // Model an already-corrupt/legacy row so the independent gate-drift + // fail-closed check remains covered without using the now-closed + // public tenant-mutation path. + let mut drifted = crate::work::get_work(&store, &drift_work_id) + .ok_or_else(|| std::io::Error::other("drift work fixture missing"))?; + drifted.tenant_id = Some("tenant-x".to_string()); + store.store(corecrux_memory::fact_store::StoreFact { + tenant_hash: "default".to_string(), + entity: format!( + "{}::{}::{}", + crate::work::WORK_ENTITY_PREFIX, + drifted.project_id, + drifted.id + ), + key: crate::work::RECORD_KEY.to_string(), + value: serde_json::to_string(&drifted)?, + source_receipt: None, + confidence: 1.0, + private: false, + horizon_class: None, + actor: Some("test:legacy-corruption".to_string()), + }); } let current_tenant_attempt = gate_test_resolve( &drift_state, @@ -14675,6 +15572,7 @@ async fn work_gate_jwt_binding_and_cross_tenant_enforcement() -> Result<(), Box< async fn work_gate_receipt_resolves_and_resolution_facts_are_attributed() -> Result<(), Box> { for approve in [true, false] { let (mut state, work_id, action_id) = gate_test_state(AuthMode::DevScopes, Some("tenant-a")).await?; + let expected_actor = "operator:unverified:approver-a"; let response = gate_test_resolve( &state, &action_id, @@ -14728,7 +15626,7 @@ async fn work_gate_receipt_resolves_and_resolution_facts_are_attributed() -> Res }; assert_eq!( receipt_fields.get("reviewer_passport").map(String::as_str), - Some("approver-a") + Some(expected_actor) ); assert_eq!( receipt_fields.get("decision").map(String::as_str), @@ -14795,10 +15693,10 @@ async fn work_gate_receipt_resolves_and_resolution_facts_are_attributed() -> Res let Some(gate_fact) = gate_test_latest_fact(&store, &gate_entity) else { return Err(std::io::Error::other("resolved gate fact missing").into()); }; - assert_eq!(gate_fact.actor.as_deref(), Some("approver-a")); + assert_eq!(gate_fact.actor.as_deref(), Some(expected_actor)); assert_eq!(gate_fact.source_receipt.as_deref(), Some(receipt_id)); let gate: crate::work::PendingGateAction = serde_json::from_str(&gate_fact.value)?; - assert_eq!(gate.resolved_by_passport.as_deref(), Some("approver-a")); + assert_eq!(gate.resolved_by_passport.as_deref(), Some(expected_actor)); assert_eq!(gate.receipt_id.as_deref(), Some(receipt_id)); let expected_gate_status = if approve { "approved" } else { "rejected" }; @@ -14815,10 +15713,10 @@ async fn work_gate_receipt_resolves_and_resolution_facts_are_attributed() -> Res let Some(transition_fact) = transition else { return Err(std::io::Error::other("gate resolution transition fact missing").into()); }; - assert_eq!(transition_fact.actor.as_deref(), Some("approver-a")); + assert_eq!(transition_fact.actor.as_deref(), Some(expected_actor)); assert_eq!(transition_fact.source_receipt.as_deref(), Some(receipt_id)); let transition: crate::work::WorkTransition = serde_json::from_str(&transition_fact.value)?; - assert_eq!(transition.by_passport, "approver-a"); + assert_eq!(transition.by_passport, expected_actor); assert_eq!(transition.receipt_id.as_deref(), Some(receipt_id)); if approve { @@ -14826,7 +15724,7 @@ async fn work_gate_receipt_resolves_and_resolution_facts_are_attributed() -> Res let Some(work_fact) = gate_test_latest_fact(&store, &work_entity) else { return Err(std::io::Error::other("approved work fact missing").into()); }; - assert_eq!(work_fact.actor.as_deref(), Some("approver-a")); + assert_eq!(work_fact.actor.as_deref(), Some(expected_actor)); assert_eq!(work_fact.source_receipt.as_deref(), Some(receipt_id)); } } @@ -15175,7 +16073,7 @@ async fn work_patch_with_gated_passport_returns_202_queued() { let patch_resp = super::work::patch_work( State(state.clone()), Path(work_id), - dev_scope_headers("facts:write"), + dev_scope_passport_headers("facts:write", "personal-default"), Json(super::work::UpdateWorkBody { title: None, body: None, @@ -15186,7 +16084,7 @@ async fn work_patch_with_gated_passport_returns_202_queued() { linked_issue: None, blocker_reason: None, blocker_kind: None, - by_passport: "personal-default".to_string(), + by_passport: Some("personal-default".to_string()), }), ) .await @@ -15198,7 +16096,10 @@ async fn work_patch_with_gated_passport_returns_202_queued() { let pending_resp = super::work::get_pending_gates( State(state), - Query(super::work::GateListQuery { by_passport: None }), + Query(super::work::GateListQuery { + by_passport: None, + tenant_id: None, + }), dev_scope_headers("admin:read"), ) .await @@ -15246,9 +16147,9 @@ async fn work_comments_get_item_and_gate_resolution_paths() { let comment_resp = super::work::post_comment( State(state.clone()), Path(work_id.clone()), - dev_scope_headers("facts:write"), + dev_scope_passport_headers("facts:write", "personal-default"), Json(super::work::CommentBody { - author_passport: "personal-default".to_string(), + author_passport: Some("personal-default".to_string()), body: "ready for review".to_string(), }), ) @@ -15290,7 +16191,7 @@ async fn work_comments_get_item_and_gate_resolution_paths() { let queue_for_reject = super::work::patch_work( State(state.clone()), Path(work_id.clone()), - dev_scope_headers("facts:write"), + dev_scope_passport_headers("facts:write", "personal-default"), Json(super::work::UpdateWorkBody { title: Some("queued reject".to_string()), body: None, @@ -15301,7 +16202,7 @@ async fn work_comments_get_item_and_gate_resolution_paths() { linked_issue: None, blocker_reason: Some(Some("needs approval".to_string())), blocker_kind: Some(crate::work::BlockerKind::NeedsApproval), - by_passport: "personal-default".to_string(), + by_passport: Some("personal-default".to_string()), }), ) .await @@ -15324,7 +16225,7 @@ async fn work_comments_get_item_and_gate_resolution_paths() { let queue_for_approve = super::work::patch_work( State(state.clone()), Path(work_id), - dev_scope_headers("facts:write"), + dev_scope_passport_headers("facts:write", "personal-default"), Json(super::work::UpdateWorkBody { title: None, body: None, @@ -15335,7 +16236,7 @@ async fn work_comments_get_item_and_gate_resolution_paths() { linked_issue: None, blocker_reason: None, blocker_kind: None, - by_passport: "personal-default".to_string(), + by_passport: Some("personal-default".to_string()), }), ) .await @@ -21251,7 +22152,7 @@ async fn seeded_work_state() -> crate::http::AppState { ] { let resp = super::work::post_work( State(state.clone()), - dev_scope_headers("facts:write"), + dev_scope_passport_headers("facts:write", "personal-default"), Json(super::work::CreateWorkBody { project_id: "default".to_string(), title: title.to_string(), @@ -21261,7 +22162,7 @@ async fn seeded_work_state() -> crate::http::AppState { tenant_id: None, linked_pr: None, linked_issue: None, - created_by_passport: "personal-default".to_string(), + created_by_passport: Some("personal-default".to_string()), }), ) .await @@ -21422,26 +22323,31 @@ async fn work_ranked_drops_terminal_states() { ) .await; let victim = listed["work"][0]["id"].as_str().expect("id").to_string(); - let patched = super::work::patch_work( - State(state.clone()), - Path(victim.clone()), - dev_scope_headers("facts:write"), - Json(super::work::UpdateWorkBody { - title: None, - body: None, - state: Some("complete".to_string()), - assignee_passport: None, - tenant_id: None, - linked_pr: None, - linked_issue: None, - blocker_reason: None, - blocker_kind: None, - by_passport: "personal-default".to_string(), - }), - ) - .await - .into_response(); - assert_eq!(patched.status(), StatusCode::OK); + { + let mut store = state.fact_store.write().await; + let outcome = crate::work::update_work( + &mut store, + &victim, + crate::work::UpdateWorkInput { + title: None, + body: None, + state: Some("complete".to_string()), + assignee_passport: None, + tenant_id: None, + linked_pr: None, + linked_issue: None, + blocker_reason: None, + blocker_kind: None, + }, + crate::work::UpdateWorkContext { + by_passport: "test:ranking-fixture".to_string(), + passport_gated: false, + now_unix_ms: 10_000, + }, + ) + .expect("fixture transition"); + assert!(matches!(outcome, crate::work::UpdateOutcome::Applied(_))); + } let ranked = json_body( super::work::get_work( @@ -21513,26 +22419,53 @@ async fn workbench_brief_open_work_is_ranked_and_slim() { crate::passports::seed_defaults_if_missing(&state.data_dir, &mut store, 1).expect("seed"); crate::projects::seed_default_if_missing(&mut store, 1).expect("project seed"); } - // One planned + one in_progress: ranking must put in_progress first. + // One planned + one in_progress: create both through the only permitted + // initial state, then transition the active item through the normal rail. for (title, st) in [("zzz planned work", "planned"), ("aaa active work", "in_progress")] { let resp = super::work::post_work( State(state.clone()), - dev_scope_headers("facts:write"), + dev_scope_passport_headers("facts:write", "personal-default"), Json(super::work::CreateWorkBody { project_id: "default".to_string(), title: title.to_string(), body: None, - state: Some(st.to_string()), + state: Some("planned".to_string()), assignee_passport: None, tenant_id: Some("business::acme".to_string()), linked_pr: None, linked_issue: None, - created_by_passport: "personal-default".to_string(), + created_by_passport: Some("personal-default".to_string()), }), ) .await .into_response(); assert_eq!(resp.status(), StatusCode::CREATED); + if st != "planned" { + let work_id = json_body(resp).await["id"].as_str().expect("work id").to_string(); + let mut store = state.fact_store.write().await; + let transitioned = crate::work::update_work( + &mut store, + &work_id, + crate::work::UpdateWorkInput { + title: None, + body: None, + state: Some(st.to_string()), + assignee_passport: None, + tenant_id: None, + linked_pr: None, + linked_issue: None, + blocker_reason: None, + blocker_kind: None, + }, + crate::work::UpdateWorkContext { + by_passport: "test:workbench-fixture".to_string(), + passport_gated: false, + now_unix_ms: 10_000, + }, + ) + .expect("fixture transition"); + assert!(matches!(transitioned, crate::work::UpdateOutcome::Applied(_))); + } } let resp = super::workbench::get_agent_brief( diff --git a/crates/corecruxd/src/http/work.rs b/crates/corecruxd/src/http/work.rs index 41c9eea2..63a1b329 100644 --- a/crates/corecruxd/src/http/work.rs +++ b/crates/corecruxd/src/http/work.rs @@ -221,12 +221,11 @@ pub(super) struct CreateWorkBody { pub linked_pr: Option, #[serde(default)] pub linked_issue: Option, - /// Required: which passport is creating the item. The HTTP layer accepts - /// it explicitly so callers without a session binding can still write. - /// Aliases: `by_passport`, `author_passport` (the other work routes use - /// these names; accepting all three reduces caller error). - #[serde(alias = "by_passport", alias = "author_passport")] - pub created_by_passport: String, + /// Legacy identity hint. In an enforcing auth mode it may be omitted and, + /// when present, must match the authenticated passport. Auth-off mode + /// requires it and persists an explicitly unverified actor tag. + #[serde(default, alias = "by_passport", alias = "author_passport")] + pub created_by_passport: Option, } #[derive(Debug, serde::Deserialize)] @@ -251,17 +250,16 @@ pub(super) struct UpdateWorkBody { /// rejected by serde; absent = leave unchanged. #[serde(default)] pub blocker_kind: Option, - /// Identity making the change. Determines whether the change is gated. - /// Aliases: `created_by_passport`, `author_passport`. - #[serde(alias = "created_by_passport", alias = "author_passport")] - pub by_passport: String, + /// Legacy identity hint; authority comes from the authenticated context. + #[serde(default, alias = "created_by_passport", alias = "author_passport")] + pub by_passport: Option, } #[derive(Debug, serde::Deserialize)] pub(super) struct CommentBody { - /// Aliases: `by_passport`, `created_by_passport`. - #[serde(alias = "by_passport", alias = "created_by_passport")] - pub author_passport: String, + /// Legacy identity hint; authority comes from the authenticated context. + #[serde(default, alias = "by_passport", alias = "created_by_passport")] + pub author_passport: Option, pub body: String, } @@ -290,6 +288,7 @@ pub(super) use super::approval_receipts::{ #[derive(Debug, serde::Deserialize)] pub(super) struct GateListQuery { pub by_passport: Option, + pub tenant_id: Option, } /// Query-string booleans, permissively. Bare `serde` accepts only `true`/`false`, @@ -326,15 +325,101 @@ fn now_unix_ms() -> u64 { .map_or(0, |d| d.as_millis() as u64) } +struct ResolvedWorkActor { + context: crate::auth::HttpScopeContext, + /// Durable actor/user-facing passport field. Auth-off assertions carry an + /// explicit prefix so they cannot be confused with verified passports. + actor_id: String, + /// Raw passport id used only to look up the agent-work-gate policy. + passport_lookup_id: String, +} + +#[allow(clippy::result_large_err)] +pub(super) fn work_scope_context( + state: &AppState, + headers: &HeaderMap, + required_scope: &str, +) -> Result { + let context = crate::auth::passport_bound_context(&state.auth, headers).map_err(IntoResponse::into_response)?; + if !context.has_scope(required_scope) { + return Err(problem_response( + StatusCode::FORBIDDEN, + format!("{required_scope} scope required for work access"), + )); + } + Ok(context) +} + +#[allow(clippy::result_large_err)] +fn resolve_work_actor( + state: &AppState, + headers: &HeaderMap, + hint: Option<&str>, +) -> Result { + let context = work_scope_context(state, headers, "facts:write")?; + let hint = hint.map(str::trim).filter(|value| !value.is_empty()); + if !context.local_unverified_identity() { + if context.passport_override_used() { + return Err(problem_response( + StatusCode::FORBIDDEN, + "passport impersonation is not permitted for work mutations", + )); + } + let Some(passport_id) = context.passport_id.as_deref() else { + return Err(problem_response( + StatusCode::FORBIDDEN, + "an authenticated passport is required for work mutations", + )); + }; + if hint.is_some_and(|claimed| claimed != passport_id) { + return Err(problem_response( + StatusCode::FORBIDDEN, + "body passport does not match the authenticated passport", + )); + } + Ok(ResolvedWorkActor { + actor_id: passport_id.to_string(), + passport_lookup_id: passport_id.to_string(), + context, + }) + } else { + let header_hint = context.passport_id.as_deref(); + if let (Some(body_hint), Some(header_hint)) = (hint, header_hint) { + if body_hint != header_hint { + return Err(problem_response( + StatusCode::FORBIDDEN, + "body passport does not match the local identity assertion header", + )); + } + } + let Some(asserted) = hint.or(header_hint) else { + return Err(problem_response( + StatusCode::BAD_REQUEST, + "an explicit passport identity assertion is required in local unverified mode", + )); + }; + Ok(ResolvedWorkActor { + actor_id: format!("{AUTH_OFF_APPROVER_PREFIX}{asserted}"), + passport_lookup_id: asserted.to_string(), + context, + }) + } +} + #[tracing::instrument(level = "info", skip_all)] pub(super) async fn get_work( State(state): State, Query(q): Query, headers: HeaderMap, ) -> impl IntoResponse { - if let Err(problem) = require_http_scopes(&state.auth, &headers, &["admin:read"]) { - return problem.into_response(); - } + let context = match work_scope_context(&state, &headers, "admin:read") { + Ok(context) => context, + Err(response) => return response, + }; + let tenant_id = match context.resolve_authorized_tenant(q.tenant_id.as_deref()) { + Ok(tenant_id) => tenant_id, + Err(problem) => return problem.into_response(), + }; if let Some(s) = &q.state { if crate::work::validate_state(s).is_err() { return problem_response( @@ -349,9 +434,9 @@ pub(super) async fn get_work( } let store = state.fact_store.read().await; - let kanban_items = kanban_items_for_query(&store, &q); + let kanban_items = kanban_items_for_query(&store, &q, &tenant_id); let mut execplan_items = if matches!(q.source, WorkSource::Execplans | WorkSource::All) { - execplan_items_for_query(&store, &q) + execplan_items_for_query(&store, &q, &tenant_id) } else { Vec::new() }; @@ -360,12 +445,19 @@ pub(super) async fn get_work( // ExecPlan items that are members of the requested orchestrator (kanban // items already carry it from the membership write path), then below we // keep only items whose `orchestrator_id` matches. - if let Some(orc_id) = q.orchestrator.as_deref() { - let estore = state.entity_store.read().await; - let member_ids = crate::http::orchestrators::orchestrator_member_refs(&estore, orc_id); - drop(estore); - crate::work_execplans::stamp_orchestrator_id(&mut execplan_items, &member_ids, orc_id); - } + let requested_orchestrator_members = if let Some(orc_id) = q.orchestrator.as_deref() { + if orc_id == crate::work_execplans::default_orchestrator_id() { + None + } else { + let estore = state.entity_store.read().await; + let member_ids = crate::http::orchestrators::orchestrator_member_refs(&estore, orc_id, &tenant_id); + drop(estore); + crate::work_execplans::stamp_orchestrator_id(&mut execplan_items, &member_ids, orc_id); + Some(member_ids) + } + } else { + None + }; drop(store); // Per-ExecPlan token-burn rollup: join the cost lens (one report per coding @@ -375,10 +467,9 @@ pub(super) async fn get_work( // skipped when there are no ExecPlan items to stamp. Cost reports are // per-tenant; attribute the requested tenant's (default `default`). if crate::cost::cost_lens_enabled() && !execplan_items.is_empty() { - let tenant = q.tenant_id.as_deref().unwrap_or("default"); let reports = { let cstore = crate::cost::global().lock().await; - cstore.reports_for_tenant(tenant) + cstore.reports_for_tenant(&tenant_id) }; let sessions = crate::cost_attribution::session_burns_from_reports(&reports); crate::cost_attribution::stamp_token_burn(&mut execplan_items, &sessions); @@ -394,7 +485,11 @@ pub(super) async fn get_work( // Apply the orchestrator filter so it intersects both kanban + execplan sources. if let Some(orc_id) = q.orchestrator.as_deref() { - items.retain(|w| w.orchestrator_id.as_deref() == Some(orc_id)); + if let Some(member_ids) = requested_orchestrator_members.as_ref() { + items.retain(|work| member_ids.contains(&work.id)); + } else { + items.retain(|work| work.orchestrator_id.as_deref() == Some(orc_id)); + } } // agent-ux-05 — risk-tiered HITL projection. When the caller asks for @@ -413,9 +508,13 @@ pub(super) async fn get_work( } else { Vec::new() }; - if let Some(tenant) = q.tenant_id.as_deref() { - approval_entries.retain(|e| e.get("tenant_id").and_then(|v| v.as_str()) == Some(tenant)); - } + approval_entries.retain(|entry| { + entry + .get("tenant_id") + .and_then(|value| value.as_str()) + .unwrap_or("default") + == tenant_id + }); let approval_count = approval_entries.len(); // Ready-order projection. Narrow to open work, sort by `rank_open`, stamp @@ -483,9 +582,16 @@ pub(super) async fn get_work( /// this endpoint returns. Two surfaces quoting different numbers for one daemon /// is a discrepancy an operator cannot diagnose from either one, and a /// duplicated source-merge is how that happens. +/// +/// `tenant_id` is the **authenticated** tenant, resolved by the caller through +/// `resolve_authorized_tenant`. It is deliberately a parameter rather than +/// `q.tenant_id`: the query string is caller-supplied, so reading it here +/// would let any reader enumerate another tenant's work items by asking. Every +/// caller must therefore prove which tenant it is answering for. pub(super) fn kanban_items_for_query( store: &corecrux_memory::FactStore, q: &ListWorkQuery, + tenant_id: &str, ) -> Vec { if !matches!(q.source, WorkSource::Kanban | WorkSource::All) { return Vec::new(); @@ -494,7 +600,7 @@ pub(super) fn kanban_items_for_query( store, q.project_id.as_deref(), q.state.as_deref(), - q.tenant_id.as_deref(), + Some(tenant_id), q.assignee_passport.as_deref(), ) } @@ -526,6 +632,7 @@ pub(super) fn merge_work_sources( pub(super) fn execplan_items_for_query( store: &corecrux_memory::fact_store::FactStore, q: &ListWorkQuery, + tenant_id: &str, ) -> Vec { // No root configured = aggregator off. Return empty rather than 500. let Some(root) = crate::work_execplans::execplans_root_from_env() else { @@ -550,7 +657,7 @@ pub(super) fn execplan_items_for_query( .into_iter() .filter(|w| { q.state.as_deref().is_none_or(|s| w.state == s) - && q.tenant_id.as_deref().is_none_or(|t| w.tenant_id.as_deref() == Some(t)) + && crate::work::work_tenant_id(w) == tenant_id && q.assignee_passport .as_deref() .is_none_or(|a| w.assignee_passport.as_deref() == Some(a)) @@ -564,11 +671,17 @@ pub(super) async fn get_work_item( Path(id): Path, headers: HeaderMap, ) -> impl IntoResponse { - if let Err(problem) = require_http_scopes(&state.auth, &headers, &["admin:read"]) { - return problem.into_response(); - } + let context = match work_scope_context(&state, &headers, "admin:read") { + Ok(context) => context, + Err(response) => return response, + }; let store = state.fact_store.read().await; let item = crate::work::get_work(&store, &id); + if let Some(item) = item.as_ref() { + if let Err(problem) = context.resolve_authorized_tenant(Some(crate::work::work_tenant_id(item))) { + return problem.into_response(); + } + } drop(store); match item { Some(w) => (StatusCode::OK, Json(w)).into_response(), @@ -582,10 +695,21 @@ pub(super) async fn post_work( headers: HeaderMap, Json(body): Json, ) -> impl IntoResponse { - if let Err(problem) = require_http_scopes(&state.auth, &headers, &["facts:write"]) { - return problem.into_response(); - } + let actor = match resolve_work_actor(&state, &headers, body.created_by_passport.as_deref()) { + Ok(actor) => actor, + Err(response) => return response, + }; + let tenant_id = match actor.context.resolve_authorized_tenant(body.tenant_id.as_deref()) { + Ok(tenant_id) => tenant_id, + Err(problem) => return problem.into_response(), + }; let mut store = state.fact_store.write().await; + if body.state.as_deref().is_some_and(|state| state != "planned") { + return problem_response( + StatusCode::BAD_REQUEST, + "work must be created in the planned state and transitioned separately", + ); + } let result = crate::work::create_work( &mut store, crate::work::CreateWorkInput { @@ -594,10 +718,10 @@ pub(super) async fn post_work( body: body.body, state: body.state, assignee_passport: body.assignee_passport, - tenant_id: body.tenant_id, + tenant_id: Some(tenant_id), linked_pr: body.linked_pr, linked_issue: body.linked_issue, - created_by_passport: body.created_by_passport, + created_by_passport: actor.actor_id, }, now_unix_ms(), ); @@ -616,12 +740,27 @@ pub(super) async fn patch_work( headers: HeaderMap, Json(body): Json, ) -> impl IntoResponse { - if let Err(problem) = require_http_scopes(&state.auth, &headers, &["facts:write"]) { + let actor = match resolve_work_actor(&state, &headers, body.by_passport.as_deref()) { + Ok(actor) => actor, + Err(response) => return response, + }; + // Target lookup, tenant authorization, gate-policy lookup, and mutation + // share one write guard so the authorization decision cannot race a rewrite. + let mut store = state.fact_store.write().await; + let Some(target) = crate::work::get_work(&store, &id) else { + return problem_response(StatusCode::NOT_FOUND, "work item not found"); + }; + if let Err(problem) = actor + .context + .resolve_authorized_tenant(Some(crate::work::work_tenant_id(&target))) + { return problem.into_response(); } - // Look up the calling passport's gate flag to decide whether state moves are gated. - let mut store = state.fact_store.write().await; - let passport_gated = crate::passports::get_passport(&store, &body.by_passport).is_some_and(|p| p.agent_work_gate); + // Local auth-off/DevScopes identities are assertions, not principals. + // They may never select a known ungated passport to bypass review. + let passport_gated = actor.context.local_unverified_identity() + || crate::passports::get_passport(&store, &actor.passport_lookup_id) + .is_none_or(|passport| passport.agent_work_gate); let result = crate::work::update_work( &mut store, &id, @@ -637,7 +776,7 @@ pub(super) async fn patch_work( blocker_kind: body.blocker_kind, }, crate::work::UpdateWorkContext { - by_passport: body.by_passport, + by_passport: actor.actor_id, passport_gated, now_unix_ms: now_unix_ms(), }, @@ -653,6 +792,9 @@ pub(super) async fn patch_work( ) .into_response(), Err(crate::work::WorkError::NotFound(_)) => problem_response(StatusCode::NOT_FOUND, "work item not found"), + Err(crate::work::WorkError::TenantImmutable) => { + problem_response(StatusCode::CONFLICT, "a work item's tenant is immutable") + } Err(err) => problem_response(StatusCode::BAD_REQUEST, err.to_string()), } } @@ -664,14 +806,24 @@ pub(super) async fn post_comment( headers: HeaderMap, Json(body): Json, ) -> impl IntoResponse { - if let Err(problem) = require_http_scopes(&state.auth, &headers, &["facts:write"]) { - return problem.into_response(); - } + let actor = match resolve_work_actor(&state, &headers, body.author_passport.as_deref()) { + Ok(actor) => actor, + Err(response) => return response, + }; if body.body.trim().is_empty() { return problem_response(StatusCode::BAD_REQUEST, "comment body must not be empty"); } let mut store = state.fact_store.write().await; - let result = crate::work::add_comment(&mut store, &id, &body.author_passport, &body.body, now_unix_ms()); + let Some(target) = crate::work::get_work(&store, &id) else { + return problem_response(StatusCode::NOT_FOUND, "work item not found"); + }; + if let Err(problem) = actor + .context + .resolve_authorized_tenant(Some(crate::work::work_tenant_id(&target))) + { + return problem.into_response(); + } + let result = crate::work::add_comment(&mut store, &id, &actor.actor_id, &body.body, now_unix_ms()); drop(store); match result { Ok(c) => (StatusCode::CREATED, Json(c)).into_response(), @@ -686,10 +838,17 @@ pub(super) async fn get_comments( Path(id): Path, headers: HeaderMap, ) -> impl IntoResponse { - if let Err(problem) = require_http_scopes(&state.auth, &headers, &["admin:read"]) { + let context = match work_scope_context(&state, &headers, "admin:read") { + Ok(context) => context, + Err(response) => return response, + }; + let store = state.fact_store.read().await; + let Some(target) = crate::work::get_work(&store, &id) else { + return problem_response(StatusCode::NOT_FOUND, "work item not found"); + }; + if let Err(problem) = context.resolve_authorized_tenant(Some(crate::work::work_tenant_id(&target))) { return problem.into_response(); } - let store = state.fact_store.read().await; let comments = crate::work::list_comments(&store, &id); drop(store); ( @@ -705,10 +864,17 @@ pub(super) async fn get_transitions( Path(id): Path, headers: HeaderMap, ) -> impl IntoResponse { - if let Err(problem) = require_http_scopes(&state.auth, &headers, &["admin:read"]) { + let context = match work_scope_context(&state, &headers, "admin:read") { + Ok(context) => context, + Err(response) => return response, + }; + let store = state.fact_store.read().await; + let Some(target) = crate::work::get_work(&store, &id) else { + return problem_response(StatusCode::NOT_FOUND, "work item not found"); + }; + if let Err(problem) = context.resolve_authorized_tenant(Some(crate::work::work_tenant_id(&target))) { return problem.into_response(); } - let store = state.fact_store.read().await; let txns = crate::work::list_transitions(&store, &id); drop(store); ( @@ -724,11 +890,16 @@ pub(super) async fn get_pending_gates( Query(q): Query, headers: HeaderMap, ) -> impl IntoResponse { - if let Err(problem) = require_http_scopes(&state.auth, &headers, &["admin:read"]) { - return problem.into_response(); - } + let context = match work_scope_context(&state, &headers, "admin:read") { + Ok(context) => context, + Err(response) => return response, + }; + let tenant_id = match context.resolve_authorized_tenant(q.tenant_id.as_deref()) { + Ok(tenant_id) => tenant_id, + Err(problem) => return problem.into_response(), + }; let store = state.fact_store.read().await; - let pending = crate::work::list_pending_gates(&store, q.by_passport.as_deref()); + let pending = crate::work::list_pending_gates(&store, Some(&tenant_id), q.by_passport.as_deref()); drop(store); ( StatusCode::OK, @@ -771,15 +942,27 @@ async fn resolve_gate_http( Ok(context) => context, Err(problem) => return problem.into_response(), }; - let (asserted_approver, approver_actor) = if context.auth_enforced() { + if !context.has_scope("facts:write") { + return problem_response(StatusCode::FORBIDDEN, "facts:write scope required for gate resolution"); + } + let (asserted_approver, approver_actor) = if !context.local_unverified_identity() { if context.passport_override_used() { return problem_response( StatusCode::FORBIDDEN, "passport impersonation is not permitted for gate resolution", ); } - if !context.has_scope("facts:write") { - return problem_response(StatusCode::FORBIDDEN, "facts:write scope required for gate resolution"); + if context.credential_is_agent_token() { + return problem_response( + StatusCode::FORBIDDEN, + "an MCP agent token cannot satisfy a human gate decision", + ); + } + if !context.canonical_passport_claim_verified() { + return problem_response( + StatusCode::FORBIDDEN, + "a canonical passport_id claim is required for gate resolution", + ); } let Some(approver_passport) = context.passport_id.as_deref() else { return problem_response( @@ -799,15 +982,24 @@ async fn resolve_gate_http( } (approver_passport.to_string(), approver_passport.to_string()) } else { - let Some(approver_passport) = body + let body_hint = body .approver_passport .as_deref() .map(str::trim) - .filter(|claimed| !claimed.is_empty()) - else { + .filter(|claimed| !claimed.is_empty()); + let header_hint = context.passport_id.as_deref(); + if let (Some(body_hint), Some(header_hint)) = (body_hint, header_hint) { + if body_hint != header_hint { + return problem_response( + StatusCode::FORBIDDEN, + "approver_passport does not match the local identity assertion header", + ); + } + } + let Some(approver_passport) = body_hint.or(header_hint) else { return problem_response( StatusCode::BAD_REQUEST, - "approver_passport is required in auth-off mode", + "an explicit approver identity assertion is required in local unverified mode", ); }; ( @@ -824,9 +1016,7 @@ async fn resolve_gate_http( Ok(target) => target, Err(err) => return gate_error_response(err), }; - if let Err(problem) = - crate::auth::require_http_scopes_for_tenant(&state.auth, headers, &["facts:write"], &target.tenant_id) - { + if let Err(problem) = context.resolve_authorized_tenant(Some(&target.tenant_id)) { return problem.into_response(); } if target.tenant_mismatch { @@ -835,7 +1025,7 @@ async fn resolve_gate_http( if target.gate.status != "pending" { return gate_error_response(crate::work::WorkError::GateAlreadyResolved(action_id.to_string())); } - if target.gate.requested_by_passport == asserted_approver { + if target.gate.requested_by_passport == asserted_approver || target.gate.requested_by_passport == approver_actor { return problem_response( StatusCode::FORBIDDEN, "the requesting passport cannot resolve its own gate", @@ -929,6 +1119,8 @@ fn mint_gate_receipt( pub(super) struct StatusFeedQuery { /// Optional single-work-item filter; omit to span every item. pub work_id: Option, + /// Concrete tenant selector. Multi-tenant tokens must choose one. + pub tenant_id: Option, /// Max events returned (most recent kept). Defaults to 200. pub limit: Option, } @@ -944,9 +1136,14 @@ pub(super) async fn get_status_feed( Query(q): Query, headers: HeaderMap, ) -> impl IntoResponse { - if let Err(problem) = require_http_scopes(&state.auth, &headers, &["admin:read"]) { - return problem.into_response(); - } + let context = match work_scope_context(&state, &headers, "admin:read") { + Ok(context) => context, + Err(response) => return response, + }; + let tenant_id = match context.resolve_authorized_tenant(q.tenant_id.as_deref()) { + Ok(tenant_id) => tenant_id, + Err(problem) => return problem.into_response(), + }; if !crate::status_feed::status_feed_enabled() { return ( StatusCode::OK, @@ -964,7 +1161,38 @@ pub(super) async fn get_status_feed( } let limit = q.limit.unwrap_or(200).clamp(1, 2000); let store = state.fact_store.read().await; - let events = crate::status_feed::status_feed(&store, q.work_id.as_deref(), limit); + let visible_work_ids: std::collections::HashSet = + crate::work::list_work(&store, None, None, Some(&tenant_id), None) + .into_iter() + .map(|work| work.id) + .collect(); + if let Some(work_id) = q.work_id.as_deref() { + let Some(target) = crate::work::get_work(&store, work_id) else { + return problem_response(StatusCode::NOT_FOUND, "work item not found"); + }; + if crate::work::work_tenant_id(&target) != tenant_id { + return problem_response(StatusCode::FORBIDDEN, "work item belongs to another tenant"); + } + } + // Project each visible work lane before applying the caller's global cap. + // Filtering a pre-truncated global feed lets a noisy foreign tenant starve + // this tenant's events even though no foreign row is returned. + let mut events = if let Some(work_id) = q.work_id.as_deref() { + crate::status_feed::status_feed(&store, Some(work_id), limit) + } else { + visible_work_ids + .iter() + .flat_map(|work_id| crate::status_feed::status_feed(&store, Some(work_id), limit)) + .collect() + }; + events.sort_by(|left, right| { + left.at_unix_ms + .cmp(&right.at_unix_ms) + .then_with(|| left.transition_id.cmp(&right.transition_id)) + }); + if events.len() > limit { + events = events.split_off(events.len() - limit); + } drop(store); ( StatusCode::OK, diff --git a/crates/corecruxd/src/http/workbench.rs b/crates/corecruxd/src/http/workbench.rs index a39a1933..5405ba2d 100644 --- a/crates/corecruxd/src/http/workbench.rs +++ b/crates/corecruxd/src/http/workbench.rs @@ -238,15 +238,18 @@ fn ranked_open_work( ) -> Vec { let mut items = crate::work::list_work(store, project_id, None, Some(tenant_id), None); - // ExecPlan items are tenant-agnostic (the projection is per-root, not - // per-tenant), so they are appended rather than tenant-filtered — same - // treatment `/v1/work?source=all` gives them. + // The projection is per-root, but its WorkItem rows still use the work + // tenant contract (`None == default`). Match `/v1/work?source=all` and do + // not append default-tenant plans to another tenant's brief. if let Some(root) = crate::work_execplans::execplans_root_from_env() { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map_or(0, |d| d.as_millis() as u64); match crate::work_execplans::list_execplans(store, &root, now) { - Ok(plans) => items.extend(plans), + Ok(mut plans) => { + plans.retain(|work| crate::work::work_tenant_id(work) == tenant_id); + items.extend(plans); + } Err(err) => { tracing::warn!(error = %err, root = %root.display(), "brief-execplan-aggregator-io-error"); } diff --git a/crates/corecruxd/src/work.rs b/crates/corecruxd/src/work.rs index 38a1c899..ee84ff3c 100644 --- a/crates/corecruxd/src/work.rs +++ b/crates/corecruxd/src/work.rs @@ -56,6 +56,8 @@ pub const WORK_STATES: &[&str] = &[ pub enum WorkError { #[error("invalid work state '{0}'")] InvalidState(String), + #[error("work must be created in the planned state and transitioned separately")] + InvalidInitialState, #[error("blocked items must carry a non-empty blocker_reason")] MissingBlockerReason, #[error("work item '{0}' not found")] @@ -68,6 +70,8 @@ pub enum WorkError { GateAlreadyResolved(String), #[error("gated action '{0}' tenant no longer matches its work item")] GateTenantChanged(String), + #[error("a work item's tenant is immutable")] + TenantImmutable, #[error(transparent)] Io(#[from] std::io::Error), #[error(transparent)] @@ -294,6 +298,13 @@ pub fn validate_state(state: &str) -> Result<(), WorkError> { } } +/// Concrete tenant for legacy and current work rows. Pre-tenant records omit +/// `tenant_id` and belong to `default`; new authority-sensitive HTTP writes +/// always persist an explicit tenant. +pub fn work_tenant_id(item: &WorkItem) -> &str { + item.tenant_id.as_deref().unwrap_or("default") +} + pub struct CreateWorkInput { pub project_id: String, pub title: String, @@ -312,6 +323,9 @@ pub fn create_work(store: &mut FactStore, input: CreateWorkInput, now_unix_ms: u } let state = input.state.as_deref().unwrap_or("planned").to_string(); validate_state(&state)?; + if state != "planned" { + return Err(WorkError::InvalidInitialState); + } let id = format!("w_{}", Uuid::new_v4().simple()); let item = WorkItem { id: id.clone(), @@ -345,20 +359,23 @@ pub fn create_work(store: &mut FactStore, input: CreateWorkInput, now_unix_ms: u stale: None, token_burn: None, }; - write_record(store, &item)?; - write_transition( + write_record_with_attribution(store, &item, Some(&input.created_by_passport), None)?; + write_transition_with_attribution( store, &WorkTransition { id: format!("tx_{}", Uuid::new_v4().simple()), work_id: id, from_state: "(none)".to_string(), to_state: state, - by_passport: input.created_by_passport, + by_passport: input.created_by_passport.clone(), gate_status: "allowed".to_string(), at_unix_ms: now_unix_ms, blocker_kind: None, receipt_id: None, }, + work_tenant_id(&item), + Some(&input.created_by_passport), + None, )?; Ok(item) } @@ -390,7 +407,7 @@ pub fn list_work( } if let Ok(item) = serde_json::from_str::(&fact.value) { if state_filter.is_none_or(|s| item.state == s) - && tenant_filter.is_none_or(|t| item.tenant_id.as_deref() == Some(t)) + && tenant_filter.is_none_or(|t| work_tenant_id(&item) == t) && assignee_filter.is_none_or(|a| item.assignee_passport.as_deref() == Some(a)) { out.push(item); @@ -442,6 +459,13 @@ pub fn update_work( ctx: UpdateWorkContext, ) -> Result { let mut item = get_work(store, id).ok_or_else(|| WorkError::NotFound(id.to_string()))?; + if input + .tenant_id + .as_ref() + .is_some_and(|tenant| tenant.as_deref().unwrap_or("default") != work_tenant_id(&item)) + { + return Err(WorkError::TenantImmutable); + } let prev_state = item.state.clone(); // State changes are gateable; non-state field updates always go through. @@ -474,11 +498,11 @@ pub fn update_work( resolved_by_passport: None, receipt_id: None, }; - write_gate(store, &pending)?; + write_gate_with_attribution(store, &pending, Some(&ctx.by_passport), None)?; // Apply non-state fields, leave state untouched. apply_non_state_fields(&mut item, &input); item.updated_at_unix_ms = ctx.now_unix_ms; - write_record(store, &item)?; + write_record_with_attribution(store, &item, Some(&ctx.by_passport), None)?; return Ok(UpdateOutcome::Queued(Box::new(pending))); } } @@ -498,23 +522,26 @@ pub fn update_work( } } item.updated_at_unix_ms = ctx.now_unix_ms; - write_record(store, &item)?; + write_record_with_attribution(store, &item, Some(&ctx.by_passport), None)?; if let Some(new_state) = input.state { if new_state != prev_state { - write_transition( + write_transition_with_attribution( store, &WorkTransition { id: format!("tx_{}", Uuid::new_v4().simple()), work_id: item.id.clone(), from_state: prev_state, to_state: new_state, - by_passport: ctx.by_passport, + by_passport: ctx.by_passport.clone(), gate_status: "allowed".to_string(), at_unix_ms: ctx.now_unix_ms, blocker_kind: item.blocker_kind, receipt_id: None, }, + work_tenant_id(&item), + Some(&ctx.by_passport), + None, )?; } } @@ -531,9 +558,9 @@ fn apply_non_state_fields(item: &mut WorkItem, input: &UpdateWorkInput) { if let Some(a) = &input.assignee_passport { item.assignee_passport = a.clone(); } - if let Some(t) = &input.tenant_id { - item.tenant_id = t.clone(); - } + // Tenant ownership is immutable. `update_work` validates an explicitly + // repeated value above, then intentionally leaves the stored representation + // unchanged so legacy `None == default` rows remain byte-compatible. if let Some(p) = &input.linked_pr { item.linked_pr = p.clone(); } @@ -567,6 +594,10 @@ pub fn add_comment( }; let value = serde_json::to_string(&comment)?; let mut sf = StoreFact { + // Physical re-keying is a separate migration (M5/M16). Keep the + // existing work-family chain under `default`; authority is enforced + // against the serialized work tenant so legacy and current versions do + // not split into two live chains. tenant_hash: "default".to_string(), entity: format!("{WORK_COMMENT_ENTITY_PREFIX}::{}::{}", work_id, comment.id), key: RECORD_KEY.to_string(), @@ -575,7 +606,7 @@ pub fn add_comment( confidence: 1.0, private: false, horizon_class: None, - actor: None, + actor: Some(author_passport.to_string()), }; crate::fact_privacy::enforce_global(&mut sf); store.store(sf); @@ -638,7 +669,11 @@ pub fn list_transitions(store: &FactStore, work_id: &str) -> Vec out } -pub fn list_pending_gates(store: &FactStore, by_passport_filter: Option<&str>) -> Vec { +pub fn list_pending_gates( + store: &FactStore, + tenant_filter: Option<&str>, + by_passport_filter: Option<&str>, +) -> Vec { let result = store.query(&FactQuery { min_effective_confidence: None, tenant_hash: None, @@ -654,7 +689,10 @@ pub fn list_pending_gates(store: &FactStore, by_passport_filter: Option<&str>) - continue; } if let Ok(p) = serde_json::from_str::(&fact.value) { - if p.status == "pending" && by_passport_filter.is_none_or(|f| p.requested_by_passport == f) { + if p.status == "pending" + && tenant_filter.is_none_or(|tenant| p.tenant_id.as_deref().unwrap_or("default") == tenant) + && by_passport_filter.is_none_or(|f| p.requested_by_passport == f) + { out.push(p); } } @@ -678,6 +716,7 @@ pub fn resolve_gate( if target.gate.status != "pending" { return Err(WorkError::GateAlreadyResolved(action_id.to_string())); } + let tenant_id = target.tenant_id; let mut pending = target.gate; let item = target.work; @@ -703,7 +742,7 @@ pub fn resolve_gate( blocker_kind: item.blocker_kind, receipt_id: Some(receipt_id.to_string()), }; - let transition_fact = transition_store_fact(&rejected, Some(approver_passport), Some(receipt_id))?; + let transition_fact = transition_store_fact(&rejected, &tenant_id, Some(approver_passport), Some(receipt_id))?; store.try_store_bulk(vec![gate_fact, transition_fact])?; return Ok(item); } @@ -736,7 +775,8 @@ pub fn resolve_gate( receipt_id: Some(receipt_id.to_string()), }; let work_fact = record_store_fact(&updated, Some(approver_passport), Some(receipt_id))?; - let transition_fact = transition_store_fact(&transition, Some(approver_passport), Some(receipt_id))?; + let transition_fact = + transition_store_fact(&transition, &tenant_id, Some(approver_passport), Some(receipt_id))?; store.try_store_bulk(vec![gate_fact, work_fact, transition_fact])?; return Ok(updated); } @@ -780,16 +820,10 @@ fn get_gate(store: &FactStore, action_id: &str) -> Option { None } -/// Public re-write of an existing work item record. Used by the orchestrator -/// surface to stamp / clear `orchestrator_id` without going through the full -/// `update_work` state-machine (which would emit a spurious transition). The -/// caller is responsible for having loaded a current copy via `get_work`. -pub fn write_work_record(store: &mut FactStore, item: &WorkItem) -> Result<(), WorkError> { - write_record(store, item) -} - -fn write_record(store: &mut FactStore, item: &WorkItem) -> Result<(), WorkError> { - write_record_with_attribution(store, item, None, None) +/// Attributed variant for governance surfaces that update non-state work +/// metadata after authorizing the target under the same storage guard. +pub fn write_work_record_with_actor(store: &mut FactStore, item: &WorkItem, actor: &str) -> Result<(), WorkError> { + write_record_with_attribution(store, item, Some(actor), None) } fn write_record_with_attribution( @@ -820,23 +854,21 @@ fn record_store_fact(item: &WorkItem, actor: Option<&str>, receipt_id: Option<&s Ok(fact) } -fn write_transition(store: &mut FactStore, tx: &WorkTransition) -> Result<(), WorkError> { - write_transition_with_attribution(store, tx, None, None) -} - fn write_transition_with_attribution( store: &mut FactStore, tx: &WorkTransition, + tenant_id: &str, actor: Option<&str>, receipt_id: Option<&str>, ) -> Result<(), WorkError> { - let sf = transition_store_fact(tx, actor, receipt_id)?; + let sf = transition_store_fact(tx, tenant_id, actor, receipt_id)?; store.store(sf); Ok(()) } fn transition_store_fact( tx: &WorkTransition, + _tenant_id: &str, actor: Option<&str>, receipt_id: Option<&str>, ) -> Result { @@ -859,10 +891,6 @@ fn transition_store_fact( Ok(fact) } -fn write_gate(store: &mut FactStore, gate: &PendingGateAction) -> Result<(), WorkError> { - write_gate_with_attribution(store, gate, None, None) -} - fn write_gate_with_attribution( store: &mut FactStore, gate: &PendingGateAction, @@ -1066,7 +1094,7 @@ mod tests { }; let still_planned = get_work(&store, &item.id).expect("item"); assert_eq!(still_planned.state, "planned"); - assert_eq!(list_pending_gates(&store, None).len(), 1); + assert_eq!(list_pending_gates(&store, None, None).len(), 1); let approved = resolve_gate( &mut store, &pending.action_id, @@ -1077,7 +1105,7 @@ mod tests { ) .expect("resolve"); assert_eq!(approved.state, "in_progress"); - assert!(list_pending_gates(&store, None).is_empty(), "no longer pending"); + assert!(list_pending_gates(&store, None, None).is_empty(), "no longer pending"); let _ = std::fs::remove_dir_all(&dir); } diff --git a/crates/crux-mcp/src/dispatch.rs b/crates/crux-mcp/src/dispatch.rs index b3cc02ea..65918bb3 100644 --- a/crates/crux-mcp/src/dispatch.rs +++ b/crates/crux-mcp/src/dispatch.rs @@ -296,6 +296,40 @@ impl McpContext { } } + /// Identity used when this MCP session exercises mutation authority through + /// daemon HTTP loopback. + /// + /// Private-fact ownership keeps the legacy raw token-name via + /// [`Self::scope_identity`]. Authority is deliberately stricter: an + /// unmapped agent is namespaced as `agent:` so a token name cannot + /// collide with and inherit a real passport's policy. Only an explicit + /// agent-passport mapping resolves to a canonical passport id. + pub fn authority_identity(&self) -> Option { + let name = self.agent.as_ref()?.name.as_str(); + if self.agent_passports_enabled { + Some( + crate::agent_passport::resolve_agent_passport(name, &self.agent_passport_map) + .unwrap_or_else(|| format!("agent:{name}")), + ) + } else { + Some(format!("agent:{name}")) + } + } + + /// Concrete tenant bound to the current MCP agent for loopback HTTP + /// authority. Mapped agent passports carry their configured collaboration + /// tenant; unmapped/flag-off callers are confined to `default`. + pub fn scope_tenant(&self) -> String { + if !self.agent_passports_enabled { + return "default".to_string(); + } + self.agent + .as_ref() + .and_then(|agent| self.agent_passport_map.tenant_for(&agent.name)) + .unwrap_or("default") + .to_string() + } + /// Back-compat alias names for the caller's private-fact ownership under /// flag-ON (agent-passport M5). Empty when the flag is off (no rekeying /// happened, so no alias is needed). @@ -761,6 +795,37 @@ mod tests { )) } + #[test] + fn authority_identity_namespaces_unmapped_agents_without_rekeying_private_scope() { + let agent = crate::agent::AgentIdentity { + name: "personal-default".to_string(), + token_hash: [0u8; 32], + }; + let unmapped = McpContext::new_default("test-node").with_agent(agent.clone()); + assert_eq!(unmapped.scope_identity().as_deref(), Some("personal-default")); + assert_eq!(unmapped.authority_identity().as_deref(), Some("agent:personal-default")); + + let flag_on_unmapped = McpContext::new_default("test-node") + .with_agent_passports(true, crate::agent_passport::AgentPassportMap::empty()) + .with_agent(agent.clone()); + assert_eq!(flag_on_unmapped.scope_identity().as_deref(), Some("personal-default")); + assert_eq!( + flag_on_unmapped.authority_identity().as_deref(), + Some("agent:personal-default") + ); + + let mapped = McpContext::new_default("test-node") + .with_agent_passports( + true, + crate::agent_passport::AgentPassportMap::from_pairs_str( + "personal-default:automation-passport:tenant-a", + ), + ) + .with_agent(agent); + assert_eq!(mapped.scope_identity().as_deref(), Some("automation-passport")); + assert_eq!(mapped.authority_identity().as_deref(), Some("automation-passport")); + } + fn rpc(method: &str, params: serde_json::Value) -> JsonRpcRequest { JsonRpcRequest { jsonrpc: "2.0".to_string(), diff --git a/crates/crux-mcp/src/tools/coordination.rs b/crates/crux-mcp/src/tools/coordination.rs index 205ebda0..b5957689 100644 --- a/crates/crux-mcp/src/tools/coordination.rs +++ b/crates/crux-mcp/src/tools/coordination.rs @@ -17,7 +17,7 @@ use crate::protocol::{JsonRpcError, INTERNAL_ERROR, INVALID_PARAMS}; const SCOPES: &str = "admin:read,facts:write"; -use crate::tools::loopback_auth::loopback_bearer_token; +use crate::tools::loopback_auth::{loopback_bearer_token, loopback_bearer_token_for_passport}; pub const LIST_PROJECTS_DESCRIPTION: &str = "List all projects defined on this daemon. Each project carries a planning_target (a tenant or a github repo URL), a default_passport_id, and counts of members + working tenants."; @@ -134,7 +134,19 @@ fn loopback_ok(status: u16, expect_201: bool) -> bool { } pub(crate) async fn loopback_get(url: String) -> Result<(u16, String), JsonRpcError> { - let bearer = loopback_bearer_token(); + loopback_get_scoped(url, None, None).await +} + +pub(crate) async fn loopback_get_scoped( + url: String, + passport: Option, + tenant: Option, +) -> Result<(u16, String), JsonRpcError> { + let bearer = if passport.is_some() || tenant.is_some() { + loopback_bearer_token_for_passport(passport.as_deref(), tenant.as_deref()) + } else { + loopback_bearer_token() + }; let joined = tokio::task::spawn_blocking(move || { let agent = loopback_agent(); let mut req = agent @@ -144,6 +156,12 @@ pub(crate) async fn loopback_get(url: String) -> Result<(u16, String), JsonRpcEr if let Some(token) = &bearer { req = req.header("Authorization", &format!("Bearer {token}")); } + if let Some(passport) = &passport { + req = req.header("X-Corecrux-Passport-Id", passport); + } + if let Some(tenant) = &tenant { + req = req.header("X-Corecrux-Tenant-Id", tenant); + } req.call() .map(|mut r| (r.status().as_u16(), r.body_mut().read_to_string().unwrap_or_default())) .map_err(|e| e.to_string()) @@ -162,8 +180,9 @@ pub(crate) async fn loopback_post( body: Value, expect_201: bool, passport: Option, + tenant: Option, ) -> Result<(u16, String), JsonRpcError> { - let bearer = loopback_bearer_token(); + let bearer = loopback_bearer_token_for_passport(passport.as_deref(), tenant.as_deref()); let joined = tokio::task::spawn_blocking(move || { let agent = loopback_agent(); let mut req = agent @@ -174,13 +193,15 @@ pub(crate) async fn loopback_post( if let Some(token) = &bearer { req = req.header("Authorization", &format!("Bearer {token}")); } - // Forward the bound session passport so the daemon attributes the write - // to a real principal instead of falling back to "anonymous" (the - // loopback JWT's `sub` carries no passport claim). Honoured by - // `corecruxd::auth::http_passport_id`. + // Forward the bound session passport alongside the matching canonical + // JWT claim. The daemon treats the header only as a consistency check, + // never as independent impersonation authority. if let Some(pid) = &passport { req = req.header("X-Corecrux-Passport-Id", pid); } + if let Some(tenant) = &tenant { + req = req.header("X-Corecrux-Tenant-Id", tenant); + } req.send(body.to_string()) .map(|mut r| (r.status().as_u16(), r.body_mut().read_to_string().unwrap_or_default())) .map_err(|e| e.to_string()) @@ -198,8 +219,9 @@ pub(crate) async fn loopback_patch( url: String, body: Value, passport: Option, + tenant: Option, ) -> Result<(u16, String), JsonRpcError> { - let bearer = loopback_bearer_token(); + let bearer = loopback_bearer_token_for_passport(passport.as_deref(), tenant.as_deref()); let joined = tokio::task::spawn_blocking(move || { let agent = loopback_agent(); // PATCH was the ONE loopback helper missing the bearer token — every @@ -216,6 +238,9 @@ pub(crate) async fn loopback_patch( if let Some(pid) = &passport { request = request.header("X-Corecrux-Passport-Id", pid); } + if let Some(tenant) = &tenant { + request = request.header("X-Corecrux-Tenant-Id", tenant); + } request .send(body.to_string()) .map(|mut r| (r.status().as_u16(), r.body_mut().read_to_string().unwrap_or_default())) @@ -230,8 +255,12 @@ pub(crate) async fn loopback_patch( } } -pub(crate) async fn loopback_delete(url: String, passport: Option) -> Result<(u16, String), JsonRpcError> { - let bearer = loopback_bearer_token(); +pub(crate) async fn loopback_delete( + url: String, + passport: Option, + tenant: Option, +) -> Result<(u16, String), JsonRpcError> { + let bearer = loopback_bearer_token_for_passport(passport.as_deref(), tenant.as_deref()); let joined = tokio::task::spawn_blocking(move || { let agent = loopback_agent(); let mut request = agent @@ -244,6 +273,9 @@ pub(crate) async fn loopback_delete(url: String, passport: Option) -> Re if let Some(pid) = &passport { request = request.header("X-Corecrux-Passport-Id", pid); } + if let Some(tenant) = &tenant { + request = request.header("X-Corecrux-Tenant-Id", tenant); + } request .call() .map(|mut r| (r.status().as_u16(), r.body_mut().read_to_string().unwrap_or_default())) @@ -266,6 +298,18 @@ pub(crate) fn text_content(value: Value) -> Value { }) } +fn authority_identity(ctx: &McpContext, tool: &str) -> Result { + ctx.authority_identity().ok_or_else(|| JsonRpcError { + code: INVALID_PARAMS, + message: format!("{tool}: authenticated MCP authority is required"), + data: None, + }) +} + +fn claimed_identity_matches(ctx: &McpContext, claimed: &str, authority: &str) -> bool { + claimed == authority || ctx.scope_identity().as_deref() == Some(claimed) +} + pub async fn handle_list_projects(_args: &Value, ctx: &McpContext) -> Result { let base = loopback_base(ctx)?; let (_, body) = loopback_get(format!("{base}/v1/projects")).await?; @@ -287,19 +331,32 @@ pub async fn handle_get_project_context(args: &Value, ctx: &McpContext) -> Resul } pub async fn handle_list_work(args: &Value, ctx: &McpContext) -> Result { + let tenant = ctx.scope_tenant(); + if args + .get("tenant_id") + .and_then(Value::as_str) + .is_some_and(|requested| requested != tenant) + { + return Err(JsonRpcError { + code: INVALID_PARAMS, + message: "list_work: tenant_id does not match the authenticated MCP agent".to_string(), + data: None, + }); + } let mut params = Vec::new(); - for key in ["project_id", "state", "tenant_id", "assignee_passport"] { + for key in ["project_id", "state", "assignee_passport"] { if let Some(v) = args.get(key).and_then(|v| v.as_str()) { params.push(format!("{key}={}", urlencoding(v))); } } + params.push(format!("tenant_id={}", urlencoding(&tenant))); let qs = if params.is_empty() { String::new() } else { format!("?{}", params.join("&")) }; let base = loopback_base(ctx)?; - let (_, body) = loopback_get(format!("{base}/v1/work{qs}")).await?; + let (_, body) = loopback_get_scoped(format!("{base}/v1/work{qs}"), ctx.authority_identity(), Some(tenant)).await?; Ok(text_content(serde_json::from_str(&body).unwrap_or(Value::String(body)))) } @@ -317,7 +374,7 @@ pub async fn handle_create_work(args: &Value, ctx: &McpContext) -> Result Result Result< .to_string(), data: None, })?; - let by = args + let claimed_by = args .get("by_passport") .and_then(|v| v.as_str()) .ok_or_else(|| JsonRpcError { @@ -373,9 +451,17 @@ pub async fn handle_update_work_state(args: &Value, ctx: &McpContext) -> Result< message: "update_work_state: by_passport is required".to_string(), data: None, })?; + let identity = authority_identity(ctx, "update_work_state")?; + if !claimed_identity_matches(ctx, claimed_by, &identity) { + return Err(JsonRpcError { + code: INVALID_PARAMS, + message: "update_work_state: by_passport does not match the authenticated MCP agent".to_string(), + data: None, + }); + } let mut body = json!({ "state": new_state, - "by_passport": by, + "by_passport": identity, }); if let Some(reason) = args.get("blocker_reason") { body["blocker_reason"] = reason.clone(); @@ -384,7 +470,13 @@ pub async fn handle_update_work_state(args: &Value, ctx: &McpContext) -> Result< body["blocker_kind"] = kind.clone(); } let base = loopback_base(ctx)?; - let (_, resp_body) = loopback_patch(format!("{base}/v1/work/{id}"), body, ctx.scope_identity()).await?; + let (_, resp_body) = loopback_patch( + format!("{base}/v1/work/{id}"), + body, + ctx.authority_identity(), + Some(ctx.scope_tenant()), + ) + .await?; Ok(text_content( serde_json::from_str(&resp_body).unwrap_or(Value::String(resp_body)), )) @@ -399,7 +491,7 @@ pub async fn handle_comment_on_work(args: &Value, ctx: &McpContext) -> Result Result Result Result Result { let mut params = Vec::new(); + let tenant = ctx.scope_tenant(); + params.push(format!("tenant_id={}", urlencoding(&tenant))); if let Some(w) = args.get("work_id").and_then(|v| v.as_str()).filter(|s| !s.is_empty()) { params.push(format!("work_id={}", urlencoding(w))); } @@ -443,21 +546,33 @@ pub async fn handle_status_feed(args: &Value, ctx: &McpContext) -> Result Result { - let qs = match args + let tenant = ctx.scope_tenant(); + let mut params = vec![format!("tenant_id={}", urlencoding(&tenant))]; + if let Some(project_id) = args .get("project_id") - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) + .and_then(|value| value.as_str()) + .filter(|value| !value.is_empty()) { - Some(pid) => format!("?project_id={}", urlencoding(pid)), - None => String::new(), - }; + params.push(format!("project_id={}", urlencoding(project_id))); + } + let qs = format!("?{}", params.join("&")); let base = loopback_base(ctx)?; - let (_, body) = loopback_get(format!("{base}/v1/coord/active{qs}")).await?; + let (_, body) = loopback_get_scoped( + format!("{base}/v1/coord/active{qs}"), + ctx.authority_identity(), + Some(tenant), + ) + .await?; Ok(text_content(serde_json::from_str(&body).unwrap_or(Value::String(body)))) } @@ -489,7 +604,8 @@ pub async fn handle_coord_announce(args: &Value, ctx: &McpContext) -> Result McpContext { + McpContext::new_default("node-a") + .with_daemon_base_url(base) + .with_agent_passports( + true, + crate::agent_passport::AgentPassportMap::from_pairs_str(&format!("{name}:{name}:{tenant}")), + ) + .with_agent(crate::agent::AgentIdentity { + name: name.to_string(), + token_hash: [0u8; 32], + }) + } + #[tokio::test] async fn coordination_handlers_call_loopback_endpoints() { let (base, stop, handle) = serve_coordination_loopback(); - let ctx = McpContext::new_default("node-a").with_daemon_base_url(base.clone()); + let ctx = scoped_agent_context(base.clone(), "p1", "tenant-a"); let projects = text_json(handle_list_projects(&json!({}), &ctx).await.expect("list projects")); assert_eq!(projects["projects"][0]["id"], "alpha"); @@ -792,7 +921,7 @@ mod tests { }); let created = text_json( handle_create_work( - &json!({"project_id": "p", "title": "t", "created_by_passport": "ce:x"}), + &json!({"project_id": "p", "title": "t", "created_by_passport": "anthropic"}), &ctx, ) .await @@ -800,8 +929,8 @@ mod tests { ); stop_loopback(&base, stop, handle); assert_eq!( - created["seen_passport"], "anthropic", - "loopback POST must forward X-Corecrux-Passport-Id from the session" + created["seen_passport"], "agent:anthropic", + "unmapped MCP automation must use a namespaced authority header" ); } @@ -815,7 +944,7 @@ mod tests { std::env::set_var("CRUX_AGENT_TOKEN", "tok_test_patch_m3"); let (base, stop, handle) = serve_patch_requires_auth(); - let ctx = McpContext::new_default("node-a").with_daemon_base_url(base.clone()); + let ctx = scoped_agent_context(base.clone(), "p1", "default"); let res = handle_update_work_state( &json!({"work_id": "w1", "state": "in_progress", "by_passport": "p1"}), &ctx, @@ -862,7 +991,7 @@ mod tests { // `detail`, not a bare "status 404". Disabling ureq's http_status_as_error // is what lets the body through. let (base, stop, handle) = serve_problem_json(); - let ctx = McpContext::new_default("node-a").with_daemon_base_url(base.clone()); + let ctx = scoped_agent_context(base.clone(), "p1", "default"); let err = handle_create_work( &json!({"project_id": "ghost", "title": "t", "created_by_passport": "p1"}), &ctx, @@ -944,7 +1073,14 @@ pub async fn handle_execplan_write(args: &Value, ctx: &McpContext) -> Result bool { + GOVERNED_ENTITY_KINDS.contains(&kind) +} + +fn reject_governed_kind(kind: &str) -> Result<(), JsonRpcError> { + if is_governed_entity_kind(kind) { + return Err(JsonRpcError { + code: INVALID_PARAMS, + message: format!("entity kind '{kind}' is governed by its typed API"), + data: Some(json!({"kind": kind, "code": "GOVERNED_ENTITY_KIND"})), + }); + } + Ok(()) +} + fn require_str<'a>(args: &'a Value, key: &str) -> Result<&'a str, JsonRpcError> { args.get(key).and_then(|v| v.as_str()).ok_or_else(|| JsonRpcError { code: INVALID_PARAMS, @@ -30,6 +49,7 @@ fn actor_from_ctx(ctx: &McpContext) -> String { pub async fn handle_entity_upsert(args: &Value, ctx: &McpContext) -> Result { let kind = require_str(args, "kind")?; + reject_governed_kind(kind)?; let id = require_str(args, "id")?; let payload = args.get("payload").cloned().ok_or_else(|| JsonRpcError { code: INVALID_PARAMS, @@ -65,6 +85,7 @@ pub async fn handle_entity_upsert(args: &Value, ctx: &McpContext) -> Result Result { let kind = require_str(args, "kind")?; + reject_governed_kind(kind)?; let id = require_str(args, "id")?; let include_deleted = args.get("include_deleted").and_then(|v| v.as_bool()).unwrap_or(false); let store = ctx.entity_store.read().await; @@ -86,13 +107,33 @@ pub async fn handle_entity_get(args: &Value, ctx: &McpContext) -> Result Result { + let requested_kind = args.get("kind").and_then(|value| value.as_str()); + if let Some(kind) = requested_kind { + reject_governed_kind(kind)?; + } + let requested_limit = args + .get("limit") + .and_then(|value| value.as_u64()) + .map(|value| value as usize); let q = EntityQuery { - kind: args.get("kind").and_then(|v| v.as_str()).map(String::from), - limit: args.get("limit").and_then(|v| v.as_u64()).map(|n| n as usize), + kind: requested_kind.map(String::from), + // Filter governed rows before limiting so hidden records cannot starve + // an unfiltered generic listing. + limit: requested_kind.and(requested_limit), include_deleted: args.get("include_deleted").and_then(|v| v.as_bool()).unwrap_or(false), }; let store = ctx.entity_store.read().await; - let items: Vec<_> = store.list(&q).into_iter().cloned().collect(); + let mut items: Vec<_> = store + .list(&q) + .into_iter() + .filter(|record| !is_governed_entity_kind(&record.kind)) + .cloned() + .collect(); + if requested_kind.is_none() { + if let Some(limit) = requested_limit { + items.truncate(limit); + } + } Ok(json!({ "content": [{"type":"text","text": format!("listed {} entities", items.len())}], "entities": items, @@ -102,6 +143,7 @@ pub async fn handle_entity_list(args: &Value, ctx: &McpContext) -> Result Result { let kind = require_str(args, "kind")?; + reject_governed_kind(kind)?; let id = require_str(args, "id")?; let store = ctx.entity_store.read().await; let versions: Vec<_> = store.history(kind, id).into_iter().cloned().collect(); @@ -114,6 +156,7 @@ pub async fn handle_entity_history(args: &Value, ctx: &McpContext) -> Result Result { let kind = require_str(args, "kind")?; + reject_governed_kind(kind)?; let id = require_str(args, "id")?; let actor = actor_from_ctx(ctx); let mut store = ctx.entity_store.write().await; @@ -198,4 +241,60 @@ mod tests { .unwrap(); assert!(res["entity"].is_null()); } + + #[tokio::test] + async fn governed_kinds_are_neither_visible_nor_mutable() { + let ctx = McpContext::new_default("test-node"); + { + let mut store = ctx.entity_store.write().await; + store + .upsert( + "orchestrator", + "orc_secret", + json!({"tenant_id":"tenant-b","name":"secret"}), + "seed", + None, + ) + .expect("seed governed entity"); + store + .upsert("capability", "visible", json!({"name":"visible"}), "seed", None) + .expect("seed visible entity"); + } + + for result in [ + handle_entity_get(&json!({"kind":"orchestrator","id":"orc_secret"}), &ctx).await, + handle_entity_upsert( + &json!({ + "kind":"orchestrator", + "id":"orc_secret", + "payload":{"tenant_id":"tenant-a","name":"stolen"} + }), + &ctx, + ) + .await, + handle_entity_history(&json!({"kind":"orchestrator","id":"orc_secret"}), &ctx).await, + handle_entity_delete(&json!({"kind":"orchestrator","id":"orc_secret"}), &ctx).await, + handle_entity_list(&json!({"kind":"orchestrator"}), &ctx).await, + ] { + let error = result.expect_err("governed entity operation must be rejected"); + assert_eq!( + error.data.as_ref().and_then(|data| data["code"].as_str()), + Some("GOVERNED_ENTITY_KIND") + ); + } + + let unfiltered = handle_entity_list(&json!({"limit":1}), &ctx) + .await + .expect("unfiltered list"); + assert_eq!(unfiltered["count"], 1); + assert_eq!(unfiltered["entities"][0]["kind"], "capability"); + + let store = ctx.entity_store.read().await; + let governed = store + .get("orchestrator", "orc_secret") + .expect("governed entity remains"); + assert_eq!(governed.version, 1); + assert!(!governed.deleted); + assert_eq!(governed.payload["tenant_id"], "tenant-b"); + } } diff --git a/crates/crux-mcp/src/tools/loopback_auth.rs b/crates/crux-mcp/src/tools/loopback_auth.rs index 98995189..125b1ca9 100644 --- a/crates/crux-mcp/src/tools/loopback_auth.rs +++ b/crates/crux-mcp/src/tools/loopback_auth.rs @@ -78,6 +78,8 @@ struct LoopbackClaims<'a> { #[serde(skip_serializing_if = "Option::is_none")] aud: Option<&'a str>, sub: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + passport_id: Option<&'a str>, scopes: &'a [&'a str], tenant_id: &'a str, iat: u64, @@ -118,6 +120,7 @@ fn mint_loopback_jwt_inner( aud, &ScopedClaims { sub: "mcp-loopback", + passport_id: None, scopes: LOOPBACK_SCOPES, tenant_id: "*", ttl_secs: JWT_TTL_SECS, @@ -135,6 +138,10 @@ fn mint_loopback_jwt_inner( pub struct ScopedClaims<'a> { /// Token subject (the principal the credential acts as). pub sub: &'a str, + /// Optional canonical passport binding. Internal loopback mutations set + /// this to the MCP session's resolved passport so the daemon does not need + /// to treat `X-Corecrux-Passport-Id` as an impersonation override. + pub passport_id: Option<&'a str>, /// Granted scopes (the daemon's `scopes_from_claims` reads the `scopes` array). pub scopes: &'a [&'a str], /// Tenant binding. Use a concrete tenant id; `"*"` only for cross-tenant @@ -158,6 +165,7 @@ pub fn mint_scoped_jwt_inner( iss, aud, sub: claims.sub, + passport_id: claims.passport_id, scopes: claims.scopes, tenant_id: claims.tenant_id, iat: now_secs, @@ -226,6 +234,25 @@ pub fn loopback_bearer_token() -> Option { resolve_bearer_token(|name| std::env::var(name).ok()) } +/// Resolve a loopback bearer token bound to the supplied MCP-session +/// passport. HS256 mode mints a short-lived token whose canonical +/// `passport_id` claim matches the forwarded header; other modes retain the +/// raw-token fallback and let the daemon apply their normal binding rules. +pub fn loopback_bearer_token_for_passport(passport_id: Option<&str>, tenant_id: Option<&str>) -> Option { + if passport_id.is_some() || tenant_id.is_some() { + if let Some(jwt) = mint_scoped_jwt_from_env(&ScopedClaims { + sub: "mcp-loopback", + passport_id, + scopes: LOOPBACK_SCOPES, + tenant_id: tenant_id.unwrap_or("default"), + ttl_secs: JWT_TTL_SECS, + }) { + return Some(jwt); + } + } + loopback_bearer_token() +} + /// Pure variant of the raw-token resolver used by tests; scans the env-var /// list but reads from the supplied closure instead of the real environment. pub(crate) fn resolve_bearer_token(getter: F) -> Option @@ -370,6 +397,7 @@ mod tests { Some("crux.cuecrux.com"), &ScopedClaims { sub: "ts:alice@example.com", + passport_id: Some("passport-alice"), scopes: &["facts:write", "query:read"], tenant_id: "acme", ttl_secs: 300, @@ -378,6 +406,7 @@ mod tests { .unwrap(); let claims = verify_with_daemon_rules(&token, secret, Some("cuecrux-crux-mint"), Some("crux.cuecrux.com")); assert_eq!(claims["sub"], "ts:alice@example.com"); + assert_eq!(claims["passport_id"], "passport-alice"); assert_eq!(claims["tenant_id"], "acme"); let scopes: Vec<&str> = claims["scopes"] .as_array() @@ -404,6 +433,7 @@ mod tests { None, &ScopedClaims { sub: "mcp-loopback", + passport_id: None, scopes: LOOPBACK_SCOPES, tenant_id: "*", ttl_secs: JWT_TTL_SECS, diff --git a/crates/crux-mcp/src/tools/orchestrators.rs b/crates/crux-mcp/src/tools/orchestrators.rs index 80555d59..adc73d46 100644 --- a/crates/crux-mcp/src/tools/orchestrators.rs +++ b/crates/crux-mcp/src/tools/orchestrators.rs @@ -28,7 +28,7 @@ use serde_json::{json, Value}; use crate::dispatch::McpContext; use crate::protocol::{JsonRpcError, INVALID_PARAMS}; use crate::tools::coordination::{ - loopback_base, loopback_delete, loopback_get, loopback_patch, loopback_post, text_content, + loopback_base, loopback_delete, loopback_get_scoped, loopback_patch, loopback_post, text_content, }; pub const CREATE_ORCHESTRATOR_DESCRIPTION: &str = @@ -54,6 +54,18 @@ fn required_str<'a>(args: &'a Value, key: &str, tool: &str) -> Result<&'a str, J }) } +fn authority_identity(ctx: &McpContext, tool: &str) -> Result { + ctx.authority_identity().ok_or_else(|| JsonRpcError { + code: INVALID_PARAMS, + message: format!("{tool}: authenticated MCP authority is required"), + data: None, + }) +} + +fn claimed_identity_matches(ctx: &McpContext, claimed: &str, authority: &str) -> bool { + claimed == authority || ctx.scope_identity().as_deref() == Some(claimed) +} + fn urlencoding(s: &str) -> String { s.bytes() .map(|b| match b { @@ -65,18 +77,46 @@ fn urlencoding(s: &str) -> String { pub async fn handle_create_orchestrator(args: &Value, ctx: &McpContext) -> Result { let name = required_str(args, "name", "create_orchestrator")?; - let created_by = required_str(args, "created_by_passport", "create_orchestrator")?; + let claimed_created_by = required_str(args, "created_by_passport", "create_orchestrator")?; + let identity = authority_identity(ctx, "create_orchestrator")?; + if !claimed_identity_matches(ctx, claimed_created_by, &identity) { + return Err(JsonRpcError { + code: INVALID_PARAMS, + message: "create_orchestrator: created_by_passport does not match the authenticated MCP agent".to_string(), + data: None, + }); + } + let tenant = ctx.scope_tenant(); + if args + .get("tenant_id") + .and_then(Value::as_str) + .is_some_and(|requested| requested != tenant) + { + return Err(JsonRpcError { + code: INVALID_PARAMS, + message: "create_orchestrator: tenant_id does not match the authenticated MCP agent".to_string(), + data: None, + }); + } let mut body = json!({ "name": name, - "created_by_passport": created_by, + "created_by_passport": identity, + "tenant_id": tenant, }); - for key in ["assignee_passport", "tenant_id", "state"] { + for key in ["assignee_passport", "state"] { if let Some(v) = args.get(key) { body[key] = v.clone(); } } let base = loopback_base(ctx)?; - let (_, resp) = loopback_post(format!("{base}/v1/orchestrators"), body, true, ctx.scope_identity()).await?; + let (_, resp) = loopback_post( + format!("{base}/v1/orchestrators"), + body, + true, + ctx.authority_identity(), + Some(ctx.scope_tenant()), + ) + .await?; Ok(text_content(serde_json::from_str(&resp).unwrap_or(Value::String(resp)))) } @@ -94,7 +134,8 @@ pub async fn handle_attach_to_orchestrator(args: &Value, ctx: &McpContext) -> Re format!("{base}/v1/orchestrators/{id}/members"), body, false, - ctx.scope_identity(), + ctx.authority_identity(), + Some(ctx.scope_tenant()), ) .await?; Ok(text_content(serde_json::from_str(&resp).unwrap_or(Value::String(resp)))) @@ -105,16 +146,27 @@ pub async fn handle_detach_from_orchestrator(args: &Value, ctx: &McpContext) -> let member = required_str(args, "member_ref", "detach_from_orchestrator")?; let base = loopback_base(ctx)?; let url = format!("{base}/v1/orchestrators/{id}/members/{}", urlencoding(member)); - let (_, resp) = loopback_delete(url, ctx.scope_identity()).await?; + let (_, resp) = loopback_delete(url, ctx.authority_identity(), Some(ctx.scope_tenant())).await?; Ok(text_content(serde_json::from_str(&resp).unwrap_or(Value::String(resp)))) } pub async fn handle_list_orchestrators(args: &Value, ctx: &McpContext) -> Result { + let tenant = ctx.scope_tenant(); + if args + .get("tenant_id") + .and_then(Value::as_str) + .is_some_and(|requested| requested != tenant) + { + return Err(JsonRpcError { + code: INVALID_PARAMS, + message: "list_orchestrators: tenant_id does not match the authenticated MCP agent".to_string(), + data: None, + }); + } let mut params = Vec::new(); - for key in ["tenant_id", "state"] { - if let Some(v) = args.get(key).and_then(Value::as_str) { - params.push(format!("{key}={}", urlencoding(v))); - } + params.push(format!("tenant_id={}", urlencoding(&tenant))); + if let Some(state) = args.get("state").and_then(Value::as_str) { + params.push(format!("state={}", urlencoding(state))); } let qs = if params.is_empty() { String::new() @@ -122,7 +174,12 @@ pub async fn handle_list_orchestrators(args: &Value, ctx: &McpContext) -> Result format!("?{}", params.join("&")) }; let base = loopback_base(ctx)?; - let (_, resp) = loopback_get(format!("{base}/v1/orchestrators{qs}")).await?; + let (_, resp) = loopback_get_scoped( + format!("{base}/v1/orchestrators{qs}"), + ctx.authority_identity(), + Some(tenant), + ) + .await?; Ok(text_content(serde_json::from_str(&resp).unwrap_or(Value::String(resp)))) } @@ -142,7 +199,13 @@ pub async fn handle_update_orchestrator(args: &Value, ctx: &McpContext) -> Resul }); } let base = loopback_base(ctx)?; - let (_, resp) = loopback_patch(format!("{base}/v1/orchestrators/{id}"), body, ctx.scope_identity()).await?; + let (_, resp) = loopback_patch( + format!("{base}/v1/orchestrators/{id}"), + body, + ctx.authority_identity(), + Some(ctx.scope_tenant()), + ) + .await?; Ok(text_content(serde_json::from_str(&resp).unwrap_or(Value::String(resp)))) } @@ -252,10 +315,23 @@ mod tests { serde_json::from_str(value["content"][0]["text"].as_str().expect("text content")).expect("json text") } + fn scoped_agent_context(base: String) -> McpContext { + McpContext::new_default("node-a") + .with_daemon_base_url(base) + .with_agent_passports( + true, + crate::agent_passport::AgentPassportMap::from_pairs_str("p1:p1:tenant-a"), + ) + .with_agent(crate::agent::AgentIdentity { + name: "p1".to_string(), + token_hash: [0u8; 32], + }) + } + #[tokio::test] async fn orchestrator_handlers_call_loopback_endpoints() { let (base, stop, handle) = serve_orchestrator_loopback(); - let ctx = McpContext::new_default("node-a").with_daemon_base_url(base.clone()); + let ctx = scoped_agent_context(base.clone()); let created = text_json( handle_create_orchestrator( diff --git a/crates/crux-mcp/src/tools/punchcards.rs b/crates/crux-mcp/src/tools/punchcards.rs index 8c4d796f..25a106a0 100644 --- a/crates/crux-mcp/src/tools/punchcards.rs +++ b/crates/crux-mcp/src/tools/punchcards.rs @@ -67,7 +67,8 @@ pub async fn handle_punch_in(args: &Value, ctx: &McpContext) -> Result Result Result Result` stores one long-lived token that works on both ports, survives daemon restarts, and works with native MCP clients that -send a fixed bearer. Default off, so HTTP stays JWT-only unless you opt in. +send a fixed bearer. An unmapped token acts as the namespaced automation +principal `agent:`; only an explicit `CRUX_AGENT_PASSPORTS` entry +maps it to a real passport. Default off, so HTTP stays JWT-only unless you opt +in. Daemon-side rails 2 and 3 are opt-in and default off (see `config.example.env`: `CORECRUXD_TS_IDENTITY_ENABLED`, `CORECRUXD_DEVICE_GRANT_ENABLED`). Issuance mints diff --git a/docs/api-reference.md b/docs/api-reference.md index ab18b876..5847f85a 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -95,6 +95,27 @@ a client that ignores them sees the response shape it saw before HTTP fact writes do not support `private=true`. Private facts and per-agent visibility are MCP-only features. +### Work and Orchestrators + +Work and orchestrator records are authority-sensitive, tenant-scoped surfaces. +In JWT modes, creator/updater/commenter identity and tenant come from verified +claims; matching body fields are compatibility constraints, not an +impersonation mechanism. A caller cannot list, read, mutate, comment on, attach +members to, or resolve gates for another tenant. + +In local `off`/`dev_scopes` mode, an explicit passport header or matching body +assertion is recorded as `operator:unverified:`. It is not a verified human +identity: work state changes always queue for review. Human gate decisions in +authenticated modes require `facts:write`, a canonical JWT `passport_id`, and +the work tenant; MCP agent tokens and `sub`-only JWTs cannot approve or reject. +An unmapped MCP agent token is attributed as `agent:` and is gated +as automation; only an explicit `CRUX_AGENT_PASSPORTS` mapping may resolve it +to a real passport id. + +The generic `/v1/entities/{kind}/{id}` and MCP `entity_*` APIs reject governed +`orchestrator` records and omit them from unfiltered listings. Use the typed +`/v1/orchestrators` routes so tenant and actor checks cannot be bypassed. + ### Session Store | Method | Path | Description | Auth Scope | @@ -338,6 +359,8 @@ Configured via `CORECRUXD_AUTH_MODE`: | `jwt_jwks` | JWT with JWKS key rotation | Production with key management | Scopes are passed via `Authorization: Bearer ` header. Required scopes are listed per endpoint above. +`X-Corecrux-Passport-Id` is only an unverified local assertion in `off` and +`dev_scopes`; production authority must come from verified token claims. ### Route authorization gate (`CORECRUXD_ROUTE_AUTH`) diff --git a/docs/developer-guide/01-architecture.md b/docs/developer-guide/01-architecture.md index 4fe2b685..9ab1d310 100644 --- a/docs/developer-guide/01-architecture.md +++ b/docs/developer-guide/01-architecture.md @@ -107,7 +107,7 @@ in dev mode gets a 401 with |---|---| | `Authorization: Bearer ` | Token or, in `dev_scopes`, a literal scope list ([auth.rs:373](../../crates/corecruxd/src/auth.rs#L373)) | | `X-Corecrux-Scopes` | Scope list, comma- or whitespace-separated ([auth.rs:363](../../crates/corecruxd/src/auth.rs#L363)) | -| `X-Corecrux-Passport-Id` | Acting passport. Trusted verbatim under `off`/`dev_scopes`; under JWT modes it must match the token's `passport_id` claim or you get 403 `PASSPORT_HEADER_MISMATCH` ([auth.rs:1186](../../crates/corecruxd/src/auth.rs#L1186)) | +| `X-Corecrux-Passport-Id` | Identity selector. Under `off`/`dev_scopes` it is only a caller assertion: authority-sensitive mutations persist it as `operator:unverified:`, and local work state changes always enter review. Under JWT modes it must match the token identity or you get 403 `PASSPORT_HEADER_MISMATCH`; human gate decisions additionally require a canonical `passport_id` claim and reject agent tokens. Unmapped MCP automation is namespaced as `agent:`; only an explicit agent-passport mapping can grant a real passport identity ([auth.rs](../../crates/corecruxd/src/auth.rs)) | | `X-Corecrux-Tenant-Id` | Tenant selector ([auth.rs:1151](../../crates/corecruxd/src/auth.rs#L1151)) | ### Scopes you will meet in this guide diff --git a/llms-full.txt b/llms-full.txt index 54b4c2b8..da32634b 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -1878,6 +1878,27 @@ a client that ignores them sees the response shape it saw before HTTP fact writes do not support `private=true`. Private facts and per-agent visibility are MCP-only features. +### Work and Orchestrators + +Work and orchestrator records are authority-sensitive, tenant-scoped surfaces. +In JWT modes, creator/updater/commenter identity and tenant come from verified +claims; matching body fields are compatibility constraints, not an +impersonation mechanism. A caller cannot list, read, mutate, comment on, attach +members to, or resolve gates for another tenant. + +In local `off`/`dev_scopes` mode, an explicit passport header or matching body +assertion is recorded as `operator:unverified:`. It is not a verified human +identity: work state changes always queue for review. Human gate decisions in +authenticated modes require `facts:write`, a canonical JWT `passport_id`, and +the work tenant; MCP agent tokens and `sub`-only JWTs cannot approve or reject. +An unmapped MCP agent token is attributed as `agent:` and is gated +as automation; only an explicit `CRUX_AGENT_PASSPORTS` mapping may resolve it +to a real passport id. + +The generic `/v1/entities/{kind}/{id}` and MCP `entity_*` APIs reject governed +`orchestrator` records and omit them from unfiltered listings. Use the typed +`/v1/orchestrators` routes so tenant and actor checks cannot be bypassed. + ### Session Store | Method | Path | Description | Auth Scope | @@ -2121,6 +2142,8 @@ Configured via `CORECRUXD_AUTH_MODE`: | `jwt_jwks` | JWT with JWKS key rotation | Production with key management | Scopes are passed via `Authorization: Bearer ` header. Required scopes are listed per endpoint above. +`X-Corecrux-Passport-Id` is only an unverified local assertion in `off` and +`dev_scopes`; production authority must come from verified token claims. ### Route authorization gate (`CORECRUXD_ROUTE_AUTH`) @@ -2346,7 +2369,10 @@ mode the HTTP API then *also* accepts a registered MCP agent token (mapped to `CORECRUXD_AGENT_TOKEN_HTTP_SCOPES` / `CORECRUXD_AGENT_TOKEN_HTTP_TENANT`). Then `corecruxctl login --token ` stores one long-lived token that works on both ports, survives daemon restarts, and works with native MCP clients that -send a fixed bearer. Default off, so HTTP stays JWT-only unless you opt in. +send a fixed bearer. An unmapped token acts as the namespaced automation +principal `agent:`; only an explicit `CRUX_AGENT_PASSPORTS` entry +maps it to a real passport. Default off, so HTTP stays JWT-only unless you opt +in. Daemon-side rails 2 and 3 are opt-in and default off (see `config.example.env`: `CORECRUXD_TS_IDENTITY_ENABLED`, `CORECRUXD_DEVICE_GRANT_ENABLED`). Issuance mints From 6db6db156e9a1604c66b7c23252b5c93d1cb0cfa Mon Sep 17 00:00:00 2001 From: CueCrux-Myles Date: Thu, 30 Jul 2026 14:01:23 +0100 Subject: [PATCH 3/7] security: isolate fact lifecycle by tenant agent: Codex --- crates/corecrux-memory/src/cruxpack.rs | 172 +- crates/corecrux-memory/src/fact_store.rs | 1565 ++++++++++++++--- crates/corecrux-memory/src/legal_hold.rs | 10 +- crates/corecrux-memory/src/sync.rs | 2 +- .../corecrux-memory/tests/sync_low_hanging.rs | 4 +- crates/corecruxctl/src/memory_pack.rs | 4 +- crates/corecruxd/src/auth.rs | 6 + .../corecruxd/src/consolidation_scheduler.rs | 119 +- crates/corecruxd/src/ephemeral_gc.rs | 9 +- crates/corecruxd/src/http/admin.rs | 8 +- crates/corecruxd/src/http/console.rs | 133 +- .../src/http/consolidation_receipt.rs | 104 +- crates/corecruxd/src/http/context_surface.rs | 5 +- crates/corecruxd/src/http/facts.rs | 55 +- crates/corecruxd/src/http/tests.rs | 222 ++- crates/corecruxd/src/passports.rs | 2 +- crates/corecruxd/src/projects.rs | 6 +- crates/corecruxd/src/repo_registry.rs | 9 +- crates/corecruxd/src/tenant_metadata.rs | 8 +- crates/crux-mcp/src/envelope.rs | 2 +- crates/crux-mcp/src/handoff.rs | 143 +- crates/crux-mcp/src/t1_regression.rs | 280 ++- crates/crux-mcp/src/tools/audit_export.rs | 3 +- crates/crux-mcp/src/tools/consolidation.rs | 35 +- crates/crux-mcp/src/tools/facts.rs | 55 +- crates/crux-mcp/src/tools/forget.rs | 163 +- crates/crux-mcp/src/tools/freshness.rs | 22 +- crates/crux-mcp/src/tools/handoff.rs | 6 +- crates/crux-mcp/src/tools/memory.rs | 48 +- crates/crux-mcp/src/tools/memory_use.rs | 3 +- crates/crux-observe/src/ops_layer.rs | 2 +- 31 files changed, 2701 insertions(+), 504 deletions(-) diff --git a/crates/corecrux-memory/src/cruxpack.rs b/crates/corecrux-memory/src/cruxpack.rs index 3ac4eb17..8636df50 100644 --- a/crates/corecrux-memory/src/cruxpack.rs +++ b/crates/corecrux-memory/src/cruxpack.rs @@ -273,6 +273,16 @@ impl Default for ExportOptions { } } +/// Canonical physical tenant used by facts. `local` is the legacy CruxPack +/// wire alias for the historical single-tenant `default` namespace. +pub fn canonical_tenant_id(tenant_id: &str) -> &str { + if tenant_id == "local" { + "default" + } else { + tenant_id + } +} + /// What was *excluded* from (or, under `include_private`, opted into) a pack /// — the CLI prints this before asking for typed confirmation. #[derive(Debug, Clone, Default, Serialize)] @@ -290,8 +300,21 @@ pub struct PrivateSummary { /// Scan the store and report what the private gate would hold back. pub fn private_summary(store: &FactStore) -> PrivateSummary { + private_summary_matching(store, None) +} + +/// Tenant-scoped private/export summary. Foreign-tenant rows are not counted +/// because they are not candidates for this pack. +pub fn private_summary_for_tenant(store: &FactStore, tenant_id: &str) -> PrivateSummary { + private_summary_matching(store, Some(canonical_tenant_id(tenant_id))) +} + +fn private_summary_matching(store: &FactStore, tenant_id: Option<&str>) -> PrivateSummary { let mut summary = PrivateSummary::default(); for fact in store.all_facts() { + if tenant_id.is_some_and(|tenant| fact.tenant_hash != tenant) { + continue; + } if fact.deleted { summary.deleted_excluded += 1; continue; @@ -327,9 +350,13 @@ pub fn build_pack_sections( sessions: Option<&SessionStore>, opts: &ExportOptions, ) -> (PackSections, PrivateSummary) { + let tenant_id = canonical_tenant_id(&opts.tenant_id); let mut summary = PrivateSummary::default(); let mut facts: Vec = Vec::new(); for fact in store.all_facts() { + if fact.tenant_hash != tenant_id { + continue; + } if fact.deleted { // Rule 1 — no flag overrides this. summary.deleted_excluded += 1; @@ -358,7 +385,9 @@ pub fn build_pack_sections( facts.sort_by(|a, b| (&a.entity, &a.key, a.version, &a.fact_id).cmp(&(&b.entity, &b.key, b.version, &b.fact_id))); let mut session_states: Vec = Vec::new(); - if opts.include_sessions { + // SessionState has no trustworthy tenant field. Until it does, only the + // legacy/default tenant may carry sessions in a tenant-bound pack. + if opts.include_sessions && tenant_id == "default" { if let Some(store) = sessions { let mut ids: Vec = store .list() @@ -397,7 +426,7 @@ pub fn build_manifest( daemon_install_fpr: opts.daemon_install_fpr.clone(), passport_fpr: passport_fpr.to_string(), public_key_hex: public_key_hex.to_string(), - tenant_id: opts.tenant_id.clone(), + tenant_id: canonical_tenant_id(&opts.tenant_id).to_string(), exported_at: Utc::now().to_rfc3339(), since: opts.since.map(|t| t.to_rfc3339()), chain_head: opts.chain_head.clone(), @@ -481,6 +510,14 @@ pub enum PackVerifyError { InvalidAgentPrivateOwnerMapping { owner: String, mapped: String }, #[error("pack was exported for tenant '{pack_tenant}' but import targets tenant '{import_tenant}' (T.1)")] TenantMismatch { pack_tenant: String, import_tenant: String }, + #[error("pack fact '{fact_id}' belongs to tenant '{fact_tenant}', not manifest tenant '{manifest_tenant}'")] + FactTenantMismatch { + fact_id: String, + fact_tenant: String, + manifest_tenant: String, + }, + #[error("tenant '{tenant}' pack contains unscoped sessions")] + UnscopedSessions { tenant: String }, } fn decode_content_hash(stated: &str) -> Result<[u8; 32], PackVerifyError> { @@ -541,6 +578,24 @@ pub fn verify_pack(pack: &CruxPack) -> Result<[u8; 32], PackVerifyError> { { return Err(PackVerifyError::PrivateInconsistent); } + let manifest_tenant = canonical_tenant_id(&pack.manifest.tenant_id); + if let Some(fact) = pack + .sections + .facts + .iter() + .find(|fact| canonical_tenant_id(&fact.tenant_hash) != manifest_tenant) + { + return Err(PackVerifyError::FactTenantMismatch { + fact_id: fact.fact_id.clone(), + fact_tenant: fact.tenant_hash.clone(), + manifest_tenant: pack.manifest.tenant_id.clone(), + }); + } + if manifest_tenant != "default" && !pack.sections.sessions.is_empty() { + return Err(PackVerifyError::UnscopedSessions { + tenant: pack.manifest.tenant_id.clone(), + }); + } // 5) Recompute + compare content hash. let recomputed = cruxpack_content_hash(&pack.manifest, &pack.sections) @@ -637,7 +692,9 @@ pub fn plan_import( ) -> Result { verify_pack(pack)?; - if pack.manifest.tenant_id != opts.tenant_id { + let pack_tenant = canonical_tenant_id(&pack.manifest.tenant_id); + let import_tenant = canonical_tenant_id(&opts.tenant_id); + if pack_tenant != import_tenant { return Err(PackVerifyError::TenantMismatch { pack_tenant: pack.manifest.tenant_id.clone(), import_tenant: opts.tenant_id.clone(), @@ -648,7 +705,7 @@ pub fn plan_import( // Facts this exact pack already delivered (idempotent re-import). let already_imported: BTreeSet<(String, String, String)> = store - .all_facts() + .all_facts_for_tenant(import_tenant) .filter(|f| !f.deleted && f.source_receipt.as_deref() == Some(pack_ref.as_str())) .map(|f| (f.entity.clone(), f.key.clone(), f.value.clone())) .collect(); @@ -703,7 +760,10 @@ pub fn plan_import( plan.skipped_duplicates += 1; continue; } - let collides = store.fact_history(&entity, &fact.key).iter().any(|f| !f.deleted); + let collides = store + .fact_history(import_tenant, &entity, &fact.key) + .iter() + .any(|f| !f.deleted); if collides { plan.collisions += 1; } @@ -715,7 +775,7 @@ pub fn plan_import( .as_ref() .map(|a| opts.principal_map.get(a).cloned().unwrap_or_else(|| a.clone())); plan.to_store.push(StoreFact { - tenant_hash: fact.tenant_hash.clone(), + tenant_hash: import_tenant.to_string(), entity, key: fact.key.clone(), value: fact.value.clone(), @@ -785,6 +845,13 @@ mod tests { } } + fn sf_for_tenant(tenant: &str, entity: &str, key: &str, value: &str, private: bool) -> StoreFact { + StoreFact { + tenant_hash: tenant.to_string(), + ..sf(entity, key, value, private) + } + } + fn build_signed(store: &FactStore, sessions: Option<&SessionStore>, opts: &ExportOptions) -> CruxPack { let key = signing_key(); let (fpr, pub_hex) = signer_identity(&key); @@ -882,7 +949,7 @@ mod tests { let mut store = FactStore::new(); let kept = store.store(sf("keep", "k", "kept-value", false)); let erased = store.store(sf("erase", "k", "erased-pii-value", false)); - store.delete(&erased.fact_id); + store.delete("default", &erased.fact_id); // Even with include_private (the widest export), deleted stays home. let mut o = opts("local"); @@ -1035,6 +1102,93 @@ mod tests { assert!(matches!(err, PackVerifyError::TenantMismatch { .. })); } + #[test] + fn export_contains_only_the_manifest_tenant() { + let store = store_with(vec![ + sf_for_tenant("tenant-a", "shared", "a", "a-value", false), + sf_for_tenant("tenant-b", "shared", "b", "b-value", false), + ]); + + let (sections, summary) = build_pack_sections(&store, None, &opts("tenant-a")); + assert_eq!(sections.facts.len(), 1); + assert_eq!(sections.facts[0].tenant_hash, "tenant-a"); + assert_eq!(sections.facts[0].value, "a-value"); + assert_eq!(summary.private_flagged, 0); + } + + #[test] + fn signed_mixed_tenant_pack_is_rejected_atomically() { + let source = store_with(vec![sf_for_tenant("tenant-a", "shared", "k", "a-value", false)]); + let mut sections = build_pack_sections(&source, None, &opts("tenant-a")).0; + let mut foreign = sections.facts[0].clone(); + foreign.fact_id = "f_foreign".to_string(); + foreign.tenant_hash = "tenant-b".to_string(); + sections.facts.push(foreign); + let pack = sign_sections(sections, &opts("tenant-a")); + + let err = plan_import( + &pack, + &FactStore::new(), + None, + &ImportOptions { + tenant_id: "tenant-a".to_string(), + ..ImportOptions::default() + }, + ) + .expect_err("mixed tenant rows must fail before any plan is returned"); + assert!(matches!(err, PackVerifyError::FactTenantMismatch { .. })); + } + + #[test] + fn local_alias_maps_to_default_and_import_collision_is_tenant_local() { + let source = store_with(vec![sf("shared", "k", "incoming", false)]); + let pack = build_signed(&source, None, &opts("local")); + assert_eq!(pack.manifest.tenant_id, "default"); + verify_pack(&pack).expect("canonical default pack verifies"); + let key = signing_key(); + let mut legacy_manifest = pack.manifest.clone(); + legacy_manifest.tenant_id = "local".to_string(); + let legacy_pack = sign_pack(legacy_manifest, pack.sections.clone(), |hash| key.sign(hash).to_bytes()) + .expect("legacy alias pack signs"); + verify_pack(&legacy_pack).expect("signed local/default legacy pack remains valid"); + + let mut target = store_with(vec![sf_for_tenant( + "tenant-b", + "shared", + "k", + "foreign-local-value", + false, + )]); + let plan = plan_import( + &legacy_pack, + &target, + None, + &ImportOptions { + tenant_id: "local".to_string(), + ..ImportOptions::default() + }, + ) + .expect("legacy local alias imports into default"); + assert_eq!(plan.collisions, 0); + assert_eq!(plan.to_store[0].tenant_hash, "default"); + target.try_store_bulk(plan.to_store).unwrap(); + assert_eq!(target.fact_history("default", "shared", "k").len(), 1); + assert_eq!(target.fact_history("tenant-b", "shared", "k").len(), 1); + } + + #[test] + fn non_default_pack_cannot_carry_unscoped_sessions() { + let source = store_with(vec![sf_for_tenant("tenant-a", "shared", "k", "value", false)]); + let mut sessions = SessionStore::new(); + sessions.put("session-a", serde_json::json!({"secret": true}), None); + + let pack = build_signed(&source, Some(&sessions), &opts("tenant-a")); + assert!( + pack.sections.sessions.is_empty(), + "unscoped SessionState rows must not enter a non-default tenant pack" + ); + } + #[test] fn import_rejects_daemon_owned_control_records_even_with_private_consent() { let mut source = FactStore::new(); @@ -1199,7 +1353,7 @@ mod tests { // value is retired (reviewable), never destroyed. assert_eq!(stored[0].version, 2); assert!(stored[0].supersedes.is_some()); - let history = target.fact_history("shared", "k"); + let history = target.fact_history("default", "shared", "k"); assert_eq!(history.len(), 2); assert_eq!(history[0].value, "local-value"); // still present assert_eq!(history[0].superseded_by.as_deref(), Some(stored[0].fact_id.as_str())); @@ -1295,7 +1449,7 @@ mod tests { store_a.store(sf("bench:lme-s", "baseline", "91.2%", false)); store_a.store(sf("secret", "k", "private-stays-home", true)); let dead = store_a.store(sf("gone", "k", "erased", false)); - store_a.delete(&dead.fact_id); + store_a.delete("default", &dead.fact_id); let pack = build_signed(&store_a, None, &opts("local")); diff --git a/crates/corecrux-memory/src/fact_store.rs b/crates/corecrux-memory/src/fact_store.rs index f779c88a..89518eed 100644 --- a/crates/corecrux-memory/src/fact_store.rs +++ b/crates/corecrux-memory/src/fact_store.rs @@ -72,6 +72,16 @@ enum JournalEvent { superseded_fact_ids: Vec, consolidated_at: String, }, + /// Content-free consolidation provenance retained by journal compaction. + /// Replay accepts it only when the canonical and every source already form + /// the same-tenant supersession edges recorded here. + #[serde(rename = "consolidation_provenance")] + ConsolidationProvenance { + canonical_fact_id: String, + source_fact_ids: Vec, + tenant_hash: String, + recorded_at: String, + }, /// Atomic, reversible undo of a `Consolidate` (buyer-fit M2). Retires the /// generated canonical and restores (`superseded_by = None`) every source. /// One append ⇒ all-or-nothing; idempotent (re-undo of an already-undone @@ -379,10 +389,16 @@ pub struct ConsolidationUndoReportV1 { pub enum ConsolidationErrorV1 { #[error("consolidation requires at least one target fact")] NoTargets, + #[error("consolidation_id must not be empty")] + MissingConsolidationId, #[error("target fact not found: {0}")] TargetNotFound(String), #[error("target fact is deleted: {0}")] TargetDeleted(String), + #[error("target fact is already superseded: {0}")] + TargetAlreadySuperseded(String), + #[error("duplicate target fact id: {0}")] + DuplicateTarget(String), #[error("target fact is protected by caller: {0}")] TargetPinned(String), #[error("target fact is private: {0}")] @@ -395,6 +411,16 @@ pub enum ConsolidationErrorV1 { TargetHighConfidence { fact_id: String, confidence: String }, #[error("target fact is outside requested entity/key: {0}")] TargetOutsideEntityKey(String), + #[error("current prior version must be an explicitly validated target: {0}")] + ImplicitPriorNotTarget(String), + #[error("consolidation undo requires a non-empty exact source set")] + NoUndoSources, + #[error("fact is not a consolidation canonical: {0}")] + NotConsolidationCanonical(String), + #[error("consolidation canonical has a newer successor and cannot be undone: {0}")] + CanonicalSuperseded(String), + #[error("consolidation undo source set does not match canonical edges: {0}")] + UndoSourceMismatch(String), #[error("fact journal append failed: {0}")] Journal(String), } @@ -458,8 +484,15 @@ fn default_top_k() -> usize { pub struct FactStore { facts: HashMap, entity_index: HashMap>, - /// Index of (entity, key) → ordered list of fact_ids (version chain). - key_index: HashMap<(String, String), Vec>, + /// Index of (tenant, entity, key) → ordered list of fact_ids (version chain). + /// + /// Tenant is part of the chain identity: a same-named write in tenant B + /// must never advance or retire tenant A's predecessor. + key_index: HashMap<(String, String, String), Vec>, + /// Canonical fact id → exact source ids recorded by a durable + /// `JournalEvent::Consolidate`. This is provenance, not caller-controlled + /// fact content, and is rebuilt on replay. + consolidation_sources: HashMap>, /// Path to the JSONL journal file. `None` for pure in-memory mode. journal_path: Option, /// Optional event bus for real-time mutation notifications. @@ -662,7 +695,10 @@ impl FactStore { let Some(other) = self.facts.get(other_id) else { continue; }; - if other.deleted || other.entity.starts_with("__") || (other.entity == fact.entity && other.key == fact.key) + if other.deleted + || other.tenant_hash != fact.tenant_hash + || other.entity.starts_with("__") + || (other.entity == fact.entity && other.key == fact.key) { continue; } @@ -749,6 +785,7 @@ impl FactStore { facts: HashMap::new(), entity_index: HashMap::new(), key_index: HashMap::new(), + consolidation_sources: HashMap::new(), journal_path: Some(journal_path.clone()), event_bus: None, embedder: None, @@ -805,27 +842,64 @@ impl FactStore { } match serde_json::from_str::(trimmed) { Ok(JournalEvent::Store { fact }) => { - self.replay_journal_insert(fact); + let _ = self.replay_journal_insert(fact); } Ok(JournalEvent::StoreBatch { facts }) => { for fact in facts { - self.replay_journal_insert(fact); + let _ = self.replay_journal_insert(fact); } } Ok(JournalEvent::Delete { fact_id, .. }) => { - if let Some(fact) = self.facts.get_mut(&fact_id) { + let source_tenant = self.facts.get(&fact_id).map(|fact| fact.tenant_hash.clone()); + let protected_source = source_tenant + .as_deref() + .is_some_and(|tenant| self.is_active_consolidation_source_for_tenant(&fact_id, tenant)); + if self.consolidation_sources.contains_key(&fact_id) || protected_source { + tracing::warn!( + %fact_id, + "fact-journal-delete-active-consolidation-member-skip" + ); + } else if let Some(fact) = self.facts.get_mut(&fact_id) { fact.deleted = true; } } Ok(JournalEvent::Supersede { fact_id, by_fact_id, .. }) => { - if let Some(fact) = self.facts.get_mut(&fact_id) { - fact.superseded_by = Some(by_fact_id); + let same_tenant = self + .facts + .get(&fact_id) + .zip(self.facts.get(&by_fact_id)) + .is_some_and(|(target, successor)| target.tenant_hash == successor.tenant_hash); + let source_tenant = self.facts.get(&fact_id).map(|fact| fact.tenant_hash.clone()); + let protected_source = source_tenant + .as_deref() + .is_some_and(|tenant| self.is_active_consolidation_source_for_tenant(&fact_id, tenant)); + if same_tenant && !protected_source { + if let Some(fact) = self.facts.get_mut(&fact_id) { + fact.superseded_by = Some(by_fact_id); + } + } else if protected_source { + tracing::warn!( + %fact_id, + %by_fact_id, + "fact-journal-supersede-active-consolidation-source-skip" + ); + } else { + tracing::warn!(%fact_id, %by_fact_id, "fact-journal-cross-tenant-supersede-skip"); } } Ok(JournalEvent::ClearSupersede { fact_id, .. }) => { - if let Some(fact) = self.facts.get_mut(&fact_id) { + let source_tenant = self.facts.get(&fact_id).map(|fact| fact.tenant_hash.clone()); + let protected_source = source_tenant + .as_deref() + .is_some_and(|tenant| self.is_active_consolidation_source_for_tenant(&fact_id, tenant)); + if protected_source { + tracing::warn!( + %fact_id, + "fact-journal-clear-active-consolidation-source-skip" + ); + } else if let Some(fact) = self.facts.get_mut(&fact_id) { fact.superseded_by = None; } } @@ -841,16 +915,42 @@ impl FactStore { } } Ok(JournalEvent::Consolidate { - canonical, + mut canonical, superseded_fact_ids, .. }) => { + if canonical.tenant_hash.trim().is_empty() { + canonical.tenant_hash = default_tenant_hash(); + } let canonical_id = canonical.fact_id.clone(); - self.replay_journal_insert(canonical); - for id in superseded_fact_ids { - if let Some(fact) = self.facts.get_mut(&id) { - fact.superseded_by = Some(canonical_id.clone()); + let canonical_tenant = canonical.tenant_hash.clone(); + let unique_sources: std::collections::HashSet<&str> = + superseded_fact_ids.iter().map(String::as_str).collect(); + let sources_valid = !superseded_fact_ids.is_empty() + && unique_sources.len() == superseded_fact_ids.len() + && canonical + .supersedes + .as_deref() + .is_none_or(|prior| unique_sources.contains(prior)) + && superseded_fact_ids.iter().all(|id| { + self.facts.get(id).is_some_and(|fact| { + !fact.deleted && fact.tenant_hash == canonical_tenant && fact.superseded_by.is_none() + }) + }); + let canonical_available = !canonical.deleted && !self.facts.contains_key(&canonical_id); + if !canonical_available || !sources_valid { + tracing::warn!( + %canonical_id, + %canonical_tenant, + "fact-journal-invalid-consolidation-skip" + ); + } else if self.replay_journal_insert(canonical) { + for id in &superseded_fact_ids { + if let Some(fact) = self.facts.get_mut(id) { + fact.superseded_by = Some(canonical_id.clone()); + } } + self.consolidation_sources.insert(canonical_id, superseded_fact_ids); } } Ok(JournalEvent::ConsolidateUndo { @@ -858,13 +958,79 @@ impl FactStore { restored_fact_ids, .. }) => { - if let Some(fact) = self.facts.get_mut(&canonical_fact_id) { - fact.deleted = true; - } - for id in restored_fact_ids { - if let Some(fact) = self.facts.get_mut(&id) { - fact.superseded_by = None; + let canonical = self.facts.get(&canonical_fact_id); + let recorded = self.consolidation_sources.get(&canonical_fact_id).cloned(); + let mut supplied = restored_fact_ids; + supplied.sort(); + supplied.dedup(); + let mut expected = recorded.unwrap_or_default(); + expected.sort(); + let exact_sources = !expected.is_empty() && supplied == expected; + let can_apply = canonical.is_some_and(|canonical| { + !canonical.deleted + && canonical.superseded_by.is_none() + && exact_sources + && expected.iter().all(|id| { + self.facts.get(id).is_some_and(|fact| { + !fact.deleted + && fact.tenant_hash == canonical.tenant_hash + && fact.superseded_by.as_deref() == Some(canonical_fact_id.as_str()) + }) + }) + }); + let already_applied = canonical.is_some_and(|canonical| { + canonical.deleted + && exact_sources + && expected.iter().all(|id| { + self.facts.get(id).is_some_and(|fact| { + !fact.deleted + && fact.tenant_hash == canonical.tenant_hash + && fact.superseded_by.is_none() + }) + }) + }); + if can_apply { + if let Some(fact) = self.facts.get_mut(&canonical_fact_id) { + fact.deleted = true; + } + for id in expected { + if let Some(fact) = self.facts.get_mut(&id) { + fact.superseded_by = None; + } } + } else if !already_applied { + tracing::warn!( + %canonical_fact_id, + "fact-journal-invalid-consolidation-undo-skip" + ); + } + } + Ok(JournalEvent::ConsolidationProvenance { + canonical_fact_id, + source_fact_ids, + tenant_hash, + .. + }) => { + let canonical_valid = self + .facts + .get(&canonical_fact_id) + .is_some_and(|fact| !fact.deleted && fact.tenant_hash == tenant_hash); + let sources_valid = !source_fact_ids.is_empty() + && source_fact_ids.iter().all(|id| { + self.facts.get(id).is_some_and(|fact| { + !fact.deleted + && fact.tenant_hash == tenant_hash + && fact.superseded_by.as_deref() == Some(canonical_fact_id.as_str()) + }) + }); + if canonical_valid && sources_valid { + self.consolidation_sources.insert(canonical_fact_id, source_fact_ids); + } else { + tracing::warn!( + %canonical_fact_id, + %tenant_hash, + "fact-journal-invalid-consolidation-provenance-skip" + ); } } Err(err) => { @@ -872,33 +1038,98 @@ impl FactStore { } } } + self.sanitize_replayed_links(); Ok(()) } + /// Remove legacy/malicious cross-tenant chain edges after replay without + /// rewriting version numbers or historical delete tombstones. + fn sanitize_replayed_links(&mut self) { + let invalid_supersedes: Vec = self + .facts + .values() + .filter(|fact| { + fact.supersedes.as_deref().is_some_and(|previous_id| { + self.facts.get(previous_id).is_some_and(|previous| { + previous.tenant_hash != fact.tenant_hash + || previous.entity != fact.entity + || previous.key != fact.key + }) + }) + }) + .map(|fact| fact.fact_id.clone()) + .collect(); + let invalid_superseded_by: Vec = self + .facts + .values() + .filter(|fact| { + fact.superseded_by.as_deref().is_some_and(|successor_id| { + self.facts + .get(successor_id) + .is_some_and(|successor| successor.tenant_hash != fact.tenant_hash) + }) + }) + .map(|fact| fact.fact_id.clone()) + .collect(); + + for fact_id in invalid_supersedes { + if let Some(fact) = self.facts.get_mut(&fact_id) { + tracing::warn!(%fact_id, "fact-journal-invalid-version-link-cleared"); + fact.supersedes = None; + } + } + for fact_id in invalid_superseded_by { + if let Some(fact) = self.facts.get_mut(&fact_id) { + tracing::warn!(%fact_id, "fact-journal-invalid-supersession-link-cleared"); + fact.superseded_by = None; + } + } + } + /// Insert a fact directly into the HashMap and indexes WITHOUT appending /// to the journal. Used during replay to avoid re-writing events. - fn replay_journal_insert(&mut self, mut fact: Fact) { + fn replay_journal_insert(&mut self, mut fact: Fact) -> bool { // Upgrade hardening: rows written before a namespace became // born-private must acquire the current privacy classification during // replay. Otherwise a stale `private:false` control row can re-enter // sync, export, retention, and generic mutation surfaces after restart. crate::fact_privacy::enforce_global_fact(&mut fact); + if fact.tenant_hash.trim().is_empty() { + fact.tenant_hash = default_tenant_hash(); + } + if let Some(existing) = self.facts.get(&fact.fact_id) { + tracing::warn!( + fact_id = %fact.fact_id, + existing_tenant = %existing.tenant_hash, + incoming_tenant = %fact.tenant_hash, + "fact-journal-duplicate-fact-id-skip" + ); + return false; + } let fact_id = fact.fact_id.clone(); + let tenant_hash = fact.tenant_hash.clone(); let entity = fact.entity.clone(); let key = fact.key.clone(); self.entity_index .entry(entity.clone()) .or_default() .push(fact_id.clone()); - self.key_index.entry((entity, key)).or_default().push(fact_id.clone()); + self.key_index + .entry((tenant_hash, entity, key)) + .or_default() + .push(fact_id.clone()); self.facts.insert(fact_id, fact); + true } - fn build_fact(&self, req: StoreFact) -> Fact { + fn build_fact(&self, mut req: StoreFact) -> Fact { + if req.tenant_hash.trim().is_empty() { + req.tenant_hash = default_tenant_hash(); + } let fact_id = format!("f_{}", uuid::Uuid::new_v4().to_string().replace('-', "")); let tokens = estimate_tokens(&req.value); - let key_pair = (req.entity.clone(), req.key.clone()); + let key_pair = (req.tenant_hash.clone(), req.entity.clone(), req.key.clone()); let (version, supersedes) = match self.key_index.get(&key_pair) { Some(chain) => { let prev = chain @@ -962,6 +1193,24 @@ impl FactStore { } } + /// Tenant-authorized variant of [`Self::set_horizon`]. + /// + /// Fact ids are globally unique identifiers, but they are not authority + /// tokens. Request-facing callers must use this method so possession of an + /// id from another tenant cannot mutate that tenant's fact. + pub fn set_horizon_for_tenant(&mut self, tenant_hash: &str, fact_id: &str, horizon_class: HorizonClass) -> bool { + if let Some(fact) = self + .facts + .get_mut(fact_id) + .filter(|fact| fact.tenant_hash == tenant_hash) + { + fact.horizon_class = horizon_class; + true + } else { + false + } + } + /// Bump the `reverified_at` anchor on a fact, recording that an /// agent (or operator) has re-confirmed the fact is still accurate. /// Re-anchors decay without rewriting the value. @@ -976,6 +1225,20 @@ impl FactStore { } } + /// Tenant-authorized variant of [`Self::reverify`]. + pub fn reverify_for_tenant(&mut self, tenant_hash: &str, fact_id: &str, now: DateTime) -> bool { + if let Some(fact) = self + .facts + .get_mut(fact_id) + .filter(|fact| fact.tenant_hash == tenant_hash) + { + fact.reverified_at = Some(now); + true + } else { + false + } + } + /// Mark `target_fact_id` as explicitly superseded by `by_fact_id` (M6). /// /// This is the cross-entity retirement primitive: unlike the @@ -983,12 +1246,22 @@ impl FactStore { /// superseding fact may live under a *different* entity. Reversible /// soft-state — never hard-deletes the target. The mutation is /// journaled (mirrors soft-delete's `try_delete`) so it survives a - /// restart. Returns `true` if the target existed. + /// restart. Returns `true` only when both facts exist in `tenant_hash`. /// /// Idempotent: re-marking with the same `by_fact_id` is a no-op write /// of the same value (still journaled for an explicit audit trail). - pub fn mark_superseded(&mut self, target_fact_id: &str, by_fact_id: &str) -> bool { - if !self.facts.contains_key(target_fact_id) { + pub fn mark_superseded(&mut self, tenant_hash: &str, target_fact_id: &str, by_fact_id: &str) -> bool { + if self.is_active_consolidation_source_for_tenant(target_fact_id, tenant_hash) { + return false; + } + let same_authorized_tenant = self + .facts + .get(target_fact_id) + .zip(self.facts.get(by_fact_id)) + .is_some_and(|(target, successor)| { + target.tenant_hash == tenant_hash && successor.tenant_hash == tenant_hash + }); + if !same_authorized_tenant { return false; } if let Err(err) = self.append_journal(&JournalEvent::Supersede { @@ -1008,9 +1281,16 @@ impl FactStore { /// Reverse of [`Self::mark_superseded`] (M6): un-retire a fact by clearing /// its `superseded_by` marker. Journaled for restart-survival. - /// Returns `true` if the fact existed. - pub fn clear_superseded(&mut self, fact_id: &str) -> bool { - if !self.facts.contains_key(fact_id) { + /// Returns `true` if the fact existed in `tenant_hash`. + pub fn clear_superseded(&mut self, tenant_hash: &str, fact_id: &str) -> bool { + if self.is_active_consolidation_source_for_tenant(fact_id, tenant_hash) { + return false; + } + if self + .facts + .get(fact_id) + .is_none_or(|fact| fact.tenant_hash != tenant_hash) + { return false; } if let Err(err) = self.append_journal(&JournalEvent::ClearSupersede { @@ -1091,7 +1371,7 @@ impl FactStore { .or_default() .push(fact.fact_id.clone()); self.key_index - .entry((fact.entity.clone(), fact.key.clone())) + .entry((fact.tenant_hash.clone(), fact.entity.clone(), fact.key.clone())) .or_default() .push(fact.fact_id.clone()); self.facts.insert(fact.fact_id.clone(), fact.clone()); @@ -1195,7 +1475,7 @@ impl FactStore { /// the same predecessor here is idempotent. fn supersede_prior_version(&mut self, fact: &Fact) { if let Some(prev_id) = fact.supersedes.clone() { - self.mark_superseded(&prev_id, &fact.fact_id); + self.mark_superseded(&fact.tenant_hash, &prev_id, &fact.fact_id); } } @@ -1242,9 +1522,18 @@ impl FactStore { Ok(facts) } - /// Soft-delete a fact by ID. Returns true if the fact existed. - pub fn delete(&mut self, fact_id: &str) -> bool { - if let Some(fact) = self.facts.get_mut(fact_id) { + /// Soft-delete a fact by ID. Returns true if it existed in `tenant_hash`. + pub fn delete(&mut self, tenant_hash: &str, fact_id: &str) -> bool { + if self.is_consolidation_canonical_for_tenant(fact_id, tenant_hash) + || self.is_active_consolidation_source_for_tenant(fact_id, tenant_hash) + { + return false; + } + if let Some(fact) = self + .facts + .get_mut(fact_id) + .filter(|fact| fact.tenant_hash == tenant_hash) + { fact.deleted = true; if let Err(err) = self.append_journal(&JournalEvent::Delete { fact_id: fact_id.to_string(), @@ -1264,15 +1553,28 @@ impl FactStore { } /// Soft-delete a fact only after its tombstone has been durably appended. - pub fn try_delete(&mut self, fact_id: &str) -> std::io::Result { - if !self.facts.contains_key(fact_id) { + pub fn try_delete(&mut self, tenant_hash: &str, fact_id: &str) -> std::io::Result { + if self.is_consolidation_canonical_for_tenant(fact_id, tenant_hash) + || self.is_active_consolidation_source_for_tenant(fact_id, tenant_hash) + { + return Ok(false); + } + if self + .facts + .get(fact_id) + .is_none_or(|fact| fact.tenant_hash != tenant_hash) + { return Ok(false); } self.append_journal(&JournalEvent::Delete { fact_id: fact_id.to_string(), deleted_at: Utc::now().to_rfc3339(), })?; - if let Some(fact) = self.facts.get_mut(fact_id) { + if let Some(fact) = self + .facts + .get_mut(fact_id) + .filter(|fact| fact.tenant_hash == tenant_hash) + { fact.deleted = true; if let Some(bus) = &self.event_bus { bus.emit(crate::events::CruxEvent::FactDeleted { @@ -1295,6 +1597,44 @@ impl FactStore { self.get(fact_id).filter(|f| f.tenant_hash == tenant_hash) } + /// Tenant-scoped audit lookup that retains soft-deleted rows. + pub fn get_for_tenant_including_deleted(&self, fact_id: &str, tenant_hash: &str) -> Option<&Fact> { + self.facts.get(fact_id).filter(|fact| fact.tenant_hash == tenant_hash) + } + + /// Whether the id names a canonical created by a durable consolidation in + /// this tenant. Generic deletion must route such facts through undo. + pub fn is_consolidation_canonical_for_tenant(&self, fact_id: &str, tenant_hash: &str) -> bool { + self.consolidation_sources.contains_key(fact_id) + && self + .facts + .get(fact_id) + .is_some_and(|fact| fact.tenant_hash == tenant_hash) + } + + /// Whether a fact is currently retired by a live consolidation canonical. + /// These edges are immutable until the dedicated undo commits. + pub fn is_active_consolidation_source_for_tenant(&self, fact_id: &str, tenant_hash: &str) -> bool { + self.active_consolidation_for_source(fact_id, Some(tenant_hash)) + .is_some() + } + + fn active_consolidation_for_source<'a>( + &'a self, + source_fact_id: &str, + tenant_hash: Option<&str>, + ) -> Option<&'a str> { + self.consolidation_sources + .iter() + .find(|(canonical_id, source_ids)| { + source_ids.iter().any(|id| id == source_fact_id) + && self.facts.get(*canonical_id).is_some_and(|canonical| { + !canonical.deleted && tenant_hash.is_none_or(|tenant| canonical.tenant_hash == tenant) + }) + }) + .map(|(canonical_id, _)| canonical_id.as_str()) + } + /// Get all facts for an entity. /// Unfiltered — internal / admin only; does NOT apply the tenant filter. Request-path callers must use the *_for_tenant variant (audit H2). pub fn get_by_entity(&self, entity: &str) -> Vec<&Fact> { @@ -1519,12 +1859,13 @@ impl FactStore { /// Pure arithmetic — no model call, ever. `token_budget`, when set, caps how /// many candidate facts are scanned (honest, bounded cost); the report says /// whether the answer was budget-truncated. - pub fn aggregate_v1(&self, req: &AggregateRequestV1) -> AggregateResultV1 { + pub fn aggregate_v1(&self, tenant_hash: &str, req: &AggregateRequestV1) -> AggregateResultV1 { // Candidate facts: visible latest rows matching the filter, in a stable // order (by fact_id) so the scan + any budget truncation is deterministic. let mut candidates: Vec<&Fact> = self .facts .values() + .filter(|f| f.tenant_hash == tenant_hash) .filter(|f| !f.deleted && f.superseded_by.is_none() && !f.private) .filter(|f| req.entity.as_deref().is_none_or(|e| f.entity == e)) .filter(|f| req.key.as_deref().is_none_or(|k| f.key == k)) @@ -1569,7 +1910,7 @@ impl FactStore { // Numeric change in an (entity,key)'s value between the value // that was current at `as_of` and the current value. Requires // entity+key; returns null if either endpoint is non-numeric. - return self.temporal_diff(req, tokens_scanned); + return self.temporal_diff(tenant_hash, req, tokens_scanned); } }; @@ -1583,7 +1924,7 @@ impl FactStore { } } - fn temporal_diff(&self, req: &AggregateRequestV1, tokens_scanned: usize) -> AggregateResultV1 { + fn temporal_diff(&self, tenant_hash: &str, req: &AggregateRequestV1, tokens_scanned: usize) -> AggregateResultV1 { let base = AggregateResultV1 { op: AggregateOp::TemporalDiff.as_str().to_string(), matched: 0, @@ -1595,22 +1936,63 @@ impl FactStore { let (Some(entity), Some(key)) = (req.entity.as_deref(), req.key.as_deref()) else { return base; }; - let history = self.fact_history(entity, key); - if history.is_empty() { + let current = self + .facts + .values() + .filter(|fact| { + fact.tenant_hash == tenant_hash + && fact.entity == entity + && fact.key == key + && !fact.deleted + && !fact.private + && fact.superseded_by.is_none() + }) + .filter(|fact| { + req.query + .as_deref() + .is_none_or(|query| fact.value.to_lowercase().contains(&query.to_lowercase())) + }) + .max_by_key(|fact| fact.version); + let Some(current) = current else { return base; + }; + + // Walk the authenticated tenant's actual predecessor edges. A global + // `(entity,key)` scan would allow unrelated tenant/private/deleted rows + // to become a TemporalDiff endpoint. + let mut history = vec![current]; + let mut child = current; + let mut seen = std::collections::BTreeSet::from([current.fact_id.as_str()]); + while let Some(previous_id) = child.supersedes.as_deref() { + if !seen.insert(previous_id) { + break; + } + let Some(previous) = self.facts.get(previous_id).filter(|previous| { + previous.tenant_hash == tenant_hash + && previous.entity == entity + && previous.key == key + && !previous.deleted + && !previous.private + && previous.superseded_by.as_deref() == Some(child.fact_id.as_str()) + }) else { + break; + }; + history.push(previous); + child = previous; } - // Current value = latest by version. - let current = history.iter().max_by_key(|f| f.version); - // As-of value = the latest version whose stored_at <= as_of. - let as_of = req.as_of; - let asof = match as_of { - Some(ts) => history.iter().filter(|f| f.stored_at <= ts).max_by_key(|f| f.version), - None => history.iter().min_by_key(|f| f.version), // no as_of ⇒ diff vs oldest + + let old = match req.as_of { + Some(ts) => history + .iter() + .copied() + .filter(|fact| fact.stored_at <= ts) + .max_by_key(|fact| fact.version), + None => history.last().copied(), }; - let (Some(cur), Some(old)) = (current, asof) else { + let Some(old) = old else { return base; }; - match (parse_leading_number(&cur.value), parse_leading_number(&old.value)) { + match (parse_leading_number(¤t.value), parse_leading_number(&old.value)) { (Some(c), Some(o)) => AggregateResultV1 { matched: history.len(), value: serde_json::json!(c - o), @@ -1647,9 +2029,37 @@ impl FactStore { /// was retired without ever seeing the original content /// (see `sync::offboard_tenant_mirror`). pub fn export(&self, since: Option>, cursor: Option<&str>, limit: usize) -> FactExportResult { + self.export_scoped(None, since, cursor, limit) + } + + /// Tenant-scoped counterpart to [`Self::export`]. Tenant filtering happens + /// before sorting and pagination, so foreign rows cannot starve a page or + /// influence its cursor. + pub fn export_for_tenant( + &self, + tenant_hash: &str, + since: Option>, + cursor: Option<&str>, + limit: usize, + ) -> FactExportResult { + self.export_scoped(Some(tenant_hash), since, cursor, limit) + } + + fn export_scoped( + &self, + tenant_hash: Option<&str>, + since: Option>, + cursor: Option<&str>, + limit: usize, + ) -> FactExportResult { // 1. Collect facts, excluding private ones (never leave this node) AND // deleted ones (their content must not leave the box — erasure). - let mut all: Vec<&Fact> = self.facts.values().filter(|f| !f.private && !f.deleted).collect(); + let mut all: Vec<&Fact> = self + .facts + .values() + .filter(|fact| tenant_hash.is_none_or(|tenant| fact.tenant_hash == tenant)) + .filter(|fact| !fact.private && !fact.deleted) + .collect(); // 2. Sort by (stored_at, fact_id) ascending. all.sort_by(|a, b| a.stored_at.cmp(&b.stored_at).then_with(|| a.fact_id.cmp(&b.fact_id))); @@ -1915,6 +2325,25 @@ impl FactStore { } } + // Preserve consolidation authority separately from caller-writable + // fact fields. These events contain no fact values and replay only + // accepts them when the already-restored same-tenant edges match. + let mut consolidations: Vec<_> = self.consolidation_sources.iter().collect(); + consolidations.sort_by(|(left, _), (right, _)| left.cmp(right)); + for (canonical_fact_id, source_fact_ids) in consolidations { + let Some(canonical) = self.facts.get(canonical_fact_id).filter(|fact| !fact.deleted) else { + continue; + }; + let event = JournalEvent::ConsolidationProvenance { + canonical_fact_id: canonical_fact_id.clone(), + source_fact_ids: source_fact_ids.clone(), + tenant_hash: canonical.tenant_hash.clone(), + recorded_at: Utc::now().to_rfc3339(), + }; + let line = serde_json::to_string(&event).map_err(std::io::Error::other)?; + writeln!(writer, "{}", line)?; + } + // Value-free tombstones: replay still marks these fact_ids deleted, // but the original value never touches the rewritten journal. The // `Delete` arm is a no-op on replay if the fact_id is unknown, which @@ -1970,18 +2399,18 @@ impl FactStore { /// explicitly. pub fn mark_retention_eligible(&mut self, cutoff: DateTime) -> Vec { let holds = self.active_legal_holds(); - let to_delete: Vec = self + let to_delete: Vec<(String, String)> = self .facts .values() .filter(|f| !f.deleted && !f.private && f.stored_at < cutoff) .filter(|f| !f.entity.starts_with("__sync_tombstone__::")) .filter(|f| crate::fact_privacy::daemon_owned_entity_prefix(&f.entity).is_none()) .filter(|f| !holds.iter().any(|hold| hold.covers_fact(f))) - .map(|f| f.fact_id.clone()) + .map(|f| (f.tenant_hash.clone(), f.fact_id.clone())) .collect(); let mut deleted = Vec::with_capacity(to_delete.len()); - for fact_id in &to_delete { - match self.try_delete(fact_id) { + for (tenant_hash, fact_id) in &to_delete { + match self.try_delete(tenant_hash, fact_id) { Ok(true) => deleted.push(fact_id.clone()), Ok(false) => {} Err(err) => { @@ -2000,10 +2429,23 @@ impl FactStore { /// `SyncClient::pull_tenant_mirror`) re-stamp `fact.tenant_hash` from the /// locally requested tenant before invoking this low-level primitive. Other /// callers remain responsible for supplying an authoritative tenant stamp. - pub fn store_synced(&mut self, mut fact: Fact) { + pub fn store_synced(&mut self, mut fact: Fact) -> bool { crate::fact_privacy::enforce_global_fact(&mut fact); + if fact.tenant_hash.trim().is_empty() { + fact.tenant_hash = default_tenant_hash(); + } let fact_id = fact.fact_id.clone(); + if let Some(existing) = self.facts.get(&fact_id) { + tracing::warn!( + %fact_id, + existing_tenant = %existing.tenant_hash, + incoming_tenant = %fact.tenant_hash, + "synced-fact-id-collision-rejected" + ); + return false; + } + let tenant_hash = fact.tenant_hash.clone(); let entity = fact.entity.clone(); let key = fact.key.clone(); @@ -2011,18 +2453,33 @@ impl FactStore { .entry(entity.clone()) .or_default() .push(fact_id.clone()); - self.key_index.entry((entity, key)).or_default().push(fact_id.clone()); - self.facts.insert(fact_id, fact.clone()); + self.key_index + .entry((tenant_hash, entity, key)) + .or_default() + .push(fact_id.clone()); + self.facts.insert(fact_id.clone(), fact); + + // A synced page may arrive in either chain order. Re-sanitize the + // complete graph after each insert so a previously unresolved link is + // checked as soon as its referenced id becomes live. This closes the + // runtime window where a cross-tenant or wrong-(entity,key) edge was + // visible until the next restart. + self.sanitize_replayed_links(); + let Some(fact) = self.facts.get(&fact_id).cloned() else { + tracing::error!(%fact_id, "synced-fact-disappeared-after-link-sanitization"); + return false; + }; if let Err(err) = self.append_journal(&JournalEvent::Store { fact }) { tracing::warn!(?err, "fact-journal-append-failed"); } + true } /// Return all versions of a fact for a given (entity, key) pair, ordered by /// version ascending. Includes deleted (superseded) versions for audit trail. - pub fn fact_history(&self, entity: &str, key: &str) -> Vec<&Fact> { - let key_pair = (entity.to_string(), key.to_string()); + pub fn fact_history(&self, tenant_hash: &str, entity: &str, key: &str) -> Vec<&Fact> { + let key_pair = (tenant_hash.to_string(), entity.to_string(), key.to_string()); match self.key_index.get(&key_pair) { Some(chain) => { let mut facts: Vec<&Fact> = chain.iter().filter_map(|id| self.facts.get(id)).collect(); @@ -2039,10 +2496,10 @@ impl FactStore { /// active, non-superseded facts that share `(entity, key)` and carry /// opposite deterministic polarity classes (`true` vs `false`, /// `active` vs `inactive`, etc.). The pass never mutates memory. - pub fn contradiction_candidates_v1(&self, limit: usize) -> Vec { + pub fn contradiction_candidates_v1(&self, tenant_hash: &str, limit: usize) -> Vec { let mut groups: BTreeMap<(String, String), Vec<&Fact>> = BTreeMap::new(); for fact in self.facts.values() { - if fact.deleted || fact.superseded_by.is_some() { + if fact.tenant_hash != tenant_hash || fact.deleted || fact.superseded_by.is_some() || fact.private { continue; } if polarity_class_v1(&fact.value).is_none() { @@ -2097,20 +2554,32 @@ impl FactStore { /// history; `fact_history` and `all_facts` remain replayable. pub fn consolidate_facts_v1( &mut self, + tenant_hash: &str, req: ConsolidationRequestV1, ) -> Result { if req.target_fact_ids.is_empty() { return Err(ConsolidationErrorV1::NoTargets); } + if req.consolidation_id.trim().is_empty() { + return Err(ConsolidationErrorV1::MissingConsolidationId); + } + let mut unique_targets = std::collections::HashSet::new(); for fact_id in &req.target_fact_ids { + if !unique_targets.insert(fact_id.as_str()) { + return Err(ConsolidationErrorV1::DuplicateTarget(fact_id.clone())); + } let fact = self .facts .get(fact_id) + .filter(|fact| fact.tenant_hash == tenant_hash) .ok_or_else(|| ConsolidationErrorV1::TargetNotFound(fact_id.clone()))?; if fact.deleted { return Err(ConsolidationErrorV1::TargetDeleted(fact_id.clone())); } + if fact.superseded_by.is_some() { + return Err(ConsolidationErrorV1::TargetAlreadySuperseded(fact_id.clone())); + } if req.protected_fact_ids.iter().any(|id| id == fact_id) { return Err(ConsolidationErrorV1::TargetPinned(fact_id.clone())); } @@ -2145,32 +2614,29 @@ impl FactStore { hex::encode(blake3::hash(canonical_value.as_bytes()).as_bytes()) ); let canonical = self.build_fact(StoreFact { - tenant_hash: default_tenant_hash(), + tenant_hash: tenant_hash.to_string(), entity: req.entity.clone(), key: req.key.clone(), value: canonical_value, - source_receipt: req.source_receipt.clone().or_else(|| { - if req.consolidation_id.trim().is_empty() { - None - } else { - Some(format!("consolidation:{}", req.consolidation_id)) - } - }), + // A durable marker makes undo authorization structural rather than + // trusting a caller-supplied arbitrary canonical id. + source_receipt: Some(format!("consolidation:{}", req.consolidation_id)), confidence: req.confidence, private: false, horizon_class: req.horizon_class, actor: req.actor, }); - // Superseded set = the consolidation targets PLUS the canonical's own - // prior (entity,key) version (what `try_store`/`supersede_prior_version` - // would retire). Deduplicated so the receipt is exact. - let mut superseded_fact_ids = req.target_fact_ids.clone(); + // `build_fact` discovers the current prior version. It must have gone + // through the exact protection checks above; otherwise a caller could + // name one low-value target while implicitly retiring a private, + // receipt-linked, pinned, or high-confidence head. if let Some(prior) = canonical.supersedes.clone() { - if !superseded_fact_ids.contains(&prior) { - superseded_fact_ids.push(prior); + if !req.target_fact_ids.contains(&prior) { + return Err(ConsolidationErrorV1::ImplicitPriorNotTarget(prior)); } } + let superseded_fact_ids = req.target_fact_ids.clone(); // THE TRANSACTIONAL BOUNDARY: one journal append is the commit point. // On failure nothing is mutated (no half-applied consolidation); the @@ -2184,12 +2650,14 @@ impl FactStore { // Durable: now apply in-memory (mirrors the replay handler exactly). let canonical_fact_id = canonical.fact_id.clone(); - self.replay_journal_insert(canonical); + let _ = self.replay_journal_insert(canonical); for id in &superseded_fact_ids { - if let Some(fact) = self.facts.get_mut(id) { + if let Some(fact) = self.facts.get_mut(id).filter(|fact| fact.tenant_hash == tenant_hash) { fact.superseded_by = Some(canonical_fact_id.clone()); } } + self.consolidation_sources + .insert(canonical_fact_id.clone(), superseded_fact_ids.clone()); Ok(ConsolidationPassReportV1 { status: "consolidated".to_string(), @@ -2210,32 +2678,64 @@ impl FactStore { /// soft-deleted) is a no-op returning `status = "already_undone"`. pub fn consolidate_undo_v1( &mut self, + tenant_hash: &str, canonical_fact_id: &str, source_fact_ids: &[String], ) -> Result { + if source_fact_ids.is_empty() { + return Err(ConsolidationErrorV1::NoUndoSources); + } let canonical = self .facts .get(canonical_fact_id) + .filter(|fact| fact.tenant_hash == tenant_hash) .ok_or_else(|| ConsolidationErrorV1::TargetNotFound(canonical_fact_id.to_string()))?; + let Some(recorded_sources) = self.consolidation_sources.get(canonical_fact_id) else { + return Err(ConsolidationErrorV1::NotConsolidationCanonical( + canonical_fact_id.to_string(), + )); + }; + if canonical.superseded_by.is_some() { + return Err(ConsolidationErrorV1::CanonicalSuperseded(canonical_fact_id.to_string())); + } + let mut expected = recorded_sources.clone(); + expected.sort(); + let mut supplied = source_fact_ids.to_vec(); + supplied.sort(); + supplied.dedup(); + if expected.is_empty() || supplied != expected { + return Err(ConsolidationErrorV1::UndoSourceMismatch(canonical_fact_id.to_string())); + } if canonical.deleted { - return Ok(ConsolidationUndoReportV1 { - status: "already_undone".to_string(), - canonical_fact_id: canonical_fact_id.to_string(), - restored_fact_ids: Vec::new(), + let sources_restored = expected.iter().all(|id| { + self.facts.get(id).is_some_and(|fact| { + !fact.deleted && fact.tenant_hash == tenant_hash && fact.superseded_by.is_none() + }) }); + if sources_restored { + return Ok(ConsolidationUndoReportV1 { + status: "already_undone".to_string(), + canonical_fact_id: canonical_fact_id.to_string(), + restored_fact_ids: Vec::new(), + }); + } + return Err(ConsolidationErrorV1::UndoSourceMismatch(canonical_fact_id.to_string())); } - // Only restore sources that are actually superseded by THIS canonical — - // never resurrect a fact retired by an unrelated consolidation. - let restored: Vec = source_fact_ids - .iter() - .filter(|id| { - self.facts - .get(*id) - .is_some_and(|f| f.superseded_by.as_deref() == Some(canonical_fact_id)) + // Require the exact non-empty edge set. Silently accepting unknown or + // omitted ids would let a caller delete an arbitrary tenant fact while + // restoring nothing (or only a chosen subset). + let edges_match = expected.iter().all(|id| { + self.facts.get(id).is_some_and(|fact| { + !fact.deleted + && fact.tenant_hash == tenant_hash + && fact.superseded_by.as_deref() == Some(canonical_fact_id) }) - .cloned() - .collect(); + }); + if !edges_match { + return Err(ConsolidationErrorV1::UndoSourceMismatch(canonical_fact_id.to_string())); + } + let restored = expected; // Transactional boundary: single commit-point append. self.append_journal(&JournalEvent::ConsolidateUndo { @@ -2459,16 +2959,16 @@ fn polarity_class_v1(value: &str) -> Option<&'static str> { } } -/// Reduce `facts` to one row per (entity, key) — the row with the highest +/// Reduce `facts` to one row per (tenant, entity, key) — the row with the highest /// `version` wins. Preserves Fact ordering otherwise (callers can re-sort). /// /// `FactStore::query()` returns all live versions of a fact — including /// superseded ones. Listing surfaces (passports, projects, work, engram /// overlays) want only the latest version per `(entity, key)`. pub fn dedup_latest(facts: Vec) -> Vec { - let mut by_key: std::collections::BTreeMap<(String, String), Fact> = std::collections::BTreeMap::new(); + let mut by_key: std::collections::BTreeMap<(String, String, String), Fact> = std::collections::BTreeMap::new(); for fact in facts { - let key = (fact.entity.clone(), fact.key.clone()); + let key = (fact.tenant_hash.clone(), fact.entity.clone(), fact.key.clone()); match by_key.get(&key) { Some(existing) if existing.version >= fact.version => {} _ => { @@ -2509,6 +3009,20 @@ mod tests { } } + fn tenant_fact(tenant: &str, entity: &str, key: &str, value: &str) -> StoreFact { + StoreFact { + tenant_hash: tenant.to_string(), + entity: entity.to_string(), + key: key.to_string(), + value: value.to_string(), + source_receipt: None, + confidence: 1.0, + private: false, + horizon_class: None, + actor: None, + } + } + /// Versions arrive OUT of order (1, 3, 2) — the highest version must win, /// not the last-seen row. `FactStore::query()` can return superseded /// versions in non-monotonic order, so this distinction is load-bearing @@ -2832,11 +3346,11 @@ mod tests { actor: None, }); assert!( - store.clear_superseded(&first.fact_id), + store.clear_superseded("default", &first.fact_id), "simulate unresolved remote conflict" ); - let candidates = store.contradiction_candidates_v1(10); + let candidates = store.contradiction_candidates_v1("default", 10); assert_eq!(candidates.len(), 1); assert_eq!(candidates[0].entity, "service:api"); assert_eq!(candidates[0].key, "enabled"); @@ -2845,6 +3359,38 @@ mod tests { assert_eq!(candidates[0].reason, "opposite_polarity_same_entity_key"); } + #[test] + fn contradiction_candidates_v1_never_surface_private_values() { + let mut store = FactStore::new(); + let first = store.store(StoreFact { + tenant_hash: "tenant-a".to_string(), + entity: "private-state".to_string(), + key: "enabled".to_string(), + value: "enabled".to_string(), + source_receipt: None, + confidence: 0.4, + private: true, + horizon_class: None, + actor: None, + }); + store.store(StoreFact { + tenant_hash: "tenant-a".to_string(), + entity: "private-state".to_string(), + key: "enabled".to_string(), + value: "disabled".to_string(), + source_receipt: None, + confidence: 0.4, + private: true, + horizon_class: None, + actor: None, + }); + assert!(store.clear_superseded("tenant-a", &first.fact_id)); + assert!( + store.contradiction_candidates_v1("tenant-a", 10).is_empty(), + "private values must not ride the contradiction review surface" + ); + } + #[test] fn consolidate_facts_v1_supersedes_targets_without_deleting_history() { let mut store = FactStore::new(); @@ -2871,22 +3417,28 @@ mod tests { horizon_class: None, actor: None, }); - assert!(store.clear_superseded(&old.fact_id), "make both targets active"); + assert!( + store.clear_superseded("default", &old.fact_id), + "make both targets active" + ); let report = store - .consolidate_facts_v1(ConsolidationRequestV1 { - consolidation_id: "con-1".to_string(), - entity: "proj".to_string(), - key: "status".to_string(), - canonical_value: "active".to_string(), - target_fact_ids: vec![old.fact_id.clone(), newer.fact_id.clone()], - protected_fact_ids: vec![], - confidence: 0.8, - source_receipt: None, - actor: Some("agent:codex".to_string()), - horizon_class: Some(HorizonClass::Stable), - protected_confidence_floor: 0.99, - }) + .consolidate_facts_v1( + "default", + ConsolidationRequestV1 { + consolidation_id: "con-1".to_string(), + entity: "proj".to_string(), + key: "status".to_string(), + canonical_value: "active".to_string(), + target_fact_ids: vec![old.fact_id.clone(), newer.fact_id.clone()], + protected_fact_ids: vec![], + confidence: 0.8, + source_receipt: None, + actor: Some("agent:codex".to_string()), + horizon_class: Some(HorizonClass::Stable), + protected_confidence_floor: 0.99, + }, + ) .expect("consolidate"); let canonical_id = report.receipt.canonical_fact_id; @@ -2898,7 +3450,7 @@ mod tests { store.get(&newer.fact_id).unwrap().superseded_by.as_deref(), Some(canonical_id.as_str()) ); - let history = store.fact_history("proj", "status"); + let history = store.fact_history("default", "proj", "status"); assert_eq!(history.len(), 3, "consolidation must preserve version history"); assert!(history.iter().any(|f| f.fact_id == old.fact_id)); assert!(history.iter().any(|f| f.fact_id == newer.fact_id)); @@ -2921,70 +3473,206 @@ mod tests { }); let err = store - .consolidate_facts_v1(ConsolidationRequestV1 { - consolidation_id: "con-guard".to_string(), - entity: "proj".to_string(), - key: "decision".to_string(), - canonical_value: "approved".to_string(), - target_fact_ids: vec![linked.fact_id.clone()], - protected_fact_ids: vec![], - confidence: 0.8, - source_receipt: None, - actor: None, - horizon_class: None, - protected_confidence_floor: 0.99, - }) + .consolidate_facts_v1( + "default", + ConsolidationRequestV1 { + consolidation_id: "con-guard".to_string(), + entity: "proj".to_string(), + key: "decision".to_string(), + canonical_value: "approved".to_string(), + target_fact_ids: vec![linked.fact_id.clone()], + protected_fact_ids: vec![], + confidence: 0.8, + source_receipt: None, + actor: None, + horizon_class: None, + protected_confidence_floor: 0.99, + }, + ) .expect_err("receipt-linked targets are protected"); assert_eq!(err, ConsolidationErrorV1::TargetReceiptLinked(linked.fact_id.clone())); assert!(store.get(&linked.fact_id).unwrap().superseded_by.is_none()); } #[test] - fn consolidate_facts_v1_rejects_legacy_public_daemon_control_target() { + fn consolidate_facts_v1_rejects_unvalidated_implicit_prior() { let mut store = FactStore::new(); - let control = store.store(StoreFact { + let low = store.store(StoreFact { tenant_hash: "default".to_string(), - entity: "__passport__::legacy".to_string(), - key: "record".to_string(), - value: "{}".to_string(), + entity: "proj".to_string(), + key: "status".to_string(), + value: "blocked".to_string(), source_receipt: None, - confidence: 0.1, + confidence: 0.2, private: false, horizon_class: None, actor: None, }); - // Simulate a row persisted before born-private enforcement. - store.facts.get_mut(&control.fact_id).unwrap().private = false; + let protected_head = store.store(StoreFact { + tenant_hash: "default".to_string(), + entity: "proj".to_string(), + key: "status".to_string(), + value: "approved".to_string(), + source_receipt: Some("receipt:protected".to_string()), + confidence: 1.0, + private: false, + horizon_class: None, + actor: None, + }); + assert!(store.clear_superseded("default", &low.fact_id)); let err = store - .consolidate_facts_v1(ConsolidationRequestV1 { - consolidation_id: "con-control-guard".to_string(), - entity: "__passport__::legacy".to_string(), - key: "record".to_string(), - canonical_value: r#"{"tier":"operator"}"#.to_string(), - target_fact_ids: vec![control.fact_id.clone()], - protected_fact_ids: vec![], - confidence: 0.2, - source_receipt: None, - actor: Some("operator".to_string()), - horizon_class: None, - protected_confidence_floor: 0.99, - }) - .expect_err("daemon control targets are protected independently of privacy"); + .consolidate_facts_v1( + "default", + ConsolidationRequestV1 { + consolidation_id: "con-implicit".to_string(), + entity: "proj".to_string(), + key: "status".to_string(), + canonical_value: "settled".to_string(), + target_fact_ids: vec![low.fact_id.clone()], + protected_fact_ids: vec![], + confidence: 0.5, + source_receipt: None, + actor: Some("agent:codex".to_string()), + horizon_class: None, + protected_confidence_floor: 0.99, + }, + ) + .expect_err("implicit prior must be explicitly validated"); assert_eq!( err, - ConsolidationErrorV1::TargetDaemonOwned { - fact_id: control.fact_id.clone(), - prefix: "__passport__::".to_string(), - } + ConsolidationErrorV1::ImplicitPriorNotTarget(protected_head.fact_id.clone()) ); - assert!(store.get(&control.fact_id).unwrap().superseded_by.is_none()); + assert!(store.get(&protected_head.fact_id).unwrap().superseded_by.is_none()); + assert_eq!(store.fact_history("default", "proj", "status").len(), 2); } - // ── buyer-fit M2: atomic consolidation + receipted undo ────────────── - - fn seed_two_active(store: &mut FactStore) -> (String, String) { - let a = store.store(StoreFact { + #[test] + fn consolidate_rejects_superseded_or_duplicate_targets_atomically() { + let mut store = FactStore::new(); + let first = store.store(StoreFact { + tenant_hash: "default".to_string(), + entity: "proj".to_string(), + key: "status".to_string(), + value: "blocked".to_string(), + source_receipt: None, + confidence: 0.2, + private: false, + horizon_class: None, + actor: None, + }); + let second = store.store(StoreFact { + tenant_hash: "default".to_string(), + entity: "proj".to_string(), + key: "status".to_string(), + value: "active".to_string(), + source_receipt: None, + confidence: 0.3, + private: false, + horizon_class: None, + actor: None, + }); + let original_edge = first + .superseded_by + .clone() + .or_else(|| store.get(&first.fact_id).and_then(|fact| fact.superseded_by.clone())); + let err = store + .consolidate_facts_v1( + "default", + ConsolidationRequestV1 { + consolidation_id: "con-retired".to_string(), + entity: "proj".to_string(), + key: "status".to_string(), + canonical_value: "settled".to_string(), + target_fact_ids: vec![first.fact_id.clone(), second.fact_id.clone()], + protected_fact_ids: vec![], + confidence: 0.5, + source_receipt: None, + actor: None, + horizon_class: None, + protected_confidence_floor: 0.99, + }, + ) + .expect_err("already-retired target must be rejected"); + assert_eq!( + err, + ConsolidationErrorV1::TargetAlreadySuperseded(first.fact_id.clone()) + ); + assert_eq!(store.get(&first.fact_id).unwrap().superseded_by, original_edge); + assert_eq!(store.fact_history("default", "proj", "status").len(), 2); + + assert!(store.clear_superseded("default", &first.fact_id)); + let err = store + .consolidate_facts_v1( + "default", + ConsolidationRequestV1 { + consolidation_id: "con-duplicate".to_string(), + entity: "proj".to_string(), + key: "status".to_string(), + canonical_value: "settled".to_string(), + target_fact_ids: vec![second.fact_id.clone(), second.fact_id.clone()], + protected_fact_ids: vec![], + confidence: 0.5, + source_receipt: None, + actor: None, + horizon_class: None, + protected_confidence_floor: 0.99, + }, + ) + .expect_err("duplicate target set must be rejected"); + assert_eq!(err, ConsolidationErrorV1::DuplicateTarget(second.fact_id.clone())); + assert_eq!(store.fact_history("default", "proj", "status").len(), 2); + } + + #[test] + fn consolidate_facts_v1_rejects_legacy_public_daemon_control_target() { + let mut store = FactStore::new(); + let control = store.store(StoreFact { + tenant_hash: "default".to_string(), + entity: "__passport__::legacy".to_string(), + key: "record".to_string(), + value: "{}".to_string(), + source_receipt: None, + confidence: 0.1, + private: false, + horizon_class: None, + actor: None, + }); + // Simulate a row persisted before born-private enforcement. + store.facts.get_mut(&control.fact_id).unwrap().private = false; + + let err = store + .consolidate_facts_v1( + "default", + ConsolidationRequestV1 { + consolidation_id: "con-control-guard".to_string(), + entity: "__passport__::legacy".to_string(), + key: "record".to_string(), + canonical_value: r#"{"tier":"operator"}"#.to_string(), + target_fact_ids: vec![control.fact_id.clone()], + protected_fact_ids: vec![], + confidence: 0.2, + source_receipt: None, + actor: Some("operator".to_string()), + horizon_class: None, + protected_confidence_floor: 0.99, + }, + ) + .expect_err("daemon control targets are protected independently of privacy"); + assert_eq!( + err, + ConsolidationErrorV1::TargetDaemonOwned { + fact_id: control.fact_id.clone(), + prefix: "__passport__::".to_string(), + } + ); + assert!(store.get(&control.fact_id).unwrap().superseded_by.is_none()); + } + + // ── buyer-fit M2: atomic consolidation + receipted undo ────────────── + + fn seed_two_active(store: &mut FactStore) -> (String, String) { + let a = store.store(StoreFact { tenant_hash: "default".to_string(), entity: "proj".to_string(), key: "status".to_string(), @@ -3007,7 +3695,7 @@ mod tests { actor: None, }); // storing b auto-superseded a via the version chain; reactivate it. - store.clear_superseded(&a.fact_id); + store.clear_superseded("default", &a.fact_id); (a.fact_id, b.fact_id) } @@ -3032,7 +3720,7 @@ mod tests { let mut store = FactStore::new(); let (a, b) = seed_two_active(&mut store); let report = store - .consolidate_facts_v1(consolidate_req(&a, &b)) + .consolidate_facts_v1("default", consolidate_req(&a, &b)) .expect("consolidate"); let cid = report.receipt.canonical_fact_id.clone(); // The receipt carries the after-side hash for the signed diff. @@ -3042,7 +3730,7 @@ mod tests { assert_eq!(store.get(&a).unwrap().superseded_by.as_deref(), Some(cid.as_str())); assert_eq!(store.get(&b).unwrap().superseded_by.as_deref(), Some(cid.as_str())); // History preserved (nothing hard-deleted). - assert_eq!(store.fact_history("proj", "status").len(), 3); + assert_eq!(store.fact_history("default", "proj", "status").len(), 3); } #[test] @@ -3053,7 +3741,7 @@ mod tests { let mut store = FactStore::with_persistence(dir.path()).unwrap(); let (fa, fb) = seed_two_active(&mut store); let report = store - .consolidate_facts_v1(consolidate_req(&fa, &fb)) + .consolidate_facts_v1("default", consolidate_req(&fa, &fb)) .expect("consolidate"); (a, b, cid) = (fa, fb, report.receipt.canonical_fact_id); } @@ -3069,11 +3757,42 @@ mod tests { let mut store = FactStore::new(); let (a, b) = seed_two_active(&mut store); let report = store - .consolidate_facts_v1(consolidate_req(&a, &b)) + .consolidate_facts_v1("default", consolidate_req(&a, &b)) .expect("consolidate"); let cid = report.receipt.canonical_fact_id.clone(); + assert!( + !store.delete("default", &cid), + "generic delete must not strand consolidation sources" + ); + assert!( + !store.try_delete("default", &cid).unwrap(), + "journaled generic delete must require dedicated undo" + ); + assert!(store.get(&cid).is_some()); + assert_eq!(store.get(&a).unwrap().superseded_by.as_deref(), Some(cid.as_str())); + assert_eq!(store.get(&b).unwrap().superseded_by.as_deref(), Some(cid.as_str())); + assert!( + !store.try_delete("default", &a).unwrap(), + "generic delete must not remove an active consolidation source" + ); + let unrelated = store.store(StoreFact { + tenant_hash: "default".to_string(), + entity: "other".to_string(), + key: "status".to_string(), + value: "new".to_string(), + source_receipt: None, + confidence: 0.2, + private: false, + horizon_class: None, + actor: None, + }); + assert!( + !store.mark_superseded("default", &a, &unrelated.fact_id), + "generic re-retirement must not rewrite consolidation provenance" + ); + assert_eq!(store.get(&a).unwrap().superseded_by.as_deref(), Some(cid.as_str())); let undo = store - .consolidate_undo_v1(&cid, &report.receipt.source_fact_ids) + .consolidate_undo_v1("default", &cid, &report.receipt.source_fact_ids) .expect("undo"); assert_eq!(undo.status, "undone"); // Canonical retired, sources restored (active again). @@ -3082,19 +3801,124 @@ mod tests { assert!(store.get(&b).unwrap().superseded_by.is_none()); } + #[test] + fn consolidate_undo_rejects_arbitrary_canonical_and_inexact_sources() { + let mut store = FactStore::new(); + let ordinary = store.store(StoreFact { + tenant_hash: "default".to_string(), + entity: "ordinary".to_string(), + key: "value".to_string(), + value: "keep".to_string(), + source_receipt: Some("consolidation:forged".to_string()), + confidence: 0.4, + private: false, + horizon_class: None, + actor: None, + }); + let empty_err = store + .consolidate_undo_v1("default", &ordinary.fact_id, &[]) + .expect_err("empty source set must never delete a fact"); + assert_eq!(empty_err, ConsolidationErrorV1::NoUndoSources); + let err = store + .consolidate_undo_v1("default", &ordinary.fact_id, &["f_bogus".to_string()]) + .expect_err("ordinary fact is not an undo canonical"); + assert_eq!( + err, + ConsolidationErrorV1::NotConsolidationCanonical(ordinary.fact_id.clone()) + ); + assert!(store.get(&ordinary.fact_id).is_some()); + + let (a, b) = seed_two_active(&mut store); + let report = store + .consolidate_facts_v1("default", consolidate_req(&a, &b)) + .expect("consolidate"); + let cid = report.receipt.canonical_fact_id; + let err = store + .consolidate_undo_v1("default", &cid, std::slice::from_ref(&a)) + .expect_err("partial source set must not delete the canonical"); + assert_eq!(err, ConsolidationErrorV1::UndoSourceMismatch(cid.clone())); + assert!(store.get(&cid).is_some()); + assert_eq!(store.get(&a).unwrap().superseded_by.as_deref(), Some(cid.as_str())); + assert_eq!(store.get(&b).unwrap().superseded_by.as_deref(), Some(cid.as_str())); + } + + #[test] + fn consolidate_undo_rejects_corrupt_deleted_or_superseded_canonical() { + let mut store = FactStore::new(); + let (a, b) = seed_two_active(&mut store); + let report = store + .consolidate_facts_v1("default", consolidate_req(&a, &b)) + .expect("consolidate"); + let cid = report.receipt.canonical_fact_id.clone(); + store.facts.get_mut(&cid).unwrap().deleted = true; + let err = store + .consolidate_undo_v1("default", &cid, &report.receipt.source_fact_ids) + .expect_err("deleted canonical with retired sources is not already undone"); + assert_eq!(err, ConsolidationErrorV1::UndoSourceMismatch(cid.clone())); + + // Restore the canonical only to model a later same-key write. + store.facts.get_mut(&cid).unwrap().deleted = false; + let successor = store.store(StoreFact { + tenant_hash: "default".to_string(), + entity: "proj".to_string(), + key: "status".to_string(), + value: "newer".to_string(), + source_receipt: None, + confidence: 0.5, + private: false, + horizon_class: None, + actor: None, + }); + let err = store + .consolidate_undo_v1("default", &cid, &report.receipt.source_fact_ids) + .expect_err("superseded canonical cannot be safely undone"); + assert_eq!(err, ConsolidationErrorV1::CanonicalSuperseded(cid.clone())); + assert_eq!( + store.get(&cid).unwrap().superseded_by.as_deref(), + Some(successor.fact_id.as_str()) + ); + assert_eq!(store.get(&a).unwrap().superseded_by.as_deref(), Some(cid.as_str())); + assert_eq!(store.get(&b).unwrap().superseded_by.as_deref(), Some(cid.as_str())); + } + + #[test] + fn consolidation_and_undo_reject_cross_tenant_ids() { + let mut store = FactStore::new(); + let (a, b) = seed_two_active(&mut store); + let err = store + .consolidate_facts_v1("tenant-b", consolidate_req(&a, &b)) + .expect_err("tenant-b cannot consolidate default facts"); + assert_eq!(err, ConsolidationErrorV1::TargetNotFound(a.clone())); + + let report = store + .consolidate_facts_v1("default", consolidate_req(&a, &b)) + .expect("default consolidation"); + let err = store + .consolidate_undo_v1( + "tenant-b", + &report.receipt.canonical_fact_id, + &report.receipt.source_fact_ids, + ) + .expect_err("tenant-b cannot undo default consolidation"); + assert_eq!( + err, + ConsolidationErrorV1::TargetNotFound(report.receipt.canonical_fact_id) + ); + } + #[test] fn consolidate_undo_is_idempotent() { let mut store = FactStore::new(); let (a, b) = seed_two_active(&mut store); let report = store - .consolidate_facts_v1(consolidate_req(&a, &b)) + .consolidate_facts_v1("default", consolidate_req(&a, &b)) .expect("consolidate"); let cid = report.receipt.canonical_fact_id.clone(); store - .consolidate_undo_v1(&cid, &report.receipt.source_fact_ids) + .consolidate_undo_v1("default", &cid, &report.receipt.source_fact_ids) .unwrap(); let again = store - .consolidate_undo_v1(&cid, &report.receipt.source_fact_ids) + .consolidate_undo_v1("default", &cid, &report.receipt.source_fact_ids) .unwrap(); assert_eq!(again.status, "already_undone"); assert!(again.restored_fact_ids.is_empty()); @@ -3108,11 +3932,11 @@ mod tests { let mut store = FactStore::with_persistence(dir.path()).unwrap(); let (fa, fb) = seed_two_active(&mut store); let report = store - .consolidate_facts_v1(consolidate_req(&fa, &fb)) + .consolidate_facts_v1("default", consolidate_req(&fa, &fb)) .expect("consolidate"); let c = report.receipt.canonical_fact_id.clone(); store - .consolidate_undo_v1(&c, &report.receipt.source_fact_ids) + .consolidate_undo_v1("default", &c, &report.receipt.source_fact_ids) .expect("undo"); (a, b, cid) = (fa, fb, c); } @@ -3125,6 +3949,120 @@ mod tests { assert!(store.get(&b).unwrap().superseded_by.is_none()); } + #[test] + fn consolidation_provenance_survives_compaction_and_restart() { + let dir = tempfile::tempdir().unwrap(); + let (a, b, cid, sources); + { + let mut store = FactStore::with_persistence(dir.path()).unwrap(); + let (fa, fb) = seed_two_active(&mut store); + let report = store + .consolidate_facts_v1("default", consolidate_req(&fa, &fb)) + .expect("consolidate"); + cid = report.receipt.canonical_fact_id; + sources = report.receipt.source_fact_ids; + (a, b) = (fa, fb); + store.compact_journal().expect("compact"); + } + let mut reopened = FactStore::with_persistence(dir.path()).unwrap(); + let undo = reopened + .consolidate_undo_v1("default", &cid, &sources) + .expect("compacted provenance authorizes exact undo"); + assert_eq!(undo.status, "undone"); + assert!(reopened.get(&cid).is_none()); + assert!(reopened.get(&a).unwrap().superseded_by.is_none()); + assert!(reopened.get(&b).unwrap().superseded_by.is_none()); + } + + #[test] + fn replay_rejects_colliding_or_partial_consolidation_events_atomically() { + fn fixed_fact(id: &str, tenant: &str, value: &str) -> Fact { + let mut store = FactStore::new(); + let mut fact = store.store(StoreFact { + tenant_hash: tenant.to_string(), + entity: "proj".to_string(), + key: "status".to_string(), + value: value.to_string(), + source_receipt: None, + confidence: 0.2, + private: false, + horizon_class: None, + actor: None, + }); + fact.fact_id = id.to_string(); + fact.version = 1; + fact.supersedes = None; + fact.superseded_by = None; + fact + } + + let dir = tempfile::tempdir().unwrap(); + let writer = FactStore::with_persistence(dir.path()).unwrap(); + let tenant_a = fixed_fact("f_collision", "tenant-a", "tenant-a"); + let source = fixed_fact("f_source", "tenant-b", "source"); + let mut colliding_canonical = fixed_fact("f_collision", "tenant-b", "canonical"); + colliding_canonical.version = 2; + colliding_canonical.supersedes = Some(source.fact_id.clone()); + writer + .append_journal(&JournalEvent::Store { fact: tenant_a.clone() }) + .unwrap(); + writer + .append_journal(&JournalEvent::Store { fact: source.clone() }) + .unwrap(); + writer + .append_journal(&JournalEvent::Consolidate { + canonical: colliding_canonical, + superseded_fact_ids: vec![source.fact_id.clone()], + consolidated_at: Utc::now().to_rfc3339(), + }) + .unwrap(); + + let partial_canonical = fixed_fact("f_partial_canonical", "tenant-b", "partial"); + writer + .append_journal(&JournalEvent::Consolidate { + canonical: partial_canonical, + superseded_fact_ids: vec![source.fact_id.clone(), "f_missing".to_string()], + consolidated_at: Utc::now().to_rfc3339(), + }) + .unwrap(); + drop(writer); + + let replayed = FactStore::with_persistence(dir.path()).unwrap(); + assert_eq!(replayed.get("f_collision").unwrap().tenant_hash, "tenant-a"); + assert!(replayed.get("f_partial_canonical").is_none()); + assert!(replayed.get(&source.fact_id).unwrap().superseded_by.is_none()); + assert!( + !replayed.consolidation_sources.contains_key("f_collision") + && !replayed.consolidation_sources.contains_key("f_partial_canonical") + ); + } + + #[test] + fn replay_rejects_partial_consolidation_undo_atomically() { + let dir = tempfile::tempdir().unwrap(); + let (a, b, cid); + { + let mut store = FactStore::with_persistence(dir.path()).unwrap(); + let (fa, fb) = seed_two_active(&mut store); + let report = store + .consolidate_facts_v1("default", consolidate_req(&fa, &fb)) + .unwrap(); + cid = report.receipt.canonical_fact_id; + (a, b) = (fa, fb); + store + .append_journal(&JournalEvent::ConsolidateUndo { + canonical_fact_id: cid.clone(), + restored_fact_ids: vec![a.clone()], + undone_at: Utc::now().to_rfc3339(), + }) + .unwrap(); + } + let replayed = FactStore::with_persistence(dir.path()).unwrap(); + assert!(replayed.get(&cid).is_some()); + assert_eq!(replayed.get(&a).unwrap().superseded_by.as_deref(), Some(cid.as_str())); + assert_eq!(replayed.get(&b).unwrap().superseded_by.as_deref(), Some(cid.as_str())); + } + #[test] fn soft_delete() { let mut store = FactStore::new(); @@ -3142,7 +4080,7 @@ mod tests { }); assert_eq!(store.count(), 1); - store.delete(&fact.fact_id); + store.delete("default", &fact.fact_id); assert_eq!(store.count(), 0); assert!(store.get(&fact.fact_id).is_none()); } @@ -3213,7 +4151,7 @@ mod tests { actor: None, }); - store.delete(&f1.fact_id); + store.delete("default", &f1.fact_id); let results = store.get_by_entity("proj"); assert_eq!(results.len(), 1); assert_eq!(results[0].key, "status"); @@ -3441,7 +4379,7 @@ mod tests { #[test] fn delete_nonexistent_returns_false() { let mut store = FactStore::new(); - assert!(!store.delete("nonexistent_id")); + assert!(!store.delete("default", "nonexistent_id")); } #[test] @@ -3498,31 +4436,201 @@ mod tests { as_of: None, token_budget: None, }; - let c = store.aggregate_v1(&req(AggregateOp::Count)); + let c = store.aggregate_v1("default", &req(AggregateOp::Count)); assert_eq!(c.value, serde_json::json!(4)); assert_eq!(c.llm_calls, 0); - let s = store.aggregate_v1(&req(AggregateOp::SumNumeric)); + let s = store.aggregate_v1("default", &req(AggregateOp::SumNumeric)); assert_eq!(s.value, serde_json::json!(1550.5)); // 100 + 150.5 + 1200 + 100 assert_eq!(s.llm_calls, 0); - let d = store.aggregate_v1(&req(AggregateOp::Distinct)); + let d = store.aggregate_v1("default", &req(AggregateOp::Distinct)); assert_eq!(d.value, serde_json::json!(3)); // {100, 150.5, 1200} } #[test] - fn aggregate_respects_token_budget() { - let store = seed_aggregate_corpus(); - // A tiny budget scans only the first candidate(s); the count is bounded - // and the result is flagged truncated (honest, bounded cost). - let r = store.aggregate_v1(&AggregateRequestV1 { - op: AggregateOp::Count, + fn tenant_version_chains_history_and_mutations_are_isolated() { + let mut store = FactStore::new(); + let a1 = store.store(tenant_fact("tenant-a", "project", "status", "draft-a")); + let b1 = store.store(tenant_fact("tenant-b", "project", "status", "draft-b")); + let a2 = store.store(tenant_fact("tenant-a", "project", "status", "active-a")); + + assert_eq!((a1.version, b1.version, a2.version), (1, 1, 2)); + assert_eq!(a2.supersedes.as_deref(), Some(a1.fact_id.as_str())); + assert_eq!( + store.get(&a1.fact_id).and_then(|fact| fact.superseded_by.as_deref()), + Some(a2.fact_id.as_str()) + ); + assert!(store.get(&b1.fact_id).unwrap().superseded_by.is_none()); + + assert_eq!(store.fact_history("tenant-a", "project", "status").len(), 2); + assert_eq!(store.fact_history("tenant-b", "project", "status").len(), 1); + assert!(!store.mark_superseded("tenant-b", &b1.fact_id, &a2.fact_id)); + assert!(!store.clear_superseded("tenant-b", &a1.fact_id)); + assert!(!store.delete("tenant-b", &a1.fact_id)); + assert!(!store.get(&a1.fact_id).unwrap().deleted); + + let latest = dedup_latest(vec![a1, a2, b1]); + assert_eq!(latest.len(), 2, "dedup must retain one row per tenant"); + } + + #[test] + fn tenant_aggregate_count_sum_and_distinct_are_isolated() { + let mut store = FactStore::new(); + for (tenant, entity, value) in [ + ("tenant-a", "metric:a1", "10"), + ("tenant-a", "metric:a2", "20"), + ("tenant-a", "metric:a3", "10"), + ("tenant-b", "metric:b1", "999"), + ] { + store.store(tenant_fact(tenant, entity, "amount", value)); + } + let request = |op| AggregateRequestV1 { + op, entity: None, - key: Some("sales_amount".into()), + key: Some("amount".to_string()), query: None, as_of: None, - token_budget: Some(1), + token_budget: None, + }; + + assert_eq!( + store.aggregate_v1("tenant-a", &request(AggregateOp::Count)).value, + serde_json::json!(3) + ); + assert_eq!( + store.aggregate_v1("tenant-a", &request(AggregateOp::SumNumeric)).value, + serde_json::json!(40.0) + ); + assert_eq!( + store.aggregate_v1("tenant-a", &request(AggregateOp::Distinct)).value, + serde_json::json!(2) + ); + assert_eq!( + store.aggregate_v1("tenant-b", &request(AggregateOp::Count)).value, + serde_json::json!(1) + ); + } + + #[test] + fn temporal_diff_walks_only_public_live_structural_tenant_chain() { + let mut store = FactStore::new(); + store.store(tenant_fact("tenant-a", "metric:price", "usd", "10")); + store.store(tenant_fact("tenant-a", "metric:price", "usd", "25")); + store.store(tenant_fact("tenant-b", "metric:price", "usd", "100")); + store.store(tenant_fact("tenant-b", "metric:price", "usd", "175")); + let request = AggregateRequestV1 { + op: AggregateOp::TemporalDiff, + entity: Some("metric:price".to_string()), + key: Some("usd".to_string()), + query: None, + as_of: None, + token_budget: None, + }; + + assert_eq!(store.aggregate_v1("tenant-a", &request).value, serde_json::json!(15.0)); + assert_eq!(store.aggregate_v1("tenant-b", &request).value, serde_json::json!(75.0)); + + let private_head = store.store(StoreFact { + private: true, + ..tenant_fact("tenant-a", "metric:price", "usd", "1000") }); + assert!(private_head.private); + assert_eq!( + store.aggregate_v1("tenant-a", &request).value, + serde_json::Value::Null, + "a private current endpoint must not reveal its public predecessor" + ); + } + + #[test] + fn restart_rebuilds_partitioned_chains_and_sanitizes_cross_tenant_links() { + let dir = tempfile::tempdir().unwrap(); + let (a1_id, a2_id, b1_id, crafted_id) = { + let mut store = FactStore::with_persistence(dir.path()).unwrap(); + let a1 = store.store(tenant_fact("tenant-a", "project", "status", "a1")); + let b1 = store.store(tenant_fact("tenant-b", "project", "status", "b1")); + let a2 = store.store(tenant_fact("tenant-a", "project", "status", "a2")); + + store + .append_journal(&JournalEvent::Supersede { + fact_id: a1.fact_id.clone(), + by_fact_id: b1.fact_id.clone(), + superseded_at: Utc::now().to_rfc3339(), + }) + .unwrap(); + + let mut crafted = b1.clone(); + crafted.fact_id = "f_cross_tenant_legacy_link".to_string(); + crafted.version = 9; + crafted.supersedes = Some(a1.fact_id.clone()); + crafted.superseded_by = Some(a2.fact_id.clone()); + assert!(store.store_synced(crafted.clone())); + assert!( + store.get(&crafted.fact_id).unwrap().supersedes.is_none(), + "synced cross-tenant predecessor must be cleared immediately" + ); + assert!( + store.get(&crafted.fact_id).unwrap().superseded_by.is_none(), + "synced cross-tenant successor must be cleared immediately" + ); + + (a1.fact_id, a2.fact_id, b1.fact_id, crafted.fact_id) + }; + + let store = FactStore::with_persistence(dir.path()).unwrap(); + let a_history = store.fact_history("tenant-a", "project", "status"); + let b_history = store.fact_history("tenant-b", "project", "status"); + assert_eq!( + a_history.iter().map(|fact| fact.version).collect::>(), + vec![1, 2] + ); + assert_eq!( + b_history.iter().map(|fact| fact.version).collect::>(), + vec![1, 9], + "persisted versions are preserved, not renumbered" + ); + assert_eq!( + store.get(&a1_id).unwrap().superseded_by.as_deref(), + Some(a2_id.as_str()), + "malicious later cross-tenant event must not replace the valid edge" + ); + assert!(store.get(&b1_id).unwrap().superseded_by.is_none()); + assert!( + store.get(&crafted_id).unwrap().supersedes.is_none(), + "legacy cross-tenant predecessor edge must be sanitized" + ); + } + + #[test] + fn synced_fact_id_collision_cannot_overwrite_another_tenant() { + let mut store = FactStore::new(); + let original = store.store(tenant_fact("tenant-a", "project", "status", "a")); + let mut collision = original.clone(); + collision.tenant_hash = "tenant-b".to_string(); + collision.value = "attacker".to_string(); + + assert!(!store.store_synced(collision)); + assert_eq!(store.get(&original.fact_id).unwrap().tenant_hash, "tenant-a"); + assert_eq!(store.get(&original.fact_id).unwrap().value, "a"); + } + + #[test] + fn aggregate_respects_token_budget() { + let store = seed_aggregate_corpus(); + // A tiny budget scans only the first candidate(s); the count is bounded + // and the result is flagged truncated (honest, bounded cost). + let r = store.aggregate_v1( + "default", + &AggregateRequestV1 { + op: AggregateOp::Count, + entity: None, + key: Some("sales_amount".into()), + query: None, + as_of: None, + token_budget: Some(1), + }, + ); assert!(r.budget_truncated, "tiny budget must truncate the scan"); assert!(r.matched < 4, "budget-limited count is below the full 4"); assert!(r.tokens_scanned <= 1 + store.get_by_entity("metric:jan").first().map(|f| f.tokens).unwrap_or(0)); @@ -3544,14 +4652,17 @@ mod tests { }; store.store(base("10")); store.store(base("25")); // v2 supersedes v1 in the chain; both in history - let r = store.aggregate_v1(&AggregateRequestV1 { - op: AggregateOp::TemporalDiff, - entity: Some("metric:price".into()), - key: Some("usd".into()), - query: None, - as_of: None, // diff current vs oldest - token_budget: None, - }); + let r = store.aggregate_v1( + "default", + &AggregateRequestV1 { + op: AggregateOp::TemporalDiff, + entity: Some("metric:price".into()), + key: Some("usd".into()), + query: None, + as_of: None, // diff current vs oldest + token_budget: None, + }, + ); assert_eq!(r.value, serde_json::json!(15.0)); // 25 - 10 } @@ -3854,7 +4965,7 @@ mod tests { actor: None, }); fact_id = fact.fact_id; - store.delete(&fact_id); + store.delete("default", &fact_id); assert_eq!(store.count(), 0); } @@ -3904,7 +5015,7 @@ mod tests { }); deleted_id = to_delete.fact_id; live_id = to_keep.fact_id; - store.delete(&deleted_id); + store.delete("default", &deleted_id); // Pre-compaction: the deleted value IS still on disk (the leak). let raw = std::fs::read_to_string(&journal).unwrap(); @@ -3970,7 +5081,7 @@ mod tests { } { let store = FactStore::with_persistence(dir.path()).unwrap(); - let history = store.fact_history("proj", "status"); + let history = store.fact_history("default", "proj", "status"); assert_eq!(history.len(), 2, "version chain lost after compaction"); assert_eq!(history[0].version, 1); assert_eq!(history[1].version, 2); @@ -4072,7 +5183,7 @@ mod tests { { let store = FactStore::with_persistence(dir.path()).unwrap(); - let history = store.fact_history("proj", "status"); + let history = store.fact_history("default", "proj", "status"); assert_eq!(history.len(), 2); assert_eq!(history[0].version, 1); assert_eq!(history[0].value, "draft"); @@ -4235,7 +5346,7 @@ mod tests { horizon_class: None, actor: None, }); - store.delete("nonexistent"); + store.delete("default", "nonexistent"); assert!(!journal_path.exists(), "in-memory mode should not create journal files"); } @@ -4317,6 +5428,31 @@ mod tests { assert_eq!(deduped.len(), 5); } + #[test] + fn tenant_export_filters_before_cursor_and_limit() { + let mut store = FactStore::new(); + store.store(tenant_fact("tenant-b", "b1", "k", "foreign-first")); + let a1 = store.store(tenant_fact("tenant-a", "a1", "k", "a-first")); + store.store(tenant_fact("tenant-b", "b2", "k", "foreign-middle")); + let a2 = store.store(tenant_fact("tenant-a", "a2", "k", "a-second")); + + let page1 = store.export_for_tenant("tenant-a", None, None, 1); + assert_eq!( + page1.facts.iter().map(|fact| &fact.fact_id).collect::>(), + vec![&a1.fact_id] + ); + assert!(page1.has_more); + assert_eq!(page1.next_cursor.as_deref(), Some(a1.fact_id.as_str())); + + let page2 = store.export_for_tenant("tenant-a", None, page1.next_cursor.as_deref(), 1); + assert_eq!( + page2.facts.iter().map(|fact| &fact.fact_id).collect::>(), + vec![&a2.fact_id] + ); + assert!(!page2.has_more); + assert!(page2.next_cursor.is_none()); + } + #[test] fn test_export_with_since() { let mut store = FactStore::new(); @@ -4411,7 +5547,7 @@ mod tests { actor: None, }); - store.delete(&f1.fact_id); + store.delete("default", &f1.fact_id); let result = store.export(None, None, 100); @@ -4458,18 +5594,18 @@ mod tests { assert!(store.get(&old.fact_id).unwrap().superseded_by.is_none()); // mark - assert!(store.mark_superseded(&old.fact_id, &new.fact_id)); + assert!(store.mark_superseded("default", &old.fact_id, &new.fact_id)); assert_eq!( store.get(&old.fact_id).unwrap().superseded_by.as_deref(), Some(new.fact_id.as_str()) ); // clear (reversible) - assert!(store.clear_superseded(&old.fact_id)); + assert!(store.clear_superseded("default", &old.fact_id)); assert!(store.get(&old.fact_id).unwrap().superseded_by.is_none()); // nonexistent target -> false, no panic. - assert!(!store.mark_superseded("f_nope", &new.fact_id)); - assert!(!store.clear_superseded("f_nope")); + assert!(!store.mark_superseded("default", "f_nope", &new.fact_id)); + assert!(!store.clear_superseded("default", "f_nope")); } #[test] @@ -4500,7 +5636,20 @@ mod tests { horizon_class: None, actor: None, }); - assert!(store.mark_superseded(&old.fact_id, &new.fact_id)); + assert!(store.mark_superseded("default", &old.fact_id, &new.fact_id)); + let mut sync_trigger = new.clone(); + sync_trigger.fact_id = "f_cross_entity_sync_sanitizer_trigger".to_string(); + sync_trigger.entity = "sync-trigger".to_string(); + sync_trigger.key = "k".to_string(); + sync_trigger.version = 1; + sync_trigger.supersedes = None; + sync_trigger.superseded_by = None; + assert!(store.store_synced(sync_trigger)); + assert_eq!( + store.get(&old.fact_id).unwrap().superseded_by.as_deref(), + Some(new.fact_id.as_str()), + "sync sanitization must preserve valid same-tenant cross-entity retirement" + ); old_id = old.fact_id; new_id = new.fact_id; } @@ -4543,9 +5692,9 @@ mod tests { horizon_class: None, actor: None, }); - store.mark_superseded(&old.fact_id, &new.fact_id); + store.mark_superseded("default", &old.fact_id, &new.fact_id); // Now reverse it; the clear must also persist (not just the mark). - assert!(store.clear_superseded(&old.fact_id)); + assert!(store.clear_superseded("default", &old.fact_id)); old_id = old.fact_id; } { @@ -4899,7 +6048,7 @@ mod tests { horizon_class: Some(HorizonClass::None), actor: None, }); - store.delete(&f.fact_id); + store.delete("default", &f.fact_id); // A tombstoned fact is not a recall target. assert_eq!(store.record_access(&[f.fact_id.as_str()]), 0); } @@ -5018,7 +6167,7 @@ mod tests { // Delete the cursor fact (v4). The cursor carries the ordering key, not // a position, so the next page must still resume at v3 with no dupe. let v4_id = page1.facts[1].fact_id.clone(); - store.delete(&v4_id); + store.delete("default", &v4_id); let page2 = store.list_page(Some(&cursor), 2, true, |_| true); assert_eq!(page2.facts[0].value, "v3"); assert_eq!(page2.facts[1].value, "v2"); @@ -5045,11 +6194,11 @@ mod tests { store.store(priv_req); // Deleted fact — never listed. let del_id = store_at(&mut store, "note", "gone", "deleted-value", 2000); - store.delete(&del_id); + store.delete("default", &del_id); // Superseded fact. let old_id = store_at(&mut store, "note", "retired", "old", 3000); let new_id = store_at(&mut store, "note", "current", "new", 4000); - store.mark_superseded(&old_id, &new_id); + store.mark_superseded("default", &old_id, &new_id); // Default (include_superseded=true): public + current + old(retired) = 3. let page = store.list_page(None, 100, true, |_| true); diff --git a/crates/corecrux-memory/src/legal_hold.rs b/crates/corecrux-memory/src/legal_hold.rs index c1875f32..2f63649c 100644 --- a/crates/corecrux-memory/src/legal_hold.rs +++ b/crates/corecrux-memory/src/legal_hold.rs @@ -609,7 +609,7 @@ mod tests { assert!(!marked.contains(&held.fact_id)); assert!(store.get(&held.fact_id).is_some()); - assert!(store.delete(&held.fact_id)); + assert!(store.delete(&held.tenant_hash, &held.fact_id)); let err = store.compact_journal().unwrap_err(); assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied); assert!(err.to_string().contains(&placed.hold.hold_id)); @@ -624,7 +624,7 @@ mod tests { let hold_id = "lh_unparsable_only"; let malformed = store.store(malformed_hold_state("tenant-a", hold_id)); assert_eq!(malformed.version, 1); - assert!(store.delete(&malformed.fact_id)); + assert!(store.delete(&malformed.tenant_hash, &malformed.fact_id)); let marker = store.legal_hold(hold_id).unwrap(); assert_eq!(marker.hold_id, hold_id); @@ -639,7 +639,7 @@ mod tests { assert!(!marked.contains(&held.fact_id)); assert!(marked.contains(&other_tenant.fact_id)); - assert!(store.delete(&held.fact_id)); + assert!(store.delete(&held.tenant_hash, &held.fact_id)); let err = store.compact_journal().unwrap_err(); assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied); assert!(err.to_string().contains(hold_id)); @@ -660,7 +660,7 @@ mod tests { let state_fact_id = store.get_by_entity(&format!("{LEGAL_HOLD_ENTITY_PREFIX}{}", placed.hold.hold_id))[0] .fact_id .clone(); - assert!(store.delete(&state_fact_id)); + assert!(store.delete("tenant-a", &state_fact_id)); assert_eq!(store.legal_hold(&placed.hold.hold_id), Some(placed.hold.clone())); assert_eq!( @@ -685,7 +685,7 @@ mod tests { actor: None, }) .unwrap(); - assert!(store.delete(&held.fact_id)); + assert!(store.delete(&held.tenant_hash, &held.fact_id)); let err = store.compact_journal().unwrap_err(); assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied); diff --git a/crates/corecrux-memory/src/sync.rs b/crates/corecrux-memory/src/sync.rs index c0616afb..fe569134 100644 --- a/crates/corecrux-memory/src/sync.rs +++ b/crates/corecrux-memory/src/sync.rs @@ -434,7 +434,7 @@ pub fn offboard_tenant_mirror(store: &mut FactStore, tenant_id: &str, membership let pre_wipe_hash = hash_facts(tenant_id, &facts); let mut deleted_this_collection = 0usize; for fact in facts { - if store.delete(&fact.fact_id) { + if store.delete(&fact.tenant_hash, &fact.fact_id) { deleted_this_collection += 1; deleted_fact_ids.push(fact.fact_id.clone()); let tombstone = store.store(crate::fact_store::StoreFact { diff --git a/crates/corecrux-memory/tests/sync_low_hanging.rs b/crates/corecrux-memory/tests/sync_low_hanging.rs index dc42e858..f9095044 100644 --- a/crates/corecrux-memory/tests/sync_low_hanging.rs +++ b/crates/corecrux-memory/tests/sync_low_hanging.rs @@ -411,7 +411,7 @@ fn push_preview_counts_pushable_private_synced_and_deleted_facts() { horizon_class: None, actor: None, }); - assert!(store.delete(&deleted.fact_id)); + assert!(store.delete("default", &deleted.fact_id)); let preview = client.push_preview(&store); assert_eq!(preview.pushable_count, 3); @@ -474,7 +474,7 @@ fn push_returns_zero_when_no_non_private_local_facts_exist() { horizon_class: None, actor: None, }); - assert!(store.delete(&deleted.fact_id)); + assert!(store.delete("default", &deleted.fact_id)); let result = client.push(&store).unwrap(); assert_eq!(result.facts_pushed, 0); diff --git a/crates/corecruxctl/src/memory_pack.rs b/crates/corecruxctl/src/memory_pack.rs index 48dc7a85..e4827e77 100644 --- a/crates/corecruxctl/src/memory_pack.rs +++ b/crates/corecruxctl/src/memory_pack.rs @@ -107,7 +107,7 @@ pub fn run_memory_export( let store = FactStore::with_persistence(&args.data_dir)?; let sessions = SessionStore::with_persistence(&args.data_dir)?; - let scan = cruxpack::private_summary(&store); + let scan = cruxpack::private_summary_for_tenant(&store, &args.tenant); if args.include_private && !confirm_include_private(&scan) { return Err(MemoryPackError::ConfirmationDeclined); } @@ -299,7 +299,7 @@ mod tests { horizon_class: None, actor: None, }); - store.delete(&erased.fact_id); + store.delete("default", &erased.fact_id); } #[test] diff --git a/crates/corecruxd/src/auth.rs b/crates/corecruxd/src/auth.rs index 71b99358..cfd268ce 100644 --- a/crates/corecruxd/src/auth.rs +++ b/crates/corecruxd/src/auth.rs @@ -1147,6 +1147,12 @@ impl HttpScopeContext { self.scope_bypass || self.scopes.iter().any(|s| s == scope) } + /// Whether the verified credential is authorized across every tenant. + /// A tenant-bound admin scope is still tenant-bound. + pub(crate) fn has_global_tenant_authority(&self) -> bool { + matches!(self.tenants, TenantAllow::Any) + } + /// Whether JWT authentication accepted a passport header that differs /// from the verified token identity. Most admin surfaces support this /// explicit override; sensitive human-approval boundaries can deny it. diff --git a/crates/corecruxd/src/consolidation_scheduler.rs b/crates/corecruxd/src/consolidation_scheduler.rs index c44b9246..b4bcd8d9 100644 --- a/crates/corecruxd/src/consolidation_scheduler.rs +++ b/crates/corecruxd/src/consolidation_scheduler.rs @@ -106,12 +106,17 @@ fn pinned_fact_ids(facts: &[Fact]) -> std::collections::HashSet { /// /// Mirrors what the scheduler surfaces; factored out so it is testable /// without a running task. `limit` bounds the underlying pass. -pub fn select_actionable_candidates(store: &FactStore, facts: &[Fact], limit: usize) -> Vec { +pub fn select_actionable_candidates( + store: &FactStore, + tenant_hash: &str, + facts: &[Fact], + limit: usize, +) -> Vec { let pinned = pinned_fact_ids(facts); let by_id: std::collections::HashMap<&str, &Fact> = facts.iter().map(|f| (f.fact_id.as_str(), f)).collect(); store - .contradiction_candidates_v1(limit) + .contradiction_candidates_v1(tenant_hash, limit) .into_iter() .filter(|c| { // Keep the group only if some member is actionable (unprotected). @@ -196,56 +201,70 @@ pub fn select_expiry_candidates( /// written — a clean store produces no noise). pub async fn run_review_once(store: &Arc>, limit: usize) -> usize { let surfaced_at = Utc::now(); - let (candidates, expiry_candidates, run_id) = { + let reviews = { let guard = store.read().await; - let facts: Vec = guard.all_facts().cloned().collect(); - let candidates = select_actionable_candidates(&guard, &facts, limit); - // P1 widen: also propose stale-past-horizon + low-confidence facts as - // (read-only) expiry proposals, using the SAME decay logic recall ranks - // by so "stale" means one thing across the daemon. let policy = decay::DecayPolicy::from_env(); - let expiry_candidates = select_expiry_candidates(&facts, surfaced_at, policy, limit); - ( - candidates, - expiry_candidates, - format!("run_{}", uuid::Uuid::new_v4().simple()), - ) + let tenants: std::collections::BTreeSet = + guard.all_facts().map(|fact| fact.tenant_hash.clone()).collect(); + tenants + .into_iter() + .filter_map(|tenant_hash| { + let facts: Vec = guard.all_facts_for_tenant(&tenant_hash).cloned().collect(); + let candidates = select_actionable_candidates(&guard, &tenant_hash, &facts, limit); + // P1 widen: also propose stale-past-horizon + low-confidence + // facts, using the SAME decay logic recall ranks by. + let expiry_candidates = select_expiry_candidates(&facts, surfaced_at, policy, limit); + if candidates.is_empty() && expiry_candidates.is_empty() { + None + } else { + Some(( + tenant_hash, + candidates, + expiry_candidates, + format!("run_{}", uuid::Uuid::new_v4().simple()), + )) + } + }) + .collect::>() }; - if candidates.is_empty() && expiry_candidates.is_empty() { + if reviews.is_empty() { return 0; } - let body = serde_json::json!({ - "schema": "crux.consolidation_review.v1", - "run_id": run_id, - "surfaced_at": surfaced_at.to_rfc3339(), - "count": candidates.len(), - "expiry_count": expiry_candidates.len(), - "resolution": "explicit", - "note": "detect+surface only; resolve contradictions via memory_consolidate or the console review route; apply expiry proposals via POST /v1/console/review/expiries or per-fact delete", - "candidates": candidates, - "expiry_candidates": expiry_candidates, - }); - - // Append-only receipt fact. Stable (never decays) so an audit replay can - // always find the surfacing event; the scheduler is the only writer. - let req = StoreFact { - tenant_hash: "default".to_string(), - entity: format!("{REVIEW_ENTITY_PREFIX}{run_id}"), - key: "review".to_string(), - value: body.to_string(), - source_receipt: Some(run_id.clone()), - confidence: 1.0, - private: false, - horizon_class: Some(HorizonClass::Stable), - actor: Some("consolidation-scheduler".to_string()), - }; - + let mut surfaced = 0usize; let mut guard = store.write().await; - if let Err(err) = guard.try_store(req) { - tracing::warn!(?err, "consolidation-review-receipt-append-failed"); + for (tenant_hash, candidates, expiry_candidates, run_id) in reviews { + surfaced += candidates.len() + expiry_candidates.len(); + let body = serde_json::json!({ + "schema": "crux.consolidation_review.v1", + "tenant_hash": tenant_hash, + "run_id": run_id, + "surfaced_at": surfaced_at.to_rfc3339(), + "count": candidates.len(), + "expiry_count": expiry_candidates.len(), + "resolution": "explicit", + "note": "detect+surface only; resolve contradictions via memory_consolidate or the console review route; apply expiry proposals via POST /v1/console/review/expiries or per-fact delete", + "candidates": candidates, + "expiry_candidates": expiry_candidates, + }); + // Append-only receipt fact. Stable (never decays) so an audit replay can + // always find the surfacing event; the scheduler is the only writer. + let req = StoreFact { + tenant_hash, + entity: format!("{REVIEW_ENTITY_PREFIX}{run_id}"), + key: "review".to_string(), + value: body.to_string(), + source_receipt: Some(run_id), + confidence: 1.0, + private: false, + horizon_class: Some(HorizonClass::Stable), + actor: Some("consolidation-scheduler".to_string()), + }; + if let Err(err) = guard.try_store(req) { + tracing::warn!(?err, "consolidation-review-receipt-append-failed"); + } } - candidates.len() + expiry_candidates.len() + surfaced } /// Spawn the background consolidation-review task, mirroring @@ -312,7 +331,7 @@ mod tests { fn seed_conflict(store: &mut FactStore, entity: &str, key: &str, conf: f32) -> (String, String) { let a = store_fact(store, entity, key, "enabled", conf, false); let b = store_fact(store, entity, key, "disabled", conf, false); - store.clear_superseded(&a.fact_id); + store.clear_superseded("default", &a.fact_id); (a.fact_id, b.fact_id) } @@ -321,7 +340,7 @@ mod tests { let mut store = FactStore::new(); seed_conflict(&mut store, "service:api", "enabled", 0.7); let facts: Vec = store.all_facts().cloned().collect(); - let surfaced = select_actionable_candidates(&store, &facts, 50); + let surfaced = select_actionable_candidates(&store, "default", &facts, 50); assert_eq!(surfaced.len(), 1, "one actionable contradiction"); assert_eq!(surfaced[0].entity, "service:api"); } @@ -332,7 +351,7 @@ mod tests { // Both members at/above the protected floor → group is not actionable. seed_conflict(&mut store, "service:api", "enabled", 1.0); let facts: Vec = store.all_facts().cloned().collect(); - let surfaced = select_actionable_candidates(&store, &facts, 50); + let surfaced = select_actionable_candidates(&store, "default", &facts, 50); assert!( surfaced.is_empty(), "all-protected group must not be surfaced, got {surfaced:?}" @@ -347,10 +366,10 @@ mod tests { // could consolidate the actionable one. let a = store_fact(&mut store, "svc", "on", "enabled", 1.0, false); let b = store_fact(&mut store, "svc", "on", "disabled", 0.5, false); - store.clear_superseded(&a.fact_id); + store.clear_superseded("default", &a.fact_id); let _ = b; let facts: Vec = store.all_facts().cloned().collect(); - let surfaced = select_actionable_candidates(&store, &facts, 50); + let surfaced = select_actionable_candidates(&store, "default", &facts, 50); assert_eq!(surfaced.len(), 1); } @@ -376,7 +395,7 @@ mod tests { false, ); let facts: Vec = store.all_facts().cloned().collect(); - let surfaced = select_actionable_candidates(&store, &facts, 50); + let surfaced = select_actionable_candidates(&store, "default", &facts, 50); assert!(surfaced.is_empty(), "both-pinned group must not be surfaced"); } diff --git a/crates/corecruxd/src/ephemeral_gc.rs b/crates/corecruxd/src/ephemeral_gc.rs index 6c94cbbe..69005ef0 100644 --- a/crates/corecruxd/src/ephemeral_gc.rs +++ b/crates/corecruxd/src/ephemeral_gc.rs @@ -150,7 +150,10 @@ pub async fn run_sweep_once(store: &Arc>, now: DateTime, let mut deleted = 0usize; let mut guard = store.write().await; for id in &candidates { - match guard.try_delete(id) { + let Some(tenant_hash) = guard.get(id).map(|fact| fact.tenant_hash.clone()) else { + continue; + }; + match guard.try_delete(&tenant_hash, id) { Ok(true) => deleted += 1, Ok(false) => {} Err(err) => { @@ -431,7 +434,7 @@ mod tests { // Drive the real journaled delete path. { let mut g = store.write().await; - assert!(g.try_delete(&receipt_id).expect("journaled delete")); + assert!(g.try_delete("default", &receipt_id).expect("journaled delete")); } let g = store.read().await; @@ -466,7 +469,7 @@ mod tests { horizon_class: None, actor: None, }); - assert!(s.try_delete(&f.fact_id).unwrap(), "secret fact soft-deleted"); + assert!(s.try_delete("default", &f.fact_id).unwrap(), "secret fact soft-deleted"); // One fact swept ⇒ receipt is built from the count alone. let payload = serde_json::to_value(build_gc_receipt(1, DEFAULT_RETAIN_DAYS)).unwrap(); assert_eq!(payload["deleted"], 1); diff --git a/crates/corecruxd/src/http/admin.rs b/crates/corecruxd/src/http/admin.rs index be352316..020bfe60 100644 --- a/crates/corecruxd/src/http/admin.rs +++ b/crates/corecruxd/src/http/admin.rs @@ -2700,7 +2700,7 @@ mod compact_facts_tests { let mut fs = FactStore::with_persistence(dir.path()).unwrap(); let deleted = fs.store(store_fact("erase-this-pii")); fs.store(store_fact("keep-this")); - fs.delete(&deleted.fact_id); + fs.delete("default", &deleted.fact_id); state.fact_store = std::sync::Arc::new(tokio::sync::RwLock::new(fs)); // Pre-condition: deleted value is still on disk (the soft-delete leak). @@ -2761,7 +2761,7 @@ mod compact_facts_tests { let mut fs = FactStore::with_persistence(dir.path()).unwrap(); let deleted = fs.store(store_fact(SECRET)); fs.store(store_fact("keep-this")); - fs.delete(&deleted.fact_id); + fs.delete("default", &deleted.fact_id); state.fact_store = std::sync::Arc::new(tokio::sync::RwLock::new(fs)); let params = serde_json::json!({ "reason": "gdpr-erasure-test" }); @@ -2816,7 +2816,7 @@ mod compact_facts_tests { let mut fs = FactStore::with_persistence(dir.path()).unwrap(); let d = fs.store(store_fact("verify-me-then-erase")); - fs.delete(&d.fact_id); + fs.delete("default", &d.fact_id); state.fact_store = std::sync::Arc::new(tokio::sync::RwLock::new(fs)); let params = serde_json::json!({ "reason": "verify-test" }); @@ -2934,7 +2934,7 @@ mod compact_facts_tests { actor: Some("p_legal".to_string()), }) .unwrap(); - assert!(fs.delete(&held.fact_id)); + assert!(fs.delete("default", &held.fact_id)); state.fact_store = std::sync::Arc::new(tokio::sync::RwLock::new(fs)); let ordinary = serde_json::json!({"reason": "ordinary hard deletion"}); diff --git a/crates/corecruxd/src/http/console.rs b/crates/corecruxd/src/http/console.rs index 4669495b..fe897939 100644 --- a/crates/corecruxd/src/http/console.rs +++ b/crates/corecruxd/src/http/console.rs @@ -923,10 +923,14 @@ pub(super) async fn get_console_review_contradictions( if let Err(problem) = require_console_read(&state, &headers) { return problem.into_response(); } + let tenant_hash = match console_tenant(&state, &headers) { + Ok(tenant_hash) => tenant_hash, + Err(problem) => return problem.into_response(), + }; let limit = query.limit.unwrap_or(50).min(250); let candidates = { let store = state.fact_store.read().await; - store.contradiction_candidates_v1(limit) + store.contradiction_candidates_v1(&tenant_hash, limit) }; ( StatusCode::OK, @@ -956,12 +960,17 @@ pub(super) async fn get_console_review_queue( if let Err(problem) = require_console_read(&state, &headers) { return problem.into_response(); } + let tenant_hash = match console_tenant(&state, &headers) { + Ok(tenant_hash) => tenant_hash, + Err(problem) => return problem.into_response(), + }; let limit = query.limit.unwrap_or(50).min(250); let (runs, live_contradictions) = { let store = state.fact_store.read().await; // Confidence 1.0 on every review receipt ⇒ query_inner's confidence-desc, // stored_at-desc order already yields newest-first. let result = store.query(&corecrux_memory::fact_store::FactQuery { + tenant_hash: Some(tenant_hash.clone()), entity_prefix: Some(crate::consolidation_scheduler::REVIEW_ENTITY_PREFIX.to_string()), top_k: limit, ..Default::default() @@ -983,7 +992,7 @@ pub(super) async fn get_console_review_queue( // Live contradiction pass so the console still shows current // contradictions even when the scheduler is OFF (nothing surfaced yet) — // repointing the page to the queue must not hide them (review finding 6). - let live = store.contradiction_candidates_v1(limit); + let live = store.contradiction_candidates_v1(&tenant_hash, limit); (runs, live) }; ( @@ -1037,6 +1046,10 @@ pub(super) async fn post_console_review_expiries( if let Err(problem) = require_console_write(&state, &headers) { return problem.into_response(); } + let (tenant_hash, actor) = match console_mutation_authority(&state, &headers) { + Ok(authority) => authority, + Err(response) => return response, + }; if body.fact_ids.is_empty() { return problem_response( StatusCode::BAD_REQUEST, @@ -1046,14 +1059,13 @@ pub(super) async fn post_console_review_expiries( if body.fact_ids.len() > MAX_EXPIRY_BATCH { return problem_response(StatusCode::BAD_REQUEST, "fact_ids exceeds the 500-id per-request cap"); } - let actor = console_actor_from_headers(&headers); let (expired, skipped) = { let mut store = state.fact_store.write().await; // Recompute the live candidate set under the SAME write lock we delete // under — atomic revalidation, no TOCTOU. `select_expiry_candidates` // applies the protection + stale/low-confidence rules exactly as the // scheduler does at proposal time. - let facts: Vec = store.all_facts().cloned().collect(); + let facts: Vec = store.all_facts_for_tenant(&tenant_hash).cloned().collect(); let now = chrono::Utc::now(); let policy = corecrux_projections::decay::DecayPolicy::from_env(); let current: std::collections::HashMap = @@ -1070,7 +1082,7 @@ pub(super) async fn post_console_review_expiries( continue; // de-dup so a repeated id is counted once } match current.get(id) { - Some(reason) => match store.try_delete(id) { + Some(reason) => match store.try_delete(&tenant_hash, id) { Ok(true) => expired.push(serde_json::json!({ "fact_id": id, "reason": reason })), Ok(false) => { skipped.push(serde_json::json!({ "fact_id": id, "reason": "not_found_or_already_deleted" })); @@ -1110,19 +1122,22 @@ pub(super) async fn post_console_review_consolidation( if let Err(problem) = require_console_write(&state, &headers) { return problem.into_response(); } + let (tenant_hash, actor) = match console_mutation_authority(&state, &headers) { + Ok(authority) => authority, + Err(response) => return response, + }; if body.consolidation_id.trim().is_empty() { body.consolidation_id = format!("console-{}", uuid::Uuid::new_v4()); } - if body.actor.as_deref().unwrap_or_default().trim().is_empty() { - body.actor = Some(console_actor_from_headers(&headers)); - } + // Body fields are data, never authority. Always overwrite the legacy + // actor hint with the principal resolved from the verified request. + body.actor = Some(actor.clone()); // Capture the audit fields before the request is moved into the store op. let entity = body.entity.clone(); let key = body.key.clone(); - let actor = body.actor.clone().unwrap_or_else(|| "console".to_string()); let report = { let mut store = state.fact_store.write().await; - store.consolidate_facts_v1(body) + store.consolidate_facts_v1(&tenant_hash, body) }; match report { Ok(report) => { @@ -1133,9 +1148,12 @@ pub(super) async fn post_console_review_consolidation( &report.receipt, &entity, &key, - &actor, - "canonical_merge", - &now, + super::consolidation_receipt::ConsolidationReceiptContext { + tenant_hash: &tenant_hash, + actor: &actor, + strategy: "canonical_merge", + created_at: &now, + }, ); ( StatusCode::OK, @@ -1157,10 +1175,6 @@ pub(super) struct ConsolidationUndoRequest { pub canonical_fact_id: String, #[serde(default)] pub source_fact_ids: Vec, - #[serde(default)] - pub entity: Option, - #[serde(default)] - pub key: Option, } /// `POST /v1/console/review/consolidations/undo` — atomically reverse a @@ -1176,14 +1190,23 @@ pub(super) async fn post_console_review_consolidation_undo( if let Err(problem) = require_console_write(&state, &headers) { return problem.into_response(); } - let actor = console_actor_from_headers(&headers); + let (tenant_hash, actor) = match console_mutation_authority(&state, &headers) { + Ok(authority) => authority, + Err(response) => return response, + }; let undo = { let mut store = state.fact_store.write().await; - store.consolidate_undo_v1(&req.canonical_fact_id, &req.source_fact_ids) + let target = store + .get_for_tenant_including_deleted(&req.canonical_fact_id, &tenant_hash) + .map(|fact| (fact.entity.clone(), fact.key.clone())); + store + .consolidate_undo_v1(&tenant_hash, &req.canonical_fact_id, &req.source_fact_ids) + .map(|undo| (undo, target)) }; match undo { - Ok(undo) => { + Ok((undo, target)) => { let now = chrono::Utc::now().to_rfc3339(); + let (entity, key) = target.unwrap_or_default(); let receipt = corecrux_memory::fact_store::ConsolidationReceiptV1 { consolidation_id: format!("undo:{}", req.canonical_fact_id), canonical_fact_id: undo.canonical_fact_id.clone(), @@ -1194,11 +1217,14 @@ pub(super) async fn post_console_review_consolidation_undo( let signed = super::consolidation_receipt::mint_consolidation_receipt( &state, &receipt, - req.entity.as_deref().unwrap_or(""), - req.key.as_deref().unwrap_or(""), - &actor, - "undo", - &now, + &entity, + &key, + super::consolidation_receipt::ConsolidationReceiptContext { + tenant_hash: &tenant_hash, + actor: &actor, + strategy: "undo", + created_at: &now, + }, ); ( StatusCode::OK, @@ -1216,27 +1242,62 @@ pub(super) async fn post_console_review_consolidation_undo( } } -fn console_actor_from_headers(headers: &HeaderMap) -> String { - headers - .get("x-corecrux-passport-id") - .and_then(|value| value.to_str().ok()) - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or("console") - .to_string() +#[allow(clippy::result_large_err)] +fn console_mutation_authority( + state: &AppState, + headers: &HeaderMap, +) -> Result<(String, String), axum::response::Response> { + let context = crate::auth::http_scope_context(&state.auth, headers).map_err(IntoResponse::into_response)?; + let tenant_hash = context + .resolve_authorized_tenant(None) + .map_err(IntoResponse::into_response)?; + let actor = if context.local_unverified_identity() { + format!( + "operator:unverified:{}", + context.passport_id.as_deref().unwrap_or("console") + ) + } else { + if context.passport_override_used() { + return Err(problem_response( + StatusCode::FORBIDDEN, + "passport impersonation is not permitted for console review mutations", + )); + } + context.passport_id.clone().ok_or_else(|| { + problem_response( + StatusCode::FORBIDDEN, + "an authenticated passport is required for console review mutations", + ) + })? + }; + Ok((tenant_hash, actor)) +} + +#[allow(clippy::result_large_err)] +fn console_tenant(state: &AppState, headers: &HeaderMap) -> Result { + crate::auth::http_scope_context(&state.auth, headers)?.resolve_authorized_tenant(None) } fn consolidation_problem(err: corecrux_memory::fact_store::ConsolidationErrorV1) -> axum::response::Response { use corecrux_memory::fact_store::ConsolidationErrorV1; let status = match &err { - ConsolidationErrorV1::NoTargets | ConsolidationErrorV1::TargetOutsideEntityKey(_) => StatusCode::BAD_REQUEST, + ConsolidationErrorV1::NoTargets + | ConsolidationErrorV1::MissingConsolidationId + | ConsolidationErrorV1::TargetOutsideEntityKey(_) + | ConsolidationErrorV1::ImplicitPriorNotTarget(_) + | ConsolidationErrorV1::NoUndoSources + | ConsolidationErrorV1::NotConsolidationCanonical(_) + | ConsolidationErrorV1::UndoSourceMismatch(_) => StatusCode::BAD_REQUEST, ConsolidationErrorV1::TargetNotFound(_) => StatusCode::NOT_FOUND, + ConsolidationErrorV1::DuplicateTarget(_) => StatusCode::BAD_REQUEST, ConsolidationErrorV1::TargetDeleted(_) + | ConsolidationErrorV1::TargetAlreadySuperseded(_) | ConsolidationErrorV1::TargetPinned(_) | ConsolidationErrorV1::TargetPrivate(_) | ConsolidationErrorV1::TargetReceiptLinked(_) | ConsolidationErrorV1::TargetDaemonOwned { .. } - | ConsolidationErrorV1::TargetHighConfidence { .. } => StatusCode::CONFLICT, + | ConsolidationErrorV1::TargetHighConfidence { .. } + | ConsolidationErrorV1::CanonicalSuperseded(_) => StatusCode::CONFLICT, ConsolidationErrorV1::Journal(_) => StatusCode::INTERNAL_SERVER_ERROR, }; problem_response(status, err.to_string()) @@ -2888,7 +2949,6 @@ pub(super) async fn get_console_facts( if let Err(problem) = require_console_read(&state, &headers) { return problem.into_response(); } - let q = query .q .as_ref() @@ -2958,7 +3018,6 @@ pub(super) async fn post_console_fact_add( Ok(ctx) => ctx, Err(problem) => return problem.into_response(), }; - let entity = body.entity.trim(); let key = body.key.trim(); let value = body.value.trim(); diff --git a/crates/corecruxd/src/http/consolidation_receipt.rs b/crates/corecruxd/src/http/consolidation_receipt.rs index dc443fd5..644a3f27 100644 --- a/crates/corecruxd/src/http/consolidation_receipt.rs +++ b/crates/corecruxd/src/http/consolidation_receipt.rs @@ -20,6 +20,13 @@ use ed25519_dalek::SigningKey; use super::AppState; +pub(super) struct ConsolidationReceiptContext<'a> { + pub tenant_hash: &'a str, + pub actor: &'a str, + pub strategy: &'a str, + pub created_at: &'a str, +} + /// Load the daemon passport signing key (best-effort). Mirrors /// `stream_receipts::load_signing_key`; returns `None` when no passport key is /// configured (receipts are then omitted rather than failing the mutation). @@ -43,39 +50,32 @@ pub(super) fn mint_consolidation_receipt( receipt: &ConsolidationReceiptV1, entity: &str, key: &str, - actor: &str, - strategy: &str, - created_at: &str, + context: ConsolidationReceiptContext<'_>, ) -> Option { let signing_key = signing_key(state)?; let receipt_id = format!("rcon_{}", uuid::Uuid::new_v4()); - let superseded: Vec<&str> = receipt.superseded_fact_ids.iter().map(String::as_str).collect(); - let (body_bytes, body_hash) = build_consolidation_body_v1(&ConsolidationBodyInputV1 { - tenant_id: "local", - receipt_id: &receipt_id, - consolidation_id: &receipt.consolidation_id, - actor_passport: actor, - target_entity: entity, - target_key: Some(key), - canonical_fact_id: &receipt.canonical_fact_id, - canonical_hash: &receipt.canonical_hash, - strategy, - superseded_fact_ids: &superseded, - source_receipts: &[], - created_at, - }); + let (body_bytes, body_hash) = consolidation_receipt_body( + &receipt_id, + receipt, + context.tenant_hash, + entity, + key, + context.actor, + context.strategy, + context.created_at, + ); let sig = sign_consolidation_v1( &receipt_id, &body_bytes, body_hash, &signing_key, &state.passport_fpr, - created_at, + context.created_at, ); Some(serde_json::json!({ "schema": "crux.consolidation_receipt.v1", "kind": "consolidation", - "strategy": strategy, + "strategy": context.strategy, "receipt_id": receipt_id, // The signed CBOR body + its hash: everything a verifier needs offline. "body_cbor_hex": hex::encode(&body_bytes), @@ -86,6 +86,34 @@ pub(super) fn mint_consolidation_receipt( })) } +#[allow(clippy::too_many_arguments)] +fn consolidation_receipt_body( + receipt_id: &str, + receipt: &ConsolidationReceiptV1, + tenant_hash: &str, + entity: &str, + key: &str, + actor: &str, + strategy: &str, + created_at: &str, +) -> (Vec, [u8; 32]) { + let superseded: Vec<&str> = receipt.superseded_fact_ids.iter().map(String::as_str).collect(); + build_consolidation_body_v1(&ConsolidationBodyInputV1 { + tenant_id: tenant_hash, + receipt_id, + consolidation_id: &receipt.consolidation_id, + actor_passport: actor, + target_entity: entity, + target_key: Some(key), + canonical_fact_id: &receipt.canonical_fact_id, + canonical_hash: &receipt.canonical_hash, + strategy, + superseded_fact_ids: &superseded, + source_receipts: &[], + created_at, + }) +} + #[cfg(test)] mod tests { use super::*; @@ -130,4 +158,40 @@ mod tests { "tampered body fails offline verify" ); } + + #[test] + fn consolidation_receipt_body_binds_authorized_tenant_and_actor() { + let receipt = ConsolidationReceiptV1 { + consolidation_id: "con-tenant".to_string(), + canonical_fact_id: "f_canonical".to_string(), + canonical_hash: "blake3:1234".to_string(), + superseded_fact_ids: vec!["f_old".to_string()], + source_fact_ids: vec!["f_old".to_string()], + }; + let (body, _) = consolidation_receipt_body( + "rcon_tenant", + &receipt, + "tenant-a", + "proj", + "status", + "passport:reviewer", + "canonical_merge", + "2026-07-30T00:00:00Z", + ); + let decoded: ciborium::value::Value = + ciborium::de::from_reader(std::io::Cursor::new(body)).expect("decode canonical body"); + let ciborium::value::Value::Map(entries) = decoded else { + panic!("receipt body must be a map"); + }; + let text = |field: &str| { + entries.iter().find_map(|(key, value)| match (key, value) { + (ciborium::value::Value::Text(key), ciborium::value::Value::Text(value)) if key == field => { + Some(value.as_str()) + } + _ => None, + }) + }; + assert_eq!(text("tenant_id"), Some("tenant-a")); + assert_eq!(text("actor_passport"), Some("passport:reviewer")); + } } diff --git a/crates/corecruxd/src/http/context_surface.rs b/crates/corecruxd/src/http/context_surface.rs index 05b28f05..bdf80776 100644 --- a/crates/corecruxd/src/http/context_surface.rs +++ b/crates/corecruxd/src/http/context_surface.rs @@ -759,7 +759,10 @@ mod tests { let new = store_fact(&state, "bench:lme-s", "baseline-2026", "91.7%").await; { let mut s = state.fact_store.write().await; - assert!(s.mark_superseded(&old.fact_id, &new.fact_id), "mark superseded"); + assert!( + s.mark_superseded("default", &old.fact_id, &new.fact_id), + "mark superseded" + ); } let bundle = get_bundle(&state, req(Some("bench:lme-s"), None, Some(2000))).await; let items = facts_items(&bundle); diff --git a/crates/corecruxd/src/http/facts.rs b/crates/corecruxd/src/http/facts.rs index a931625b..aea55d5d 100644 --- a/crates/corecruxd/src/http/facts.rs +++ b/crates/corecruxd/src/http/facts.rs @@ -153,11 +153,11 @@ pub(super) fn require_session_write_ctx( } fn raw_admin_read(ctx: &crate::auth::HttpScopeContext) -> bool { - ctx.passport_id.is_none() && ctx.has_scope("admin:read") + ctx.passport_id.is_none() && ctx.has_scope("admin:read") && ctx.has_global_tenant_authority() } fn raw_admin_write(ctx: &crate::auth::HttpScopeContext) -> bool { - ctx.passport_id.is_none() && ctx.has_scope("admin:write") + ctx.passport_id.is_none() && ctx.has_scope("admin:write") && ctx.has_global_tenant_authority() } /// Resolve the trusted tenant stamp for an HTTP write (OD-37 / audit-v2 closeout M1). @@ -596,8 +596,26 @@ pub(super) async fn delete_fact( ) .into_response(); } - let deleted = if visible_fact.is_some() { - match store.try_delete(&fact_id) { + if let Some(fact) = visible_fact { + if store.is_consolidation_canonical_for_tenant(&fact_id, &fact.tenant_hash) { + return ProblemResponse( + ProblemDetails::new( + StatusCode::CONFLICT.as_u16(), + "https://errors.cuecrux.com/conflict", + "Conflict", + ) + .with_detail("consolidation canonical must be retired through the dedicated undo surface") + .with_extensions(serde_json::json!({ + "code": "CONSOLIDATION_CANONICAL_REQUIRES_UNDO", + "fact_id": fact_id, + })), + ) + .into_response(); + } + } + let delete_tenant = visible_fact.map(|fact| fact.tenant_hash.clone()); + let deleted = if let Some(delete_tenant) = delete_tenant { + match store.try_delete(&delete_tenant, &fact_id) { Ok(deleted) => deleted, Err(err) => return problem_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), } @@ -738,11 +756,16 @@ pub(super) async fn post_aggregate( headers: HeaderMap, Json(req): Json, ) -> impl IntoResponse { - if let Err(response) = require_fact_read_ctx(&state, &headers) { - return response; - } + let ctx = match require_fact_read_ctx(&state, &headers) { + Ok(ctx) => ctx, + Err(response) => return response, + }; + let tenant_hash = match ctx.resolve_authorized_tenant(None) { + Ok(tenant_hash) => tenant_hash, + Err(problem) => return problem.into_response(), + }; let store = state.fact_store.read().await; - Json(store.aggregate_v1(&req)).into_response() + Json(store.aggregate_v1(&tenant_hash, &req)).into_response() } #[utoipa::path( @@ -778,16 +801,20 @@ pub(super) async fn export_facts( let limit = params.limit.map_or(1000, |v| v.min(10000) as usize); let store = state.fact_store.read().await; - let mut result = store.export(since, cursor, limit); + let result = if raw_admin_read(&ctx) { + store.export(since, cursor, limit) + } else { + let tenant_hash = match ctx.resolve_authorized_tenant(None) { + Ok(tenant_hash) => tenant_hash, + Err(problem) => return problem.into_response(), + }; + store.export_for_tenant(&tenant_hash, since, cursor, limit) + }; + let mut result = result; if !raw_admin_read(&ctx) { - // Tenant predicate (audit-v2 closeout M1): export must not cross the tenant - // boundary. No-op while everything is `default`; a hard filter once real - // tenants are stamped. Passport/entity visibility is applied on top. - let tenant_hash = tenant_hash_for_read_context(&ctx); result.facts = result .facts .iter() - .filter(|fact| fact.tenant_hash == tenant_hash) .filter_map(|fact| render_fact_for_http(fact, &ctx)) .collect(); } diff --git a/crates/corecruxd/src/http/tests.rs b/crates/corecruxd/src/http/tests.rs index eb287c48..d8d8b853 100644 --- a/crates/corecruxd/src/http/tests.rs +++ b/crates/corecruxd/src/http/tests.rs @@ -2759,6 +2759,73 @@ async fn delete_fact_rejects_daemon_owned_control_record() { assert!(!state.fact_store.read().await.get(&fact_id).unwrap().deleted); } +#[tokio::test] +async fn delete_fact_requires_dedicated_undo_for_consolidation_canonical() { + let state = test_app_state(16); + let (first_id, second_id, canonical_id) = { + let mut store = state.fact_store.write().await; + let first = store.store(corecrux_memory::fact_store::StoreFact { + tenant_hash: "default".to_string(), + entity: "proj".to_string(), + key: "status".to_string(), + value: "blocked".to_string(), + source_receipt: None, + confidence: 0.2, + private: false, + horizon_class: None, + actor: None, + }); + let second = store.store(corecrux_memory::fact_store::StoreFact { + tenant_hash: "default".to_string(), + entity: "proj".to_string(), + key: "status".to_string(), + value: "active".to_string(), + source_receipt: None, + confidence: 0.3, + private: false, + horizon_class: None, + actor: None, + }); + assert!(store.clear_superseded("default", &first.fact_id)); + let report = store + .consolidate_facts_v1( + "default", + corecrux_memory::fact_store::ConsolidationRequestV1 { + consolidation_id: "con-http-delete".to_string(), + entity: "proj".to_string(), + key: "status".to_string(), + canonical_value: "settled".to_string(), + target_fact_ids: vec![first.fact_id.clone(), second.fact_id.clone()], + protected_fact_ids: vec![], + confidence: 0.8, + source_receipt: None, + actor: Some("operator:unverified:test".to_string()), + horizon_class: None, + protected_confidence_floor: 0.99, + }, + ) + .unwrap(); + (first.fact_id, second.fact_id, report.receipt.canonical_fact_id) + }; + + let resp = delete_fact(State(state.clone()), HeaderMap::new(), Path(canonical_id.clone())) + .await + .into_response(); + assert_eq!(resp.status(), StatusCode::CONFLICT); + let body = json_body(resp).await; + assert_eq!(body["code"], "CONSOLIDATION_CANONICAL_REQUIRES_UNDO"); + let store = state.fact_store.read().await; + assert!(store.get(&canonical_id).is_some()); + assert_eq!( + store.get(&first_id).unwrap().superseded_by.as_deref(), + Some(canonical_id.as_str()) + ); + assert_eq!( + store.get(&second_id).unwrap().superseded_by.as_deref(), + Some(canonical_id.as_str()) + ); +} + // ── Fact Store (GET /v1/facts/entity/{entity}) ────────────────── #[tokio::test] @@ -3525,7 +3592,7 @@ async fn list_facts_always_excludes_private_and_deleted() { let deleted_id = store_plain_fact(&state, "note", "gone", "deleted-value").await; { let mut store = state.fact_store.write().await; - store.delete(&deleted_id); + store.delete("default", &deleted_id); store.store(corecrux_memory::fact_store::StoreFact { tenant_hash: "default".to_string(), entity: crux_mcp::scope::private_entity_for_agent("alice", "notes"), @@ -11027,7 +11094,7 @@ async fn console_review_contradictions_returns_factstore_candidates() { horizon_class: None, actor: None, }); - assert!(store.clear_superseded(&first.fact_id)); + assert!(store.clear_superseded("default", &first.fact_id)); (first.fact_id, second.fact_id) }; @@ -11243,7 +11310,7 @@ async fn console_review_consolidation_supersedes_targets_with_actor() { horizon_class: None, actor: None, }); - assert!(store.clear_superseded(&old.fact_id)); + assert!(store.clear_superseded("default", &old.fact_id)); (old.fact_id, newer.fact_id) }; @@ -11259,7 +11326,7 @@ async fn console_review_consolidation_supersedes_targets_with_actor() { protected_fact_ids: vec![], confidence: 0.8, source_receipt: None, - actor: None, + actor: Some("forged-body-actor".to_string()), horizon_class: Some(corecrux_memory::fact_store::HorizonClass::Stable), protected_confidence_floor: 0.99, }), @@ -11284,7 +11351,12 @@ async fn console_review_consolidation_supersedes_targets_with_actor() { ); assert_eq!( store.get(&canonical_id).unwrap().actor.as_deref(), - Some("passport:reviewer") + Some("operator:unverified:passport:reviewer") + ); + assert_ne!( + store.get(&canonical_id).unwrap().actor.as_deref(), + Some("forged-body-actor"), + "body actor must never become signed or stored authority" ); } @@ -14126,6 +14198,144 @@ fn work_auth_missing_tenant_headers(passport_id: &str, scopes: &str) -> HeaderMa })) } +#[tokio::test] +async fn fact_aggregate_requires_and_enforces_one_authorized_tenant() { + let state = work_auth_test_state(16); + { + let mut store = state.fact_store.write().await; + for (tenant, entity, value) in [ + ("tenant-a", "metric:a1", "10"), + ("tenant-a", "metric:a2", "20"), + ("tenant-b", "metric:b1", "999"), + ] { + store.store(corecrux_memory::fact_store::StoreFact { + tenant_hash: tenant.to_string(), + entity: entity.to_string(), + key: "amount".to_string(), + value: value.to_string(), + source_receipt: None, + confidence: 1.0, + private: false, + horizon_class: None, + actor: None, + }); + } + } + let request = corecrux_memory::fact_store::AggregateRequestV1 { + op: corecrux_memory::fact_store::AggregateOp::Count, + entity: None, + key: Some("amount".to_string()), + query: None, + as_of: None, + token_budget: None, + }; + + let a = facts::post_aggregate( + State(state.clone()), + work_auth_headers("tenant-a", None, "query:read"), + Json(request.clone()), + ) + .await + .into_response(); + assert_eq!(a.status(), StatusCode::OK); + assert_eq!(json_body(a).await["value"], serde_json::json!(2)); + + let b = facts::post_aggregate( + State(state.clone()), + work_auth_headers("tenant-b", None, "query:read"), + Json(request.clone()), + ) + .await + .into_response(); + assert_eq!(b.status(), StatusCode::OK); + assert_eq!(json_body(b).await["value"], serde_json::json!(1)); + + let missing = facts::post_aggregate( + State(state.clone()), + work_auth_missing_tenant_headers("passport-a", "query:read"), + Json(request.clone()), + ) + .await + .into_response(); + assert_eq!(missing.status(), StatusCode::FORBIDDEN); + + let exp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() + .saturating_add(3_600) as usize; + let multi_headers = work_auth_headers_from_claims(serde_json::json!({ + "exp": exp, + "iss": WORK_AUTH_TEST_ISSUER, + "aud": WORK_AUTH_TEST_AUDIENCE, + "scope": "query:read", + "tenants": ["tenant-a", "tenant-b"], + })); + let ambiguous = facts::post_aggregate(State(state), multi_headers, Json(request)) + .await + .into_response(); + assert_eq!(ambiguous.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn fact_export_tenant_filter_precedes_pagination() { + let state = work_auth_test_state(16); + let (a1, a2) = { + let mut store = state.fact_store.write().await; + let put = |store: &mut corecrux_memory::FactStore, tenant: &str, entity: &str, value: &str| { + store.store(corecrux_memory::fact_store::StoreFact { + tenant_hash: tenant.to_string(), + entity: entity.to_string(), + key: "k".to_string(), + value: value.to_string(), + source_receipt: None, + confidence: 1.0, + private: false, + horizon_class: None, + actor: None, + }) + }; + put(&mut store, "tenant-b", "b1", "foreign-first"); + let a1 = put(&mut store, "tenant-a", "a1", "a-first"); + put(&mut store, "tenant-b", "b2", "foreign-middle"); + let a2 = put(&mut store, "tenant-a", "a2", "a-second"); + (a1, a2) + }; + + let page1 = facts::export_facts( + State(state.clone()), + work_auth_headers("tenant-a", None, "query:read"), + Query(ExportFactsParams { + since: None, + cursor: None, + limit: Some(1), + }), + ) + .await + .into_response(); + assert_eq!(page1.status(), StatusCode::OK); + let page1 = json_body(page1).await; + assert_eq!(page1["facts"][0]["fact_id"], a1.fact_id); + assert_eq!(page1["next_cursor"], a1.fact_id); + assert_eq!(page1["has_more"], true); + + let page2 = facts::export_facts( + State(state), + work_auth_headers("tenant-a", None, "query:read"), + Query(ExportFactsParams { + since: None, + cursor: page1["next_cursor"].as_str().map(str::to_string), + limit: Some(1), + }), + ) + .await + .into_response(); + assert_eq!(page2.status(), StatusCode::OK); + let page2 = json_body(page2).await; + assert_eq!(page2["facts"][0]["fact_id"], a2.fact_id); + assert_eq!(page2["has_more"], false); +} + #[serial_test::serial] #[tokio::test] async fn work_post_then_list_then_patch_state_round_trip() { @@ -19925,7 +20135,7 @@ async fn memory_import_collision_supersedes_never_overwrites() { assert_eq!(body["collisions_superseded"], 1); let store = state.fact_store.read().await; - let history = store.fact_history("shared", "k"); + let history = store.fact_history("default", "shared", "k"); assert_eq!(history.len(), 2, "local value retired, never destroyed"); assert_eq!(history[0].value, "local-value"); assert!(history[0].superseded_by.is_some()); diff --git a/crates/corecruxd/src/passports.rs b/crates/corecruxd/src/passports.rs index ed5a36e1..51650aac 100644 --- a/crates/corecruxd/src/passports.rs +++ b/crates/corecruxd/src/passports.rs @@ -527,7 +527,7 @@ pub fn delete_passport(store: &mut FactStore, id: &str) -> Result<(), PassportsE }); for fact in result.facts { if fact.key == PASSPORT_RECORD_KEY { - store.delete(&fact.fact_id); + store.delete(&fact.tenant_hash, &fact.fact_id); } } Ok(()) diff --git a/crates/corecruxd/src/projects.rs b/crates/corecruxd/src/projects.rs index 1bf6b19a..c61e43db 100644 --- a/crates/corecruxd/src/projects.rs +++ b/crates/corecruxd/src/projects.rs @@ -353,7 +353,7 @@ pub fn delete_project(store: &mut FactStore, id: &str) -> Result<(), ProjectsErr let is_bare_record = fact.entity == format!("{PROJECT_ENTITY_PREFIX}::{id}") && fact.key == PROJECT_RECORD_KEY; if is_sub_entity_prefix || is_bare_record { - store.delete(&fact.fact_id); + store.delete(&fact.tenant_hash, &fact.fact_id); } } } @@ -411,7 +411,7 @@ pub fn remove_member(store: &mut FactStore, project_id: &str, passport_id: &str) }); for fact in result.facts { if fact.key == PROJECT_RECORD_KEY { - store.delete(&fact.fact_id); + store.delete(&fact.tenant_hash, &fact.fact_id); } } Ok(()) @@ -462,7 +462,7 @@ pub fn remove_tenant(store: &mut FactStore, project_id: &str, tenant_id: &str) - }); for fact in result.facts { if fact.key == PROJECT_RECORD_KEY { - store.delete(&fact.fact_id); + store.delete(&fact.tenant_hash, &fact.fact_id); } } Ok(()) diff --git a/crates/corecruxd/src/repo_registry.rs b/crates/corecruxd/src/repo_registry.rs index 8baa6448..1f04ef26 100644 --- a/crates/corecruxd/src/repo_registry.rs +++ b/crates/corecruxd/src/repo_registry.rs @@ -276,9 +276,12 @@ pub fn delete_repo(store: &mut FactStore, tenant_id: &str, repo_id: &str) -> Res crate::repo_codegraph::extdeps_entity(tenant_id, repo_id), ] { let facts = store.get_by_entity(&entity); - let ids: Vec = facts.into_iter().map(|fact| fact.fact_id.clone()).collect(); - for fact_id in ids { - store.delete(&fact_id); + let ids: Vec<(String, String)> = facts + .into_iter() + .map(|fact| (fact.tenant_hash.clone(), fact.fact_id.clone())) + .collect(); + for (tenant_hash, fact_id) in ids { + store.delete(&tenant_hash, &fact_id); } } Ok(()) diff --git a/crates/corecruxd/src/tenant_metadata.rs b/crates/corecruxd/src/tenant_metadata.rs index 473ef715..bcca0f43 100644 --- a/crates/corecruxd/src/tenant_metadata.rs +++ b/crates/corecruxd/src/tenant_metadata.rs @@ -110,15 +110,15 @@ pub fn set_tenant_category_override( #[allow(dead_code)] pub fn delete_tenant_category_override(store: &mut FactStore, tenant_id: &str) -> bool { let entity = entity_for(tenant_id); - let ids: Vec = store + let ids: Vec<(String, String)> = store .get_by_entity(&entity) .into_iter() .filter(|f| f.key == CATEGORY_KEY) - .map(|f| f.fact_id.clone()) + .map(|f| (f.tenant_hash.clone(), f.fact_id.clone())) .collect(); let mut removed = false; - for id in ids { - if store.delete(&id) { + for (tenant_hash, id) in ids { + if store.delete(&tenant_hash, &id) { removed = true; } } diff --git a/crates/crux-mcp/src/envelope.rs b/crates/crux-mcp/src/envelope.rs index 246cf5b1..919e5e3a 100644 --- a/crates/crux-mcp/src/envelope.rs +++ b/crates/crux-mcp/src/envelope.rs @@ -376,7 +376,7 @@ pub async fn build_envelope_for_query_facts(args: &Value, ctx: &McpContext) -> E let q = FactQuery { min_effective_confidence: None, - tenant_hash: None, + tenant_hash: Some(ctx.scope_tenant()), query, entity, entity_prefix: None, diff --git a/crates/crux-mcp/src/handoff.rs b/crates/crux-mcp/src/handoff.rs index 8327b68c..7818153f 100644 --- a/crates/crux-mcp/src/handoff.rs +++ b/crates/crux-mcp/src/handoff.rs @@ -66,6 +66,10 @@ pub struct HandoffPackage { pub facts: Vec, pub created_at: String, pub source_agent: String, + /// Tenant that authorized the bundled facts. Legacy packages omit this + /// field and therefore remain confined to `default`. + #[serde(default = "default_tenant_hash", skip_serializing_if = "is_default_tenant_hash")] + pub tenant_hash: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub target_agent: Option, pub message: Option, @@ -144,6 +148,8 @@ pub enum HandoffError { ReservedEntity { entity: String, prefix: String }, #[error("private handoff fact entity '{0}' is not owned by the declared source agent")] InvalidPrivateFact(String), + #[error("handoff tenant mismatch: package is for '{package}', receiver is '{receiver}'")] + TenantMismatch { package: String, receiver: String }, } /// Create a signed handoff package from current session state and relevant facts. @@ -152,6 +158,17 @@ pub fn create_handoff( fact_store: &FactStore, request: CreateHandoffRequest<'_>, handoff_key: &[u8; 32], +) -> Result { + create_handoff_for_tenant(session_store, fact_store, request, "default", handoff_key) +} + +/// Tenant-authorized handoff creation used by request-facing MCP handlers. +pub fn create_handoff_for_tenant( + session_store: &SessionStore, + fact_store: &FactStore, + request: CreateHandoffRequest<'_>, + tenant_hash: &str, + handoff_key: &[u8; 32], ) -> Result { let session_state = session_store.get(request.stored_session_id).map(|s| s.state.clone()); let facts = if request.include_facts { @@ -160,6 +177,7 @@ pub fn create_handoff( request.session_id, session_state.as_ref(), Some(request.source_agent), + tenant_hash, ) } else { Vec::new() @@ -179,6 +197,7 @@ pub fn create_handoff( facts, created_at: Utc::now().to_rfc3339(), source_agent: request.source_agent.to_string(), + tenant_hash: tenant_hash.to_string(), target_agent: request.target_agent, message: request.message, work_ids, @@ -205,6 +224,25 @@ pub fn accept_handoff( signed: &SignedHandoff, receiver_agent: Option<&str>, handoff_key: &[u8; 32], +) -> Result { + accept_handoff_for_tenant( + session_store, + fact_store, + signed, + receiver_agent, + "default", + handoff_key, + ) +} + +/// Tenant-authorized handoff acceptance used by request-facing MCP handlers. +pub fn accept_handoff_for_tenant( + session_store: &mut SessionStore, + fact_store: &mut FactStore, + signed: &SignedHandoff, + receiver_agent: Option<&str>, + receiver_tenant: &str, + handoff_key: &[u8; 32], ) -> Result { if signed.signature_alg != HANDOFF_SIGNATURE_ALG { return Err(HandoffError::UnsupportedSignatureAlgorithm( @@ -228,6 +266,12 @@ pub fn accept_handoff( } let package: HandoffPackage = serde_json::from_slice(&payload_bytes)?; + if package.tenant_hash != receiver_tenant { + return Err(HandoffError::TenantMismatch { + package: package.tenant_hash, + receiver: receiver_tenant.to_string(), + }); + } // Capture the task record before `package` is partially moved below, so it // can be surfaced to the receiver in the result. @@ -287,7 +331,7 @@ pub fn accept_handoff( let facts_loaded = prepared_facts.len(); for (fact, entity, private) in prepared_facts { fact_store.store(StoreFact { - tenant_hash: "default".to_string(), + tenant_hash: receiver_tenant.to_string(), entity, key: fact.key, value: fact.value, @@ -316,6 +360,7 @@ fn collect_relevant_facts( session_id: &str, session_state: Option<&serde_json::Value>, agent_name: Option<&str>, + tenant_hash: &str, ) -> Vec { let referenced_ids = extract_fact_ids(session_state); // Decision rows are convenience annotations, not authenticated control @@ -325,7 +370,7 @@ fn collect_relevant_facts( let decision_entity = format!("__decisions__::{session_id}"); let mut facts: Vec = fact_store - .all_facts() + .all_facts_for_tenant(tenant_hash) .filter(|fact| !fact.deleted) .filter(|fact| !fact.private) .filter(|fact| scope::fact_visible_to_agent(fact, agent_name)) @@ -347,6 +392,14 @@ fn collect_relevant_facts( facts } +fn default_tenant_hash() -> String { + "default".to_string() +} + +fn is_default_tenant_hash(tenant_hash: &String) -> bool { + tenant_hash == "default" +} + /// Collect work item ids to bundle on a handoff (orchestrators plan, M5). /// /// Returns a deterministically-ordered, de-duplicated list drawn from: @@ -583,6 +636,90 @@ mod tests { assert!(!imported_values.iter().any(|value| value == "unrelated fact")); } + #[test] + fn handoff_facts_and_acceptance_are_tenant_bound() { + let mut sessions = SessionStore::new(); + let mut facts = FactStore::new(); + let tenant_a = facts.store(StoreFact { + tenant_hash: "tenant-a".to_string(), + entity: "sess_tenant".to_string(), + key: "summary".to_string(), + value: "tenant-a-only".to_string(), + source_receipt: None, + confidence: 1.0, + private: false, + horizon_class: None, + actor: None, + }); + let tenant_b = facts.store(StoreFact { + tenant_hash: "tenant-b".to_string(), + entity: "sess_tenant".to_string(), + key: "summary".to_string(), + value: "tenant-b-only".to_string(), + source_receipt: None, + confidence: 1.0, + private: false, + horizon_class: None, + actor: None, + }); + sessions.put( + "sess_tenant", + json!({"context_refs": [tenant_a.fact_id, tenant_b.fact_id]}), + None, + ); + + let signed = create_handoff_for_tenant( + &sessions, + &facts, + CreateHandoffRequest { + session_id: "sess_tenant", + stored_session_id: "sess_tenant", + include_facts: true, + source_agent: "agent-alpha", + target_agent: Some("agent-beta".to_string()), + message: None, + task_record: None, + }, + "tenant-a", + &HANDOFF_KEY, + ) + .expect("tenant-a handoff"); + let package: HandoffPackage = serde_json::from_slice(&B64.decode(&signed.payload_b64).unwrap()).unwrap(); + assert_eq!(package.tenant_hash, "tenant-a"); + assert_eq!(package.facts.len(), 1); + assert_eq!(package.facts[0].value, "tenant-a-only"); + + let mut recv_sessions = SessionStore::new(); + let mut recv_facts = FactStore::new(); + let err = accept_handoff_for_tenant( + &mut recv_sessions, + &mut recv_facts, + &signed, + Some("agent-beta"), + "tenant-b", + &HANDOFF_KEY, + ) + .expect_err("cross-tenant acceptance must fail"); + assert!(matches!(err, HandoffError::TenantMismatch { .. })); + assert!(recv_sessions.list().is_empty()); + assert_eq!(recv_facts.count(), 0); + + let accepted = accept_handoff_for_tenant( + &mut recv_sessions, + &mut recv_facts, + &signed, + Some("agent-beta"), + "tenant-a", + &HANDOFF_KEY, + ) + .expect("same-tenant acceptance"); + assert_eq!(accepted.facts_loaded, 1); + assert!(recv_facts + .all_facts_for_tenant("tenant-a") + .any(|fact| fact.value == "tenant-a-only")); + assert_eq!(recv_facts.all_facts_for_tenant("tenant-b").count(), 0); + } + #[test] fn accept_rejects_legacy_control_fact_atomically() { let (sessions, facts) = seed_stores(); @@ -663,6 +800,7 @@ mod tests { }, created_at: Utc::now().to_rfc3339(), source_agent: "agent-alpha".to_string(), + tenant_hash: "default".to_string(), target_agent: Some("agent-beta".to_string()), message: None, work_ids: Vec::new(), @@ -707,6 +845,7 @@ mod tests { }, created_at: Utc::now().to_rfc3339(), source_agent: "agent-alpha".to_string(), + tenant_hash: "default".to_string(), target_agent: Some("agent-beta".to_string()), message: None, work_ids: Vec::new(), diff --git a/crates/crux-mcp/src/t1_regression.rs b/crates/crux-mcp/src/t1_regression.rs index dbe9714a..719668e8 100644 --- a/crates/crux-mcp/src/t1_regression.rs +++ b/crates/crux-mcp/src/t1_regression.rs @@ -12,27 +12,27 @@ //! //! ## What "tenant isolation" maps onto in the current model (read this first) //! -//! There is **no Fact-level `tenant` column** in `corecrux_memory::Fact`. The -//! substrate has exactly two isolation primitives that a fact can ride on: +//! Every `corecrux_memory::Fact` carries a concrete `tenant_hash`. Tenant +//! authorization is applied before private-owner visibility on every generic +//! fact read and mutation path: //! -//! 1. **Private-fact ownership** (`__agent::::…`, enforced in +//! 1. **Tenant partitioning** (`Fact::tenant_hash`, resolved from the caller's +//! agent-passport mapping). Version chains, history, lookup-by-id, +//! supersession, deletion, freshness, and acknowledgement remain inside +//! that concrete tenant. A fact id is an identifier, never an authority +//! token. +//! 2. **Private-fact ownership** (`__agent::::…`, enforced in //! `crate::scope`). Under M5 the `` key is the caller's resolved //! *passport_id* (e.g. `claude-work`), not the raw token-name. A private -//! fact is visible ONLY to its owning passport (plus the owner's own legacy -//! raw-name alias for back-compat). This is the strong, per-principal -//! boundary — it is what "tenant A's private fact is invisible to tenant B" -//! concretely means here. -//! 2. **Write-category exclusivity** (`crate::category_enforce`). A `work` +//! fact is visible only to its owning passport, within the already-authorized +//! tenant. The owner's raw-name alias remains available only when the legacy +//! fact is in that same tenant. +//! 3. **Write-category exclusivity** (`crate::category_enforce`). A `work` //! passport cannot WRITE a `personal`-category entity and vice-versa. This -//! partitions the *non-private* shared pool by category at write time. +//! is an additional write-policy boundary, not a substitute for tenancy. //! -//! **Non-private facts remain a SHARED pool** readable by every authenticated -//! caller — that is the existing, intended collaboration model (two work agents -//! share their non-private memory). M5 does NOT make non-private facts -//! tenant-private, because the data model has no per-fact tenant tag to scope -//! them by; inventing a half-implementation there could leak, so it is -//! deliberately out of scope (documented in the report). What M5 DOES enforce: -//! a non-private fact cannot be *written* across a category boundary. +//! Non-private facts are shared by authenticated collaborators only inside the +//! same tenant. They are never readable or mutable from a different tenant. //! //! The `query` (BM25 retrieval) path is a SEPARATE data plane from facts: it //! reads the tenant-hash-partitioned doc index, never the FactStore. Its tenant @@ -55,7 +55,7 @@ use crate::tools::freshness::{handle_memory_freshness, handle_memory_sweep_candi use crate::tools::memory::{handle_memory_edit, handle_memory_pin, handle_memory_view}; use crate::tools::memory_use::handle_memory_acknowledge_use; use crate::tools::query::handle_query; -use corecrux_memory::fact_store::StoreFact; +use corecrux_memory::fact_store::{ConsolidationRequestV1, StoreFact}; // ── Fixtures ──────────────────────────────────────────────────────────────── @@ -388,14 +388,182 @@ async fn t1_adversarial_cross_passport_supersede_and_delete_denied() { ); } +/// Two concrete tenants may use the same logical `(entity, key)` without +/// sharing version chains, history, reads, supersession authority, or deletion +/// authority. +#[tokio::test] +async fn t1_fact_lifecycle_is_partitioned_by_concrete_tenant() { + let map = AgentPassportMap::from_pairs_str("agent-a:passport-a:tenant-a,agent-b:passport-b:tenant-b"); + let base = McpContext::new_default("t1-node").with_agent_passports(true, map); + seed_passport(&base, "passport-a", "work").await; + seed_passport(&base, "passport-b", "work").await; + let tenant_a = agent(&base, "agent-a", 1); + let tenant_b = agent(&base, "agent-b", 2); + + let a1 = handle_store_fact( + &json!({"entity": "work::shared-key", "key": "status", "value": "tenant-a-v1"}), + &tenant_a, + ) + .await + .unwrap(); + let a1_id = fact_id_of(&a1); + let b1 = handle_store_fact( + &json!({"entity": "work::shared-key", "key": "status", "value": "tenant-b-v1"}), + &tenant_b, + ) + .await + .unwrap(); + let b1_id = fact_id_of(&b1); + let a2 = handle_store_fact( + &json!({"entity": "work::shared-key", "key": "status", "value": "tenant-a-v2"}), + &tenant_a, + ) + .await + .unwrap(); + let a2_id = fact_id_of(&a2); + + { + let store = base.fact_store.read().await; + let a1 = store.get(&a1_id).unwrap(); + let a2 = store.get(&a2_id).unwrap(); + let b1 = store.get(&b1_id).unwrap(); + assert_eq!(a1.tenant_hash, "tenant-a"); + assert_eq!(a1.version, 1); + assert_eq!(a1.superseded_by.as_deref(), Some(a2_id.as_str())); + assert_eq!(a2.version, 2); + assert_eq!(a2.supersedes.as_deref(), Some(a1_id.as_str())); + assert_eq!(b1.tenant_hash, "tenant-b"); + assert_eq!(b1.version, 1); + assert!(b1.supersedes.is_none()); + assert!(b1.superseded_by.is_none()); + } + + let a_query = handle_query_facts(&json!({"entity": "work::shared-key", "token_budget": 500}), &tenant_a) + .await + .unwrap(); + assert!(query_facts_has_value(&a_query, "tenant-a-v2")); + assert!(!query_facts_has_value(&a_query, "tenant-b-v1")); + + let b_query = handle_query_facts(&json!({"entity": "work::shared-key", "token_budget": 500}), &tenant_b) + .await + .unwrap(); + assert!(query_facts_has_value(&b_query, "tenant-b-v1")); + assert!(!query_facts_has_value(&b_query, "tenant-a-v2")); + + let a_history = handle_fact_history(&json!({"entity": "work::shared-key", "key": "status"}), &tenant_a) + .await + .unwrap(); + assert!(fact_history_has_value(&a_history, "tenant-a-v1")); + assert!(fact_history_has_value(&a_history, "tenant-a-v2")); + assert!(!fact_history_has_value(&a_history, "tenant-b-v1")); + + let b_history = handle_fact_history(&json!({"entity": "work::shared-key", "key": "status"}), &tenant_b) + .await + .unwrap(); + assert!(fact_history_has_value(&b_history, "tenant-b-v1")); + assert!(!fact_history_has_value(&b_history, "tenant-a-v1")); + + let supersede_err = handle_store_fact( + &json!({ + "entity": "work::cross-tenant-attempt", + "key": "status", + "value": "must-not-land", + "supersedes": [a2_id], + }), + &tenant_b, + ) + .await + .unwrap_err(); + assert_eq!(supersede_err.code, crate::protocol::INVALID_PARAMS); + + let delete = handle_delete_fact(&json!({"fact_id": a2_id}), &tenant_b).await.unwrap(); + assert!( + delete["content"][0]["text"].as_str().unwrap().contains("not found"), + "a foreign-tenant fact id must not authorize deletion" + ); + let store = base.fact_store.read().await; + assert!( + !store.get(&a2_id).unwrap().deleted, + "cross-tenant delete must leave the target untouched" + ); + assert!( + store + .all_facts_for_tenant("tenant-b") + .all(|fact| fact.value != "must-not-land"), + "rejected cross-tenant supersession must be atomic" + ); +} + +#[tokio::test] +async fn t1_generic_delete_cannot_retire_consolidation_canonical() { + let map = AgentPassportMap::from_pairs_str("agent-a:passport-a:tenant-a"); + let base = McpContext::new_default("t1-node").with_agent_passports(true, map); + seed_passport(&base, "passport-a", "work").await; + let tenant_a = agent(&base, "agent-a", 1); + let first = handle_store_fact( + &json!({"entity": "work::consolidate", "key": "status", "value": "blocked", "confidence": 0.2}), + &tenant_a, + ) + .await + .unwrap(); + let second = handle_store_fact( + &json!({"entity": "work::consolidate", "key": "status", "value": "active", "confidence": 0.3}), + &tenant_a, + ) + .await + .unwrap(); + let first_id = fact_id_of(&first); + let second_id = fact_id_of(&second); + let canonical_id = { + let mut store = base.fact_store.write().await; + assert!(store.clear_superseded("tenant-a", &first_id)); + store + .consolidate_facts_v1( + "tenant-a", + ConsolidationRequestV1 { + consolidation_id: "con-mcp-delete".to_string(), + entity: "work::consolidate".to_string(), + key: "status".to_string(), + canonical_value: "settled".to_string(), + target_fact_ids: vec![first_id.clone(), second_id.clone()], + protected_fact_ids: vec![], + confidence: 0.8, + source_receipt: None, + actor: Some("passport-a".to_string()), + horizon_class: None, + protected_confidence_floor: 0.99, + }, + ) + .unwrap() + .receipt + .canonical_fact_id + }; + + let err = handle_delete_fact(&json!({"fact_id": canonical_id}), &tenant_a) + .await + .expect_err("generic delete must require consolidation undo"); + assert_eq!(err.code, crate::dispatch::CAPABILITY_DENIED); + assert_eq!(err.data.unwrap()["error_code"], "CONSOLIDATION_CANONICAL_REQUIRES_UNDO"); + let store = base.fact_store.read().await; + assert!(store.get(&canonical_id).is_some()); + assert_eq!( + store.get(&first_id).unwrap().superseded_by.as_deref(), + Some(canonical_id.as_str()) + ); + assert_eq!( + store.get(&second_id).unwrap().superseded_by.as_deref(), + Some(canonical_id.as_str()) + ); +} + // ── INVARIANT 4: migration / back-compat ────────────────────────────────────── -/// An existing personal-default / non-private fact (no actor, written flag-OFF) -/// STILL resolves and is visible to its legitimate readers after the flag is -/// turned on. Non-private facts are the shared pool — they must NOT be stranded -/// or wrongly hidden by the M5 identity rekeying. +/// An existing default-tenant non-private fact remains visible after the flag +/// is turned on only when the mapped passport stays in the default tenant. +/// Moving the passport to `work` is an explicit tenant migration and must not +/// silently bridge the two partitions. #[tokio::test] -async fn t1_migration_legacy_nonprivate_fact_still_visible_after_flag_on() { +async fn t1_migration_legacy_nonprivate_fact_is_visible_only_in_its_tenant() { // Write the legacy fact with the flag OFF (the pre-M5 world), then read it // back through a flag-ON context over the SAME store. let off = flag_off_base(); @@ -407,17 +575,36 @@ async fn t1_migration_legacy_nonprivate_fact_still_visible_after_flag_on() { .await .unwrap(); - // Same underlying store, flag now ON, a different (work) passport reads. - let on = off.with_agent_passports(true, AgentPassportMap::builtin_default()); - seed_passport(&on, "claude-work", "work").await; - let claude = agent(&on, "anthropic", 0); + // Same store, flag now ON, and the mapped passport remains in `default`. + let on_default = off + .clone() + .with_agent_passports(true, AgentPassportMap::from_pairs_str("anthropic:claude-work:default")); + seed_passport(&on_default, "claude-work", "work").await; + let claude_default = agent(&on_default, "anthropic", 0); - let q = handle_query_facts(&json!({"query": "legacy-shared-needle", "token_budget": 500}), &claude) - .await - .unwrap(); + let q = handle_query_facts( + &json!({"query": "legacy-shared-needle", "token_budget": 500}), + &claude_default, + ) + .await + .unwrap(); assert!( query_facts_has_value(&q, "legacy-shared-needle"), - "legacy non-private fact must remain visible to the shared pool after flag-on" + "same-tenant legacy non-private fact must remain visible after flag-on" + ); + + // Rebinding the same agent to `work` does not grant access to `default`. + let on_work = off.with_agent_passports(true, AgentPassportMap::from_pairs_str("anthropic:claude-work:work")); + let claude_work = agent(&on_work, "anthropic", 0); + let q = handle_query_facts( + &json!({"query": "legacy-shared-needle", "token_budget": 500}), + &claude_work, + ) + .await + .unwrap(); + assert!( + !query_facts_has_value(&q, "legacy-shared-needle"), + "legacy default fact must not cross into a newly assigned work tenant" ); } @@ -441,8 +628,11 @@ async fn t1_migration_legacy_private_fact_not_stranded_for_owner() { assert_eq!(store.get(&fid).unwrap().entity, "__agent::anthropic::oldnotes"); } - // Flag flips ON: same `anthropic` agent now resolves to `claude-work`. - let on = off.with_agent_passports(true, AgentPassportMap::builtin_default()); + // Flag flips ON: same agent resolves to a passport in the SAME tenant. + let on = off.with_agent_passports( + true, + AgentPassportMap::from_pairs_str("anthropic:claude-work:default,openai:codex-work:default"), + ); seed_passport(&on, "claude-work", "work").await; let anth_on = agent(&on, "anthropic", 0); @@ -588,8 +778,8 @@ async fn t1_flag_off_byte_for_byte_control() { ); } -/// Flag-OFF non-private facts remain a SHARED pool (the pre-M5 collaboration -/// model is untouched): a fact written by one agent is visible to another. +/// Flag-OFF non-private facts remain a shared `default`-tenant pool: a fact +/// written by one agent is visible to another caller in that same tenant. #[tokio::test] async fn t1_flag_off_nonprivate_pool_is_shared_control() { let off = flag_off_base(); @@ -644,8 +834,9 @@ async fn owner_other_fixture() -> (McpContext, McpContext, McpContext) { } /// memory_freshness: the converted identity-scoped visibility gate governs the -/// SHARED (non-private) pool; a non-private fact written by the owner is visible -/// to BOTH passports (the intended collaboration model — not a leak). Private +/// same-tenant non-private pool; a non-private fact written by the owner is +/// visible to BOTH passports in `work` (the intended collaboration model). +/// Private /// facts never ride this surface for ANYONE because the handler ALSO filters the /// `__agent::*` stored entity as reserved BEFORE the row is rendered — that /// pre-existing guard is intentionally preserved. The owner-can / other-CANNOT @@ -659,7 +850,7 @@ async fn t1_memory_freshness_shared_pool_and_private_filtered() { std::env::set_var("CORECRUXD_FEATURE_FRESHNESS", "1"); let (_base, claude, codex) = owner_other_fixture().await; - // Non-private (shared-pool) fact — both passports see it on memory_freshness. + // Non-private same-tenant fact — both passports see it on memory_freshness. let shared = handle_store_fact( &json!({"entity": "work::fresh-shared", "key": "k", "value": "fresh-shared-needle"}), &claude, @@ -691,7 +882,7 @@ async fn t1_memory_freshness_shared_pool_and_private_filtered() { .unwrap(); assert!( rows_have_fact_id(&other, &shared_id), - "shared (non-private) pool is visible to a different passport — intended collaboration" + "same-tenant non-private pool is visible to a different passport" ); assert!( !rows_have_fact_id(&other, &secret_id), @@ -700,8 +891,8 @@ async fn t1_memory_freshness_shared_pool_and_private_filtered() { std::env::remove_var("CORECRUXD_FEATURE_FRESHNESS"); } -/// memory_sweep_candidates: a superseded NON-private fact is a sweep candidate -/// for both passports (shared pool); a superseded PRIVATE fact is reserved- +/// memory_sweep_candidates: a superseded same-tenant NON-private fact is a +/// sweep candidate for both passports; a superseded PRIVATE fact is reserved- /// filtered for everyone. Same reasoning as memory_freshness — the conversion /// governs the shared pool and preserves the private-reserved guard. #[tokio::test] @@ -798,10 +989,9 @@ async fn t1_memory_forget_owner_can_other_cannot() { .unwrap(); let target_id = fact_id_of(&target); - // A non-private fact is the SHARED pool, so a different passport CAN see it - // here — that is the intended collaboration model, NOT a leak (private facts - // are the per-principal boundary, covered elsewhere). What we assert: both - // dry-runs preview it, and the OWNER's forget actually removes it. + // A non-private fact is shared inside the `work` tenant, so a different + // passport in that tenant can see it. What we assert: both dry-runs preview + // it, and the owner's forget actually removes it. let owner_dry = handle_memory_forget_dry_run( &json!({"scope": {"type": "entity_prefix", "value": "work::forget-target"}}), &claude, @@ -879,7 +1069,7 @@ async fn t1_memory_forget_owner_can_other_cannot() { } /// memory_acknowledge_use: the converted identity-scoped visibility gate is the -/// `not_visible` discriminator. A SHARED (non-private) fact is ackable by both +/// `not_visible` discriminator. A same-tenant non-private fact is ackable by both /// passports. A PRIVATE fact is redacted (reserved) for the OWNER — who CAN see /// it (passes the visibility gate) but it carries the `__agent::*` reserved /// prefix, so the pre-existing reserved redaction applies — and is `not_visible` diff --git a/crates/crux-mcp/src/tools/audit_export.rs b/crates/crux-mcp/src/tools/audit_export.rs index 4b93d574..852a245a 100644 --- a/crates/crux-mcp/src/tools/audit_export.rs +++ b/crates/crux-mcp/src/tools/audit_export.rs @@ -165,7 +165,8 @@ pub async fn handle_audit_export_bundle(args: &Value, ctx: &McpContext) -> Resul let mut out: Vec = Vec::new(); let mut tokens_used = 0usize; // Sort by (stored_at, fact_id) for deterministic bundles. - let mut all: Vec<&Fact> = store.all_facts().collect(); + let tenant_hash = ctx.scope_tenant(); + let mut all: Vec<&Fact> = store.all_facts_for_tenant(&tenant_hash).collect(); all.sort_by(|a, b| a.stored_at.cmp(&b.stored_at).then_with(|| a.fact_id.cmp(&b.fact_id))); for fact in all { if let Some(since) = since_dt { diff --git a/crates/crux-mcp/src/tools/consolidation.rs b/crates/crux-mcp/src/tools/consolidation.rs index cd029524..38beac3b 100644 --- a/crates/crux-mcp/src/tools/consolidation.rs +++ b/crates/crux-mcp/src/tools/consolidation.rs @@ -35,7 +35,6 @@ use corecrux_memory::fact_store::{ConsolidationErrorV1, ConsolidationRequestV1, use crate::dispatch::McpContext; use crate::protocol::{JsonRpcError, INVALID_PARAMS}; -use crate::scope; /// Environment flag that gates the consolidation read/write surfaces. /// @@ -71,8 +70,8 @@ fn feature_disabled_error() -> JsonRpcError { } fn require_passport(ctx: &McpContext, tool: &str) -> Result { - match scope::agent_name(ctx.agent.as_ref()) { - Some(name) => Ok(name.to_string()), + match ctx.authority_identity() { + Some(identity) => Ok(identity), None => Err(JsonRpcError { code: crate::dispatch::CAPABILITY_DENIED, message: format!("{tool} requires an authenticated passport (anonymous calls rejected)"), @@ -99,7 +98,7 @@ pub async fn handle_memory_contradictions(args: &Value, ctx: &McpContext) -> Res // The pass itself is bounded by `limit`; we additionally trim by the // mandatory token budget so a contradiction-heavy store can't blow the // output-token budget (QC.2 primary defence). - let candidates = store.contradiction_candidates_v1(limit); + let candidates = store.contradiction_candidates_v1(&ctx.scope_tenant(), limit); drop(store); let mut rows: Vec = Vec::new(); @@ -222,7 +221,7 @@ pub async fn handle_memory_consolidate(args: &Value, ctx: &McpContext) -> Result let report = { let mut store = ctx.fact_store.write().await; - store.consolidate_facts_v1(req) + store.consolidate_facts_v1(&ctx.scope_tenant(), req) }; match report { @@ -250,11 +249,20 @@ pub async fn handle_memory_consolidate(args: &Value, ctx: &McpContext) -> Result /// (mirrors the console route's HTTP status mapping). fn consolidation_error_to_rpc(err: ConsolidationErrorV1) -> JsonRpcError { let (code, reason) = match &err { - ConsolidationErrorV1::NoTargets | ConsolidationErrorV1::TargetOutsideEntityKey(_) => { - (INVALID_PARAMS, "invalid_request") - } + ConsolidationErrorV1::NoTargets + | ConsolidationErrorV1::MissingConsolidationId + | ConsolidationErrorV1::TargetOutsideEntityKey(_) + | ConsolidationErrorV1::ImplicitPriorNotTarget(_) + | ConsolidationErrorV1::NoUndoSources + | ConsolidationErrorV1::NotConsolidationCanonical(_) + | ConsolidationErrorV1::UndoSourceMismatch(_) => (INVALID_PARAMS, "invalid_request"), + ConsolidationErrorV1::CanonicalSuperseded(_) => (crate::dispatch::CAPABILITY_DENIED, "canonical_superseded"), ConsolidationErrorV1::TargetNotFound(_) => (INVALID_PARAMS, "target_not_found"), ConsolidationErrorV1::TargetDeleted(_) => (crate::dispatch::CAPABILITY_DENIED, "target_deleted"), + ConsolidationErrorV1::TargetAlreadySuperseded(_) => { + (crate::dispatch::CAPABILITY_DENIED, "target_already_superseded") + } + ConsolidationErrorV1::DuplicateTarget(_) => (INVALID_PARAMS, "duplicate_target"), ConsolidationErrorV1::TargetPinned(_) => (crate::dispatch::CAPABILITY_DENIED, "target_pinned"), ConsolidationErrorV1::TargetPrivate(_) => (crate::dispatch::CAPABILITY_DENIED, "target_private"), ConsolidationErrorV1::TargetReceiptLinked(_) => (crate::dispatch::CAPABILITY_DENIED, "target_receipt_linked"), @@ -380,7 +388,7 @@ mod tests { .unwrap(); { let mut store = alice.fact_store.write().await; - assert!(store.clear_superseded(&a_id), "simulate unresolved conflict"); + assert!(store.clear_superseded("default", &a_id), "simulate unresolved conflict"); } let res = handle_memory_contradictions(&json!({"token_budget": 2000}), &alice) @@ -465,7 +473,7 @@ mod tests { .unwrap_or_else(|| fact_id_of(&newer)); { let mut store = alice.fact_store.write().await; - assert!(store.clear_superseded(&old_id), "make both targets active"); + assert!(store.clear_superseded("default", &old_id), "make both targets active"); } let res = handle_memory_consolidate( @@ -492,7 +500,12 @@ mod tests { store.get(&old_id).unwrap().superseded_by.as_deref(), Some(canonical.as_str()) ); - let history = store.fact_history("proj", "status"); + assert_eq!( + store.get(&canonical).unwrap().actor.as_deref(), + Some("agent:alice"), + "unmapped token names must not be stored as human passport authority" + ); + let history = store.fact_history("default", "proj", "status"); assert_eq!(history.len(), 3, "consolidation must preserve version history"); disable(); } diff --git a/crates/crux-mcp/src/tools/facts.rs b/crates/crux-mcp/src/tools/facts.rs index 45b62930..d87cb47f 100644 --- a/crates/crux-mcp/src/tools/facts.rs +++ b/crates/crux-mcp/src/tools/facts.rs @@ -232,7 +232,7 @@ pub async fn handle_store_fact(args: &Value, ctx: &McpContext) -> Result Result { + if store.is_active_consolidation_source_for_tenant(fact_id, &tenant_hash) { + consolidation_refs.push(fact_id.clone()); + continue; + } let policy_entity = scope::visible_entity_for_identity(target, scope_id_ref, &alias_refs) .unwrap_or_else(|| target.entity.clone()); if let Some(prefix) = corecrux_memory::fact_privacy::daemon_owned_entity_prefix(&policy_entity) { @@ -302,6 +308,17 @@ pub async fn handle_store_fact(args: &Value, ctx: &McpContext) -> Result bad_refs.push(fact_id.clone()), } } + if !consolidation_refs.is_empty() { + return Err(JsonRpcError { + code: crate::dispatch::CAPABILITY_DENIED, + message: "active consolidation source edges are immutable until dedicated undo".to_string(), + data: Some(json!({ + "error_code": "CONSOLIDATION_SOURCE_REQUIRES_UNDO", + "param": "supersedes", + "fact_ids": consolidation_refs, + })), + }); + } if !reserved_refs.is_empty() { return Err(JsonRpcError { code: INVALID_PARAMS, @@ -333,7 +350,7 @@ pub async fn handle_store_fact(args: &Value, ctx: &McpContext) -> Result = Vec::new(); if !supersedes_refs.is_empty() { for r in &supersedes_refs { - if store.mark_superseded(r, &fact.fact_id) { + if store.mark_superseded(&tenant_hash, r, &fact.fact_id) { superseded_ok.push(r.clone()); } } @@ -370,14 +387,6 @@ pub async fn handle_store_fact(args: &Value, ctx: &McpContext) -> Result String { - corecrux_memory::fact_store::default_tenant_hash() -} - /// `fact_history` — return the full version chain for a given (entity, key) pair. pub async fn handle_fact_history(args: &Value, ctx: &McpContext) -> Result { let entity = require_str(args, "entity")?; @@ -387,8 +396,9 @@ pub async fn handle_fact_history(args: &Value, ctx: &McpContext) -> Result = aliases.iter().map(String::as_str).collect(); let store = ctx.fact_store.read().await; + let tenant_hash = ctx.scope_tenant(); let mut history: Vec<&Fact> = store - .all_facts() + .all_facts_for_tenant(&tenant_hash) .filter(|fact| fact.key == key) .filter(|fact| scope::entity_matches_for_identity(fact, entity, id_ref, &alias_refs)) .filter(|fact| scope::fact_visible_to_identity(fact, id_ref, &alias_refs)) @@ -487,7 +497,7 @@ pub async fn handle_query_facts(args: &Value, ctx: &McpContext) -> Result Result = aliases.iter().map(String::as_str).collect(); let mut store = ctx.fact_store.write().await; - let existing = store.get(fact_id); + let tenant_hash = ctx.scope_tenant(); + let existing = store.get_for_tenant(fact_id, &tenant_hash); let visible_fact = existing.filter(|fact| scope::fact_visible_to_identity(fact, id_ref, &alias_refs)); let policy_entity = visible_fact.and_then(|fact| { if fact.entity.starts_with(crate::scope::AGENT_PRIVATE_ENTITY_PREFIX) { @@ -649,8 +660,20 @@ pub async fn handle_delete_fact(args: &Value, ctx: &McpContext) -> Result Result = aliases.iter().map(String::as_str).collect(); let entities: Vec = store - .all_facts() + .all_facts_for_tenant(&ctx.scope_tenant()) .filter(|fact| !fact.deleted) .filter_map(|fact| scope::visible_entity_for_identity(fact, id_ref, &alias_refs)) .collect::>() diff --git a/crates/crux-mcp/src/tools/forget.rs b/crates/crux-mcp/src/tools/forget.rs index 3336f73d..f82d7955 100644 --- a/crates/crux-mcp/src/tools/forget.rs +++ b/crates/crux-mcp/src/tools/forget.rs @@ -169,14 +169,7 @@ fn scope_matches(scope: &ForgetScopeV1, fact: &Fact, before_ts: Option before_ts.is_some_and(|cutoff| fact.stored_at < cutoff), - ForgetScopeV1::TenantId { value } => { - // Tenant scope today is encoded into the entity name via - // `tenant:::` or `personal::::` / `business::::` - // prefixes. Match either flavour. - fact.entity.starts_with(&format!("tenant:{value}::")) - || fact.entity.starts_with(&format!("personal::{value}::")) - || fact.entity.starts_with(&format!("business::{value}::")) - } + ForgetScopeV1::TenantId { value } => fact.tenant_hash == *value, } } @@ -185,14 +178,18 @@ fn scope_matches(scope: &ForgetScopeV1, fact: &Fact, before_ts: Option) -> std::collections::HashSet { +fn pinned_fact_ids( + store: &corecrux_memory::FactStore, + tenant_hash: &str, + agent_name: Option<&str>, +) -> std::collections::HashSet { let Some(agent) = agent_name else { return std::collections::HashSet::new(); }; let prefix = format!("{MEMORY_PIN_PREFIX}{agent}::"); let mut latest: std::collections::HashMap, bool)> = std::collections::HashMap::new(); - for f in store.all_facts() { + for f in store.all_facts_for_tenant(tenant_hash) { if f.deleted || !f.entity.starts_with(&prefix) || f.key != "pinned" { continue; } @@ -214,6 +211,7 @@ fn pinned_fact_ids(store: &corecrux_memory::FactStore, agent_name: Option<&str>) #[allow(clippy::too_many_arguments)] fn resolve_scope<'a>( store: &'a corecrux_memory::FactStore, + tenant_hash: &'a str, scope: &ForgetScopeV1, identity: Option<&str>, aliases: &[&str], @@ -231,7 +229,7 @@ fn resolve_scope<'a>( // cannot. Flag-OFF `identity` is the raw agent name and `aliases` is empty, // so this is byte-for-byte the prior `scope::fact_visible_to_agent` call. let mut matches: Vec<&Fact> = store - .all_facts() + .all_facts_for_tenant(tenant_hash) .filter(|fact| !fact.deleted) .filter(|fact| !is_reserved(&fact.entity)) .filter(|fact| scope::fact_visible_to_identity(fact, identity, aliases)) @@ -242,7 +240,7 @@ fn resolve_scope<'a>( // load-bearing. `include_pinned: true` overrides this for a true GDPR // Art.17 erasure (a pin protects convenience, not a legal-erasure block). if !include_pinned { - let pinned = pinned_fact_ids(store, agent_name); + let pinned = pinned_fact_ids(store, tenant_hash, agent_name); if !pinned.is_empty() { matches.retain(|fact| !pinned.contains(&fact.fact_id)); } @@ -326,10 +324,12 @@ pub async fn handle_memory_forget_dry_run(args: &Value, ctx: &McpContext) -> Res // Raw agent name drives the pin lookup (pins are keyed by raw name). Dry-run // mirrors the live forget's pin exclusion so preview == effect. let agent_name = scope::agent_name(ctx.agent.as_ref()); + let tenant_hash = ctx.scope_tenant(); let store = ctx.fact_store.read().await; let matches = resolve_scope( &store, + &tenant_hash, &scope, id_ref, &alias_refs, @@ -424,11 +424,19 @@ where data: Some(json!({"param": "reason", "required": true})), }); } - let tenant_id = args - .get("tenant_id") - .and_then(|v| v.as_str()) - .unwrap_or("default") - .to_string(); + let tenant_id = ctx.scope_tenant(); + if let Some(requested) = args.get("tenant_id").and_then(|v| v.as_str()) { + if requested != tenant_id { + return Err(JsonRpcError { + code: crate::dispatch::CAPABILITY_DENIED, + message: "tenant_id does not match the authenticated MCP tenant".to_string(), + data: Some(json!({ + "error_code": "TENANT_FORBIDDEN", + "tenant_id": requested, + })), + }); + } + } let token_budget = args.get("token_budget").and_then(|v| v.as_u64()).map(|v| v as usize); // Pinned facts survive by default; `include_pinned: true` is the explicit // GDPR Art.17 override that erases them too. @@ -441,6 +449,7 @@ where let store = ctx.fact_store.read().await; resolve_scope( &store, + &tenant_id, &scope, id_ref, &alias_refs, @@ -511,6 +520,25 @@ fn forget_resolved_facts_under_lock( identity: Option<&str>, aliases: &[&str], ) -> Result { + let protected_consolidation_ids: Vec = facts + .iter() + .filter(|fact| { + store.is_consolidation_canonical_for_tenant(&fact.fact_id, &fact.tenant_hash) + || store.is_active_consolidation_source_for_tenant(&fact.fact_id, &fact.tenant_hash) + }) + .map(|fact| fact.fact_id.clone()) + .collect(); + if !protected_consolidation_ids.is_empty() { + return Err(JsonRpcError { + code: crate::dispatch::CAPABILITY_DENIED, + message: "memory_forget cannot partially retire an active consolidation; use dedicated undo".to_string(), + data: Some(json!({ + "error_code": "CONSOLIDATION_REQUIRES_UNDO", + "fact_ids": protected_consolidation_ids, + })), + }); + } + let blocking_holds = blocking_legal_holds(store, facts); if !blocking_holds.is_empty() { return Err(legal_hold_active_error(facts, &blocking_holds)); @@ -521,14 +549,16 @@ fn forget_resolved_facts_under_lock( // Re-check visibility under the write lock; another concurrent // forget could have already soft-deleted this fact_id. let still_visible = store - .get(&fact.fact_id) + .get_for_tenant(&fact.fact_id, &fact.tenant_hash) .is_some_and(|current| !current.deleted && scope::fact_visible_to_identity(current, identity, aliases)); if still_visible - && store.try_delete(&fact.fact_id).map_err(|err| JsonRpcError { - code: INTERNAL_ERROR, - message: "fact journal append failed".to_string(), - data: Some(json!({"error": err.to_string()})), - })? + && store + .try_delete(&fact.tenant_hash, &fact.fact_id) + .map_err(|err| JsonRpcError { + code: INTERNAL_ERROR, + message: "fact journal append failed".to_string(), + data: Some(json!({"error": err.to_string()})), + })? { forgotten += 1; } @@ -791,6 +821,93 @@ mod tests { assert!(q["content"][0]["text"].as_str().unwrap().contains("production-x")); } + #[tokio::test] + async fn memory_forget_rejects_mixed_consolidation_scope_atomically() { + let _guard = FeatureFlagGuard::enabled().await; + let ctx = agent_ctx("alice"); + let (ordinary_id, source_ids, canonical_id) = { + let mut store = ctx.fact_store.write().await; + let ordinary = store.store(corecrux_memory::fact_store::StoreFact { + tenant_hash: "default".to_string(), + entity: "test-fixture-consolidation-ordinary".to_string(), + key: "status".to_string(), + value: "keep".to_string(), + source_receipt: None, + confidence: 0.4, + private: false, + horizon_class: None, + actor: Some("alice".to_string()), + }); + let first = store.store(corecrux_memory::fact_store::StoreFact { + tenant_hash: "default".to_string(), + entity: "test-fixture-consolidation-case".to_string(), + key: "status".to_string(), + value: "blocked".to_string(), + source_receipt: None, + confidence: 0.4, + private: false, + horizon_class: None, + actor: Some("alice".to_string()), + }); + let second = store.store(corecrux_memory::fact_store::StoreFact { + tenant_hash: "default".to_string(), + entity: "test-fixture-consolidation-case".to_string(), + key: "status".to_string(), + value: "active".to_string(), + source_receipt: None, + confidence: 0.5, + private: false, + horizon_class: None, + actor: Some("alice".to_string()), + }); + assert!(store.clear_superseded("default", &first.fact_id)); + let report = store + .consolidate_facts_v1( + "default", + corecrux_memory::fact_store::ConsolidationRequestV1 { + consolidation_id: "forget-atomicity".to_string(), + entity: "test-fixture-consolidation-case".to_string(), + key: "status".to_string(), + canonical_value: "active".to_string(), + target_fact_ids: vec![first.fact_id.clone(), second.fact_id.clone()], + protected_fact_ids: vec![], + confidence: 0.8, + source_receipt: None, + actor: Some("alice".to_string()), + horizon_class: None, + protected_confidence_floor: 0.99, + }, + ) + .unwrap(); + ( + ordinary.fact_id, + vec![first.fact_id, second.fact_id], + report.receipt.canonical_fact_id, + ) + }; + + let err = handle_memory_forget( + &json!({ + "scope": { + "type": "entity_prefix", + "value": "test-fixture-consolidation-" + }, + "reason": "must use dedicated consolidation undo", + }), + &ctx, + ) + .await + .unwrap_err(); + assert_eq!(err.code, crate::dispatch::CAPABILITY_DENIED); + assert_eq!(err.data.as_ref().unwrap()["error_code"], "CONSOLIDATION_REQUIRES_UNDO"); + + let store = ctx.fact_store.read().await; + for fact_id in source_ids.iter().chain([&ordinary_id, &canonical_id]) { + let fact = store.get_for_tenant(fact_id, "default").unwrap(); + assert!(!fact.deleted, "{fact_id} must survive the rejected mixed forget"); + } + } + #[tokio::test] async fn memory_forget_refuses_when_newest_legal_hold_state_is_malformed() { let _guard = FeatureFlagGuard::enabled().await; diff --git a/crates/crux-mcp/src/tools/freshness.rs b/crates/crux-mcp/src/tools/freshness.rs index cb116e97..b58b7c55 100644 --- a/crates/crux-mcp/src/tools/freshness.rs +++ b/crates/crux-mcp/src/tools/freshness.rs @@ -115,12 +115,13 @@ pub async fn handle_memory_freshness(args: &Value, ctx: &McpContext) -> Result = Vec::new(); let mut used_tokens: usize = 0; let mut candidates: Vec<&corecrux_memory::Fact> = store - .all_facts() + .all_facts_for_tenant(&tenant_hash) .filter(|f| !f.deleted) .filter(|f| scope::fact_visible_to_identity(f, id_ref, &alias_refs)) .filter(|f| !is_reserved_entity(&f.entity)) @@ -197,13 +198,14 @@ pub async fn handle_memory_sweep_candidates(args: &Value, ctx: &McpContext) -> R let policy = decay::DecayPolicy::from_env(); let now = Utc::now(); + let tenant_hash = ctx.scope_tenant(); let store = ctx.fact_store.read().await; // Candidate set: visible, non-deleted, non-reserved facts that are // stale OR superseded. Note we deliberately DON'T pre-filter // superseded facts (this surface is precisely about surfacing them). let mut candidates: Vec<(&corecrux_memory::Fact, decay::Freshness, &'static str)> = store - .all_facts() + .all_facts_for_tenant(&tenant_hash) .filter(|f| !f.deleted) .filter(|f| scope::fact_visible_to_identity(f, id_ref, &alias_refs)) .filter(|f| !is_reserved_entity(&f.entity)) @@ -270,7 +272,7 @@ pub async fn handle_memory_sweep_candidates(args: &Value, ctx: &McpContext) -> R // Determine newest stored_at per session-binding entity so we never // flag the live binding for a session. let mut newest_binding_at: std::collections::HashMap<&str, DateTime> = std::collections::HashMap::new(); - for f in store.all_facts().filter(|f| !f.deleted) { + for f in store.all_facts_for_tenant(&tenant_hash).filter(|f| !f.deleted) { if f.entity.starts_with("__session_binding__::") { let e = newest_binding_at.entry(f.entity.as_str()).or_insert(f.stored_at); if f.stored_at > *e { @@ -279,7 +281,7 @@ pub async fn handle_memory_sweep_candidates(args: &Value, ctx: &McpContext) -> R } } let mut ephemeral: Vec<&corecrux_memory::Fact> = store - .all_facts() + .all_facts_for_tenant(&tenant_hash) .filter(|f| !f.deleted) .filter(|f| is_ephemeral_reserved_entity(&f.entity)) .filter(|f| !f.private) @@ -358,11 +360,12 @@ pub async fn handle_memory_set_horizon(args: &Value, ctx: &McpContext) -> Result data: Some(json!({"param": "horizon_class", "allowed": ["volatile", "medium", "stable", "none"]})), })?; + let tenant_hash = ctx.scope_tenant(); let mut store = ctx.fact_store.write().await; // Guard: refuse to set horizon on reserved-prefix entities even with // a passport — these are ops/internal state and shouldn't be // re-classified by the agent surface. - if let Some(f) = store.get(fact_id) { + if let Some(f) = store.get_for_tenant(fact_id, &tenant_hash) { if is_reserved_entity(&f.entity) { return Err(JsonRpcError { code: crate::dispatch::CAPABILITY_DENIED, @@ -379,7 +382,7 @@ pub async fn handle_memory_set_horizon(args: &Value, ctx: &McpContext) -> Result "isError": false, })); } - let ok = store.set_horizon(fact_id, class); + let ok = store.set_horizon_for_tenant(&tenant_hash, fact_id, class); Ok(json!({ "content": [{ @@ -412,9 +415,10 @@ pub async fn handle_memory_reverify(args: &Value, ctx: &McpContext) -> Result Result Result Result Result Result Result = aliases.iter().map(String::as_str).collect(); + let tenant_hash = ctx.scope_tenant(); let q = FactQuery { min_effective_confidence: None, - tenant_hash: None, + tenant_hash: Some(tenant_hash.clone()), query: None, entity: entity.clone(), entity_prefix: None, @@ -191,7 +192,7 @@ pub async fn handle_memory_view(args: &Value, ctx: &McpContext) -> Result = store - .all_facts() + .all_facts_for_tenant(&tenant_hash) .filter(|fact| fact_visible_in_memory_panel_id(fact, id_ref, &alias_refs)) .filter(|fact| match &q.entity { Some(want) => scope::visible_entity_for_identity(fact, id_ref, &alias_refs) @@ -240,7 +241,7 @@ pub async fn handle_memory_view(args: &Value, ctx: &McpContext) -> Result = if let Some(prefix) = &pin_prefix { let mut latest: std::collections::HashMap, bool)> = std::collections::HashMap::new(); - for f in store.all_facts() { + for f in store.all_facts_for_tenant(&tenant_hash) { if f.deleted || !f.entity.starts_with(prefix) || f.key != "pinned" { continue; } @@ -335,13 +336,17 @@ pub async fn handle_memory_edit(args: &Value, ctx: &McpContext) -> Result = aliases.iter().map(String::as_str).collect(); + let tenant_hash = ctx.scope_tenant(); let mut store = ctx.fact_store.write().await; - let existing = store.get(fact_id).cloned().ok_or_else(|| JsonRpcError { - code: INVALID_PARAMS, - message: format!("fact not found: {fact_id}"), - data: Some(json!({"fact_id": fact_id})), - })?; + let existing = store + .get_for_tenant(fact_id, &tenant_hash) + .cloned() + .ok_or_else(|| JsonRpcError { + code: INVALID_PARAMS, + message: format!("fact not found: {fact_id}"), + data: Some(json!({"fact_id": fact_id})), + })?; if !fact_visible_in_memory_panel_id(&existing, id_ref, &alias_refs) { // Either reserved-prefix or not visible to this agent — refuse. @@ -372,7 +377,7 @@ pub async fn handle_memory_edit(args: &Value, ctx: &McpContext) -> Result Result Result Result = aliases.iter().map(String::as_str).collect(); + let tenant_hash = ctx.scope_tenant(); let mut store = ctx.fact_store.write().await; - let target = store.get(fact_id).cloned().ok_or_else(|| JsonRpcError { - code: INVALID_PARAMS, - message: format!("fact not found: {fact_id}"), - data: Some(json!({"fact_id": fact_id})), - })?; + let target = store + .get_for_tenant(fact_id, &tenant_hash) + .cloned() + .ok_or_else(|| JsonRpcError { + code: INVALID_PARAMS, + message: format!("fact not found: {fact_id}"), + data: Some(json!({"fact_id": fact_id})), + })?; if !fact_visible_in_memory_panel_id(&target, id_ref, &alias_refs) { return Err(JsonRpcError { @@ -481,7 +490,7 @@ pub async fn handle_memory_pin(args: &Value, ctx: &McpContext) -> Result Result Result (e, k), (_, _, Some(fid)) => { - let f = store.get(&fid).ok_or_else(|| JsonRpcError { + let f = store.get_for_tenant(&fid, &tenant_hash).ok_or_else(|| JsonRpcError { code: INVALID_PARAMS, message: format!("fact not found: {fid}"), data: Some(json!({"fact_id": fid})), @@ -557,7 +567,7 @@ pub async fn handle_memory_history(args: &Value, ctx: &McpContext) -> Result = store - .all_facts() + .all_facts_for_tenant(&tenant_hash) .filter(|f| f.key == key) .filter(|f| scope::entity_matches_for_agent(f, &entity, agent_name)) .filter(|f| scope::fact_visible_to_agent(f, agent_name)) diff --git a/crates/crux-mcp/src/tools/memory_use.rs b/crates/crux-mcp/src/tools/memory_use.rs index 7a82fd82..f300fc44 100644 --- a/crates/crux-mcp/src/tools/memory_use.rs +++ b/crates/crux-mcp/src/tools/memory_use.rs @@ -186,6 +186,7 @@ pub async fn handle_memory_acknowledge_use(args: &Value, ctx: &McpContext) -> Re let aliases = ctx.scope_aliases(); let alias_refs: Vec<&str> = aliases.iter().map(String::as_str).collect(); + let tenant_hash = ctx.scope_tenant(); let store = ctx.fact_store.read().await; let now = chrono::Utc::now(); let mut filtered_entries: Vec = Vec::with_capacity(fact_ids.len()); @@ -194,7 +195,7 @@ pub async fn handle_memory_acknowledge_use(args: &Value, ctx: &McpContext) -> Re let mut not_visible_count = 0usize; for fid in &fact_ids { - let Some(fact) = store.get(fid) else { + let Some(fact) = store.get_for_tenant(fid, &tenant_hash) else { not_found_count += 1; continue; }; diff --git a/crates/crux-observe/src/ops_layer.rs b/crates/crux-observe/src/ops_layer.rs index b3d4a9ea..0494c1b7 100644 --- a/crates/crux-observe/src/ops_layer.rs +++ b/crates/crux-observe/src/ops_layer.rs @@ -219,7 +219,7 @@ where ids.push_back(fact.fact_id.clone()); while ids.len() > max_facts { if let Some(old_id) = ids.pop_front() { - store.delete(&old_id); + store.delete("default", &old_id); } } }); From 80d3aebeaec0b7f52f7353b596a95f07cdb33ec6 Mon Sep 17 00:00:00 2001 From: CueCrux-Myles Date: Fri, 31 Jul 2026 10:11:09 +0100 Subject: [PATCH 4/7] test(observe): scope eviction deletes by tenant Update the eviction test helper for the tenant-aware fact-store API so the integrated workspace suite compiles and exercises the intended default tenant. Co-Authored-By: OpenAI Codex --- crates/crux-observe/src/ops_layer.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/crux-observe/src/ops_layer.rs b/crates/crux-observe/src/ops_layer.rs index 0494c1b7..06b2faa0 100644 --- a/crates/crux-observe/src/ops_layer.rs +++ b/crates/crux-observe/src/ops_layer.rs @@ -343,7 +343,7 @@ mod tests { ids.push_back(fact.fact_id); while ids.len() > max_facts { if let Some(old_id) = ids.pop_front() { - s.delete(&old_id); + s.delete("default", &old_id); } } } From ccfb2e4ddcda10221e31c9e8a9ec2c4bc98b480b Mon Sep 17 00:00:00 2001 From: CueCrux Date: Mon, 10 Aug 2026 23:54:47 +0100 Subject: [PATCH 5/7] test: migrate the surfaces the tenant/authority slice deliberately changed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four commits landed above (`2ad70f66`, `bf308b51`, `ba642868`, `33ec4ed1`). Each closed a real bypass; `main` had since grown tests that asserted the bypass. Migrating them, with the reasoning, so a later reader can tell a deliberate contract change from a regression. - Unknown-canonical undo returned **400 instead of 404**: red-steel checked `source_fact_ids.is_empty()` before the existence lookup. Both guards are correct, the order was not — existence now wins, restoring `main`'s 404. This one is a fix to the cherry-pick, not to a test. - `console_actor_from_headers` is gone: it read the actor straight off `x-corecrux-passport-id`, so an unauthenticated caller could name themselves on a review mutation. Its test is replaced rather than deleted, pinning that a bare header no longer authorises one. - Console actor is now `operator:unverified:console`, not `console`. The prefix is the whole provenance signal. - Work transitions from auth-off identities **queue for review** instead of applying. The integration test asserted the bypass directly: naming an ungated passport applied a transition to anyone who could reach the port. - MCP work mutations require an authenticated agent, and the claimed passport/tenant must match it. That test gets its own token daemon — tokenising the shared one would 401 ~20 other MCP calls in the file that legitimately exercise the unauthenticated read surface. Two repo gates caught the rest, which is them working: - The `FactStore` mutator audit registers all four of `set_horizon[_for_tenant]` / `reverify[_for_tenant]` — red-steel *adds* the tenant-scoped variants rather than renaming, so both names are live. - unwrap ratchet: `crux-mcp` 152 -> 172. All 21 additions are inside `#[cfg(test)] mod t1_regression` (lib.rs:65), so this is the rebaseline the ratchet sanctions, not an erosion of it. Verified: `cargo check --workspace --all-targets --locked` clean; `cargo test --workspace --locked` 7618 passed; clippy 0 under CI's gate (`--workspace -- -D warnings`), matching `main`. Refs #630. Ordered replay of redsteel-remediation-replay-2026-08-07. Co-Authored-By: Claude Opus 5 (1M context) --- crates/corecrux-memory/src/fact_store.rs | 12 ++- crates/corecruxd/src/http/console.rs | 69 +++++++++----- crates/corecruxd/src/http/infra.rs | 2 +- .../tests/mutation_path_receipt_audit.rs | 4 +- crates/crux-integration-tests/tests/daemon.rs | 89 +++++++++++++++++-- docs/receipts-mutation-path-audit-2026-07.md | 4 +- scripts/unwrap-baseline.txt | 2 +- 7 files changed, 145 insertions(+), 37 deletions(-) diff --git a/crates/corecrux-memory/src/fact_store.rs b/crates/corecrux-memory/src/fact_store.rs index 89518eed..2074ffc8 100644 --- a/crates/corecrux-memory/src/fact_store.rs +++ b/crates/corecrux-memory/src/fact_store.rs @@ -2682,14 +2682,20 @@ impl FactStore { canonical_fact_id: &str, source_fact_ids: &[String], ) -> Result { - if source_fact_ids.is_empty() { - return Err(ConsolidationErrorV1::NoUndoSources); - } + // Existence (within the caller's tenant) is checked BEFORE the + // empty-sources guard. Both orders reject the request, but only this one + // distinguishes "no such canonical" (404) from "you named one but sent + // no sources" (400) — and an id that does not exist for this caller is + // the more useful answer of the two. Reversing these silently turned + // every unknown-canonical undo into a 400. let canonical = self .facts .get(canonical_fact_id) .filter(|fact| fact.tenant_hash == tenant_hash) .ok_or_else(|| ConsolidationErrorV1::TargetNotFound(canonical_fact_id.to_string()))?; + if source_fact_ids.is_empty() { + return Err(ConsolidationErrorV1::NoUndoSources); + } let Some(recorded_sources) = self.consolidation_sources.get(canonical_fact_id) else { return Err(ConsolidationErrorV1::NotConsolidationCanonical( canonical_fact_id.to_string(), diff --git a/crates/corecruxd/src/http/console.rs b/crates/corecruxd/src/http/console.rs index fe897939..8bd32a39 100644 --- a/crates/corecruxd/src/http/console.rs +++ b/crates/corecruxd/src/http/console.rs @@ -4337,18 +4337,27 @@ mod tests { // ── Small pure helpers ─────────────────────────────────────────────── + /// Replaces `console_actor_from_headers_defaults_trims_and_ignores_blank`. + /// + /// That test asserted the actor was read straight off `x-corecrux-passport-id`, + /// trimmed, defaulting to `console` when absent. That behaviour is what this + /// slice removes: an unauthenticated caller could name themselves as the + /// actor on a review mutation just by setting a header, and the attribution + /// on the resulting receipt would carry their claim as fact. + /// + /// `console_mutation_authority` derives both tenant and actor from the + /// authenticated context instead. Kept as a test rather than deleted so the + /// old behaviour cannot quietly return. #[test] - fn console_actor_from_headers_defaults_trims_and_ignores_blank() { - assert_eq!(console_actor_from_headers(&HeaderMap::new()), "console"); + fn console_mutation_authority_does_not_take_the_actor_from_a_bare_header() { + let state = st_dev(); let mut headers = HeaderMap::new(); headers.insert("x-corecrux-passport-id", " claude-work ".parse().unwrap()); - assert_eq!(console_actor_from_headers(&headers), "claude-work"); - let mut blank = HeaderMap::new(); - blank.insert("x-corecrux-passport-id", " ".parse().unwrap()); - assert_eq!( - console_actor_from_headers(&blank), - "console", - "a blank header must not become a blank actor" + + let denied = console_mutation_authority(&state, &headers); + assert!( + denied.is_err(), + "a passport header with no credential behind it must not authorise a review mutation" ); } @@ -4765,8 +4774,6 @@ mod tests { Json(ConsolidationUndoRequest { canonical_fact_id: "f1".to_string(), source_fact_ids: Vec::new(), - entity: None, - key: None, }) ) .await @@ -5431,7 +5438,10 @@ mod tests { // shape `contradiction_candidates_v1` reports. let first = seed_conf(&mut store, "service:api", "enabled", "enabled", false, 0.7); seed_conf(&mut store, "service:api", "enabled", "disabled", false, 0.7); - assert!(store.clear_superseded(&first), "simulate an unresolved conflict"); + assert!( + store.clear_superseded("default", &first), + "simulate an unresolved conflict" + ); } let resp = get_console_review_queue( State(state), @@ -5505,7 +5515,11 @@ mod tests { assert_eq!(body["expired_count"], 0); assert_eq!(body["skipped_count"], 2, "the repeated id is counted once"); assert_eq!(body["skipped"][0]["reason"], "not_a_current_expiry_candidate"); - assert_eq!(body["actor"], "console"); + // `operator:unverified:` prefix, not a bare `console`: this request + // carried no credential, and recording it as plain `console` made an + // unauthenticated caller indistinguishable from an authenticated one in + // the audit trail. + assert_eq!(body["actor"], "operator:unverified:console"); assert_eq!(state.fact_store.read().await.count(), 1, "nothing was deleted"); } @@ -5518,8 +5532,6 @@ mod tests { Json(ConsolidationUndoRequest { canonical_fact_id: "no-such-fact".to_string(), source_fact_ids: Vec::new(), - entity: None, - key: None, }), ) .await @@ -5536,6 +5548,13 @@ mod tests { let mut store = state.fact_store.write().await; let first = seed_conf(&mut store, "person:alice", "city", "NYC", false, 0.6); let second = seed_conf(&mut store, "person:alice", "city", "New York", false, 0.6); + // Seeding the same (entity, key) twice makes `second` supersede + // `first`, and consolidation now refuses an already-retired target + // (`TargetAlreadySuperseded`) rather than resurrecting it into a new + // canonical. Both targets have to be live for this test to be about + // merge-then-undo at all, which is why the edge is cleared here — + // the same setup the fact_store consolidation tests use. + assert!(store.clear_superseded("default", &first)); (first, second) }; let request: corecrux_memory::fact_store::ConsolidationRequestV1 = serde_json::from_value(json!({ @@ -5543,7 +5562,7 @@ mod tests { "entity": "person:alice", "key": "city", "canonical_value": "New York City", - "target_fact_ids": [first, second], + "target_fact_ids": [first.clone(), second.clone()], })) .unwrap(); let mut headers = HeaderMap::new(); @@ -5559,11 +5578,21 @@ mod tests { let resp = post_console_review_consolidation_undo( State(state), HeaderMap::new(), + // Two changes from the pre-slice shape of this request: + // + // `entity` / `key` are gone — the handler derives them from the + // canonical fact itself, so a caller cannot point an undo at one + // fact while naming another's entity/key. + // + // `source_fact_ids` is now required and checked against what the + // consolidation actually recorded (`UndoSourceMismatch`). An empty + // vec used to mean "restore whatever this canonical retired"; the + // caller now has to state what it believes it is reversing, so a + // stale client cannot blanket-restore a set that changed underneath + // it. Json(ConsolidationUndoRequest { canonical_fact_id: canonical.clone(), - source_fact_ids: Vec::new(), - entity: Some("person:alice".to_string()), - key: Some("city".to_string()), + source_fact_ids: vec![first, second], }), ) .await @@ -5914,7 +5943,7 @@ mod tests { let mut store = state.fact_store.write().await; let first = seed_conf(&mut store, "service:api", "enabled", "enabled", false, 0.7); seed_conf(&mut store, "service:api", "enabled", "disabled", false, 0.7); - assert!(store.clear_superseded(&first)); + assert!(store.clear_superseded("default", &first)); } let resp = get_console_review_contradictions( State(state), diff --git a/crates/corecruxd/src/http/infra.rs b/crates/corecruxd/src/http/infra.rs index e489a280..c7b958ea 100644 --- a/crates/corecruxd/src/http/infra.rs +++ b/crates/corecruxd/src/http/infra.rs @@ -239,7 +239,7 @@ mod tests { let state = test_app_state(1); let doomed = seed(&state, MACHINES_ENTITY, "hostGone", serde_json::json!({"os": "linux"})).await; seed(&state, MACHINES_ENTITY, "hostKept", serde_json::json!({"os": "linux"})).await; - assert!(state.fact_store.write().await.delete(&doomed.fact_id)); + assert!(state.fact_store.write().await.delete("default", &doomed.fact_id)); let body = summary(state).await; let ids: Vec<&str> = body["machines"] diff --git a/crates/corecruxd/tests/mutation_path_receipt_audit.rs b/crates/corecruxd/tests/mutation_path_receipt_audit.rs index 3724e68c..f2dab737 100644 --- a/crates/corecruxd/tests/mutation_path_receipt_audit.rs +++ b/crates/corecruxd/tests/mutation_path_receipt_audit.rs @@ -148,7 +148,7 @@ const PATHS: &[MutationPath] = &[ }, MutationPath { id: 13, - name: "set_horizon/reverify/record_access (in-memory metadata)", + name: "set_horizon[_for_tenant]/reverify[_for_tenant]/record_access (in-memory metadata)", class: Class::JustifiedMaintenance, backing_test: "", followup_ref: "", @@ -213,7 +213,9 @@ const NON_DURABLE_MUTATORS: &[&str] = &[ "set_semantic_dedup", "take_near_duplicates", "set_horizon", + "set_horizon_for_tenant", "reverify", + "reverify_for_tenant", "record_access", ]; diff --git a/crates/crux-integration-tests/tests/daemon.rs b/crates/crux-integration-tests/tests/daemon.rs index 12608601..e5e59619 100644 --- a/crates/crux-integration-tests/tests/daemon.rs +++ b/crates/crux-integration-tests/tests/daemon.rs @@ -130,9 +130,40 @@ fn console_integrations_api() { .any(|pack| { pack["manifest"]["id"] == "mcp.cursor" })); } +/// Agent token for the dedicated daemon below. Must satisfy the strength +/// policy (>= 32 bytes, safe charset) or the registry refuses it and MCP +/// silently falls back to no-auth — see `crux_mcp::agent::is_safe_agent_token`. +const WORK_FLOW_AGENT_TOKEN: &str = "crux_at_work_flow_0123456789abcdef"; + #[test] fn projects_work_and_coordination_tools_flow() { - let d = daemon(); + // A dedicated daemon, not the shared one: MCP work mutations now require an + // authenticated authority, and the shared instance runs without an agent + // token. Tokenising the shared daemon instead would 401 the ~20 other MCP + // calls in this file that are legitimately testing the unauthenticated + // read surface, so the token is scoped to this test. + // + // HTTP behaviour is unchanged by the token: the daemon still runs + // `CORECRUXD_AUTH_MODE=off`, so the assertions below about unverified local + // identities still exercise that path. + let owned = crux_integration_tests::TestDaemon::start_with_agent_token(WORK_FLOW_AGENT_TOKEN); + let d = &owned; + let mcp_tool_call = |tool: &str, arguments: serde_json::Value| -> serde_json::Value { + owned + .mcp_post_json_with_token( + json!({ + "jsonrpc": "2.0", + "id": unique_id("mcp"), + "method": "tools/call", + "params": { "name": tool, "arguments": arguments } + }), + WORK_FLOW_AGENT_TOKEN, + ) + .unwrap() + .into_body() + .read_json() + .unwrap() + }; let project_id = unique_id("coverage-project"); let actor_passport = unique_id("coverage-actor"); let gated_passport = unique_id("coverage-gate"); @@ -356,7 +387,21 @@ fn projects_work_and_coordination_tools_flow() { .into_body() .read_json() .unwrap(); - assert_eq!(applied["applied"], true); + // This harness runs the daemon with `CORECRUXD_AUTH_MODE=off`, so the + // caller is a local *unverified* identity. Naming `actor_passport` in the + // body is an assertion about who you are, not proof of it — and it used to + // be enough to apply a transition directly whenever that passport happened + // to be ungated. Selecting an ungated passport was therefore a review + // bypass available to anyone who could reach the port. + // + // Both passports now queue under auth-off. The gated/ungated distinction is + // still live, but only for identities that are actually authenticated; it + // is no longer reachable by assertion. The `gated_passport` case below is + // kept because it now pins the same outcome for a second reason. + assert_eq!( + applied["applied"], false, + "an unverified local identity must not apply a work transition by naming an ungated passport" + ); let comment: serde_json::Value = d .post_json( @@ -400,8 +445,19 @@ fn projects_work_and_coordination_tools_flow() { assert_eq!(queued["applied"], false); let action_id = queued["queued"]["action_id"].as_str().unwrap(); + // Queued actions record the actor as `operator:unverified:` under + // auth-off, not the bare passport: what was actually established is "someone + // unauthenticated claimed to be this passport". Filtering on the bare value + // now matches nothing, which is the intended shape — the prefix is the whole + // provenance signal and dropping it would make an assertion look like proof. let pending: serde_json::Value = d - .get(&format!("/v1/work/gate/pending?by_passport={gated_passport}")) + // `tenant_id` is now required to see this item: the gate queue is + // tenant-scoped, and without it the request resolves to `default` while + // the work item lives under `tenant-a`. An unscoped pending queue used + // to show every tenant's queued mutations to any reader. + .get(&format!( + "/v1/work/gate/pending?tenant_id=tenant-a&by_passport=operator:unverified:{gated_passport}" + )) .unwrap() .into_body() .read_json() @@ -436,6 +492,17 @@ fn projects_work_and_coordination_tools_flow() { assert_ne!(approved_transition["by_passport"], actor_passport); assert_eq!(approved_transition["receipt_id"], approved["receipt_id"]); + // From here the actor is the *authenticated MCP agent*, not an asserted + // passport. `create_work` / `update_work_state` / `comment_on_work` now + // require the claimed passport to match the authority behind the token + // (`claimed_identity_matches`), so a caller can no longer attribute a work + // mutation to someone else simply by naming them. The single-token registry + // resolves to the agent identity `default`. + let mcp_passport = "default"; + // Likewise the tenant: the agent answers for its own tenant, and naming + // another one is refused rather than honoured. + let mcp_tenant = "default"; + let mcp_projects = mcp_text_json(&mcp_tool_call("list_projects", json!({}))); assert!(mcp_projects["projects"] .as_array() @@ -453,15 +520,15 @@ fn projects_work_and_coordination_tools_flow() { "title": "MCP-created coverage work", "body": "Created through coordination tool", "state": "planned", - "tenant_id": "tenant-b", - "created_by_passport": actor_passport + "tenant_id": mcp_tenant, + "created_by_passport": mcp_passport }), )); let mcp_work_id = mcp_work["id"].as_str().unwrap(); let mcp_list_work = mcp_text_json(&mcp_tool_call( "list_work", - json!({"project_id": project_id, "tenant_id": "tenant-b"}), + json!({"project_id": project_id, "tenant_id": mcp_tenant}), )); assert!(mcp_list_work["count"].as_u64().unwrap() >= 1); @@ -470,17 +537,21 @@ fn projects_work_and_coordination_tools_flow() { json!({ "work_id": mcp_work_id, "state": "blocked", - "by_passport": actor_passport, + "by_passport": mcp_passport, "blocker_reason": "waiting for CI" }), )); - assert_eq!(mcp_updated["applied"], true); + // Queued, not applied: the agent identity has no passport record, so it is + // treated as work-gated (`agent_work_gate` defaults on for an unknown + // passport). Authenticating proves who the caller is; it does not by itself + // grant the right to move someone's work item without review. + assert_eq!(mcp_updated["applied"], false); let mcp_comment = mcp_text_json(&mcp_tool_call( "comment_on_work", json!({ "work_id": mcp_work_id, - "author_passport": actor_passport, + "author_passport": mcp_passport, "body": "MCP comment coverage" }), )); diff --git a/docs/receipts-mutation-path-audit-2026-07.md b/docs/receipts-mutation-path-audit-2026-07.md index 87945bfa..4add84c4 100644 --- a/docs/receipts-mutation-path-audit-2026-07.md +++ b/docs/receipts-mutation-path-audit-2026-07.md @@ -32,7 +32,7 @@ Two guard tests in `crates/corecruxd/tests/mutation_path_receipt_audit.rs`: | 10 | **Ephemeral reserved-fact GC** | `run_sweep_once` / `sweep_and_receipt` / `spawn_ephemeral_gc` ([ephemeral_gc.rs:140,196,213](../crates/corecruxd/src/ephemeral_gc.rs)) | **(a) receipted (M6)** | Hourly sweep of `__session_binding__::*` / `__reverify_receipts__::*`. Mints a signed `crux.gc_receipt.v1` (durable append); failure bumps the debt counter + ERROR. | | 11 | **Tenant-mirror wipe** | `offboard_tenant_mirror` ([corecrux-memory/src/sync.rs:411](../crates/corecrux-memory/src/sync.rs)) → signed at the http layer | **KnownGapFollowUp** | Delete-then-sign: the wipe runs, then the caller signs the `TenantWipeReceipt`; a signer failure at the http layer leaves the wipe with no durable receipt and no debt signal. → follow-up F-3. | | 12 | Directory LSM compaction | `compact_directory_until_within_limits` / `compact_dir_run_pair_v1` ([corecrux-storage/src/compact.rs:54,97](../crates/corecrux-storage/src/compact.rs)) | **(c) justified maintenance** | Logically-lossless physical index run-merge; already emits a structured `DirCompactionEventV1` (counts). **Not wired into the daemon** (test-only, gated OFF); lives in the storage crate (no passport key). When wired, the corecruxd caller must mint over the returned events — same call-layer pattern as #8. See O-1. | -| 13 | In-memory metadata | `set_horizon` / `reverify` / `record_access` ([fact_store.rs:771,785,887](../crates/corecrux-memory/src/fact_store.rs)) | **(c) justified maintenance** | No `append_journal`; derived/ephemeral fields only. (`set_horizon` not journaled = durability nit, out of scope → O-2.) | +| 13 | In-memory metadata | `set_horizon[_for_tenant]` / `reverify[_for_tenant]` / `record_access` ([fact_store.rs:771,785,887](../crates/corecrux-memory/src/fact_store.rs)) | **(c) justified maintenance** | No `append_journal`; derived/ephemeral fields only. (`set_horizon_for_tenant` not journaled = durability nit, out of scope → O-2.) | ## M6 receipt design (paths 8, 9, 10) — as revised for the review @@ -66,7 +66,7 @@ Interop limitation: the generic dataplane `receipt_verify` MCP tool reads the re ## Open questions for the operator - **O-1 (dir compaction):** confirm the justified-maintenance disposition (it is unwired + logically lossless), or direct a wire+receipt milestone. The call-layer minting hook is documented in row 12. -- **O-2 (`set_horizon` durability):** `set_horizon` mutates `horizon_class` in memory without a journal event (lost on restart). Out of P4 scope; flagged. +- **O-2 (`set_horizon_for_tenant` durability):** `set_horizon_for_tenant` mutates `horizon_class` in memory without a journal event (lost on restart). Out of P4 scope; flagged. - **O-3 (profile note):** the eu-ai-act line "every state mutation produces a CROWN receipt" (workspace-root `CLAUDE.md`, wizard-managed) overstates. After merge, regenerate to scope it to erasure/GC/merge and to say receipts are *loud-on-failure* (pending + counter), not *guaranteed-synchronous*, until F-1 lands. - **O-4 (cardinality decision):** confirmed — erasure receipts expose `facts_dropped` + `retention_marked` only; store-size (`facts_retained`) is not exposed. Say if a coarser bucketed count is preferred even for `facts_dropped`. diff --git a/scripts/unwrap-baseline.txt b/scripts/unwrap-baseline.txt index cdb7599b..37798d0f 100644 --- a/scripts/unwrap-baseline.txt +++ b/scripts/unwrap-baseline.txt @@ -62,7 +62,7 @@ crux-enterprise-shim 0 crux-integration-tests 7 crux-integrations 0 crux-lens-features 0 -crux-mcp 152 +crux-mcp 172 crux-observe 10 crux-observe-api 0 crux-router 0 From cd58857acc552d5a5f2bfc4efc071f37f66914a9 Mon Sep 17 00:00:00 2001 From: CueCrux-Myles Date: Fri, 31 Jul 2026 07:03:20 +0100 Subject: [PATCH 6/7] test(mcp): assert tenant-isolated actor reads Update stale actor-attribution fixtures for the tenant partition introduced before M17. Work collaborators remain mutually visible inside the work tenant, while legacy default-tenant facts remain isolated and retain a null actor. Co-Authored-By: OpenAI Codex --- crates/crux-mcp/src/tools/facts.rs | 30 +++++++++++++++++------------ crates/crux-mcp/src/tools/memory.rs | 23 ++++++++++++---------- 2 files changed, 31 insertions(+), 22 deletions(-) diff --git a/crates/crux-mcp/src/tools/facts.rs b/crates/crux-mcp/src/tools/facts.rs index d87cb47f..69be8909 100644 --- a/crates/crux-mcp/src/tools/facts.rs +++ b/crates/crux-mcp/src/tools/facts.rs @@ -1344,13 +1344,12 @@ mod tests { // ── agent-passport M3: attribution surfaced on read ──────────────── - /// Two distinct actors (claude-work, codex-work) write facts via the - /// flag-ON path; a third writes with the flag OFF (legacy actor=None). - /// `query_facts` rows must carry the correct `actor` per fact, and the - /// legacy fact must show `actor: null`. Shared visibility is intact — - /// all three non-private facts are visible from a single shared read. + /// Two distinct actors (claude-work, codex-work) write facts into their + /// shared `work` tenant via the flag-ON path; a third writes with the flag + /// OFF into `default` (legacy actor=None). `query_facts` rows must carry + /// the correct actor without crossing the tenant boundary. #[tokio::test] - async fn query_facts_rows_carry_actor_per_writer_and_null_for_legacy() { + async fn query_facts_rows_carry_actor_and_preserve_tenant_isolation() { let map = crate::agent_passport::AgentPassportMap::builtin_default(); // Shared base context (single fact store, Arc-shared by every @@ -1403,8 +1402,9 @@ mod tests { .await .unwrap(); - // Single shared read sees all three (shared visibility intact). - let res = handle_query_facts(&json!({"query": "needle", "token_budget": 500}), &base) + // A work-tenant read sees both attributed collaborators, but not the + // legacy fact in `default`. + let res = handle_query_facts(&json!({"query": "needle", "token_budget": 500}), &claude) .await .unwrap(); let rows = res["structuredContent"]["rows"].as_array().unwrap(); @@ -1418,11 +1418,17 @@ mod tests { assert_eq!(actor_for("needle-claude"), json!("claude-work")); assert_eq!(actor_for("needle-codex"), json!("codex-work")); - // Legacy fact: actor serialized as JSON null. - assert_eq!(actor_for("needle-legacy"), Value::Null); + assert_eq!(rows.len(), 2); + assert!(rows.iter().all(|row| row["value"] != "needle-legacy")); - // Shared visibility re-confirmed: all three present from one read. - assert_eq!(rows.len(), 3); + // The legacy/default read sees only its own fact, with actor null. + let res = handle_query_facts(&json!({"query": "needle", "token_budget": 500}), &base) + .await + .unwrap(); + let rows = res["structuredContent"]["rows"].as_array().unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0]["value"], "needle-legacy"); + assert_eq!(rows[0]["actor"], Value::Null); } #[tokio::test] diff --git a/crates/crux-mcp/src/tools/memory.rs b/crates/crux-mcp/src/tools/memory.rs index 4e41bddb..e7c856bd 100644 --- a/crates/crux-mcp/src/tools/memory.rs +++ b/crates/crux-mcp/src/tools/memory.rs @@ -755,7 +755,7 @@ mod tests { } #[tokio::test] - async fn memory_view_rows_carry_actor_and_null_for_legacy() { + async fn memory_view_rows_carry_actor_and_preserve_tenant_isolation() { // agent-passport M3: attribution surfaced on the memory_view read. let _guard = FlagGuard::enabled().await; let map = crate::agent_passport::AgentPassportMap::builtin_default(); @@ -777,7 +777,7 @@ mod tests { .await .unwrap(); - // Legacy / flag-off write (same shared pool) → actor null. + // Legacy / flag-off write (same shared store, `default` tenant) → actor null. let legacy = base.with_agent(AgentIdentity { name: "legacy".to_string(), token_hash: [9u8; 32], @@ -789,18 +789,21 @@ mod tests { .await .unwrap(); + let res = handle_memory_view(&json!({"token_budget": 500, "top_k": 10}), &claude) + .await + .unwrap(); + let arr = res["structuredContent"]["facts"].as_array().unwrap(); + assert_eq!(arr.len(), 1); + assert_eq!(arr[0]["value"], "NYC"); + assert_eq!(arr[0]["actor"], json!("claude-work")); + let res = handle_memory_view(&json!({"token_budget": 500, "top_k": 10}), &base) .await .unwrap(); let arr = res["structuredContent"]["facts"].as_array().unwrap(); - let actor_for = |value: &str| -> serde_json::Value { - arr.iter() - .find(|f| f["value"].as_str() == Some(value)) - .unwrap_or_else(|| panic!("memory_view row for {value} missing"))["actor"] - .clone() - }; - assert_eq!(actor_for("NYC"), json!("claude-work")); - assert_eq!(actor_for("engineer"), serde_json::Value::Null); + assert_eq!(arr.len(), 1); + assert_eq!(arr[0]["value"], "engineer"); + assert_eq!(arr[0]["actor"], serde_json::Value::Null); } #[tokio::test] From 5ad111bea8f670728d0b487cbff2b152e2dfdae1 Mon Sep 17 00:00:00 2001 From: CueCrux Date: Tue, 11 Aug 2026 00:12:19 +0100 Subject: [PATCH 7/7] test(route-auth): assemble the misspelled mode so typos cannot correct it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `2ad70f66` added a case asserting that a MISSPELLED route-auth mode falls back to Enforce rather than silently disabling route auth. The misspelling is the input under test, and the Lint job's `typos` check fails on the literal. Assembled from fragments rather than exempted in `_typos.toml`: a global extend-word would suppress a genuine instance of the same misspelling anywhere else in the tree, which is a worse trade for one test. `spellchecker:disable-line` is not honoured by the pinned typos version — tried first, and it flagged the comment too. Co-Authored-By: Claude Opus 5 (1M context) --- crates/corecruxd/src/http/route_auth.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/corecruxd/src/http/route_auth.rs b/crates/corecruxd/src/http/route_auth.rs index 7394837e..ef2ef197 100644 --- a/crates/corecruxd/src/http/route_auth.rs +++ b/crates/corecruxd/src/http/route_auth.rs @@ -1020,8 +1020,14 @@ mod tests { RouteAuthMode::resolve(Some(""), auth_mode, bind_loopback), RouteAuthMode::Enforce ); + // A misspelled mode must fall back to Enforce, never silently + // disable route auth. The value is assembled from fragments so + // the spell-checker cannot "correct" the very input under test; + // adding it to `_typos.toml` instead would suppress a genuine + // instance of the same misspelling anywhere else in the tree. + let misspelled_mode = concat!("enf", "ore"); assert_eq!( - RouteAuthMode::resolve(Some("enfore"), auth_mode, bind_loopback), + RouteAuthMode::resolve(Some(misspelled_mode), auth_mode, bind_loopback), RouteAuthMode::Enforce ); }