From dee89e09366558ab86f6ab5b67c03f23f65ee91d Mon Sep 17 00:00:00 2001 From: CueCrux-Myles Date: Thu, 30 Jul 2026 22:57:37 +0100 Subject: [PATCH 1/4] fix(security): isolate JWT fact-backed tenants Default wired HTTP fact-backed surfaces to verified JWT tenant stamping while retaining explicit off and shadow migration modes. Freeze the posture at startup, fail closed on ambiguous authority, and tenant-bind paired reads, caches, receipts, overlays, candidates, replay, and result-envelope flows. Co-Authored-By: OpenAI Codex --- CHANGELOG.md | 9 + config.example.env | 10 + crates/corecrux-memory/src/engrams.rs | 54 ++- crates/corecruxd/src/auth.rs | 348 ++++++++++---- crates/corecruxd/src/candidate_store.rs | 122 ++++- crates/corecruxd/src/http/console.rs | 23 +- crates/corecruxd/src/http/context_surface.rs | 319 +++++++++++-- crates/corecruxd/src/http/engrams.rs | 72 ++- crates/corecruxd/src/http/facts.rs | 84 +++- crates/corecruxd/src/http/gpu1.rs | 24 +- crates/corecruxd/src/http/incidents.rs | 5 +- crates/corecruxd/src/http/infra.rs | 5 +- crates/corecruxd/src/http/memory_capture.rs | 42 +- crates/corecruxd/src/http/receipts.rs | 5 +- crates/corecruxd/src/http/replay.rs | 18 +- crates/corecruxd/src/http/result_envelope.rs | 83 +++- crates/corecruxd/src/http/tests.rs | 466 ++++++++++++++++++- crates/corecruxd/src/http/workbench.rs | 5 +- crates/corecruxd/src/main.rs | 2 +- docs/THREAT_MODEL.md | 8 + docs/api-reference.md | 24 + llms-full.txt | 41 ++ 22 files changed, 1568 insertions(+), 201 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 500ba8d3..9c354d05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -760,6 +760,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security +- **JWT HTTP fact storage is tenant-isolated by default.** The daemon freezes + `CORECRUXD_TENANT_WRITE_STAMP` at startup, defaults it to `on` for JWT auth, + rejects invalid configuration and ambiguous/missing tenant authority, and + keeps `off`/`shadow` only as explicit shared-`default` migration modes. + Generic and console facts, context recall, engram overlays, memory + candidates, result envelopes, replay, and paired audit reads now preserve + one authorized tenant end to end. MCP and stores with + independent tenant contracts are explicitly outside this flag. + - **`.mcp.json` is gitignored.** MCP clients write the daemon's agent bearer token into that file at the repository root, where it was previously committable. diff --git a/config.example.env b/config.example.env index 2f81a96b..4a93165b 100644 --- a/config.example.env +++ b/config.example.env @@ -34,6 +34,16 @@ CORECRUXD_ROUTE_AUTH=enforce # CORECRUXD_JWT_HS256_SECRET= # CORECRUXD_ALLOW_WEAK_HS256_SECRET=false +# HTTP fact-backed surfaces derive their storage tenant from verified JWT +# tenant claims by default. On tenant-implicit routes, multi-tenant/wildcard +# JWTs must select one with X-Corecrux-Tenant-Id; an explicit route/body tenant +# is also a selector and must agree with the header when both are present. +# Invalid values abort startup. `off` is an explicit legacy migration override +# that shares the `default` tenant; `shadow` logs would-change decisions while +# retaining that legacy behaviour. +# This does not tenant-enable MCP or unrelated entity/session/control stores. +# CORECRUXD_TENANT_WRITE_STAMP=on + # ── Storage ──────────────────────────────────────────────────────── # Data directory for segments, indexes, and control state. diff --git a/crates/corecrux-memory/src/engrams.rs b/crates/corecrux-memory/src/engrams.rs index 0eac7aee..3d330336 100644 --- a/crates/corecrux-memory/src/engrams.rs +++ b/crates/corecrux-memory/src/engrams.rs @@ -139,10 +139,24 @@ pub fn validate_local_engram(engram: &LocalEngram) -> Result<(), String> { /// Built-in catalog merged with fact-backed overlays under `__engram__::*`. /// An overlay replaces a builtin with the same `(name, version)`. pub fn local_catalog_with_overlays(store: &FactStore) -> Vec { + local_catalog_with_overlays_inner(store, Some("default")) +} + +/// Built-in catalog merged only with overlays owned by `tenant_hash`. +/// +/// HTTP callers use this form so an overlay from one JWT tenant cannot replace +/// a same-name overlay in another tenant. The unscoped function remains for the +/// local MCP compatibility plane, which is confined to the shared `default` +/// tenant because it has no per-request JWT tenant claim. +pub fn local_catalog_with_overlays_for_tenant(store: &FactStore, tenant_hash: &str) -> Vec { + local_catalog_with_overlays_inner(store, Some(tenant_hash)) +} + +fn local_catalog_with_overlays_inner(store: &FactStore, tenant_hash: Option<&str>) -> Vec { let mut out = builtin_engrams(); let result = store.query(&FactQuery { min_effective_confidence: None, - tenant_hash: None, + tenant_hash: tenant_hash.map(str::to_string), query: None, entity: None, entity_prefix: Some(ENGRAM_ENTITY_PREFIX.trim_end_matches("::").to_string() + "::"), @@ -509,6 +523,44 @@ mod tests { assert_eq!(served[0].content, "operator-tuned ladder"); } + #[test] + fn tenant_scoped_catalog_never_merges_another_tenants_overlay() { + let mut store = FactStore::new(); + for (tenant, content) in [ + ("tenant-a", "tenant A tuned ladder"), + ("tenant-b", "tenant B tuned ladder"), + ] { + let mut custom = builtin_engrams() + .into_iter() + .find(|engram| engram.name == "code-minimalism") + .unwrap(); + custom.content = content.to_string(); + store.store(StoreFact { + tenant_hash: tenant.to_string(), + entity: format!("{ENGRAM_ENTITY_PREFIX}code-minimalism"), + key: "engram".to_string(), + value: serde_json::to_string(&custom).unwrap(), + source_receipt: None, + confidence: 1.0, + private: true, + horizon_class: None, + actor: None, + }); + } + + let tenant_a = local_catalog_with_overlays_for_tenant(&store, "tenant-a"); + let served = tenant_a.iter().find(|engram| engram.name == "code-minimalism").unwrap(); + assert_eq!(served.content, "tenant A tuned ladder"); + assert!(!tenant_a.iter().any(|engram| engram.content == "tenant B tuned ladder")); + let mcp_default = local_catalog_with_overlays(&store); + let served = mcp_default + .iter() + .find(|engram| engram.name == "code-minimalism") + .unwrap(); + assert_ne!(served.content, "tenant A tuned ladder"); + assert_ne!(served.content, "tenant B tuned ladder"); + } + #[test] fn overlay_validation_rejects_malformed_or_unbounded_control_content() { let mut overlay = builtin_engrams().remove(0); diff --git a/crates/corecruxd/src/auth.rs b/crates/corecruxd/src/auth.rs index ce5e0cd3..58de862b 100644 --- a/crates/corecruxd/src/auth.rs +++ b/crates/corecruxd/src/auth.rs @@ -119,6 +119,7 @@ type InitialJwks = ( #[derive(Clone)] pub struct Authz { mode: AuthMode, + tenant_stamp_mode: TenantStampMode, jwt_hs256: Option, jwt_jwks: Option, /// Opt-in: under a JWT mode, also accept a registered MCP agent token @@ -265,9 +266,11 @@ impl std::fmt::Debug for Authz { impl Authz { pub fn from_env(mode: AuthMode) -> Result { + let tenant_stamp_mode = TenantStampMode::from_env_for_auth(mode)?; match mode { AuthMode::Off | AuthMode::DevScopes => Ok(Self { mode, + tenant_stamp_mode, jwt_hs256: None, jwt_jwks: None, agent_http: None, @@ -280,6 +283,7 @@ impl Authz { let audience = std::env::var("CORECRUXD_JWT_AUD").ok(); Ok(Self { mode, + tenant_stamp_mode, jwt_hs256: Some(JwtHs256Config { secret, issuer, @@ -325,6 +329,7 @@ impl Authz { Ok(Self { mode, + tenant_stamp_mode, jwt_hs256: None, jwt_jwks: Some(JwtJwksConfig { issuer: resolved_issuer, @@ -350,10 +355,15 @@ impl Authz { self.mode } + pub(crate) fn tenant_stamp_mode(&self) -> TenantStampMode { + self.tenant_stamp_mode + } + #[cfg(test)] pub(crate) fn test_hs256(secret: &[u8], issuer: &str, audience: &str) -> Self { Self { mode: AuthMode::JwtHs256, + tenant_stamp_mode: TenantStampMode::On, jwt_hs256: Some(JwtHs256Config { secret: secret.to_vec(), issuer: Some(issuer.to_string()), @@ -989,6 +999,7 @@ pub struct HttpScopeContext { pub scopes: Vec, pub passport_id: Option, auth_enforced: bool, + tenant_stamp_mode: TenantStampMode, /// 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. @@ -1010,16 +1021,15 @@ pub struct HttpScopeContext { write_tenant_selector: Option, } -/// Enforcement posture for write-context tenant stamping (OD-37 / audit-v2 M1), -/// parsed from `CORECRUXD_TENANT_WRITE_STAMP`. Mirrors the `RouteAuthMode` / -/// `RedactMode` off|shadow|on ladder already used elsewhere in the daemon. +/// Enforcement posture for HTTP fact-backed tenant stamping (OD-37 / M-08). /// -/// Default **Off** (unlike `RouteAuthMode`, whose default is Shadow) because the -/// shipped v0.5.43 contract is "stamping is dark until deliberately enabled". +/// JWT modes default to `On`. `Off` and `Shadow` are deliberate compatibility +/// postures for migrating historical rows from the shared `default` tenant. +/// Auth-off and development-scope modes remain local/shared by default. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum TenantStampMode { /// Every write stamps `default`, every read resolves `default` — byte-identical - /// to pre-M1 behaviour. DEFAULT. + /// to pre-M16 behaviour. Explicit legacy compatibility only under JWT auth. Off, /// Resolve the tenant and **log what would happen**, but still stamp `default` /// and still read `default`. Observation only — zero behaviour change. Use this @@ -1030,17 +1040,24 @@ pub(crate) enum TenantStampMode { } impl TenantStampMode { - pub(crate) fn from_env() -> Self { - match std::env::var("CORECRUXD_TENANT_WRITE_STAMP") - .ok() - .map(|v| v.trim().to_ascii_lowercase()) - .as_deref() - { - Some("1") | Some("true") | Some("on") | Some("enforce") => Self::On, - Some("shadow") | Some("audit") => Self::Shadow, - // Anything else (unset, "0", "off", junk) → Off. Fail-safe towards the - // shipped behaviour, never towards silently stamping real tenants. - _ => Self::Off, + fn from_env_for_auth(auth_mode: AuthMode) -> Result { + if !matches!(auth_mode, AuthMode::JwtHs256 | AuthMode::JwtJwks) { + return Ok(Self::Off); + } + let raw = match std::env::var("CORECRUXD_TENANT_WRITE_STAMP") { + Ok(raw) => raw, + Err(std::env::VarError::NotPresent) => return Ok(Self::On), + Err(std::env::VarError::NotUnicode(_)) => { + return Err("CORECRUXD_TENANT_WRITE_STAMP must be valid UTF-8".to_string()); + } + }; + match raw.trim().to_ascii_lowercase().as_str() { + "1" | "true" | "on" | "enforce" => Ok(Self::On), + "shadow" | "audit" => Ok(Self::Shadow), + "0" | "false" | "off" | "legacy" => Ok(Self::Off), + _ => { + Err("invalid CORECRUXD_TENANT_WRITE_STAMP; valid values: on, shadow, off (JWT default: on)".to_string()) + } } } @@ -1059,10 +1076,10 @@ impl TenantStampMode { fn resolve_write_tenant_on(tenants: &TenantAllow, selector: Option<&str>) -> Result, ProblemResponse> { let selector = selector.map(str::trim).filter(|s| !s.is_empty()); match tenants { - // No tenant claim → single-tenant deployment → default (backward-compat hinge). + // General authority resolution retains the local/agent compatibility + // default. Fact-backed HTTP storage applies its stricter JWT policy in + // `resolve_implicit_fact_tenant_on` below. TenantAllow::Missing => Ok(None), - // Wildcard/admin token: a selector picks the target tenant; absent → default - // (legacy admin writes keep landing `default`). TenantAllow::Any => Ok(selector.map(str::to_string)), TenantAllow::Only(set) => match selector { Some(sel) => { @@ -1070,7 +1087,7 @@ fn resolve_write_tenant_on(tenants: &TenantAllow, selector: Option<&str>) -> Res Ok(Some(sel.to_string())) } else { Err(ProblemResponse( - ProblemDetails::forbidden("write tenant not allowed by token") + ProblemDetails::forbidden("tenant not allowed by token") .with_extensions(serde_json::json!({ "code": "TENANT_FORBIDDEN", "tenantId": sel })), )) } @@ -1082,7 +1099,7 @@ fn resolve_write_tenant_on(tenants: &TenantAllow, selector: Option<&str>) -> Res Ok(set.iter().next().cloned()) } else { Err(ProblemResponse( - ProblemDetails::forbidden("multi-tenant token must supply x-corecrux-tenant-id on write") + ProblemDetails::forbidden("multi-tenant token must supply x-corecrux-tenant-id") .with_extensions(serde_json::json!({ "code": "TENANT_SELECTOR_REQUIRED" })), )) } @@ -1091,6 +1108,30 @@ fn resolve_write_tenant_on(tenants: &TenantAllow, selector: Option<&str>) -> Res } } +/// Secure-default resolution for M16's tenant-implicit HTTP fact surfaces. +/// +/// Unlike the general authority resolver above, authenticated fact traffic may +/// never infer `default` from a missing claim or wildcard grant. +#[allow(clippy::result_large_err)] +fn resolve_implicit_fact_tenant_on( + tenants: &TenantAllow, + selector: Option<&str>, +) -> Result, ProblemResponse> { + let selector = selector.map(str::trim).filter(|value| !value.is_empty()); + match tenants { + TenantAllow::Missing => Err(ProblemResponse( + ProblemDetails::forbidden("token is missing a tenant claim").with_extensions(serde_json::json!({ + "code": "TENANT_CLAIM_MISSING", + })), + )), + TenantAllow::Any if selector.is_none() => Err(ProblemResponse( + ProblemDetails::forbidden("wildcard tenant token must select one tenant") + .with_extensions(serde_json::json!({ "code": "TENANT_SELECTOR_REQUIRED" })), + )), + _ => resolve_write_tenant_on(tenants, selector), + } +} + /// Resolve the tenant a writer stamps, honouring the posture. /// /// `Shadow` is the load-bearing case: it runs the full `On` resolution, emits a @@ -1099,27 +1140,27 @@ fn resolve_write_tenant_on(tenants: &TenantAllow, selector: Option<&str>) -> Res /// It is deliberately SILENT when `On` would also have produced `default` — so a /// window with zero `tenant_stamp_shadow_*` lines proves the flip is a no-op. #[allow(clippy::result_large_err)] -fn resolve_write_tenant_flagged( +fn resolve_implicit_fact_tenant_flagged( tenants: &TenantAllow, selector: Option<&str>, mode: TenantStampMode, ) -> Result, ProblemResponse> { match mode { TenantStampMode::Off => Ok(None), - TenantStampMode::On => resolve_write_tenant_on(tenants, selector), + TenantStampMode::On => resolve_implicit_fact_tenant_on(tenants, selector), TenantStampMode::Shadow => { - match resolve_write_tenant_on(tenants, selector) { + match resolve_implicit_fact_tenant_on(tenants, selector) { // Would orphan: this write would land under a non-default tenant, // and reads would move with it. Ok(Some(would_stamp)) => tracing::warn!( would_stamp = %would_stamp, - "tenant_stamp_shadow_would_stamp: enabling CORECRUXD_TENANT_WRITE_STAMP=1 would stamp a NON-default tenant here" + "tenant_stamp_shadow_would_stamp: enabling CORECRUXD_TENANT_WRITE_STAMP=on would use a NON-default tenant here" ), // Would break: this caller would start getting a 4xx. Err(problem) => tracing::warn!( status = problem.0.status, detail = %problem.0.detail.as_deref().unwrap_or(""), - "tenant_stamp_shadow_would_reject: enabling CORECRUXD_TENANT_WRITE_STAMP=1 would REJECT this write" + "tenant_stamp_shadow_would_reject: enabling CORECRUXD_TENANT_WRITE_STAMP=on would REJECT this request" ), // Would be `default` anyway — the quiet, safe case. No signal. Ok(None) => {} @@ -1129,23 +1170,6 @@ fn resolve_write_tenant_flagged( } } -/// Resolve the tenant a reader is scoped to. `None` = default. Kept in lockstep -/// with the write resolver so a writer and reader on the same single-tenant token -/// agree. Multi-tenant / wildcard tokens read `default` here (their concrete-tenant -/// reads go through the query path's `tenant_id` body selector or the admin bypass). -/// -/// `Shadow` reads `default` — shadow must not move reads, or it would not be -/// observation-only. -fn resolve_read_tenant_flagged(tenants: &TenantAllow, mode: TenantStampMode) -> Option { - if mode != TenantStampMode::On { - return None; - } - match tenants { - TenantAllow::Only(set) if set.len() == 1 => set.iter().next().cloned(), - _ => None, - } -} - impl HttpScopeContext { pub fn has_scope(&self, scope: &str) -> bool { self.scope_bypass || self.scopes.iter().any(|s| s == scope) @@ -1235,16 +1259,65 @@ impl HttpScopeContext { /// Tenant to stamp on an HTTP write (OD-37). `Ok(None)` → default tenant. #[allow(clippy::result_large_err)] pub(crate) fn resolve_write_tenant(&self) -> Result, ProblemResponse> { - resolve_write_tenant_flagged( + resolve_implicit_fact_tenant_flagged( &self.tenants, self.write_tenant_selector.as_deref(), - TenantStampMode::from_env(), + self.tenant_stamp_mode, ) } - /// Tenant an HTTP read is scoped to. `None` → default tenant. - pub(crate) fn resolve_read_tenant(&self) -> Option { - resolve_read_tenant_flagged(&self.tenants, TenantStampMode::from_env()) + /// Tenant an HTTP fact-backed read is scoped to. `None` → default tenant. + #[allow(clippy::result_large_err)] + pub(crate) fn resolve_read_tenant(&self) -> Result, ProblemResponse> { + resolve_implicit_fact_tenant_flagged( + &self.tenants, + self.write_tenant_selector.as_deref(), + self.tenant_stamp_mode, + ) + } + + /// Resolve an explicit fact-backed route tenant while retaining the + /// operator-selected legacy shared-default posture. + #[allow(clippy::result_large_err)] + pub(crate) fn resolve_fact_tenant(&self, requested: Option<&str>) -> Result { + match self.tenant_stamp_mode { + TenantStampMode::On => self.resolve_fact_tenant_on(requested), + TenantStampMode::Off => Ok("default".to_string()), + TenantStampMode::Shadow => { + match self.resolve_fact_tenant_on(requested) { + Ok(ref tenant) if tenant != "default" => tracing::warn!( + would_stamp = %tenant, + "tenant_stamp_shadow_would_stamp: enabling tenant stamping would use a non-default tenant" + ), + Err(ref problem) => tracing::warn!( + status = problem.0.status, + detail = %problem.0.detail.as_deref().unwrap_or(""), + "tenant_stamp_shadow_would_reject: enabling tenant stamping would reject this request" + ), + Ok(_) => {} + } + Ok("default".to_string()) + } + } + } + + #[allow(clippy::result_large_err)] + fn resolve_fact_tenant_on(&self, requested: Option<&str>) -> Result { + let resolved = self.resolve_authorized_tenant(requested)?; + let has_selector = requested.map(str::trim).filter(|value| !value.is_empty()).is_some() + || self + .write_tenant_selector + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .is_some(); + if matches!(self.tenants, TenantAllow::Any) && !has_selector { + return Err(ProblemResponse( + ProblemDetails::forbidden("wildcard tenant token must select one tenant") + .with_extensions(serde_json::json!({ "code": "TENANT_SELECTOR_REQUIRED" })), + )); + } + Ok(resolved) } /// Resolve one concrete tenant for an authority-sensitive surface, @@ -1394,6 +1467,7 @@ pub fn passport_bound_context(auth: &Authz, headers: &HeaderMap) -> Result TenantStampMode::On, - "shadow" | "audit" => TenantStampMode::Shadow, - _ => TenantStampMode::Off, - }; - assert_eq!(got, want, "parse {raw:?}"); + std::env::set_var("CORECRUXD_TENANT_WRITE_STAMP", raw); + assert_eq!( + TenantStampMode::from_env_for_auth(AuthMode::JwtHs256).unwrap(), + want, + "parse {raw:?}" + ); } + std::env::set_var("CORECRUXD_TENANT_WRITE_STAMP", "banana"); + assert!(TenantStampMode::from_env_for_auth(AuthMode::JwtHs256).is_err()); + std::env::remove_var("CORECRUXD_TENANT_WRITE_STAMP"); } #[test] - fn read_tenant_lockstep_with_write() { - // Flag OFF → default; single-tenant token → that tenant; multi/wildcard/missing → default. - assert_eq!(resolve_read_tenant_flagged(&only(&["t1"]), TenantStampMode::Off), None); - assert_eq!( - resolve_read_tenant_flagged(&only(&["t1"]), TenantStampMode::On), - Some("t1".to_string()) - ); - assert_eq!( - resolve_read_tenant_flagged(&only(&["t1", "t2"]), TenantStampMode::On), - None + #[serial_test::serial] + fn jwt_fact_tenant_resolution_requires_claim_and_honours_multi_selector() { + let _guard = env_lock().lock().unwrap(); + std::env::remove_var("CORECRUXD_TENANT_WRITE_STAMP"); + + let (auth, mut headers) = hs256_auth_headers(serde_json::json!({ + "scope": "facts:read facts:write", + "tenants": ["t1", "t2"], + })); + headers.insert("x-corecrux-tenant-id", "t2".parse().unwrap()); + let ctx = http_scope_context(&auth, &headers).unwrap(); + assert_eq!(ctx.resolve_read_tenant().unwrap(), Some("t2".to_string())); + assert_eq!(ctx.resolve_write_tenant().unwrap(), Some("t2".to_string())); + + let (auth, headers) = hs256_auth_headers(serde_json::json!({ + "scope": "facts:read facts:write", + })); + let ctx = http_scope_context(&auth, &headers).unwrap(); + let err = ctx.resolve_read_tenant().unwrap_err(); + assert_eq!(err.0.status, 403); + + let (auth, headers) = hs256_auth_headers(serde_json::json!({ + "scope": "facts:read facts:write", + "tenant_id": "*", + })); + let ctx = http_scope_context(&auth, &headers).unwrap(); + assert_eq!(ctx.resolve_read_tenant().unwrap_err().0.status, 403); + assert_eq!(ctx.resolve_write_tenant().unwrap_err().0.status, 403); + + let (auth, mut headers) = hs256_auth_headers(serde_json::json!({ + "scope": "facts:read facts:write", + "tenant_id": "*", + })); + headers.insert("x-corecrux-tenant-id", "t3".parse().unwrap()); + let ctx = http_scope_context(&auth, &headers).unwrap(); + assert_eq!(ctx.resolve_read_tenant().unwrap(), Some("t3".to_string())); + assert_eq!(ctx.resolve_write_tenant().unwrap(), Some("t3".to_string())); + + std::env::remove_var("CORECRUXD_TENANT_WRITE_STAMP"); + } + + #[test] + #[serial_test::serial] + fn tenant_stamp_shadow_simulates_missing_claim_rejection_without_moving_reads_or_writes() { + let _guard = env_lock().lock().unwrap(); + std::env::set_var("CORECRUXD_TENANT_WRITE_STAMP", "shadow"); + let (auth, headers) = hs256_auth_headers(serde_json::json!({ + "scope": "facts:read facts:write", + })); + let ctx = http_scope_context(&auth, &headers).unwrap(); + + assert_eq!(auth.tenant_stamp_mode(), TenantStampMode::Shadow); + assert!( + resolve_implicit_fact_tenant_on(&ctx.tenants, ctx.write_tenant_selector.as_deref()).is_err(), + "shadow must simulate the same missing-claim rejection as On" ); + assert_eq!(ctx.resolve_read_tenant().unwrap(), None); + assert_eq!(ctx.resolve_write_tenant().unwrap(), None); + assert_eq!(ctx.resolve_fact_tenant(None).unwrap(), "default"); + + std::env::remove_var("CORECRUXD_TENANT_WRITE_STAMP"); + } + + #[test] + fn read_tenant_lockstep_with_write() { + // Flag OFF → default; On resolves exactly one authorized tenant and + // rejects ambiguous/wildcard/missing claims. assert_eq!( - resolve_read_tenant_flagged(&TenantAllow::Any, TenantStampMode::On), + resolve_implicit_fact_tenant_flagged(&only(&["t1"]), None, TenantStampMode::Off).unwrap(), None ); assert_eq!( - resolve_read_tenant_flagged(&TenantAllow::Missing, TenantStampMode::On), - None + resolve_implicit_fact_tenant_flagged(&only(&["t1"]), None, TenantStampMode::On).unwrap(), + Some("t1".to_string()) ); + assert!(resolve_implicit_fact_tenant_flagged(&only(&["t1", "t2"]), None, TenantStampMode::On).is_err()); + assert!(resolve_implicit_fact_tenant_flagged(&TenantAllow::Any, None, TenantStampMode::On).is_err()); + assert!(resolve_implicit_fact_tenant_flagged(&TenantAllow::Missing, None, TenantStampMode::On).is_err()); } // ── parse_jwt_algs ──────────────────────────────────────────────────── diff --git a/crates/corecruxd/src/candidate_store.rs b/crates/corecruxd/src/candidate_store.rs index 5143e529..83eea615 100644 --- a/crates/corecruxd/src/candidate_store.rs +++ b/crates/corecruxd/src/candidate_store.rs @@ -154,15 +154,25 @@ pub fn candidate_entity(candidate_id: &str) -> String { /// was written under. The caller supplies `candidate_id` (content-addressed or /// uuid) and the already-built body; the receipt should already be embedded in /// `body.receipt` and `receipt_body_hash` linked as `source_receipt`. +#[cfg(test)] pub fn write_candidate( store: &mut FactStore, body: &MemoryCandidateV1, receipt_body_hash: Option, +) -> Result { + write_candidate_for_tenant(store, "default", body, receipt_body_hash) +} + +pub fn write_candidate_for_tenant( + store: &mut FactStore, + tenant_hash: &str, + body: &MemoryCandidateV1, + receipt_body_hash: Option, ) -> Result { let entity = candidate_entity(&body.candidate_id); let value = serde_json::to_string(body).map_err(|e| format!("serialize candidate: {e}"))?; let req = StoreFact { - tenant_hash: "default".to_string(), + tenant_hash: tenant_hash.to_string(), entity: entity.clone(), key: CANDIDATE_KEY.to_string(), value, @@ -181,9 +191,18 @@ pub fn write_candidate( /// Read back the latest version of every candidate, optionally filtered by /// status. Only non-deleted, non-superseded (`superseded_by == None`) records /// under [`CANDIDATE_PREFIX`] are considered (latest-wins). +#[cfg(test)] pub fn list_candidates(store: &FactStore, status: Option) -> Vec { + list_candidates_for_tenant(store, "default", status) +} + +pub fn list_candidates_for_tenant( + store: &FactStore, + tenant_hash: &str, + status: Option, +) -> Vec { store - .all_facts() + .all_facts_for_tenant(tenant_hash) .filter(|f: &&Fact| { f.entity.starts_with(CANDIDATE_PREFIX) && f.key == CANDIDATE_KEY && !f.deleted && f.superseded_by.is_none() }) @@ -193,10 +212,15 @@ pub fn list_candidates(store: &FactStore, status: Option) -> Ve } /// Latest version of a single candidate by id (None if absent/superseded away). +#[cfg(test)] pub fn get_candidate(store: &FactStore, candidate_id: &str) -> Option { + get_candidate_for_tenant(store, "default", candidate_id) +} + +pub fn get_candidate_for_tenant(store: &FactStore, tenant_hash: &str, candidate_id: &str) -> Option { let entity = candidate_entity(candidate_id); store - .all_facts() + .all_facts_for_tenant(tenant_hash) .find(|f| f.entity == entity && f.key == CANDIDATE_KEY && !f.deleted && f.superseded_by.is_none()) .and_then(|f| serde_json::from_str::(&f.value).ok()) } @@ -251,13 +275,24 @@ impl std::error::Error for ReviewError {} /// below-threshold) candidate is refused — it stays a review-only candidate. /// An `Explicit` promotion is always honoured (a human/agent decided). Callable /// from `candidate` or (re-promote) `rejected` state, but not `promoted`. +#[cfg(test)] pub fn promote( store: &mut FactStore, candidate_id: &str, mode: PromotionMode, reviewed_at: &str, ) -> Result { - let cand = get_candidate(store, candidate_id).ok_or(ReviewError::NotFound)?; + promote_for_tenant(store, "default", candidate_id, mode, reviewed_at) +} + +pub fn promote_for_tenant( + store: &mut FactStore, + tenant_hash: &str, + candidate_id: &str, + mode: PromotionMode, + reviewed_at: &str, +) -> Result { + let cand = get_candidate_for_tenant(store, tenant_hash, candidate_id).ok_or(ReviewError::NotFound)?; if cand.status == CandidateStatus::Promoted { return Err(ReviewError::AlreadyPromoted); } @@ -291,7 +326,7 @@ pub fn promote( PromotionMode::Auto { .. } => "auto-capture:auto-promoted".to_string(), }; let real = StoreFact { - tenant_hash: "default".to_string(), + tenant_hash: tenant_hash.to_string(), entity: cand.proposed_entity.clone(), key: cand.proposed_key.clone(), value: cand.proposed_value.clone(), @@ -311,15 +346,26 @@ pub fn promote( updated.promoted_fact_id = Some(promoted.fact_id.clone()); updated.reject_reason = None; updated.created_at = reviewed_at.to_string(); - write_candidate(store, &updated, None).map_err(ReviewError::Store)?; + write_candidate_for_tenant(store, tenant_hash, &updated, None).map_err(ReviewError::Store)?; Ok(promoted.fact_id) } /// Reject a candidate: record it as `rejected` with a reason. Reversible — a /// rejected candidate can later be re-promoted. Refuses a `promoted` candidate /// (retract the promoted fact directly via supersession instead). +#[cfg(test)] pub fn reject(store: &mut FactStore, candidate_id: &str, reason: &str, reviewed_at: &str) -> Result<(), ReviewError> { - let cand = get_candidate(store, candidate_id).ok_or(ReviewError::NotFound)?; + reject_for_tenant(store, "default", candidate_id, reason, reviewed_at) +} + +pub fn reject_for_tenant( + store: &mut FactStore, + tenant_hash: &str, + candidate_id: &str, + reason: &str, + reviewed_at: &str, +) -> Result<(), ReviewError> { + let cand = get_candidate_for_tenant(store, tenant_hash, candidate_id).ok_or(ReviewError::NotFound)?; if cand.status == CandidateStatus::Promoted { return Err(ReviewError::AlreadyPromoted); } @@ -328,7 +374,7 @@ pub fn reject(store: &mut FactStore, candidate_id: &str, reason: &str, reviewed_ updated.reject_reason = Some(reason.to_string()); updated.promoted_fact_id = None; updated.created_at = reviewed_at.to_string(); - write_candidate(store, &updated, None).map_err(ReviewError::Store)?; + write_candidate_for_tenant(store, tenant_hash, &updated, None).map_err(ReviewError::Store)?; Ok(()) } @@ -350,9 +396,14 @@ pub fn route_near_duplicates(store: &mut FactStore, now_rfc3339: &str) -> usize let Some(fact) = store.get(&d.fact_id) else { continue; }; - (fact.entity.clone(), fact.key.clone(), fact.value.clone()) + ( + fact.tenant_hash.clone(), + fact.entity.clone(), + fact.key.clone(), + fact.value.clone(), + ) }; - let (entity, key, value) = proposal; + let (tenant_hash, entity, key, value) = proposal; let body = MemoryCandidateV1::new_candidate( format!("neardup-{}-{}", d.fact_id, d.similar_to), entity, @@ -372,7 +423,7 @@ pub fn route_near_duplicates(store: &mut FactStore, now_rfc3339: &str) -> usize None, // receipt minted by the review route layer, not here now_rfc3339.to_string(), ); - match write_candidate(store, &body, None) { + match write_candidate_for_tenant(store, &tenant_hash, &body, None) { Ok(_) => written += 1, Err(err) => tracing::warn!(err, fact_id = %d.fact_id, "near-duplicate candidate write failed"), } @@ -450,6 +501,47 @@ mod tests { assert_eq!(list_candidates(&store, Some(CandidateStatus::Candidate)).len(), 1); } + #[test] + fn candidates_and_promotions_are_isolated_by_tenant() { + let mut store = FactStore::new(); + write_candidate_for_tenant(&mut store, "tenant-a", &sample_body("same-id"), None).unwrap(); + write_candidate_for_tenant(&mut store, "tenant-b", &sample_body("same-id"), None).unwrap(); + + let promoted = promote_for_tenant( + &mut store, + "tenant-a", + "same-id", + PromotionMode::Explicit { + reviewer: "reviewer-a".to_string(), + }, + "2026-07-30T00:00:00Z", + ) + .unwrap(); + assert!(!promoted.is_empty()); + assert_eq!( + get_candidate_for_tenant(&store, "tenant-a", "same-id").unwrap().status, + CandidateStatus::Promoted + ); + assert_eq!( + get_candidate_for_tenant(&store, "tenant-b", "same-id").unwrap().status, + CandidateStatus::Candidate + ); + assert_eq!( + store + .all_facts_for_tenant("tenant-a") + .filter(|fact| fact.entity == "person:user" && fact.key == "owns_cat_count") + .count(), + 1 + ); + assert_eq!( + store + .all_facts_for_tenant("tenant-b") + .filter(|fact| fact.entity == "person:user" && fact.key == "owns_cat_count") + .count(), + 0 + ); + } + fn scored_body(id: &str, score: f32) -> MemoryCandidateV1 { let mut b = sample_body(id); b.verifier_score = Some(score); @@ -609,7 +701,7 @@ mod tests { store.set_semantic_dedup(0.8); let mk = |entity: &str, key: &str, value: &str| StoreFact { - tenant_hash: "default".to_string(), + tenant_hash: "tenant-a".to_string(), entity: entity.to_string(), key: key.to_string(), value: value.to_string(), @@ -629,8 +721,12 @@ mod tests { assert_eq!(written, 1, "one review candidate written"); assert!(store.near_duplicates().is_empty(), "flags drained after routing"); - let candidates = list_candidates(&store, Some(CandidateStatus::Candidate)); + let candidates = list_candidates_for_tenant(&store, "tenant-a", Some(CandidateStatus::Candidate)); assert_eq!(candidates.len(), 1, "candidate visible in the review queue"); + assert!( + list_candidates(&store, Some(CandidateStatus::Candidate)).is_empty(), + "near-duplicate routing must not fall back to the shared default tenant" + ); let c = &candidates[0]; assert_eq!(c.rule, "semantic_dedup"); assert!(c.verifier_score.is_none(), "unscored ⇒ review-only (fail-closed)"); diff --git a/crates/corecruxd/src/http/console.rs b/crates/corecruxd/src/http/console.rs index f2669f4b..095e405c 100644 --- a/crates/corecruxd/src/http/console.rs +++ b/crates/corecruxd/src/http/console.rs @@ -2961,6 +2961,15 @@ pub(super) async fn get_console_facts( if let Err(problem) = require_console_read(&state, &headers) { return problem.into_response(); } + let ctx = match crate::auth::http_scope_context(&state.auth, &headers) { + Ok(ctx) => ctx, + Err(problem) => return problem.into_response(), + }; + let raw_admin = super::facts::raw_admin_read(&ctx); + let tenant_hash = match super::facts::tenant_hash_for_read_context(&ctx) { + Ok(tenant) => tenant, + Err(response) => return response, + }; let q = query .q .as_ref() @@ -2979,7 +2988,7 @@ pub(super) async fn get_console_facts( let store = state.fact_store.read().await; let result = store.query(&corecrux_memory::fact_store::FactQuery { min_effective_confidence: None, - tenant_hash: None, + tenant_hash: (!raw_admin).then_some(tenant_hash.clone()), query: q.clone(), entity: None, entity_prefix: None, @@ -3004,7 +3013,11 @@ pub(super) async fn get_console_facts( ( StatusCode::OK, Json(serde_json::json!({ - "count": store.count(), + "count": if raw_admin { + store.count() + } else { + store.all_facts_for_tenant(&tenant_hash).count() + }, "visible_count": visible_facts.len(), "query": q, "top_k": top_k, @@ -3051,6 +3064,10 @@ pub(super) async fn post_console_fact_add( .into_response(); } + let tenant_hash = match super::facts::tenant_hash_for_write_context(&ctx) { + Ok(tenant) => tenant, + Err(response) => return response, + }; let mut store = state.fact_store.write().await; if let Err(e) = crux_mcp::category_enforce::check_passport_can_write_entity(&store, ctx.passport_id.as_deref(), entity) @@ -3058,7 +3075,7 @@ pub(super) async fn post_console_fact_add( return problem_response(StatusCode::FORBIDDEN, e.to_string()); } let mut sf = corecrux_memory::fact_store::StoreFact { - tenant_hash: "default".to_string(), + tenant_hash, entity: entity.to_string(), key: key.to_string(), value: value.to_string(), diff --git a/crates/corecruxd/src/http/context_surface.rs b/crates/corecruxd/src/http/context_surface.rs index bdf80776..c2178051 100644 --- a/crates/corecruxd/src/http/context_surface.rs +++ b/crates/corecruxd/src/http/context_surface.rs @@ -109,6 +109,7 @@ fn fact_input(fact: corecrux_memory::fact_store::Fact, addressed: bool) -> cb::F async fn gather_facts( state: &AppState, ctx: &crate::auth::HttpScopeContext, + tenant_hash: &str, req: &ContextRequest, ) -> Result, corecrux_memory::embeddings::EmbeddingError> { let store = state.fact_store.read().await; @@ -127,7 +128,7 @@ async fn gather_facts( top_k: RECALL_TOP_K, token_budget: None, }; - for fact in super::facts::query_visible_http_facts(&store, &q, ctx)? { + for fact in super::facts::query_visible_http_facts(&store, &q, ctx, tenant_hash)? { if fact.superseded_by.is_some() || !seen.insert(fact.fact_id.clone()) { continue; } @@ -162,7 +163,7 @@ async fn gather_facts( }, token_budget: None, }; - for fact in super::facts::query_visible_http_facts(&store, &q, ctx)? { + for fact in super::facts::query_visible_http_facts(&store, &q, ctx, tenant_hash)? { if fact.superseded_by.is_some() || !seen.insert(fact.fact_id.clone()) { continue; } @@ -205,11 +206,13 @@ async fn gather_session_state( /// Build the structural cache key for one assembly request /// (`corecrux_projections::assembly_cache::AssemblyKey`). /// -/// `facts_chain_head` is a digest over every mutation-relevant fact field -/// (id, version, supersession, re-verify anchor, deletion, horizon) — any -/// fact write moves it, which IS the invalidation mechanism (no bus, no -/// staleness window). Folded into the same digest, because they also -/// change the assembled bundle without moving the fact chain: +/// `facts_chain_head` is a domain-separated digest over the concrete tenant +/// and every assembler-relevant fact field (identity, content, actor, +/// confidence, version, supersession, timestamps, privacy, deletion, horizon, +/// and token estimate). Any relevant fact write moves it, which IS the +/// invalidation mechanism (no bus, no staleness window). Folded into the same +/// digest, because they also change the assembled bundle without moving the +/// fact chain: /// /// - the requested session's state (the `session_state` section), /// - the request identity (`entity` / `query` / `token_budget` — the @@ -218,24 +221,37 @@ async fn gather_session_state( /// - the current UTC hour (freshness *classes* may flip at horizon /// crossings without a write; an entry therefore serves at most one /// hour of class lag). +fn hash_cache_field(hasher: &mut blake3::Hasher, bytes: &[u8]) { + hasher.update(&(bytes.len() as u64).to_le_bytes()); + hasher.update(bytes); +} + async fn assembly_cache_key( state: &AppState, ctx: &crate::auth::HttpScopeContext, + tenant_hash: &str, req: &ContextRequest, principal: &str, now_ms: i64, ) -> AssemblyKey { let mut per_fact: Vec<[u8; 32]> = { let store = state.fact_store.read().await; - let tenant_hash = super::facts::tenant_hash_for_read_context(ctx); store - .all_facts_for_tenant(&tenant_hash) + .all_facts_for_tenant(tenant_hash) .map(|f| { let mut h = blake3::Hasher::new(); - h.update(f.fact_id.as_bytes()); + h.update(b"crux.context.assembly-cache.fact.v2\0"); + hash_cache_field(&mut h, f.fact_id.as_bytes()); + hash_cache_field(&mut h, f.entity.as_bytes()); + hash_cache_field(&mut h, f.key.as_bytes()); + hash_cache_field(&mut h, f.value.as_bytes()); + hash_cache_field(&mut h, f.actor.as_deref().unwrap_or("").as_bytes()); h.update(&f.version.to_le_bytes()); h.update(&[u8::from(f.deleted), u8::from(f.private)]); - h.update(f.superseded_by.as_deref().unwrap_or("").as_bytes()); + h.update(&f.confidence.to_bits().to_le_bytes()); + h.update(&f.tokens.to_le_bytes()); + h.update(&f.stored_at.timestamp_millis().to_le_bytes()); + hash_cache_field(&mut h, f.superseded_by.as_deref().unwrap_or("").as_bytes()); h.update( &f.reverified_at .map(|t| t.timestamp_millis()) @@ -251,6 +267,8 @@ async fn assembly_cache_key( per_fact.sort_unstable(); let mut head = blake3::Hasher::new(); + head.update(b"crux.context.assembly-cache.head.v2\0"); + hash_cache_field(&mut head, tenant_hash.as_bytes()); for h in &per_fact { head.update(h); } @@ -320,10 +338,11 @@ fn mint_bundle_receipt( "budget": bundle.budget, "section_counts": section_counts, "fact_ids": bundle_fact_ids(bundle), - "session_id": session_id, + "session_id": session_id, + "tenant_id": bundle.tenant_id, }), }; - let scoped = format!("context::{principal}"); + let scoped = format!("context::{}::{principal}", bundle.tenant_id); append_one(state, &scoped, principal, body, None) .map(|(resp, _tip)| resp.observation_id) .map_err(|(_, msg)| msg) @@ -389,16 +408,23 @@ async fn handle_context(state: AppState, headers: HeaderMap, req: ContextRequest Ok(ctx) => ctx, Err(resp) => return resp, }; + let tenant_hash = match super::facts::tenant_hash_for_read_context(&ctx) { + Ok(tenant) => tenant, + Err(response) => return response, + }; - // Attribution: caller passport when bound, else the operator tag - // (anonymous writes are operator-tagged, not silently allowed — - // audit-hygiene profile). - let principal = ctx.passport_id.clone().unwrap_or_else(|| "operator".to_string()); + // The assembler must receive the exact passport id because private-owner + // visibility compares against it. Cache and receipt attribution need a + // collision-free namespace: a verified passport literally named + // `operator` must never alias an unbound local operator. + let bundle_actor = ctx.passport_id.clone().unwrap_or_else(|| "operator".to_string()); + let attributed_principal = ctx + .passport_id + .as_deref() + .map_or_else(|| "operator:unbound".to_string(), |id| format!("passport:{id}")); let bundle_req = cb::BundleRequest { - actor: principal.clone(), - // Local daemon: single-tenant store; tenant identity rides the - // passport scoping already enforced at fetch time. - tenant_id: "local".to_string(), + actor: bundle_actor, + tenant_id: tenant_hash.clone(), session_id: req.session_id.clone(), requested_budget: req.token_budget.unwrap_or(DEFAULT_REQUESTED_BUDGET), ceiling: FREE_TIER_CEILING, @@ -411,7 +437,17 @@ async fn handle_context(state: AppState, headers: HeaderMap, req: ContextRequest // before). A hit skips gather + assemble entirely; the receipt below // is still minted per serve (every serve is receipted). let cache_key = if state.assembly_cache.is_some() { - Some(assembly_cache_key(&state, &ctx, &req, &principal, bundle_req.now_ms).await) + Some( + assembly_cache_key( + &state, + &ctx, + &tenant_hash, + &req, + &attributed_principal, + bundle_req.now_ms, + ) + .await, + ) } else { None }; @@ -425,7 +461,7 @@ async fn handle_context(state: AppState, headers: HeaderMap, req: ContextRequest let bundle = if let Some(bundle) = cached { bundle } else { - let facts = match gather_facts(&state, &ctx, &req).await { + let facts = match gather_facts(&state, &ctx, &tenant_hash, &req).await { Ok(facts) => facts, Err(err) => { tracing::warn!(error = %err, "context-fact-embedding-delegation-failed"); @@ -449,15 +485,16 @@ async fn handle_context(state: AppState, headers: HeaderMap, req: ContextRequest }; // Receipt the assembly (spec §4 rule 7). - let (receipt_ref, receipt_error) = match mint_bundle_receipt(&state, &principal, req.session_id.as_deref(), &bundle) - { - Ok(id) => (Some(id), None), - Err(e) => (None, Some(e)), - }; + let (receipt_ref, receipt_error) = + match mint_bundle_receipt(&state, &attributed_principal, req.session_id.as_deref(), &bundle) { + Ok(id) => (Some(id), None), + Err(e) => (None, Some(e)), + }; let mut bundle_json = json!({ "bundle_version": bundle.stable.bundle_version, "passport": ctx.passport_id, + "tenant_id": bundle.tenant_id, "session_id": req.session_id, "assembled_at": chrono::Utc::now().to_rfc3339(), "budget": bundle.budget, @@ -548,8 +585,18 @@ mod tests { } fn new_fact(entity: &str, key: &str, value: &str, private: bool) -> corecrux_memory::fact_store::StoreFact { + new_fact_for_tenant("default", entity, key, value, private) + } + + fn new_fact_for_tenant( + tenant_hash: &str, + entity: &str, + key: &str, + value: &str, + private: bool, + ) -> corecrux_memory::fact_store::StoreFact { corecrux_memory::fact_store::StoreFact { - tenant_hash: "default".to_string(), + tenant_hash: tenant_hash.to_string(), entity: entity.to_string(), key: key.to_string(), value: value.to_string(), @@ -958,6 +1005,220 @@ mod tests { assert!(b["receipt_ref"].as_str().is_some() || b.get("receipt_error").is_some()); } + #[tokio::test] + async fn assembly_cache_and_bundle_metadata_are_tenant_bound_m16() { + use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; + + const SECRET: &str = "0123456789abcdef0123456789abcdef"; + let mut state = cached_state(); + state.auth = crate::auth::Authz::test_hs256(SECRET.as_bytes(), "corecrux-test", "corecrux"); + let headers_for = |tenant: &str| { + let claims = json!({ + "exp": chrono::Utc::now().timestamp() + 3600, + "iss": "corecrux-test", + "aud": "corecrux", + "scope": "query:read", + "passport_id": "shared-principal", + "tenant_id": tenant, + }); + let token = encode( + &Header::new(Algorithm::HS256), + &claims, + &EncodingKey::from_secret(SECRET.as_bytes()), + ) + .expect("jwt"); + let mut headers = HeaderMap::new(); + headers.insert( + axum::http::header::AUTHORIZATION, + format!("Bearer {token}").parse().unwrap(), + ); + headers + }; + + let request = || QueryExtract(req(None, None, Some(2000))); + let a = get_context(StateExtract(state.clone()), request(), headers_for("tenant-a")) + .await + .into_response(); + assert_eq!(a.status(), StatusCode::OK); + assert_eq!(body_json(a).await["tenant_id"], "tenant-a"); + + let b = get_context(StateExtract(state.clone()), request(), headers_for("tenant-b")) + .await + .into_response(); + assert_eq!(b.status(), StatusCode::OK); + assert_eq!(body_json(b).await["tenant_id"], "tenant-b"); + + let stats = cache_stats(&state); + assert_eq!(stats.hits, 0, "same-principal tenants must not share a cache entry"); + assert_eq!(stats.misses, 2); + } + + #[tokio::test] + async fn wildcard_admin_context_cache_is_scoped_to_selected_tenant_m16() { + use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; + + const SECRET: &str = "0123456789abcdef0123456789abcdef"; + let mut state = cached_state(); + state.auth = crate::auth::Authz::test_hs256(SECRET.as_bytes(), "corecrux-test", "corecrux"); + { + let mut store = state.fact_store.write().await; + store + .try_store(new_fact_for_tenant( + "tenant-a", + "tenant-a-only", + "secret", + "visible only in tenant A", + false, + )) + .expect("tenant A fact"); + store + .try_store(new_fact_for_tenant( + "tenant-b", + "tenant-b-only", + "secret", + "must never enter tenant A bundle", + false, + )) + .expect("tenant B fact"); + } + + let headers_for = |tenant_claim: &str, scope: &str, selector: Option<&str>| { + let claims = json!({ + "exp": chrono::Utc::now().timestamp() + 3600, + "iss": "corecrux-test", + "aud": "corecrux", + "scope": scope, + "tenant_id": tenant_claim, + }); + let token = encode( + &Header::new(Algorithm::HS256), + &claims, + &EncodingKey::from_secret(SECRET.as_bytes()), + ) + .expect("jwt"); + let mut headers = HeaderMap::new(); + headers.insert( + axum::http::header::AUTHORIZATION, + format!("Bearer {token}").parse().unwrap(), + ); + if let Some(selector) = selector { + headers.insert("x-corecrux-tenant-id", selector.parse().unwrap()); + } + headers + }; + + // Both calls have the same anonymous principal (`operator`), concrete + // tenant, and request shape. Before M16 the raw-admin first call cached + // a global bundle and the ordinary second call received it. + let request = || QueryExtract(req(None, None, Some(2000))); + let admin = get_context( + StateExtract(state.clone()), + request(), + headers_for("*", "admin:read", Some("tenant-a")), + ) + .await + .into_response(); + assert_eq!(admin.status(), StatusCode::OK); + let admin = body_json(admin).await; + assert_eq!(admin["tenant_id"], "tenant-a"); + let admin_text = serde_json::to_string(&admin["sections"]).expect("sections"); + assert!(admin_text.contains("tenant-a-only")); + assert!(!admin_text.contains("tenant-b-only")); + + let ordinary = get_context( + StateExtract(state.clone()), + request(), + headers_for("tenant-a", "query:read", None), + ) + .await + .into_response(); + assert_eq!(ordinary.status(), StatusCode::OK); + let ordinary = body_json(ordinary).await; + assert_eq!(ordinary["tenant_id"], "tenant-a"); + let ordinary_text = serde_json::to_string(&ordinary["sections"]).expect("sections"); + assert!(ordinary_text.contains("tenant-a-only")); + assert!(!ordinary_text.contains("tenant-b-only")); + + let stats = cache_stats(&state); + assert_eq!( + stats.misses, 1, + "admin and ordinary callers share only a tenant-scoped entry" + ); + assert_eq!(stats.hits, 1); + } + + #[tokio::test] + async fn passport_named_operator_cannot_poison_unbound_context_cache_m16() { + use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; + + const SECRET: &str = "0123456789abcdef0123456789abcdef"; + let mut state = cached_state(); + state.auth = crate::auth::Authz::test_hs256(SECRET.as_bytes(), "corecrux-test", "corecrux"); + { + let mut store = state.fact_store.write().await; + store + .try_store(new_fact_for_tenant( + "tenant-a", + "__agent::operator::private-note", + "secret", + "passport operator private value", + true, + )) + .expect("private operator fact"); + } + + let headers_for = |passport_id: Option<&str>| { + let mut claims = json!({ + "exp": chrono::Utc::now().timestamp() + 3600, + "iss": "corecrux-test", + "aud": "corecrux", + "scope": "query:read", + "tenant_id": "tenant-a", + }); + if let Some(passport_id) = passport_id { + claims["passport_id"] = Value::String(passport_id.to_string()); + } + let token = encode( + &Header::new(Algorithm::HS256), + &claims, + &EncodingKey::from_secret(SECRET.as_bytes()), + ) + .expect("jwt"); + let mut headers = HeaderMap::new(); + headers.insert( + axum::http::header::AUTHORIZATION, + format!("Bearer {token}").parse().unwrap(), + ); + headers + }; + + let request = || QueryExtract(req(None, Some("private value"), Some(2000))); + let owner = get_context(StateExtract(state.clone()), request(), headers_for(Some("operator"))) + .await + .into_response(); + assert_eq!(owner.status(), StatusCode::OK); + let owner = body_json(owner).await; + assert!(serde_json::to_string(&owner["sections"]) + .expect("sections") + .contains("passport operator private value")); + + let unbound = get_context(StateExtract(state.clone()), request(), headers_for(None)) + .await + .into_response(); + assert_eq!(unbound.status(), StatusCode::OK); + let unbound = body_json(unbound).await; + assert!( + !serde_json::to_string(&unbound["sections"]) + .expect("sections") + .contains("passport operator private value"), + "an unbound caller must not receive the passport owner's cached private fact" + ); + + let stats = cache_stats(&state); + assert_eq!(stats.hits, 0, "bound and unbound identities must not alias"); + assert_eq!(stats.misses, 2); + } + #[tokio::test] async fn fact_write_moves_the_chain_head_and_invalidates() { let state = cached_state(); diff --git a/crates/corecruxd/src/http/engrams.rs b/crates/corecruxd/src/http/engrams.rs index 75abd4f2..5e005123 100644 --- a/crates/corecruxd/src/http/engrams.rs +++ b/crates/corecruxd/src/http/engrams.rs @@ -17,9 +17,9 @@ use serde::Deserialize; use serde_json::json; use corecrux_memory::engrams::{ - build_engram_manifest, compute_engram_set_hash, current_session_procedure, hash_json, local_catalog_with_overlays, - model_id_to_capability_class, prompt_hash, resolve_from_catalog, validate_local_engram, LocalEngram, - ENGRAM_ENTITY_PREFIX, SESSION_PROCEDURE_SCHEMA, + build_engram_manifest, compute_engram_set_hash, current_session_procedure, hash_json, + local_catalog_with_overlays_for_tenant, model_id_to_capability_class, prompt_hash, resolve_from_catalog, + validate_local_engram, LocalEngram, ENGRAM_ENTITY_PREFIX, SESSION_PROCEDURE_SCHEMA, }; use super::{ @@ -110,8 +110,16 @@ pub(super) async fn list_engrams( if let Err(problem) = require_http_any_scope(&state.auth, &headers, &["query:read", "admin:read"]) { return problem.into_response(); } + let ctx = match http_scope_context(&state.auth, &headers) { + Ok(ctx) => ctx, + Err(problem) => return problem.into_response(), + }; + let tenant_hash = match super::facts::tenant_hash_for_read_context(&ctx) { + Ok(tenant) => tenant, + Err(response) => return response, + }; let store = state.fact_store.read().await; - let mut engrams = local_catalog_with_overlays(&store); + let mut engrams = local_catalog_with_overlays_for_tenant(&store, &tenant_hash); drop(store); if let Some(bucket) = query.intent_bucket.as_deref().filter(|s| !s.trim().is_empty()) { engrams.retain(|e| e.intent_bucket == bucket); @@ -267,10 +275,28 @@ pub(super) async fn memory_session_init( { return problem.into_response(); } - let tenant_id = body - .tenant_id - .or(body.tenant_id_camel) - .unwrap_or_else(|| "local".to_string()); + let requested_tenant = body.tenant_id.as_deref().or(body.tenant_id_camel.as_deref()); + let ctx = match http_scope_context(&state.auth, &headers) { + Ok(ctx) => ctx, + Err(problem) => return problem.into_response(), + }; + let tenant_hash = match requested_tenant { + Some(tenant) => match super::facts::tenant_hash_for_requested_context(&ctx, tenant) { + Ok(tenant) => tenant, + Err(response) => return response, + }, + None => match super::facts::tenant_hash_for_read_context(&ctx) { + Ok(tenant) => tenant, + Err(response) => return response, + }, + }; + let tenant_id = body.tenant_id.or(body.tenant_id_camel).unwrap_or_else(|| { + if tenant_hash == "default" { + "local".to_string() + } else { + tenant_hash.clone() + } + }); let agent_id = body .agent_id .or(body.agent_id_camel) @@ -278,7 +304,7 @@ pub(super) async fn memory_session_init( let model_id = body.model_id.or(body.model_id_camel); let capability_class = model_id_to_capability_class(model_id.as_deref()); let store = state.fact_store.read().await; - let engrams = local_catalog_with_overlays(&store); + let engrams = local_catalog_with_overlays_for_tenant(&store, &tenant_hash); drop(store); let session_procedure = current_session_procedure(); let manifest = build_engram_manifest(&engrams, &tenant_id, &capability_class); @@ -317,10 +343,28 @@ pub(super) async fn resolve_engrams( "names must contain 1..=20 name@version entries", ); } - let tenant_id = body - .tenant_id - .or(body.tenant_id_camel) - .unwrap_or_else(|| "local".to_string()); + let requested_tenant = body.tenant_id.as_deref().or(body.tenant_id_camel.as_deref()); + let ctx = match http_scope_context(&state.auth, &headers) { + Ok(ctx) => ctx, + Err(problem) => return problem.into_response(), + }; + let tenant_hash = match requested_tenant { + Some(tenant) => match super::facts::tenant_hash_for_requested_context(&ctx, tenant) { + Ok(tenant) => tenant, + Err(response) => return response, + }, + None => match super::facts::tenant_hash_for_read_context(&ctx) { + Ok(tenant) => tenant, + Err(response) => return response, + }, + }; + let tenant_id = body.tenant_id.or(body.tenant_id_camel).unwrap_or_else(|| { + if tenant_hash == "default" { + "local".to_string() + } else { + tenant_hash.clone() + } + }); let agent_id = body .agent_id .or(body.agent_id_camel) @@ -328,7 +372,7 @@ pub(super) async fn resolve_engrams( let model_id = body.model_id.or(body.model_id_camel); let capability_class = model_id_to_capability_class(model_id.as_deref()); let store = state.fact_store.read().await; - let engrams = local_catalog_with_overlays(&store); + let engrams = local_catalog_with_overlays_for_tenant(&store, &tenant_hash); drop(store); let manifest = build_engram_manifest(&engrams, &tenant_id, &capability_class); let manifest_status = match body.manifest_hash.as_deref() { diff --git a/crates/corecruxd/src/http/facts.rs b/crates/corecruxd/src/http/facts.rs index 175324f8..2c379ecf 100644 --- a/crates/corecruxd/src/http/facts.rs +++ b/crates/corecruxd/src/http/facts.rs @@ -152,7 +152,7 @@ pub(super) fn require_session_write_ctx( http_scope_context(&state.auth, headers).map_err(IntoResponse::into_response) } -fn raw_admin_read(ctx: &crate::auth::HttpScopeContext) -> bool { +pub(super) fn raw_admin_read(ctx: &crate::auth::HttpScopeContext) -> bool { ctx.passport_id.is_none() && ctx.has_scope("admin:read") && ctx.has_global_tenant_authority() } @@ -165,8 +165,9 @@ fn raw_admin_write(ctx: &crate::auth::HttpScopeContext) -> bool { /// The tenant is derived from the bearer token's tenant claim (`HttpScopeContext` /// carries `tenants`, the same authority the query path authorizes against) plus an /// optional `x-corecrux-tenant-id` selector for multi-tenant tokens — never from the -/// client-supplied fact body. Gated by `CORECRUXD_TENANT_WRITE_STAMP` (default OFF → -/// `default`, byte-identical to pre-M1). `Err` on an unauthorized/ambiguous selector. +/// client-supplied fact body. JWT auth defaults to tenant stamping; an explicit +/// `off` or `shadow` posture retains the historical shared `default` tenant. +/// `Err` on an unauthorized/ambiguous selector. /// /// Sibling surfaces (kept consistent with this resolver, verified on this base): /// - HTTP fact reads (`get_for_tenant` / `all_facts_for_tenant`, and `export_facts` @@ -183,9 +184,20 @@ pub(super) fn tenant_hash_for_write_context(ctx: &crate::auth::HttpScopeContext) } } -pub(super) fn tenant_hash_for_read_context(ctx: &crate::auth::HttpScopeContext) -> String { +#[allow(clippy::result_large_err)] +pub(super) fn tenant_hash_for_read_context(ctx: &crate::auth::HttpScopeContext) -> Result { ctx.resolve_read_tenant() - .unwrap_or_else(corecrux_memory::fact_store::default_tenant_hash) + .map(|tenant| tenant.unwrap_or_else(corecrux_memory::fact_store::default_tenant_hash)) + .map_err(IntoResponse::into_response) +} + +#[allow(clippy::result_large_err)] +pub(super) fn tenant_hash_for_requested_context( + ctx: &crate::auth::HttpScopeContext, + requested: &str, +) -> Result { + ctx.resolve_fact_tenant(Some(requested)) + .map_err(IntoResponse::into_response) } fn render_fact_for_http( @@ -293,9 +305,15 @@ pub(super) fn query_visible_http_facts( store: &corecrux_memory::FactStore, q: &corecrux_memory::fact_store::FactQuery, ctx: &crate::auth::HttpScopeContext, + tenant_hash: &str, ) -> Result, corecrux_memory::embeddings::EmbeddingError> { - // Internal callers (context_surface) never set a confidence floor; drop the count. - Ok(query_visible_http_facts_as_of(store, q, ctx, None)?.0) + // `/v1/context` is always a tenant-attributed bundle. Even a raw/global + // administrator must select one concrete tenant before assembly; allowing + // the global branch here would let an admin-populated cache entry be served + // to an ordinary caller that shares the same anonymous principal. + // + // Internal callers never set a confidence floor; drop the count. + Ok(query_visible_http_facts_as_of_inner(store, q, ctx, tenant_hash, None, false)?.0) } /// P2 confidence floor: drop facts whose recall-time EFFECTIVE confidence @@ -359,9 +377,21 @@ pub(super) fn query_visible_http_facts_as_of( store: &corecrux_memory::FactStore, q: &corecrux_memory::fact_store::FactQuery, ctx: &crate::auth::HttpScopeContext, + tenant_hash: &str, + as_of: Option>, +) -> Result<(Vec, usize), corecrux_memory::embeddings::EmbeddingError> { + query_visible_http_facts_as_of_inner(store, q, ctx, tenant_hash, as_of, true) +} + +fn query_visible_http_facts_as_of_inner( + store: &corecrux_memory::FactStore, + q: &corecrux_memory::fact_store::FactQuery, + ctx: &crate::auth::HttpScopeContext, + tenant_hash: &str, as_of: Option>, + allow_raw_admin_global: bool, ) -> Result<(Vec, usize), corecrux_memory::embeddings::EmbeddingError> { - if raw_admin_read(ctx) { + if allow_raw_admin_global && raw_admin_read(ctx) { // Run the floor filter/count over the FULL matched+ranked set, THEN // apply budget/top_k — so a below-floor row never consumes the window // and the count reflects the whole matched set, matching the scoped @@ -381,9 +411,8 @@ pub(super) fn query_visible_http_facts_as_of( } let agent_name = ctx.passport_id.as_deref(); - let tenant_hash = tenant_hash_for_read_context(ctx); let mut results: Vec<&corecrux_memory::fact_store::Fact> = store - .all_facts_for_tenant(&tenant_hash) + .all_facts_for_tenant(tenant_hash) .filter(|fact| !fact.deleted) .filter(|fact| as_of.is_none_or(|instant| fact.valid_at(instant))) .filter(|fact| crux_mcp::scope::fact_visible_to_agent(fact, agent_name)) @@ -557,8 +586,11 @@ pub(super) async fn get_fact( Ok(ctx) => ctx, Err(response) => return response, }; + let tenant_hash = match tenant_hash_for_read_context(&ctx) { + Ok(tenant) => tenant, + Err(response) => return response, + }; let store = state.fact_store.read().await; - let tenant_hash = tenant_hash_for_read_context(&ctx); let fact = if raw_admin_read(&ctx) { store.get(&fact_id) } else { @@ -592,8 +624,11 @@ pub(super) async fn delete_fact( Ok(ctx) => ctx, Err(response) => return response, }; + let tenant_hash = match tenant_hash_for_read_context(&ctx) { + Ok(tenant) => tenant, + Err(response) => return response, + }; let mut store = state.fact_store.write().await; - let tenant_hash = tenant_hash_for_read_context(&ctx); let fact = if raw_admin_write(&ctx) { store.get(&fact_id) } else { @@ -667,8 +702,11 @@ pub(super) async fn get_facts_by_entity( Ok(ctx) => ctx, Err(response) => return response, }; + let tenant_hash = match tenant_hash_for_read_context(&ctx) { + Ok(tenant) => tenant, + Err(response) => return response, + }; let store = state.fact_store.read().await; - let tenant_hash = tenant_hash_for_read_context(&ctx); let facts: Vec<_> = if raw_admin_read(&ctx) { store.get_by_entity(&entity).into_iter().cloned().collect() } else { @@ -737,8 +775,13 @@ pub(super) async fn query_facts( }, None => None, }; + let tenant_hash = match tenant_hash_for_read_context(&ctx) { + Ok(tenant) => tenant, + Err(response) => return response, + }; let store = state.fact_store.read().await; - let (facts, filtered_below_threshold) = match query_visible_http_facts_as_of(&store, &q, &ctx, as_of) { + let (facts, filtered_below_threshold) = match query_visible_http_facts_as_of(&store, &q, &ctx, &tenant_hash, as_of) + { Ok(result) => result, Err(err) => { tracing::warn!(error = %err, "fact-query-embedding-delegation-failed"); @@ -777,9 +820,9 @@ pub(super) async fn post_aggregate( Ok(ctx) => ctx, Err(response) => return response, }; - let tenant_hash = match ctx.resolve_authorized_tenant(None) { + let tenant_hash = match tenant_hash_for_read_context(&ctx) { Ok(tenant_hash) => tenant_hash, - Err(problem) => return problem.into_response(), + Err(response) => return response, }; let store = state.fact_store.read().await; Json(store.aggregate_v1(&tenant_hash, &req)).into_response() @@ -821,9 +864,9 @@ pub(super) async fn export_facts( let result = if raw_admin_read(&ctx) { store.export(since, cursor, limit) } else { - let tenant_hash = match ctx.resolve_authorized_tenant(None) { + let tenant_hash = match tenant_hash_for_read_context(&ctx) { Ok(tenant_hash) => tenant_hash, - Err(problem) => return problem.into_response(), + Err(response) => return response, }; store.export_for_tenant(&tenant_hash, since, cursor, limit) }; @@ -912,7 +955,10 @@ pub(super) async fn list_facts( // Raw-admin (auth-off console) sees the whole store; a scoped caller is // confined to its read-tenant — same authority the query path uses. let is_admin = raw_admin_read(&ctx); - let tenant_hash = tenant_hash_for_read_context(&ctx); + let tenant_hash = match tenant_hash_for_read_context(&ctx) { + Ok(tenant) => tenant, + Err(response) => return response, + }; // The consumer-surface reserved list is the single source of truth in // crux-mcp (`crux_mcp::tools::memory::RESERVED_ENTITY_PREFIXES`); the store diff --git a/crates/corecruxd/src/http/gpu1.rs b/crates/corecruxd/src/http/gpu1.rs index 05d6d45d..f9b4a5d7 100644 --- a/crates/corecruxd/src/http/gpu1.rs +++ b/crates/corecruxd/src/http/gpu1.rs @@ -789,6 +789,14 @@ async fn handle_compute( ) { return problem.into_response(); } + let ctx = match http_scope_context(&state.auth, &headers) { + Ok(ctx) => ctx, + Err(problem) => return problem.into_response(), + }; + let tenant_hash = match super::facts::tenant_hash_for_requested_context(&ctx, &tenant_id) { + Ok(tenant) => tenant, + Err(response) => return response, + }; if evidence.len() > 100 { return problem_response(StatusCode::BAD_REQUEST, "selected evidence must not exceed 100 items"); } @@ -864,11 +872,12 @@ async fn handle_compute( }, None => None, }; - store_receipts(&state, &tenant_id, service, &receipts).await; + store_receipts(&state, &tenant_id, &tenant_hash, service, &receipts).await; let answer_replay = if matches!(service, Gpu1Service::Answer) { match build_and_store_answer_capsule( &state, &tenant_id, + &tenant_hash, &payload, &result, &evidence, @@ -1162,7 +1171,13 @@ fn build_receipts( } } -async fn store_receipts(state: &AppState, tenant_id: &str, service: Gpu1Service, receipts: &Gpu1ReceiptBundle) { +async fn store_receipts( + state: &AppState, + tenant_id: &str, + tenant_hash: &str, + service: Gpu1Service, + receipts: &Gpu1ReceiptBundle, +) { let value = match serde_json::to_string(receipts) { Ok(value) => value, Err(err) => { @@ -1171,7 +1186,7 @@ async fn store_receipts(state: &AppState, tenant_id: &str, service: Gpu1Service, } }; let mut fact = StoreFact { - tenant_hash: "default".to_string(), + tenant_hash: tenant_hash.to_string(), entity: format!("{GPU1_RECEIPT_ENTITY_PREFIX}::{tenant_id}::{}", service.operation()), key: "receipt_bundle".to_string(), value, @@ -1193,6 +1208,7 @@ async fn store_receipts(state: &AppState, tenant_id: &str, service: Gpu1Service, async fn build_and_store_answer_capsule( state: &AppState, tenant_id: &str, + tenant_hash: &str, payload: &Value, result: &Value, evidence: &[Gpu1Evidence], @@ -1223,7 +1239,7 @@ async fn build_and_store_answer_capsule( local_semantic_profile_id: local_semantic_profile_id.map(str::to_string), created_at: chrono::Utc::now().to_rfc3339(), }); - super::replay::store_answer_capsule(state, &capsule).await?; + super::replay::store_answer_capsule(state, &capsule, tenant_hash).await?; Ok(json!({ "schema": corecrux_memory::replay::ANSWER_REPLAY_CAPSULE_SCHEMA, "answer_id": answer_id, diff --git a/crates/corecruxd/src/http/incidents.rs b/crates/corecruxd/src/http/incidents.rs index 2e090b3d..3529016f 100644 --- a/crates/corecruxd/src/http/incidents.rs +++ b/crates/corecruxd/src/http/incidents.rs @@ -895,7 +895,10 @@ pub(super) async fn post_incident( Ok(ctx) => ctx, Err(problem) => return problem.into_response(), }; - let tenant_hash = super::facts::tenant_hash_for_read_context(&ctx); + let tenant_hash = match super::facts::tenant_hash_for_requested_context(&ctx, &body.tenant_id) { + Ok(tenant) => tenant, + Err(response) => return response, + }; let created_by = ctx.passport_id.unwrap_or_else(|| state.passport_fpr.clone()); let case = match assemble_case(&state, body, created_by.clone(), &tenant_hash).await { Ok(case) => case, diff --git a/crates/corecruxd/src/http/infra.rs b/crates/corecruxd/src/http/infra.rs index c7b958ea..6791a6dd 100644 --- a/crates/corecruxd/src/http/infra.rs +++ b/crates/corecruxd/src/http/infra.rs @@ -62,7 +62,10 @@ pub(super) async fn get_infra_summary(State(state): State, headers: He Ok(ctx) => ctx, Err(problem) => return problem.into_response(), }; - let tenant_hash = super::facts::tenant_hash_for_read_context(&ctx); + let tenant_hash = match super::facts::tenant_hash_for_read_context(&ctx) { + Ok(tenant) => tenant, + Err(response) => return response, + }; let rails = serde_json::json!({ "tailscale": env_flag_enabled("CORECRUXD_TS_IDENTITY_ENABLED"), diff --git a/crates/corecruxd/src/http/memory_capture.rs b/crates/corecruxd/src/http/memory_capture.rs index efe4d8b1..9c65f9d8 100644 --- a/crates/corecruxd/src/http/memory_capture.rs +++ b/crates/corecruxd/src/http/memory_capture.rs @@ -109,6 +109,14 @@ pub(super) async fn post_extract( if let Err(problem) = require_http_any_scope(&state.auth, &headers, &["facts:write", "admin:write"]) { return problem.into_response(); } + let ctx = match crate::auth::http_scope_context(&state.auth, &headers) { + Ok(ctx) => ctx, + Err(problem) => return problem.into_response(), + }; + let tenant_hash = match super::facts::tenant_hash_for_write_context(&ctx) { + Ok(tenant) => tenant, + Err(response) => return response, + }; let profile = profile_from_str(req.profile.as_deref()); let facts = memory_extract::extract_facts_from_text(&req.text, &profile, req.session_date.as_deref()); @@ -126,7 +134,7 @@ pub(super) async fn post_extract( let cid = candidate_id_for(&proposed_entity, &proposed_key, &proposed_value, f.rule); // Idempotent + decision-preserving: never overwrite an existing // candidate (which may already be promoted/rejected). - if candidate_store::get_candidate(&store, &cid).is_some() { + if candidate_store::get_candidate_for_tenant(&store, &tenant_hash, &cid).is_some() { skipped += 1; continue; } @@ -155,7 +163,7 @@ pub(super) async fn post_extract( } None => None, }; - match candidate_store::write_candidate(&mut store, &body, receipt_hash) { + match candidate_store::write_candidate_for_tenant(&mut store, &tenant_hash, &body, receipt_hash) { Ok(_) => { written += 1; out.push(body); @@ -193,6 +201,14 @@ pub(super) async fn get_candidates( if let Err(problem) = require_http_any_scope(&state.auth, &headers, &["query:read", "admin:read"]) { return problem.into_response(); } + let ctx = match crate::auth::http_scope_context(&state.auth, &headers) { + Ok(ctx) => ctx, + Err(problem) => return problem.into_response(), + }; + let tenant_hash = match super::facts::tenant_hash_for_read_context(&ctx) { + Ok(tenant) => tenant, + Err(response) => return response, + }; let status = match q.status.as_deref() { Some("candidate") => Some(CandidateStatus::Candidate), Some("promoted") => Some(CandidateStatus::Promoted), @@ -204,7 +220,7 @@ pub(super) async fn get_candidates( None => None, }; let store = state.fact_store.read().await; - let candidates = candidate_store::list_candidates(&store, status); + let candidates = candidate_store::list_candidates_for_tenant(&store, &tenant_hash, status); Json(serde_json::json!({ "schema": "crux.memory_capture.candidates.v1", "count": candidates.len(), @@ -238,6 +254,14 @@ pub(super) async fn post_promote( if let Err(problem) = require_http_any_scope(&state.auth, &headers, &["facts:write", "admin:write"]) { return problem.into_response(); } + let ctx = match crate::auth::http_scope_context(&state.auth, &headers) { + Ok(ctx) => ctx, + Err(problem) => return problem.into_response(), + }; + let tenant_hash = match super::facts::tenant_hash_for_write_context(&ctx) { + Ok(tenant) => tenant, + Err(response) => return response, + }; let mode = match req.auto_threshold { Some(t) => PromotionMode::Auto { score_threshold: t }, None => PromotionMode::Explicit { @@ -246,7 +270,7 @@ pub(super) async fn post_promote( }; let now = chrono::Utc::now().to_rfc3339(); let mut store = state.fact_store.write().await; - match candidate_store::promote(&mut store, &id, mode, &now) { + match candidate_store::promote_for_tenant(&mut store, &tenant_hash, &id, mode, &now) { Ok(fact_id) => Json(serde_json::json!({ "schema": "crux.memory_capture.promote.v1", "candidate_id": id, @@ -294,9 +318,17 @@ pub(super) async fn post_reject( if let Err(problem) = require_http_any_scope(&state.auth, &headers, &["facts:write", "admin:write"]) { return problem.into_response(); } + let ctx = match crate::auth::http_scope_context(&state.auth, &headers) { + Ok(ctx) => ctx, + Err(problem) => return problem.into_response(), + }; + let tenant_hash = match super::facts::tenant_hash_for_write_context(&ctx) { + Ok(tenant) => tenant, + Err(response) => return response, + }; let now = chrono::Utc::now().to_rfc3339(); let mut store = state.fact_store.write().await; - match candidate_store::reject(&mut store, &id, &req.reason, &now) { + match candidate_store::reject_for_tenant(&mut store, &tenant_hash, &id, &req.reason, &now) { Ok(()) => Json(serde_json::json!({ "schema": "crux.memory_capture.reject.v1", "candidate_id": id, diff --git a/crates/corecruxd/src/http/receipts.rs b/crates/corecruxd/src/http/receipts.rs index adba4830..5b5cb456 100644 --- a/crates/corecruxd/src/http/receipts.rs +++ b/crates/corecruxd/src/http/receipts.rs @@ -1030,7 +1030,10 @@ pub(super) async fn get_answer_export_v1( Ok(ctx) => ctx, Err(problem) => return problem.into_response(), }; - let tenant_hash = super::facts::tenant_hash_for_read_context(&ctx); + let tenant_hash = match super::facts::tenant_hash_for_requested_context(&ctx, &q.tenant_id) { + Ok(tenant) => tenant, + Err(response) => return response, + }; let opts = match parse_receipt_export_options_v1(q.include.as_deref(), q.redaction.as_deref(), q.format.as_deref()) { diff --git a/crates/corecruxd/src/http/replay.rs b/crates/corecruxd/src/http/replay.rs index 20cea7b0..4599f473 100644 --- a/crates/corecruxd/src/http/replay.rs +++ b/crates/corecruxd/src/http/replay.rs @@ -39,7 +39,10 @@ pub(super) async fn get_answer_replay( Ok(ctx) => ctx, Err(problem) => return problem.into_response(), }; - let tenant_hash = super::facts::tenant_hash_for_read_context(&ctx); + let tenant_hash = match super::facts::tenant_hash_for_requested_context(&ctx, &q.tenant_id) { + Ok(tenant) => tenant, + Err(response) => return response, + }; let Some(capsule) = load_answer_capsule(&state, &q.tenant_id, &answer_id, &tenant_hash).await else { return problem_response(StatusCode::NOT_FOUND, "answer replay capsule not found"); }; @@ -74,7 +77,10 @@ pub(super) async fn get_answer_replay_validity( Ok(ctx) => ctx, Err(problem) => return problem.into_response(), }; - let tenant_hash = super::facts::tenant_hash_for_read_context(&ctx); + let tenant_hash = match super::facts::tenant_hash_for_requested_context(&ctx, &q.tenant_id) { + Ok(tenant) => tenant, + Err(response) => return response, + }; let Some(capsule) = load_answer_capsule(&state, &q.tenant_id, &answer_id, &tenant_hash).await else { return problem_response(StatusCode::NOT_FOUND, "answer replay capsule not found"); }; @@ -146,9 +152,13 @@ pub(super) async fn get_answer_replay_validity( // remain mounted. Test builds still exercise this writer directly. #[cfg_attr(not(feature = "hosted-surfaces"), allow(dead_code))] #[tracing::instrument(level = "info", skip_all)] -pub(super) async fn store_answer_capsule(state: &AppState, capsule: &AnswerReplayCapsule) -> std::io::Result<()> { +pub(super) async fn store_answer_capsule( + state: &AppState, + capsule: &AnswerReplayCapsule, + tenant_hash: &str, +) -> std::io::Result<()> { let mut fact = StoreFact { - tenant_hash: "default".to_string(), + tenant_hash: tenant_hash.to_string(), entity: answer_capsule_entity(&capsule.tenant_id, &capsule.answer_id), key: "capsule".to_string(), value: serde_json::to_string(capsule).map_err(std::io::Error::other)?, diff --git a/crates/corecruxd/src/http/result_envelope.rs b/crates/corecruxd/src/http/result_envelope.rs index 548a3899..32f4e90c 100644 --- a/crates/corecruxd/src/http/result_envelope.rs +++ b/crates/corecruxd/src/http/result_envelope.rs @@ -120,6 +120,10 @@ pub(super) async fn post_result_envelope_import( return err.into_response(); } } + let tenant_hash = match super::facts::tenant_hash_for_requested_context(&ctx, &tenant_id) { + Ok(tenant) => tenant, + Err(response) => return response, + }; // ---- Passport binding (§2.1 passport_fpr): mismatch → reject ------------ if let Some(envelope_fpr) = envelope.passport_fpr.as_deref() { @@ -153,7 +157,7 @@ pub(super) async fn post_result_envelope_import( ) { let mut store = state.fact_store.write().await; store.store(StoreFact { - tenant_hash: "default".to_string(), + tenant_hash: tenant_hash.clone(), entity: format!("__result_envelope_incident__::{tenant_id}"), key: envelope.job_id.clone(), value: json!({ @@ -177,7 +181,6 @@ pub(super) async fn post_result_envelope_import( // ---- Idempotency: prior receipt for this job_id with matching hash ------ let receipt_entity = import_receipt_entity(&tenant_id); - let tenant_hash = super::facts::tenant_hash_for_read_context(&ctx); { let store = state.fact_store.read().await; for fact in store.get_by_entity_for_tenant(&receipt_entity, &tenant_hash) { @@ -264,7 +267,7 @@ pub(super) async fn post_result_envelope_import( let reqs: Vec = facts_in .iter() .map(|f| StoreFact { - tenant_hash: "default".to_string(), + tenant_hash: tenant_hash.clone(), entity: f.entity.clone(), key: f.key.clone(), value: f.value.clone(), @@ -374,7 +377,7 @@ pub(super) async fn post_result_envelope_import( { let mut store = state.fact_store.write().await; store.store(StoreFact { - tenant_hash: "default".to_string(), + tenant_hash, entity: receipt_entity, key: envelope.job_id.clone(), value: receipt_value.to_string(), @@ -408,7 +411,8 @@ pub(super) async fn post_result_envelope_import( #[cfg(test)] mod tests { use super::*; - use crate::http::tests::test_app_state; + use crate::http::tests::{test_app_state, test_app_state_with_auth}; + use crate::test_support::EnvVarGuard; use axum::body::to_bytes; use corecrux_memory::result_envelope::{ result_envelope_content_hash, CompanionArtifact, EnvelopeEdge, EnvelopeEntity, EnvelopeFact, EnvelopePayload, @@ -660,6 +664,75 @@ mod tests { assert_eq!(store.get_by_entity("__result_envelope__::business::acme").len(), 1); } + #[tokio::test] + #[serial_test::serial] + async fn jwt_import_stamps_payload_incident_and_receipt_to_authorized_tenant() { + use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; + + const SECRET: &str = "0123456789abcdef0123456789abcdef"; + let (signing, _guard) = pin_platform_key(); + let _secret = EnvVarGuard::set("CORECRUXD_JWT_HS256_SECRET", SECRET); + let _issuer = EnvVarGuard::set("CORECRUXD_JWT_ISS", "corecrux-test"); + let _audience = EnvVarGuard::set("CORECRUXD_JWT_AUD", "corecrux"); + let _tenant_mode = EnvVarGuard::unset("CORECRUXD_TENANT_WRITE_STAMP"); + let state = test_app_state_with_auth(8, crate::auth::AuthMode::JwtHs256); + + let headers_for = |tenant: &str| { + let claims = json!({ + "exp": chrono::Utc::now().timestamp() + 3600, + "iss": "corecrux-test", + "aud": "corecrux", + "scope": "facts:write", + "tenant_id": tenant, + }); + let token = encode( + &Header::new(Algorithm::HS256), + &claims, + &EncodingKey::from_secret(SECRET.as_bytes()), + ) + .unwrap(); + let mut headers = HeaderMap::new(); + headers.insert( + axum::http::header::AUTHORIZATION, + format!("Bearer {token}").parse().unwrap(), + ); + headers + }; + + let denied = post_result_envelope_import( + State(state.clone()), + headers_for("business::other"), + Json(build_envelope(&signing, "job_wrong_tenant")), + ) + .await; + assert_eq!(denied.status(), StatusCode::FORBIDDEN); + assert_eq!(state.fact_store.read().await.count(), 0); + + let accepted = post_result_envelope_import( + State(state.clone()), + headers_for("business::acme"), + Json(build_envelope(&signing, "job_tenant_scoped")), + ) + .await; + assert_eq!(accepted.status(), StatusCode::CREATED); + let store = state.fact_store.read().await; + assert_eq!( + store + .get_by_entity_for_tenant("business::acme::person::ada", "business::acme") + .len(), + 1 + ); + assert_eq!( + store + .get_by_entity_for_tenant("__result_envelope__::business::acme", "business::acme") + .len(), + 1 + ); + assert!(store + .get_by_entity_for_tenant("business::acme::person::ada", "default") + .is_empty()); + } + #[tokio::test] async fn missing_pinned_keys_is_server_error() { let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); diff --git a/crates/corecruxd/src/http/tests.rs b/crates/corecruxd/src/http/tests.rs index 808aa22c..24304205 100644 --- a/crates/corecruxd/src/http/tests.rs +++ b/crates/corecruxd/src/http/tests.rs @@ -2067,9 +2067,14 @@ async fn tenant_write_stamp_isolates_reads_end_to_end_m1() { std::env::set_var("CORECRUXD_JWT_HS256_SECRET", TEST_HS256_SECRET); std::env::set_var("CORECRUXD_JWT_ISS", "corecrux-test"); std::env::set_var("CORECRUXD_JWT_AUD", "corecrux"); - std::env::set_var("CORECRUXD_TENANT_WRITE_STAMP", "1"); + std::env::remove_var("CORECRUXD_TENANT_WRITE_STAMP"); let state = test_app_state_with_auth(16, AuthMode::JwtHs256); + assert_eq!( + state.auth.tenant_stamp_mode(), + crate::auth::TenantStampMode::On, + "JWT fact tenant isolation must be secure without an opt-in flag" + ); #[derive(serde::Serialize)] struct Claims<'a> { @@ -2156,6 +2161,202 @@ async fn tenant_write_stamp_isolates_reads_end_to_end_m1() { std::env::remove_var("CORECRUXD_TENANT_WRITE_STAMP"); } +#[tokio::test] +#[serial_test::serial] +async fn tenant_write_stamp_legacy_off_is_an_explicit_shared_default_override_m16() { + use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; + const TEST_HS256_SECRET: &str = "0123456789abcdef0123456789abcdef"; + + let _secret = EnvVarGuard::set("CORECRUXD_JWT_HS256_SECRET", TEST_HS256_SECRET); + let _issuer = EnvVarGuard::set("CORECRUXD_JWT_ISS", "corecrux-test"); + let _audience = EnvVarGuard::set("CORECRUXD_JWT_AUD", "corecrux"); + let _legacy = EnvVarGuard::set("CORECRUXD_TENANT_WRITE_STAMP", "off"); + let state = test_app_state_with_auth(16, AuthMode::JwtHs256); + assert_eq!(state.auth.tenant_stamp_mode(), crate::auth::TenantStampMode::Off); + + #[derive(serde::Serialize)] + struct Claims<'a> { + exp: usize, + iss: &'a str, + aud: &'a str, + scope: &'a str, + tenant_id: &'a str, + } + let bearer = |scope: &str, tenant: &str| { + let claims = Claims { + exp: (std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() + + 3600) as usize, + iss: "corecrux-test", + aud: "corecrux", + scope, + tenant_id: tenant, + }; + let token = encode( + &Header::new(Algorithm::HS256), + &claims, + &EncodingKey::from_secret(TEST_HS256_SECRET.as_bytes()), + ) + .expect("jwt"); + let mut headers = HeaderMap::new(); + headers.insert( + axum::http::header::AUTHORIZATION, + format!("Bearer {token}").parse().unwrap(), + ); + headers + }; + + let entity = "tenant-legacy-shared-widget"; + let body: corecrux_memory::fact_store::StoreFact = + serde_json::from_value(serde_json::json!({ "entity": entity, "key": "k", "value": "v" })).unwrap(); + let resp = facts::put_fact(State(state.clone()), bearer("facts:write", "tenant-a"), Json(body)) + .await + .into_response(); + assert_eq!(resp.status(), StatusCode::CREATED); + let stored = json_body(resp).await; + assert_eq!(stored["tenant_hash"], "default"); + + let resp = facts::get_facts_by_entity( + State(state.clone()), + bearer("query:read", "tenant-b"), + Path(entity.to_string()), + ) + .await + .into_response(); + assert_eq!(resp.status(), StatusCode::OK); + let body = json_body(resp).await; + assert_eq!( + body["facts"].as_array().unwrap().len(), + 1, + "the explicit legacy override intentionally retains shared-default visibility" + ); + + let aggregate = facts::post_aggregate( + State(state.clone()), + bearer("query:read", "tenant-b"), + Json(corecrux_memory::fact_store::AggregateRequestV1 { + op: corecrux_memory::fact_store::AggregateOp::Count, + entity: Some(entity.to_string()), + key: Some("k".to_string()), + query: None, + as_of: None, + token_budget: None, + }), + ) + .await + .into_response(); + assert_eq!(aggregate.status(), StatusCode::OK); + assert_eq!( + json_body(aggregate).await["value"], + serde_json::json!(1), + "aggregate must read the same shared-default tenant used by legacy writes" + ); + + let export = facts::export_facts( + State(state), + bearer("query:read", "tenant-b"), + Query(ExportFactsParams { + since: None, + cursor: None, + limit: Some(10), + }), + ) + .await + .into_response(); + assert_eq!(export.status(), StatusCode::OK); + let exported = json_body(export).await; + assert_eq!(exported["facts"].as_array().unwrap().len(), 1); + assert_eq!(exported["facts"][0]["entity"], entity); +} + +#[tokio::test] +#[serial_test::serial] +async fn tenant_write_stamp_shadow_keeps_generic_write_aggregate_and_export_paired_m16() { + use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; + const TEST_HS256_SECRET: &str = "0123456789abcdef0123456789abcdef"; + + let _secret = EnvVarGuard::set("CORECRUXD_JWT_HS256_SECRET", TEST_HS256_SECRET); + let _issuer = EnvVarGuard::set("CORECRUXD_JWT_ISS", "corecrux-test"); + let _audience = EnvVarGuard::set("CORECRUXD_JWT_AUD", "corecrux"); + let _shadow = EnvVarGuard::set("CORECRUXD_TENANT_WRITE_STAMP", "shadow"); + let state = test_app_state_with_auth(16, AuthMode::JwtHs256); + assert_eq!(state.auth.tenant_stamp_mode(), crate::auth::TenantStampMode::Shadow); + + let bearer = |scope: &str, tenant: &str| { + let claims = serde_json::json!({ + "exp": std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() + + 3600, + "iss": "corecrux-test", + "aud": "corecrux", + "scope": scope, + "tenant_id": tenant, + }); + let token = encode( + &Header::new(Algorithm::HS256), + &claims, + &EncodingKey::from_secret(TEST_HS256_SECRET.as_bytes()), + ) + .expect("jwt"); + let mut headers = HeaderMap::new(); + headers.insert( + axum::http::header::AUTHORIZATION, + format!("Bearer {token}").parse().unwrap(), + ); + headers + }; + + let entity = "tenant-shadow-shared-widget"; + let body = serde_json::from_value(serde_json::json!({ + "entity": entity, + "key": "k", + "value": "v", + })) + .unwrap(); + let write = facts::put_fact(State(state.clone()), bearer("facts:write", "tenant-a"), Json(body)) + .await + .into_response(); + assert_eq!(write.status(), StatusCode::CREATED); + assert_eq!(json_body(write).await["tenant_hash"], "default"); + + let aggregate = facts::post_aggregate( + State(state.clone()), + bearer("query:read", "tenant-b"), + Json(corecrux_memory::fact_store::AggregateRequestV1 { + op: corecrux_memory::fact_store::AggregateOp::Count, + entity: Some(entity.to_string()), + key: Some("k".to_string()), + query: None, + as_of: None, + token_budget: None, + }), + ) + .await + .into_response(); + assert_eq!(aggregate.status(), StatusCode::OK); + assert_eq!(json_body(aggregate).await["value"], serde_json::json!(1)); + + let export = facts::export_facts( + State(state), + bearer("query:read", "tenant-b"), + Query(ExportFactsParams { + since: None, + cursor: None, + limit: Some(10), + }), + ) + .await + .into_response(); + assert_eq!(export.status(), StatusCode::OK); + let exported = json_body(export).await; + assert_eq!(exported["facts"].as_array().unwrap().len(), 1); + assert_eq!(exported["facts"][0]["entity"], entity); +} + #[tokio::test] async fn console_redacts_private_facts_and_session_state() { let state = test_app_state_with_auth(16, AuthMode::DevScopes); @@ -9373,7 +9574,7 @@ async fn workbench_audit_triage_groups_replay_failures() { local_semantic_profile_id: None, created_at: "2026-05-07T00:00:00Z".to_string(), }); - super::replay::store_answer_capsule(&state, &capsule) + super::replay::store_answer_capsule(&state, &capsule, "default") .await .expect("store replay capsule"); { @@ -9423,6 +9624,49 @@ async fn workbench_audit_triage_groups_replay_failures() { .any(|category| category == "fact_superseded")); } +#[tokio::test] +async fn explicit_tenant_workbench_route_selects_from_multi_tenant_jwt_m16() { + use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; + + const SECRET: &str = "0123456789abcdef0123456789abcdef"; + let mut state = pro_workbench_state(&["audit:triage"]); + state.auth = crate::auth::Authz::test_hs256(SECRET.as_bytes(), "corecrux-test", "corecrux"); + let token = encode( + &Header::new(Algorithm::HS256), + &serde_json::json!({ + "exp": chrono::Utc::now().timestamp() + 3600, + "iss": "corecrux-test", + "aud": "corecrux", + "scope": "audit:triage", + "tenants": ["tenant-a", "tenant-b"], + }), + &EncodingKey::from_secret(SECRET.as_bytes()), + ) + .expect("jwt"); + let mut headers = HeaderMap::new(); + headers.insert( + header::AUTHORIZATION, + HeaderValue::from_str(&format!("Bearer {token}")).expect("bearer"), + ); + + let response = super::workbench::get_audit_triage( + State(state), + headers, + Query(super::workbench::TenantWorkbenchQuery { + tenant_id: "tenant-a".to_string(), + project_id: None, + limit: None, + }), + ) + .await; + + assert_eq!( + response.status(), + StatusCode::OK, + "the explicit query tenant is the authorized selector; no header is required" + ); +} + // Drives the `http::cloud` and `http::gpu1` handlers directly — hosted-surface only (M4). #[cfg(feature = "hosted-surfaces")] #[serial_test::serial] @@ -10297,6 +10541,50 @@ async fn console_fact_add_then_search_round_trip() { ); } +#[tokio::test] +async fn console_facts_are_tenant_scoped_under_jwt_m16() { + let state = mint_test_verified_app_state(16); + let entity = "tenant-console-project"; + let add_resp = console::post_console_fact_add( + State(state.clone()), + mint_test_tenant_headers_no_identity("facts:write", "tenant-a"), + Json(console::ConsoleAddFactBody { + entity: entity.to_string(), + key: "secret_colour".to_string(), + value: "tenant-a-ultraviolet".to_string(), + confidence: 0.9, + }), + ) + .await + .into_response(); + assert_eq!(add_resp.status(), StatusCode::CREATED); + + for (tenant, expected) in [("tenant-a", 1usize), ("tenant-b", 0usize)] { + let response = console::get_console_facts( + State(state.clone()), + Query(console::ConsoleFactsQuery { + q: Some("tenant-a-ultraviolet".to_string()), + top_k: Some(10), + as_of_unix_ms: None, + }), + mint_test_tenant_headers_no_identity("admin:read", tenant), + ) + .await + .into_response(); + assert_eq!(response.status(), StatusCode::OK); + let body = json_body(response).await; + assert_eq!( + body["facts"].as_array().unwrap().len(), + expected, + "unexpected console visibility for {tenant}: {body}" + ); + } + + let store = state.fact_store.read().await; + assert_eq!(store.get_by_entity_for_tenant(entity, "tenant-a").len(), 1); + assert!(store.get_by_entity_for_tenant(entity, "default").is_empty()); +} + #[tokio::test] async fn console_fact_add_rejects_every_create_reserved_entity_prefix() { let state = test_app_state_with_auth(16, AuthMode::DevScopes); @@ -12358,6 +12646,33 @@ fn mint_test_jwt_headers(scopes: &str, identity_claim: (&str, &str)) -> HeaderMa headers } +fn mint_test_tenant_headers_no_identity(scopes: &str, tenant_id: &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 claims = serde_json::json!({ + "exp": exp, + "iss": MINT_TEST_ISSUER, + "aud": MINT_TEST_AUDIENCE, + "scope": scopes, + "tenant_id": tenant_id, + }); + let token = jsonwebtoken::encode( + &jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256), + &claims, + &jsonwebtoken::EncodingKey::from_secret(MINT_TEST_HS256_SECRET.as_bytes()), + ) + .expect("tenant test JWT"); + let mut headers = HeaderMap::new(); + headers.insert( + header::AUTHORIZATION, + HeaderValue::from_str(&format!("Bearer {token}")).expect("bearer header"), + ); + headers +} + fn mint_test_verified_headers(scopes: &str, passport_id: &str) -> HeaderMap { mint_test_jwt_headers(scopes, ("passport_id", passport_id)) } @@ -14229,6 +14544,153 @@ async fn typed_engram_upsert_rejects_wrong_scope_and_malformed_name() { .is_empty()); } +#[tokio::test] +async fn jwt_engram_overlay_reads_are_isolated_by_tenant_m16() { + let state = work_auth_test_state(16); + let mut tenant_a = test_engram_upsert_body(); + tenant_a.content = "tenant A minimalism policy".to_string(); + let mut tenant_b = test_engram_upsert_body(); + tenant_b.content = "tenant B minimalism policy".to_string(); + + for (tenant, body) in [("tenant-a", tenant_a), ("tenant-b", tenant_b)] { + let response = super::engrams::upsert_engram( + State(state.clone()), + work_auth_headers(tenant, None, "admin:write"), + Path("code-minimalism".to_string()), + Json(body), + ) + .await + .into_response(); + assert_eq!(response.status(), StatusCode::CREATED); + } + + let resolve_for = |tenant: &str| super::engrams::ResolveEngramsBody { + tenant_id: Some(tenant.to_string()), + tenant_id_camel: None, + agent_id: Some("codex".to_string()), + agent_id_camel: None, + names: vec!["code-minimalism@v1".to_string()], + manifest_hash: None, + model_id: Some("local-cpu".to_string()), + model_id_camel: None, + }; + let a = super::engrams::resolve_engrams( + State(state.clone()), + work_auth_headers("tenant-a", None, "query:read"), + Json(resolve_for("tenant-a")), + ) + .await + .into_response(); + assert_eq!(a.status(), StatusCode::OK); + assert_eq!( + json_body(a).await["engrams"][0]["content"], + "tenant A minimalism policy" + ); + + let b = super::engrams::resolve_engrams( + State(state.clone()), + work_auth_headers("tenant-b", None, "query:read"), + Json(resolve_for("tenant-b")), + ) + .await + .into_response(); + assert_eq!(b.status(), StatusCode::OK); + assert_eq!( + json_body(b).await["engrams"][0]["content"], + "tenant B minimalism policy" + ); + + let cross_tenant = super::engrams::resolve_engrams( + State(state), + work_auth_headers("tenant-a", None, "query:read"), + Json(resolve_for("tenant-b")), + ) + .await + .into_response(); + assert_eq!(cross_tenant.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn jwt_memory_candidate_list_and_promote_are_isolated_by_tenant_m16() { + let mut state = work_auth_test_state(16); + state.auto_capture_enabled = true; + let candidate = |value: &str| { + crate::candidate_store::MemoryCandidateV1::new_candidate( + "same-http-id".to_string(), + "person:user".to_string(), + "owns_cat_count".to_string(), + value.to_string(), + "fixture".to_string(), + 1.0, + "stable".to_string(), + crate::candidate_store::CandidateSource::default(), + None, + None, + "2026-07-30T00:00:00Z".to_string(), + ) + }; + { + let mut store = state.fact_store.write().await; + crate::candidate_store::write_candidate_for_tenant(&mut store, "tenant-a", &candidate("tenant-a-value"), None) + .unwrap(); + crate::candidate_store::write_candidate_for_tenant(&mut store, "tenant-b", &candidate("tenant-b-value"), None) + .unwrap(); + } + + for (tenant, expected) in [("tenant-a", "tenant-a-value"), ("tenant-b", "tenant-b-value")] { + let response = super::memory_capture::get_candidates( + State(state.clone()), + work_auth_headers(tenant, None, "query:read"), + Query(super::memory_capture::ListQuery { status: None }), + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + let body = json_body(response).await; + assert_eq!(body["count"], 1); + assert_eq!(body["candidates"][0]["proposed_value"], expected); + } + + let promoted = super::memory_capture::post_promote( + State(state.clone()), + work_auth_headers("tenant-a", None, "facts:write"), + Path("same-http-id".to_string()), + Json(super::memory_capture::PromoteRequest { + reviewer: Some("reviewer-a".to_string()), + auto_threshold: None, + }), + ) + .await; + assert_eq!(promoted.status(), StatusCode::OK); + + let store = state.fact_store.read().await; + assert_eq!( + crate::candidate_store::get_candidate_for_tenant(&store, "tenant-a", "same-http-id") + .unwrap() + .status, + crate::candidate_store::CandidateStatus::Promoted + ); + assert_eq!( + crate::candidate_store::get_candidate_for_tenant(&store, "tenant-b", "same-http-id") + .unwrap() + .status, + crate::candidate_store::CandidateStatus::Candidate + ); + assert_eq!( + store + .all_facts_for_tenant("tenant-a") + .filter(|fact| fact.entity == "person:user" && fact.key == "owns_cat_count") + .count(), + 1 + ); + assert_eq!( + store + .all_facts_for_tenant("tenant-b") + .filter(|fact| fact.entity == "person:user" && fact.key == "owns_cat_count") + .count(), + 0 + ); +} + #[tokio::test] async fn rcx_publish_passport_preview_builds_signed_schema_record() { let mut state = test_app_state_with_auth(16, AuthMode::DevScopes); diff --git a/crates/corecruxd/src/http/workbench.rs b/crates/corecruxd/src/http/workbench.rs index 5405ba2d..17232ac1 100644 --- a/crates/corecruxd/src/http/workbench.rs +++ b/crates/corecruxd/src/http/workbench.rs @@ -579,7 +579,10 @@ pub(super) async fn get_audit_triage( Ok(ctx) => ctx, Err(problem) => return problem.into_response(), }; - let tenant_hash = super::facts::tenant_hash_for_read_context(&ctx); + let tenant_hash = match super::facts::tenant_hash_for_requested_context(&ctx, &tenant_id) { + Ok(tenant) => tenant, + Err(response) => return response, + }; let sync_status = super::health::sync_runtime_status(); let scan = crate::workspace_scan::load_latest(&state.fact_store).await; let queues = { diff --git a/crates/corecruxd/src/main.rs b/crates/corecruxd/src/main.rs index f46aff40..cc28852a 100644 --- a/crates/corecruxd/src/main.rs +++ b/crates/corecruxd/src/main.rs @@ -1677,7 +1677,7 @@ async fn main() -> Result<(), Box> { // active, zero tenant_stamp_shadow_* warnings) apart from a window that // never ran (flag typo'd / not applied). Shadow is silent on the good // path, so without this the silence is ambiguous. - tenant_stamp_mode = crate::auth::TenantStampMode::from_env().as_str(), + tenant_stamp_mode = auth.tenant_stamp_mode().as_str(), "corecruxd starting" ); diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md index 1dff809d..5bb076c5 100644 --- a/docs/THREAT_MODEL.md +++ b/docs/THREAT_MODEL.md @@ -66,6 +66,14 @@ for the Crux Daemon. `__engram__::`; authenticated `PUT /v1/engrams/{name}` with `admin:write` validates the typed object and stamps daemon-owned actor, time, tenant, privacy, and provenance fields. +- JWT modes default wired HTTP fact-backed surfaces to real tenant + stamping/filtering. Multi-tenant and wildcard tokens on tenant-implicit + routes require an authorized `X-Corecrux-Tenant-Id`; an explicit route/body + tenant also selects the tenant and must agree with the header. Missing, + ambiguous, or mismatched claims fail closed. Operators may explicitly set + `CORECRUXD_TENANT_WRITE_STAMP=off` or `shadow` only while migrating + historical shared-`default` rows. This flag does not cover MCP or stores with + independent entity/session/projection tenant models. - Decision-tool rows deliberately remain compatibility annotations. Their BLAKE3 value is a content identifier, not a signature or append-only proof; consumers must require the `integrity: "untrusted_annotation"` contract and diff --git a/docs/api-reference.md b/docs/api-reference.md index 7b4c8de8..e24486d9 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -393,6 +393,30 @@ Scopes are passed via `Authorization: Bearer ` header. Required scopes ar `X-Corecrux-Passport-Id` is only an unverified local assertion in `off` and `dev_scopes`; production authority must come from verified token claims. +### HTTP fact tenant isolation (`CORECRUXD_TENANT_WRITE_STAMP`) + +In `jwt_hs256` and `jwt_jwks` modes, the default is `on`: affected HTTP +fact-backed writes stamp the verified JWT tenant and reads filter to the same +tenant. A token with one tenant needs no selector. On tenant-implicit routes, a +token with multiple tenants or a wildcard tenant must send +`X-Corecrux-Tenant-Id`; an explicit route/body tenant is itself a selector and +must agree with that header when both are present. A missing tenant claim, +ambiguous selection, mismatch, or unauthorized selection is rejected. The +separately documented raw-admin fact reads remain intentionally cross-tenant. +The policy is parsed once at startup, and an invalid value aborts startup. + +`off` is a deliberate legacy migration override: reads and writes use the +shared `default` tenant even when JWT claims differ. `shadow` preserves that +same storage behaviour while logging requests that `on` would move or reject. +Historical `default` rows are not migrated automatically. + +This switch covers wired HTTP fact-backed surfaces, including generic and +console facts, context recall, engram overlays, memory candidates, result +envelopes, replay capsules, and their paired HTTP audit/export reads. It is not +a universal daemon tenant switch: the MCP compatibility plane still uses +`default`, while entity, edge, session, projection, and other control stores +retain their own tenant contracts. + ### Route authorization gate (`CORECRUXD_ROUTE_AUTH`) Independently of `CORECRUXD_AUTH_MODE`, the daemon runs a deny-by-default route diff --git a/llms-full.txt b/llms-full.txt index 65400845..9e9a01c3 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -857,6 +857,14 @@ for the Crux Daemon. `__engram__::`; authenticated `PUT /v1/engrams/{name}` with `admin:write` validates the typed object and stamps daemon-owned actor, time, tenant, privacy, and provenance fields. +- JWT modes default wired HTTP fact-backed surfaces to real tenant + stamping/filtering. Multi-tenant and wildcard tokens on tenant-implicit + routes require an authorized `X-Corecrux-Tenant-Id`; an explicit route/body + tenant also selects the tenant and must agree with the header. Missing, + ambiguous, or mismatched claims fail closed. Operators may explicitly set + `CORECRUXD_TENANT_WRITE_STAMP=off` or `shadow` only while migrating + historical shared-`default` rows. This flag does not cover MCP or stores with + independent entity/session/projection tenant models. - Decision-tool rows deliberately remain compatibility annotations. Their BLAKE3 value is a content identifier, not a signature or append-only proof; consumers must require the `integrity: "untrusted_annotation"` contract and @@ -2228,6 +2236,30 @@ Scopes are passed via `Authorization: Bearer ` header. Required scopes ar `X-Corecrux-Passport-Id` is only an unverified local assertion in `off` and `dev_scopes`; production authority must come from verified token claims. +### HTTP fact tenant isolation (`CORECRUXD_TENANT_WRITE_STAMP`) + +In `jwt_hs256` and `jwt_jwks` modes, the default is `on`: affected HTTP +fact-backed writes stamp the verified JWT tenant and reads filter to the same +tenant. A token with one tenant needs no selector. On tenant-implicit routes, a +token with multiple tenants or a wildcard tenant must send +`X-Corecrux-Tenant-Id`; an explicit route/body tenant is itself a selector and +must agree with that header when both are present. A missing tenant claim, +ambiguous selection, mismatch, or unauthorized selection is rejected. The +separately documented raw-admin fact reads remain intentionally cross-tenant. +The policy is parsed once at startup, and an invalid value aborts startup. + +`off` is a deliberate legacy migration override: reads and writes use the +shared `default` tenant even when JWT claims differ. `shadow` preserves that +same storage behaviour while logging requests that `on` would move or reject. +Historical `default` rows are not migrated automatically. + +This switch covers wired HTTP fact-backed surfaces, including generic and +console facts, context recall, engram overlays, memory candidates, result +envelopes, replay capsules, and their paired HTTP audit/export reads. It is not +a universal daemon tenant switch: the MCP compatibility plane still uses +`default`, while entity, edge, session, projection, and other control stores +retain their own tenant contracts. + ### Route authorization gate (`CORECRUXD_ROUTE_AUTH`) Independently of `CORECRUXD_AUTH_MODE`, the daemon runs a deny-by-default route @@ -5156,6 +5188,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security +- **JWT HTTP fact storage is tenant-isolated by default.** The daemon freezes + `CORECRUXD_TENANT_WRITE_STAMP` at startup, defaults it to `on` for JWT auth, + rejects invalid configuration and ambiguous/missing tenant authority, and + keeps `off`/`shadow` only as explicit shared-`default` migration modes. + Generic and console facts, context recall, engram overlays, memory + candidates, result envelopes, replay, and paired audit reads now preserve + one authorized tenant end to end. MCP and stores with + independent tenant contracts are explicitly outside this flag. + - **`.mcp.json` is gitignored.** MCP clients write the daemon's agent bearer token into that file at the repository root, where it was previously committable. From 6a077f835e8b34c652a7b026d53b28b05c0f8b43 Mon Sep 17 00:00:00 2001 From: CueCrux-Myles Date: Sat, 22 Aug 2026 12:05:55 +0100 Subject: [PATCH 2/4] security(s3c): migrate main's call sites onto the tenant-bound fact API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The replay of 426ea8a0 applies to current main with only two conflicts, both in generated files, but it does not compile: main has grown call sites and tests against the weaker API the commit replaces. Migrated deliberately rather than reverted, one note per site. Production: - escrow.rs get_wrapped_dek — tenant_hash_for_read_context is now fail-closed and returns Result. This route landed after the branch was written, so it was never updated; it now propagates the problem response like every other fact-backed route. - replay.rs store_answer_capsule — takes an explicit tenant_hash instead of hardcoding "default". Five test call sites pass "default", which is exactly the value the function used to bake in, so their behaviour is unchanged. Tests that asserted the old contract, each migrated to assert the new one: - tenant_stamp_mode_from_env_reads_the_real_env -> ..._from_env_for_auth_defaults_on_for_jwt_and_rejects_junk. Two changes are the point of the commit and are now pinned: under JWT an unset variable resolves to On rather than Off, and an unrecognised value is an error rather than a silent fall to Off. A typo in a deployment used to disable tenant isolation and say nothing. - scope_context_write_and_read_tenant_follow_the_env_posture -> ..._tenant_resolution_uses_the_frozen_posture_not_the_env. The posture is now frozen into Authz at startup, so the old name described the behaviour this commit removes. The test now pins that a late set_var cannot move an already authenticated caller between tenants, and keeps the assertions that still carry weight (On resolves the token's tenant, an unowned selector is 403, Shadow neither stamps nor rejects — built directly, since the posture no longer comes from the environment). - multi_tenant_token_needs_a_selector_when_stamping_is_on — the read side used to fall back to reading `default` when a multi-tenant token named no tenant. That ambiguity is what the commit closes, so the read is now refused in lockstep with the write. CHANGELOG resolution kept only 426ea8a0's own entry; the merge pulled in adjacent text belonging to c093c006 (S5, not landed), which would have claimed coordination and punchcard work had shipped. llms-full.txt regenerated with scripts/build-llms-full.sh rather than hand-merged. Status: cargo check --workspace --all-targets --locked clean; 3167 of 3168 corecruxd tests pass. ONE test fails and is NOT addressed here because it is a policy question, not a merge error — see the next commit message / plan note. Co-Authored-By: Claude Opus 5 (1M context) --- crates/corecruxd/src/auth.rs | 105 +++++++++++++++++++++++----- crates/corecruxd/src/http/escrow.rs | 5 +- crates/corecruxd/src/http/replay.rs | 20 ++++-- 3 files changed, 105 insertions(+), 25 deletions(-) diff --git a/crates/corecruxd/src/auth.rs b/crates/corecruxd/src/auth.rs index 58de862b..3f90dfde 100644 --- a/crates/corecruxd/src/auth.rs +++ b/crates/corecruxd/src/auth.rs @@ -4202,6 +4202,10 @@ rG+Vg0mnrwArNdy2hX9Qkwc= for mode in [AuthMode::JwtHs256, AuthMode::JwtJwks] { let auth = Authz { mode, + // Irrelevant to this test — it asserts that a JWT mode with no + // loaded config is refused, which happens before any tenant + // resolution. `Off` is the neutral choice. + tenant_stamp_mode: TenantStampMode::Off, jwt_hs256: None, jwt_jwks: None, agent_http: None, @@ -5370,14 +5374,39 @@ rG+Vg0mnrwArNdy2hX9Qkwc= assert_eq!(TenantStampMode::On.as_str(), "on"); } + /// Migrated from `tenant_stamp_mode_from_env_reads_the_real_env`, which + /// pinned the posture this commit deliberately replaces. Two things changed + /// and both are the point of the change, so they are asserted rather than + /// dropped: + /// + /// - under JWT auth an unset variable now resolves to `On`, not `Off`; + /// - an unrecognised value is now an error, where it used to fall silently + /// to `Off`. A typo in the deployment used to disable tenant isolation + /// and say nothing. #[test] #[serial_test::serial] - fn tenant_stamp_mode_from_env_reads_the_real_env() { + fn tenant_stamp_mode_from_env_for_auth_defaults_on_for_jwt_and_rejects_junk() { let lock = env_lock(); let _g = lock.lock().unwrap(); + // Non-JWT deployments are out of scope for the flag entirely. std::env::remove_var("CORECRUXD_TENANT_WRITE_STAMP"); - assert_eq!(TenantStampMode::from_env(), TenantStampMode::Off); + for mode in [AuthMode::Off, AuthMode::DevScopes] { + assert_eq!( + TenantStampMode::from_env_for_auth(mode), + Ok(TenantStampMode::Off), + "{mode:?} must not be swept into tenant stamping" + ); + } + + // The default flip: JWT with nothing configured isolates. + for mode in [AuthMode::JwtHs256, AuthMode::JwtJwks] { + assert_eq!( + TenantStampMode::from_env_for_auth(mode), + Ok(TenantStampMode::On), + "{mode:?} must default to on when the variable is unset" + ); + } for (raw, want) in [ ("1", TenantStampMode::On), @@ -5388,11 +5417,23 @@ rG+Vg0mnrwArNdy2hX9Qkwc= ("audit", TenantStampMode::Shadow), ("0", TenantStampMode::Off), ("off", TenantStampMode::Off), - ("banana", TenantStampMode::Off), - ("", TenantStampMode::Off), + ("legacy", TenantStampMode::Off), ] { std::env::set_var("CORECRUXD_TENANT_WRITE_STAMP", raw); - assert_eq!(TenantStampMode::from_env(), want, "raw {raw:?}"); + assert_eq!( + TenantStampMode::from_env_for_auth(AuthMode::JwtHs256), + Ok(want), + "raw {raw:?}" + ); + } + + // Junk fails closed and loudly instead of quietly disabling isolation. + for raw in ["banana", "", " ", "no"] { + std::env::set_var("CORECRUXD_TENANT_WRITE_STAMP", raw); + assert!( + TenantStampMode::from_env_for_auth(AuthMode::JwtHs256).is_err(), + "raw {raw:?} must be rejected, not silently treated as off" + ); } std::env::remove_var("CORECRUXD_TENANT_WRITE_STAMP"); @@ -5400,26 +5441,43 @@ rG+Vg0mnrwArNdy2hX9Qkwc= #[test] #[serial_test::serial] - fn scope_context_write_and_read_tenant_follow_the_env_posture() { + /// Migrated from `scope_context_write_and_read_tenant_follow_the_env_posture`. + /// The posture is now frozen into `Authz` at startup, so a context does not + /// follow the environment any more — the old name described exactly the + /// behaviour this commit removes. Reading the posture per request is what + /// let a late `set_var` (a test, a supervisor, a shell hook) move an already + /// authenticated caller between tenants mid-process. + /// + /// The assertions that still carry weight are kept: `On` resolves the + /// token's own tenant, an unowned selector is refused, and `Shadow` neither + /// stamps nor rejects. + fn scope_context_tenant_resolution_uses_the_frozen_posture_not_the_env() { let lock = env_lock(); let _g = lock.lock().unwrap(); - let auth = hs256_authz(); + let auth = hs256_authz(); // test_hs256 freezes `On` let token = sign_hs256( &valid_claims(serde_json::json!({ "scope": "facts:write", "tenant_id": "t1" })), TEST_HS256_SECRET, ); - std::env::remove_var("CORECRUXD_TENANT_WRITE_STAMP"); let ctx = passport_bound_context(&auth, &bearer(&token)).expect("context"); - assert_eq!(ctx.resolve_write_tenant().unwrap(), None, "default posture is off"); - assert_eq!(ctx.resolve_read_tenant(), None); - - std::env::set_var("CORECRUXD_TENANT_WRITE_STAMP", "1"); assert_eq!(ctx.resolve_write_tenant().unwrap(), Some("t1".to_string())); - assert_eq!(ctx.resolve_read_tenant(), Some("t1".to_string())); + assert_eq!(ctx.resolve_read_tenant().unwrap(), Some("t1".to_string())); + + // The freeze: mutating the variable after the fact must not move an + // authenticated caller off its tenant. + for raw in ["0", "off", "shadow"] { + std::env::set_var("CORECRUXD_TENANT_WRITE_STAMP", raw); + assert_eq!( + ctx.resolve_write_tenant().unwrap(), + Some("t1".to_string()), + "posture is frozen at startup; a late {raw:?} must not take effect" + ); + } + std::env::remove_var("CORECRUXD_TENANT_WRITE_STAMP"); - // A selector the token does not own is refused even under `On`. + // A selector the token does not own is refused under `On`. let mut headers = bearer(&token); headers.insert("x-corecrux-tenant-id", "t2".parse().unwrap()); let ctx = passport_bound_context(&auth, &headers).expect("context"); @@ -5427,14 +5485,18 @@ rG+Vg0mnrwArNdy2hX9Qkwc= assert_eq!(err.0.status, 403); assert_eq!(problem_code(&err), "TENANT_FORBIDDEN"); - std::env::set_var("CORECRUXD_TENANT_WRITE_STAMP", "shadow"); + // Shadow is observation only — it neither stamps nor rejects. Built + // directly, because the posture no longer comes from the environment. + let shadow_auth = Authz { + tenant_stamp_mode: TenantStampMode::Shadow, + ..hs256_authz() + }; + let ctx = passport_bound_context(&shadow_auth, &headers).expect("context"); assert_eq!( ctx.resolve_write_tenant().unwrap(), None, "shadow must never reject or stamp" ); - - std::env::remove_var("CORECRUXD_TENANT_WRITE_STAMP"); } #[test] @@ -5453,8 +5515,13 @@ rG+Vg0mnrwArNdy2hX9Qkwc= let ctx = passport_bound_context(&auth, &bearer(&token)).expect("context"); let err = ctx.resolve_write_tenant().unwrap_err(); assert_eq!(problem_code(&err), "TENANT_SELECTOR_REQUIRED"); - // Multi-tenant tokens read `default`, in lockstep with the write side. - assert_eq!(ctx.resolve_read_tenant(), None); + // Still in lockstep with the write side, but the lockstep moved: a + // multi-tenant token with no selector used to fall back to reading + // `default`, which is the ambiguity this commit closes — the caller + // has not said which tenant it means, so the read is refused rather + // than silently answered from the shared tenant. + let read_err = ctx.resolve_read_tenant().unwrap_err(); + assert_eq!(problem_code(&read_err), "TENANT_SELECTOR_REQUIRED"); let mut headers = bearer(&token); headers.insert("x-corecrux-tenant-id", " t2 ".parse().unwrap()); diff --git a/crates/corecruxd/src/http/escrow.rs b/crates/corecruxd/src/http/escrow.rs index 1d3645f5..7350d3cf 100644 --- a/crates/corecruxd/src/http/escrow.rs +++ b/crates/corecruxd/src/http/escrow.rs @@ -217,7 +217,10 @@ pub(super) async fn get_wrapped_dek( if !valid_vault_id(&vault_id) { return problem_response(StatusCode::BAD_REQUEST, "vault_id must be [A-Za-z0-9_-]{1,128}"); } - let tenant = tenant_hash_for_read_context(&ctx); + let tenant = match tenant_hash_for_read_context(&ctx) { + Ok(tenant) => tenant, + Err(response) => return response, + }; let facts = { let store = state.fact_store.read().await; store diff --git a/crates/corecruxd/src/http/replay.rs b/crates/corecruxd/src/http/replay.rs index 4599f473..41531787 100644 --- a/crates/corecruxd/src/http/replay.rs +++ b/crates/corecruxd/src/http/replay.rs @@ -1207,7 +1207,9 @@ mod tests { let mut evidence = evidence_ref(&fact.fact_id); evidence.text_hash = Some(hash_text(&fact.value)); let capsule = capsule_with(tenant_id, answer_id, vec![evidence], Vec::new()); - store_answer_capsule(&state, &capsule).await.expect("store capsule"); + store_answer_capsule(&state, &capsule, "default") + .await + .expect("store capsule"); (state, capsule) } @@ -1656,7 +1658,9 @@ mod tests { let mut evidence = evidence_ref(&fact.fact_id); evidence.text_hash = Some(hash_text("something else entirely")); let capsule = capsule_with("tenant-a", "ans_1", vec![evidence], Vec::new()); - store_answer_capsule(&state, &capsule).await.expect("store capsule"); + store_answer_capsule(&state, &capsule, "default") + .await + .expect("store capsule"); let resp = get_answer_replay_validity( State(state), @@ -1688,7 +1692,9 @@ mod tests { evidence.text_hash = Some(hash_text(&fact.value)); evidence.artifact_id = Some(77); let capsule = capsule_with("tenant-a", "ans_1", vec![evidence], Vec::new()); - store_answer_capsule(&state, &capsule).await.expect("store capsule"); + store_answer_capsule(&state, &capsule, "default") + .await + .expect("store capsule"); let resp = get_answer_replay_validity( State(state), @@ -1714,7 +1720,9 @@ mod tests { async fn store_then_load_answer_capsule_round_trips() { let state = pro_replay_state(); let capsule = capsule_with("tenant-a", "ans_1", vec![evidence_ref("f_1")], Vec::new()); - store_answer_capsule(&state, &capsule).await.expect("store capsule"); + store_answer_capsule(&state, &capsule, "default") + .await + .expect("store capsule"); let loaded = load_answer_capsule(&state, "tenant-a", "ans_1", "default") .await .expect("capsule present"); @@ -2855,7 +2863,9 @@ mod tests { .is_none()); let capsule = capsule_with("tenant-a", "ans_1", vec![evidence_ref("f_1")], Vec::new()); - store_answer_capsule(&state, &capsule).await.expect("store capsule"); + store_answer_capsule(&state, &capsule, "default") + .await + .expect("store capsule"); let resp = export_answer_capsule_if_present( &state, "tenant-a", From 30c56d934749c74bd55ca63c315841c0cfd79ab1 Mon Sep 17 00:00:00 2001 From: CueCrux-Myles Date: Sat, 22 Aug 2026 12:25:01 +0100 Subject: [PATCH 3/4] security(s3c): seeded docs stay addressable; private daemon state does not MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves the one disagreement between this slice and main. 426ea8a0 routes fact-backed reads through fact_visible_to_agent, which hides born-private reserved namespaces from a caller with no passport. Main's b36a72b1 guarantees the opposite for the addressed path: internal namespaces are excluded from UNDIRECTED recall but stay reachable when named, so nothing becomes unreachable. Both rules are right; they met on one code path. On an auth-off daemon passport_id is None and the operator is nonetheless the owner, so applying the first rule everywhere made the daemon's own seeded manual unreadable through /v1/context even by name — precisely the outcome b36a72b1 exists to prevent. Operator decision, taken 2026-08-22: exempt seeded documentation only. The distinction is content, not prefix shape. __bootstrap__:: is documentation the daemon seeded about itself and is safe to hand to anyone who can already reach the port; __agent:: and __ops:: are private state whose disclosure would be a real leak — wider than the problem being solved. The exemption is therefore one namespace wide and addressed-only; undirected recall still excludes every internal namespace. Adds fact_privacy::is_addressable_without_agent_identity so the rule has one home and a name, rather than a prefix literal at the filter. Both directions are tested, because only testing the permissive one would let the exemption widen silently: - addressing_an_internal_entity_still_returns_it (main's, now passing again) - addressing_private_daemon_state_without_an_agent_returns_nothing (new). Positive-controlled: widening the helper to is_internal_namespace fails it with __agent::alice::secret in the response. Gates: cargo test --workspace --no-fail-fast --locked 8339 passed / 0 failed; clippy --workspace -D warnings clean; fmt clean; unwrap-ratchet OK (389/389). Co-Authored-By: Claude Opus 5 (1M context) --- crates/corecrux-memory/src/fact_privacy.rs | 26 +++++++++++++++ crates/corecruxd/src/http/context_surface.rs | 34 ++++++++++++++++++++ crates/corecruxd/src/http/facts.rs | 14 +++++++- 3 files changed, 73 insertions(+), 1 deletion(-) diff --git a/crates/corecrux-memory/src/fact_privacy.rs b/crates/corecrux-memory/src/fact_privacy.rs index 20ec62a4..65c8a15d 100644 --- a/crates/corecrux-memory/src/fact_privacy.rs +++ b/crates/corecrux-memory/src/fact_privacy.rs @@ -183,6 +183,32 @@ pub fn is_internal_namespace(entity: &str) -> bool { entity.starts_with(INTERNAL_NAMESPACE_MARKER) } +/// The one internal namespace that is *seeded documentation* rather than +/// somebody's private state: the first-run docs the daemon ships about itself. +pub const SEEDED_DOCUMENTATION_PREFIX: &str = "__bootstrap__::"; + +/// Whether an entity may be returned to a caller that named it explicitly but +/// carries no authenticated agent identity. +/// +/// This exists because two correct rules met and disagreed. Reserved +/// namespaces are born private, so `fact_visible_to_agent` hides them from a +/// caller with no passport. Separately, daemon-internal namespaces are kept out +/// of *undirected* recall but deliberately stay reachable when addressed, so +/// that nothing becomes unreachable. On an auth-off local daemon `passport_id` +/// is `None` and the operator is nonetheless the owner, so applying the first +/// rule everywhere made the daemon's own manual unreadable through +/// `/v1/context` — the exact outcome the second rule was written to prevent. +/// +/// The distinction that resolves it is content, not prefix shape: +/// `__bootstrap__::` is documentation the daemon seeded about itself and is +/// safe to hand to anyone who can already reach the port, whereas `__agent::` +/// and `__ops::` are private state whose disclosure would be a real leak. So +/// the exemption is deliberately one namespace wide, and addressed-only — +/// undirected recall still excludes every internal namespace. +pub fn is_addressable_without_agent_identity(entity: &str) -> bool { + entity.starts_with(SEEDED_DOCUMENTATION_PREFIX) +} + /// Reserved entity namespaces owned exclusively by daemon governance flows. /// /// Client-facing fact-create and target-mutation handlers must reject these diff --git a/crates/corecruxd/src/http/context_surface.rs b/crates/corecruxd/src/http/context_surface.rs index c2178051..0897a41d 100644 --- a/crates/corecruxd/src/http/context_surface.rs +++ b/crates/corecruxd/src/http/context_surface.rs @@ -752,6 +752,40 @@ mod tests { assert_eq!(entities, vec!["__bootstrap__::doc:api-append".to_string()]); } + /// The boundary of the exemption that keeps the test above passing. + /// Seeded documentation is addressable without an agent identity; private + /// daemon state is not. Without this, "reserved namespaces are readable + /// when named" could widen to `__agent::` / `__ops::` and nothing would + /// fail — the disclosure would be strictly worse than the problem the + /// exemption solves. + #[tokio::test] + async fn addressing_private_daemon_state_without_an_agent_returns_nothing() { + let state = enabled_state(); + for entity in ["__agent::alice::secret", "__ops::internal-state"] { + store_fact(&state, entity, "content", "private daemon state").await; + + let bundle = get_bundle(&state, req(Some(entity), None, Some(4000))).await; + let entities: Vec = bundle["sections"] + .as_array() + .expect("sections") + .iter() + .find(|s| s["kind"] == "facts") + .map(|s| { + s["facts"] + .as_array() + .expect("facts items") + .iter() + .map(|f| f["entity"].as_str().unwrap_or_default().to_string()) + .collect() + }) + .unwrap_or_default(); + assert!( + entities.is_empty(), + "{entity} must stay hidden from a caller with no agent identity, got {entities:?}" + ); + } + } + #[tokio::test] async fn stable_region_is_byte_stable_across_calls() { let state = enabled_state(); diff --git a/crates/corecruxd/src/http/facts.rs b/crates/corecruxd/src/http/facts.rs index 2c379ecf..dd1cd5d4 100644 --- a/crates/corecruxd/src/http/facts.rs +++ b/crates/corecruxd/src/http/facts.rs @@ -415,7 +415,19 @@ fn query_visible_http_facts_as_of_inner( .all_facts_for_tenant(tenant_hash) .filter(|fact| !fact.deleted) .filter(|fact| as_of.is_none_or(|instant| fact.valid_at(instant))) - .filter(|fact| crux_mcp::scope::fact_visible_to_agent(fact, agent_name)) + .filter(|fact| { + crux_mcp::scope::fact_visible_to_agent(fact, agent_name) + // Addressed-only exemption for seeded documentation. Reserved + // namespaces are born private, so an unauthenticated caller — + // which on an auth-off daemon is the operator, `passport_id` + // being `None` — otherwise cannot read the daemon's own manual + // through `/v1/context` even by naming it. Scoped to + // `__bootstrap__::` and to requests that name an entity; + // undirected recall still excludes every internal namespace, + // and `__agent::` / `__ops::` stay hidden. + || (q.entity.is_some() + && corecrux_memory::fact_privacy::is_addressable_without_agent_identity(&fact.entity)) + }) .filter(|fact| q.tenant_hash.as_ref().is_none_or(|tenant| fact.tenant_hash == *tenant)) .filter(|fact| { q.entity_prefix From 37a1860ac6291134296a9e467c269cadaea75319 Mon Sep 17 00:00:00 2001 From: CueCrux-Myles Date: Sat, 22 Aug 2026 13:35:08 +0100 Subject: [PATCH 4/4] docs(candidates): point the module doc at the tenant-scoped review API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rustdoc gate caught a real interaction between this replay and newer main code. `426ea8a0` makes `promote_for_tenant` / `reject_for_tenant` the real API and demotes `promote` / `reject` to `#[cfg(test)]` wrappers, but the module doc — added on main by `bcd38b00`, after this branch was written — links to the bare names. Outside a test build those items no longer exist, so `cargo doc -D warnings` fails with 'unresolved link to promote'. Repoint the links at the functions that actually exist in a non-test build. Verified with the gate CI runs: RUSTDOCFLAGS='-D warnings' cargo doc --locked --workspace --no-deps, exit 0. Co-Authored-By: Claude Opus 5 (1M context) --- crates/corecruxd/src/candidate_store.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/corecruxd/src/candidate_store.rs b/crates/corecruxd/src/candidate_store.rs index 83eea615..1e0f384a 100644 --- a/crates/corecruxd/src/candidate_store.rs +++ b/crates/corecruxd/src/candidate_store.rs @@ -29,7 +29,8 @@ //! defence in depth, not the primary guarantee. //! //! This module owns the whole candidate domain: the schema, the born-private -//! write, the read-back, and the review lifecycle ([`promote`]/[`reject`]) with +//! write, the read-back, and the review lifecycle +//! ([`promote_for_tenant`] / [`reject_for_tenant`]) with //! the fail-closed gate ([`PromotionMode`]). use corecrux_memory::fact_store::StoreFact;