a2a_protocol_server/push/
tenant_config_store.rs1use std::collections::HashMap;
12use std::future::Future;
13use std::pin::Pin;
14use std::sync::Arc;
15
16use a2a_protocol_types::error::A2aResult;
17use a2a_protocol_types::push::TaskPushNotificationConfig;
18use tokio::sync::RwLock;
19
20use super::config_store::{InMemoryPushConfigStore, PushConfigStore};
21use crate::store::tenant::TenantContext;
22
23#[derive(Debug)]
45pub struct TenantAwareInMemoryPushConfigStore {
46 stores: RwLock<HashMap<String, Arc<InMemoryPushConfigStore>>>,
47 max_tenants: usize,
48 max_configs_per_task: usize,
49}
50
51impl Default for TenantAwareInMemoryPushConfigStore {
52 fn default() -> Self {
53 Self::new()
54 }
55}
56
57impl TenantAwareInMemoryPushConfigStore {
58 #[must_use]
60 pub fn new() -> Self {
61 Self {
62 stores: RwLock::new(HashMap::new()),
63 max_tenants: 1000,
64 max_configs_per_task: 100,
65 }
66 }
67
68 #[must_use]
70 pub fn with_limits(max_tenants: usize, max_configs_per_task: usize) -> Self {
71 Self {
72 stores: RwLock::new(HashMap::new()),
73 max_tenants,
74 max_configs_per_task,
75 }
76 }
77
78 async fn get_store(&self) -> A2aResult<Arc<InMemoryPushConfigStore>> {
80 let tenant = TenantContext::current();
81
82 {
83 let stores = self.stores.read().await;
84 if let Some(store) = stores.get(&tenant) {
85 return Ok(Arc::clone(store));
86 }
87 }
88
89 let mut stores = self.stores.write().await;
90 if let Some(store) = stores.get(&tenant) {
91 return Ok(Arc::clone(store));
92 }
93
94 if stores.len() >= self.max_tenants {
95 return Err(a2a_protocol_types::error::A2aError::internal(format!(
96 "tenant limit exceeded: max {} tenants",
97 self.max_tenants
98 )));
99 }
100
101 let store = Arc::new(InMemoryPushConfigStore::with_max_configs_per_task(
102 self.max_configs_per_task,
103 ));
104 stores.insert(tenant, Arc::clone(&store));
105 drop(stores);
106 Ok(store)
107 }
108
109 async fn get_existing_store(&self) -> Option<Arc<InMemoryPushConfigStore>> {
116 let tenant = TenantContext::current();
117 self.stores.read().await.get(&tenant).map(Arc::clone)
118 }
119
120 pub async fn tenant_count(&self) -> usize {
122 self.stores.read().await.len()
123 }
124}
125
126#[allow(clippy::manual_async_fn)]
127impl PushConfigStore for TenantAwareInMemoryPushConfigStore {
128 fn set<'a>(
129 &'a self,
130 config: TaskPushNotificationConfig,
131 ) -> Pin<Box<dyn Future<Output = A2aResult<TaskPushNotificationConfig>> + Send + 'a>> {
132 Box::pin(async move {
133 let store = self.get_store().await?;
134 store.set(config).await
135 })
136 }
137
138 fn get<'a>(
139 &'a self,
140 task_id: &'a str,
141 id: &'a str,
142 ) -> Pin<Box<dyn Future<Output = A2aResult<Option<TaskPushNotificationConfig>>> + Send + 'a>>
143 {
144 Box::pin(async move {
145 match self.get_existing_store().await {
147 Some(store) => store.get(task_id, id).await,
148 None => Ok(None),
149 }
150 })
151 }
152
153 fn list<'a>(
154 &'a self,
155 task_id: &'a str,
156 ) -> Pin<Box<dyn Future<Output = A2aResult<Vec<TaskPushNotificationConfig>>> + Send + 'a>> {
157 Box::pin(async move {
158 match self.get_existing_store().await {
160 Some(store) => store.list(task_id).await,
161 None => Ok(Vec::new()),
162 }
163 })
164 }
165
166 fn delete<'a>(
167 &'a self,
168 task_id: &'a str,
169 id: &'a str,
170 ) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
171 Box::pin(async move {
172 match self.get_existing_store().await {
175 Some(store) => store.delete(task_id, id).await,
176 None => Ok(()),
177 }
178 })
179 }
180
181 fn count(&self) -> Pin<Box<dyn Future<Output = A2aResult<Option<usize>>> + Send + '_>> {
182 Box::pin(async move {
183 match self.get_existing_store().await {
186 Some(store) => store.count().await,
187 None => Ok(Some(0)),
188 }
189 })
190 }
191}
192
193#[cfg(test)]
194mod tests {
195 use super::*;
196 use a2a_protocol_types::push::TaskPushNotificationConfig;
197
198 fn make_config(task_id: &str, id: Option<&str>, url: &str) -> TaskPushNotificationConfig {
199 TaskPushNotificationConfig {
200 tenant: None,
201 id: id.map(String::from),
202 task_id: Some(task_id.to_string()),
203 url: url.to_string(),
204 token: None,
205 authentication: None,
206 }
207 }
208
209 #[tokio::test]
210 async fn new_store_has_zero_tenants() {
211 let store = TenantAwareInMemoryPushConfigStore::new();
212 assert_eq!(
213 store.tenant_count().await,
214 0,
215 "new store should have no tenants"
216 );
217 }
218
219 #[tokio::test]
220 async fn set_and_get_within_tenant_scope() {
221 let store = TenantAwareInMemoryPushConfigStore::new();
222 TenantContext::scope("tenant-a", async {
223 store
224 .set(make_config("task-1", Some("cfg-1"), "https://a.com/hook"))
225 .await
226 .expect("set should succeed");
227
228 let config = store
229 .get("task-1", "cfg-1")
230 .await
231 .expect("get should succeed")
232 .expect("config should exist");
233 assert_eq!(config.url, "https://a.com/hook");
234 })
235 .await;
236 }
237
238 #[tokio::test]
239 async fn tenant_isolation() {
240 let store = TenantAwareInMemoryPushConfigStore::new();
241
242 TenantContext::scope("tenant-a", async {
244 store
245 .set(make_config("task-1", Some("cfg-1"), "https://a.com"))
246 .await
247 .unwrap();
248 })
249 .await;
250
251 TenantContext::scope("tenant-b", async {
253 let result = store.get("task-1", "cfg-1").await.unwrap();
254 assert!(
255 result.is_none(),
256 "tenant-b should not see tenant-a's config"
257 );
258 })
259 .await;
260
261 TenantContext::scope("tenant-a", async {
263 let result = store.get("task-1", "cfg-1").await.unwrap();
264 assert!(result.is_some(), "tenant-a should still see its own config");
265 })
266 .await;
267 }
268
269 #[tokio::test]
270 async fn tenant_count_tracks_distinct_tenants() {
271 let store = TenantAwareInMemoryPushConfigStore::new();
272
273 TenantContext::scope("tenant-a", async {
274 store
275 .set(make_config("t1", Some("c1"), "https://a.com"))
276 .await
277 .unwrap();
278 })
279 .await;
280 assert_eq!(store.tenant_count().await, 1);
281
282 TenantContext::scope("tenant-b", async {
283 store
284 .set(make_config("t1", Some("c1"), "https://b.com"))
285 .await
286 .unwrap();
287 })
288 .await;
289 assert_eq!(store.tenant_count().await, 2);
290
291 TenantContext::scope("tenant-a", async {
293 store
294 .set(make_config("t2", Some("c2"), "https://a2.com"))
295 .await
296 .unwrap();
297 })
298 .await;
299 assert_eq!(
300 store.tenant_count().await,
301 2,
302 "re-using an existing tenant should not increase count"
303 );
304 }
305
306 #[tokio::test]
307 async fn with_limits_enforces_max_tenants() {
308 let store = TenantAwareInMemoryPushConfigStore::with_limits(1, 100);
309
310 TenantContext::scope("tenant-a", async {
311 store
312 .set(make_config("t1", Some("c1"), "https://a.com"))
313 .await
314 .unwrap();
315 })
316 .await;
317
318 let err = TenantContext::scope("tenant-b", async {
319 store
320 .set(make_config("t1", Some("c1"), "https://b.com"))
321 .await
322 })
323 .await
324 .expect_err("second tenant should exceed max_tenants limit");
325
326 let msg = format!("{err}");
327 assert!(
328 msg.contains("tenant limit exceeded"),
329 "error should mention tenant limit, got: {msg}"
330 );
331 }
332
333 #[tokio::test]
334 async fn with_limits_enforces_per_task_config_limit() {
335 let store = TenantAwareInMemoryPushConfigStore::with_limits(100, 1);
336
337 let err = TenantContext::scope("tenant-a", async {
338 store
339 .set(make_config("t1", Some("c1"), "https://a.com"))
340 .await
341 .unwrap();
342 store
343 .set(make_config("t1", Some("c2"), "https://b.com"))
344 .await
345 })
346 .await
347 .expect_err("second config should exceed per-task limit");
348
349 let msg = format!("{err}");
350 assert!(
351 msg.contains("limit exceeded"),
352 "error should mention limit exceeded, got: {msg}"
353 );
354 }
355
356 #[tokio::test]
357 async fn list_scoped_to_tenant() {
358 let store = TenantAwareInMemoryPushConfigStore::new();
359
360 TenantContext::scope("tenant-a", async {
361 store
362 .set(make_config("t1", Some("c1"), "https://a.com/1"))
363 .await
364 .unwrap();
365 store
366 .set(make_config("t1", Some("c2"), "https://a.com/2"))
367 .await
368 .unwrap();
369 })
370 .await;
371
372 TenantContext::scope("tenant-b", async {
373 store
374 .set(make_config("t1", Some("c3"), "https://b.com/1"))
375 .await
376 .unwrap();
377 })
378 .await;
379
380 let a_list =
381 TenantContext::scope("tenant-a", async { store.list("t1").await.unwrap() }).await;
382 assert_eq!(a_list.len(), 2, "tenant-a should see 2 configs for task t1");
383
384 let b_list =
385 TenantContext::scope("tenant-b", async { store.list("t1").await.unwrap() }).await;
386 assert_eq!(b_list.len(), 1, "tenant-b should see 1 config for task t1");
387 }
388
389 #[tokio::test]
390 async fn delete_scoped_to_tenant() {
391 let store = TenantAwareInMemoryPushConfigStore::new();
392
393 TenantContext::scope("tenant-a", async {
395 store
396 .set(make_config("t1", Some("c1"), "https://a.com"))
397 .await
398 .unwrap();
399 })
400 .await;
401 TenantContext::scope("tenant-b", async {
402 store
403 .set(make_config("t1", Some("c1"), "https://b.com"))
404 .await
405 .unwrap();
406 })
407 .await;
408
409 TenantContext::scope("tenant-a", async {
411 store.delete("t1", "c1").await.unwrap();
412 })
413 .await;
414
415 let a_result =
417 TenantContext::scope("tenant-a", async { store.get("t1", "c1").await.unwrap() }).await;
418 assert!(a_result.is_none(), "tenant-a config should be deleted");
419
420 let b_result =
422 TenantContext::scope("tenant-b", async { store.get("t1", "c1").await.unwrap() }).await;
423 assert!(
424 b_result.is_some(),
425 "tenant-b config should be unaffected by tenant-a's delete"
426 );
427 }
428
429 #[test]
432 fn default_impl_creates_empty_store() {
433 let store = TenantAwareInMemoryPushConfigStore::default();
434 assert_eq!(store.max_tenants, 1000);
435 assert_eq!(store.max_configs_per_task, 100);
436 }
437
438 #[tokio::test]
439 async fn default_is_same_as_new() {
440 let store = TenantAwareInMemoryPushConfigStore::default();
441 assert_eq!(store.tenant_count().await, 0);
442 TenantContext::scope("t", async {
444 store
445 .set(make_config("t1", Some("c1"), "https://x.com"))
446 .await
447 .unwrap();
448 })
449 .await;
450 assert_eq!(store.tenant_count().await, 1);
451 }
452}