From f5e01ce43be96cee93e122f9fee86a3e5bcb3b75 Mon Sep 17 00:00:00 2001 From: Neeraj Sathish Kumar Date: Tue, 30 Jun 2026 14:52:05 +0530 Subject: [PATCH] fix: scope memory upserts by user --- src/adapters/postgres.rs | 9 ++++++- src/lib.rs | 54 +++++++++++++++++++++++++++++++++++++++- src/testkit/mod.rs | 10 ++++---- 3 files changed, 66 insertions(+), 7 deletions(-) diff --git a/src/adapters/postgres.rs b/src/adapters/postgres.rs index 4214685..0e41784 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 affected_rows = 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 affected_rows == 0 { + return Err(CoreError::Conflict( + "memory id already exists for a different tenant or user".to_string(), + )); + } Ok(()) } diff --git a/src/lib.rs b/src/lib.rs index 6f140e1..c822658 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -135,6 +135,54 @@ mod tests { assert_eq!(records[0].id, "mem_1"); } + #[test] + fn local_store_upsert_scopes_caller_supplied_ids_to_tenant_and_user() { + let mut store = TestMemoryStore::new(); + let policy = IngestionPolicy { + min_importance_score: 0.0, + min_confidence_score: 0.0, + }; + + let mut victim_request = IngestMemoryRequest::new( + "tenant_1", + "victim_user", + "victim private fact", + ContentType::Fact, + MemoryType::Semantic, + SourceType::Manual, + policy.clone(), + ); + victim_request.id = Some("known_memory_id".to_string()); + victim_request.confidence_score = Some(1.0); + ingest_memory(&mut store, victim_request).unwrap(); + + let mut attacker_request = IngestMemoryRequest::new( + "tenant_1", + "attacker_user", + "attacker controlled replacement", + ContentType::Fact, + MemoryType::Semantic, + SourceType::Manual, + policy, + ); + attacker_request.id = Some("known_memory_id".to_string()); + attacker_request.confidence_score = Some(1.0); + ingest_memory(&mut store, attacker_request).unwrap(); + + let victim_memory = store + .get_memory("tenant_1", "victim_user", "known_memory_id") + .unwrap() + .expect("victim memory should remain visible to the victim"); + assert_eq!(victim_memory.content, "victim private fact"); + + let attacker_memory = store + .get_memory("tenant_1", "attacker_user", "known_memory_id") + .unwrap() + .expect("attacker memory should be stored separately"); + assert_eq!(attacker_memory.content, "attacker controlled replacement"); + assert_eq!(store.memories.len(), 2); + } + #[test] fn ingestion_retrieval_graph_and_reminders_work() { let mut store = TestMemoryStore::new(); @@ -423,7 +471,11 @@ mod tests { ) .unwrap(); assert!(response.telemetry.vector_candidates >= 1); - assert!(response.telemetry.vector_ms < 10000, "vector search took too long: {}ms", response.telemetry.vector_ms); + assert!( + response.telemetry.vector_ms < 10000, + "vector search took too long: {}ms", + response.telemetry.vector_ms + ); assert!(response.telemetry.token_utilization >= 0.0); assert!(response.telemetry.dedupe_ratio >= 0.0); } diff --git a/src/testkit/mod.rs b/src/testkit/mod.rs index 5e2ba40..2199fa7 100644 --- a/src/testkit/mod.rs +++ b/src/testkit/mod.rs @@ -81,11 +81,11 @@ impl TestMemoryStore { impl MemoryIndexStore for TestMemoryStore { fn upsert_memory(&mut self, record: MemoryRecord) -> CoreResult<()> { record.validate()?; - if let Some(existing) = self - .memories - .iter_mut() - .find(|memory| memory.id == record.id) - { + if let Some(existing) = self.memories.iter_mut().find(|memory| { + memory.tenant_id == record.tenant_id + && memory.user_id == record.user_id + && memory.id == record.id + }) { *existing = record; return Ok(()); }