From b9b6ea9c164eb555f05596e47f47135512462d81 Mon Sep 17 00:00:00 2001 From: Neeraj Sathish Kumar Date: Mon, 4 May 2026 12:22:22 +0530 Subject: [PATCH] fix: prevent cross-tenant memory id overwrite --- src/adapters/postgres.rs | 9 ++++++++- src/lib.rs | 40 ++++++++++++++++++++++++++++++++++++++++ src/testkit/mod.rs | 14 +++++++++++++- 3 files changed, 61 insertions(+), 2 deletions(-) diff --git a/src/adapters/postgres.rs b/src/adapters/postgres.rs index 4214685..856b78d 100644 --- a/src/adapters/postgres.rs +++ b/src/adapters/postgres.rs @@ -73,7 +73,7 @@ impl PostgresPort for PostgresAdapter { let last_accessed_at = optional_epoch_seconds(record.last_accessed_at.as_deref(), "last_accessed_at")?; - client + let rows_affected = client .execute( r#" INSERT INTO nextral_memories ( @@ -111,6 +111,8 @@ impl PostgresPort for PostgresAdapter { access_count = EXCLUDED.access_count, status = EXCLUDED.status, schema_version = EXCLUDED.schema_version + WHERE nextral_memories.tenant_id = EXCLUDED.tenant_id + AND nextral_memories.user_id = EXCLUDED.user_id "#, &[ &record.id, @@ -140,6 +142,11 @@ impl PostgresPort for PostgresAdapter { ], ) .map_err(|error| CoreError::Io(error.to_string()))?; + if rows_affected == 0 { + return Err(CoreError::Conflict( + "memory id is already in use by a different tenant/user scope".to_string(), + )); + } Ok(()) } diff --git a/src/lib.rs b/src/lib.rs index b6a1dca..de3715c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -23,6 +23,7 @@ pub use contracts::{CoreError, CoreResult}; #[cfg(test)] mod tests { use crate::{ + CoreError, config::{ AuthConfig, CacheConfig, EmbeddingProviderConfig, EmbeddingProviderKind, ExtractionProviderConfig, ExtractionProviderKind, IngestionPolicy, NextralConfig, @@ -192,6 +193,45 @@ mod tests { ); } + #[test] + fn ingest_rejects_cross_tenant_id_collision() { + let mut store = TestMemoryStore::new(); + let policy = IngestionPolicy { + min_importance_score: 0.0, + min_confidence_score: 0.0, + }; + + let mut tenant_a = IngestMemoryRequest::new( + "tenant_a", + "usr_1", + "Tenant A fact", + ContentType::Fact, + MemoryType::Semantic, + SourceType::Manual, + policy.clone(), + ); + tenant_a.id = Some("shared-id".to_string()); + tenant_a.importance_score = 1.0; + assert_eq!(ingest_memory(&mut store, tenant_a).unwrap().status, IngestStatus::Accepted); + + let mut tenant_b = IngestMemoryRequest::new( + "tenant_b", + "usr_1", + "Tenant B overwrite attempt", + ContentType::Fact, + MemoryType::Semantic, + SourceType::Manual, + policy, + ); + tenant_b.id = Some("shared-id".to_string()); + tenant_b.importance_score = 1.0; + + let error = ingest_memory(&mut store, tenant_b).unwrap_err(); + assert!(matches!(error, CoreError::Conflict(_))); + let original = store.get_memory("tenant_a", "usr_1", "shared-id").unwrap(); + assert_eq!(original.unwrap().content, "Tenant A fact"); + } + #[test] fn all_seven_memory_types_have_runtime_topology() { let profiles = all_profiles(); diff --git a/src/testkit/mod.rs b/src/testkit/mod.rs index 85eb963..9cfabfc 100644 --- a/src/testkit/mod.rs +++ b/src/testkit/mod.rs @@ -75,10 +75,22 @@ impl TestMemoryStore { impl MemoryIndexStore for TestMemoryStore { fn upsert_memory(&mut self, record: MemoryRecord) -> CoreResult<()> { record.validate()?; + if self.memories.iter().any(|memory| { + memory.id == record.id + && (memory.tenant_id != record.tenant_id || memory.user_id != record.user_id) + }) { + return Err(CoreError::Conflict( + "memory id is already in use by a different tenant/user scope".to_string(), + )); + } if let Some(existing) = self .memories .iter_mut() - .find(|memory| memory.id == record.id) + .find(|memory| { + memory.id == record.id + && memory.tenant_id == record.tenant_id + && memory.user_id == record.user_id + }) { *existing = record; return Ok(());