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/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/auth.rs b/crates/corecruxd/src/auth.rs index ce5e0cd3..3f90dfde 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 ──────────────────────────────────────────────────── @@ -4048,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, @@ -5216,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), @@ -5234,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"); @@ -5246,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"); @@ -5273,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] @@ -5299,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/candidate_store.rs b/crates/corecruxd/src/candidate_store.rs index 5143e529..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; @@ -154,15 +155,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 +192,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 +213,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 +276,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 +327,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 +347,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 +375,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 +397,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 +424,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 +502,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 +702,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 +722,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..0897a41d 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(), @@ -705,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(); @@ -958,6 +1039,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/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/facts.rs b/crates/corecruxd/src/http/facts.rs index 175324f8..dd1cd5d4 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,12 +411,23 @@ 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)) + .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 @@ -557,8 +598,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 +636,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 +714,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 +787,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 +832,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 +876,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 +967,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..41531787 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)?, @@ -1197,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) } @@ -1646,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), @@ -1678,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), @@ -1704,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"); @@ -2845,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", 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.