Skip to main content

aep_service/
store.rs

1use std::{
2    collections::BTreeMap,
3    sync::{Arc, Mutex},
4    time::Duration,
5};
6
7use async_trait::async_trait;
8use time::OffsetDateTime;
9
10use crate::{
11    ClientAssertionReplayRecord, ClientAssertionReplayStore, Clock, CommandIdempotencyInput,
12    CommandIdempotencyRecord, CommandIdempotencyResult, CommandIdempotencyStore, CommandOperation,
13    EnrollmentDecision, EnrollmentPolicy, EnrollmentRecord, EnrollmentStore, ServiceError,
14};
15
16type IdempotencyKey = (String, String);
17type IdempotencyLock = Arc<futures::lock::Mutex<()>>;
18
19/// How long a replayable command result stays available.
20///
21/// CORE-IDP-004 requires at least one hour, so a record is still replayable at exactly that age
22/// and is dropped after it, which is what keeps the store from growing without bound.
23const IDEMPOTENCY_RETENTION: Duration = Duration::from_secs(3600);
24
25#[derive(Default)]
26pub struct SystemClock;
27
28impl Clock for SystemClock {
29    fn now(&self) -> OffsetDateTime {
30        OffsetDateTime::now_utc()
31    }
32}
33
34pub struct StaticEnrollmentPolicy {
35    decision: EnrollmentDecision,
36}
37
38impl StaticEnrollmentPolicy {
39    pub fn new(decision: EnrollmentDecision) -> Self {
40        Self { decision }
41    }
42}
43
44impl Default for StaticEnrollmentPolicy {
45    fn default() -> Self {
46        Self::new(EnrollmentDecision::default())
47    }
48}
49
50#[async_trait]
51impl EnrollmentPolicy for StaticEnrollmentPolicy {
52    async fn decide(
53        &self,
54        _request: &aep_core::EnrollRequest,
55        _now: OffsetDateTime,
56    ) -> Result<EnrollmentDecision, ServiceError> {
57        Ok(self.decision.clone())
58    }
59}
60
61#[derive(Default)]
62pub struct MemoryEnrollmentStore {
63    records: Mutex<BTreeMap<String, EnrollmentRecord>>,
64}
65
66impl MemoryEnrollmentStore {
67    pub fn new(records: impl IntoIterator<Item = EnrollmentRecord>) -> Result<Self, ServiceError> {
68        let mut entries = BTreeMap::new();
69        for record in records {
70            if record.agent_did.is_empty() {
71                return Err(ServiceError::Store(
72                    "AEP enrollment Agent DID must not be empty".to_owned(),
73                ));
74            }
75            entries.insert(record.agent_did.clone(), record);
76        }
77        Ok(Self {
78            records: Mutex::new(entries),
79        })
80    }
81}
82
83#[async_trait]
84impl EnrollmentStore for MemoryEnrollmentStore {
85    async fn find(&self, agent_did: &str) -> Result<Option<EnrollmentRecord>, ServiceError> {
86        Ok(self
87            .records
88            .lock()
89            .map_err(lock_error)?
90            .get(agent_did)
91            .cloned())
92    }
93
94    async fn save(&self, record: EnrollmentRecord) -> Result<EnrollmentRecord, ServiceError> {
95        if record.agent_did.is_empty() {
96            return Err(ServiceError::Store(
97                "AEP enrollment Agent DID must not be empty".to_owned(),
98            ));
99        }
100        self.records
101            .lock()
102            .map_err(lock_error)?
103            .insert(record.agent_did.clone(), record.clone());
104        Ok(record)
105    }
106}
107
108#[derive(Default)]
109pub struct MemoryClientAssertionReplayStore {
110    records: Mutex<BTreeMap<(String, String), ClientAssertionReplayRecord>>,
111}
112
113#[async_trait]
114impl ClientAssertionReplayStore for MemoryClientAssertionReplayStore {
115    async fn consume(
116        &self,
117        record: ClientAssertionReplayRecord,
118        now: OffsetDateTime,
119    ) -> Result<bool, ServiceError> {
120        let mut records = self.records.lock().map_err(lock_error)?;
121        records.retain(|_, existing| existing.expires_at > now);
122        let key = (record.sub.clone(), record.jti.clone());
123        if records.contains_key(&key) {
124            return Ok(false);
125        }
126        records.insert(key, record);
127        Ok(true)
128    }
129}
130
131pub struct MemoryCommandIdempotencyStore {
132    clock: Arc<dyn Clock>,
133    locks: Mutex<BTreeMap<IdempotencyKey, IdempotencyLock>>,
134    records: Mutex<BTreeMap<IdempotencyKey, CommandIdempotencyRecord>>,
135}
136
137impl Default for MemoryCommandIdempotencyStore {
138    fn default() -> Self {
139        Self::with_clock(Arc::new(SystemClock))
140    }
141}
142
143impl MemoryCommandIdempotencyStore {
144    /// Builds an empty store that reads the time from `clock`, which is what decides when a
145    /// stored result has outlived its retention.
146    pub fn with_clock(clock: Arc<dyn Clock>) -> Self {
147        Self {
148            clock,
149            locks: Mutex::new(BTreeMap::new()),
150            records: Mutex::new(BTreeMap::new()),
151        }
152    }
153
154    pub fn new(
155        records: impl IntoIterator<Item = CommandIdempotencyRecord>,
156    ) -> Result<Self, ServiceError> {
157        let mut entries = BTreeMap::new();
158        for record in records {
159            let key = (
160                record.input.agent_did.clone(),
161                record.input.idempotency_key.clone(),
162            );
163            if key.0.is_empty() || key.1.is_empty() {
164                return Err(ServiceError::Store(
165                    "AEP idempotency record key must not be empty".to_owned(),
166                ));
167            }
168            entries.insert(key, record);
169        }
170        Ok(Self {
171            clock: Arc::new(SystemClock),
172            locks: Mutex::new(BTreeMap::new()),
173            records: Mutex::new(entries),
174        })
175    }
176
177    fn command_lock(&self, key: &IdempotencyKey) -> Result<IdempotencyLock, ServiceError> {
178        Ok(self
179            .locks
180            .lock()
181            .map_err(lock_error)?
182            .entry(key.clone())
183            .or_default()
184            .clone())
185    }
186
187    fn release_command_lock(
188        &self,
189        key: &IdempotencyKey,
190        command_lock: &IdempotencyLock,
191    ) -> Result<(), ServiceError> {
192        let mut locks = self.locks.lock().map_err(lock_error)?;
193        if locks
194            .get(key)
195            .is_some_and(|current| Arc::ptr_eq(current, command_lock))
196            && Arc::strong_count(command_lock) == 2
197        {
198            locks.remove(key);
199        }
200        Ok(())
201    }
202}
203
204#[async_trait]
205impl CommandIdempotencyStore for MemoryCommandIdempotencyStore {
206    async fn execute(
207        &self,
208        input: CommandIdempotencyInput,
209        operation: CommandOperation,
210    ) -> Result<CommandIdempotencyResult, ServiceError> {
211        let key = (input.agent_did.clone(), input.idempotency_key.clone());
212        let command_lock = self.command_lock(&key)?;
213        let result = {
214            let _guard = command_lock.lock().await;
215            let now = self.clock.now();
216            let existing = {
217                let mut records = self.records.lock().map_err(lock_error)?;
218                records.retain(|_, record| record.created_at + IDEMPOTENCY_RETENTION >= now);
219                records.get(&key).cloned()
220            };
221            if let Some(record) = existing {
222                if record.input.command == input.command
223                    && record.input.request_hash == input.request_hash
224                {
225                    Ok(CommandIdempotencyResult::Replayed(record.response))
226                } else {
227                    Ok(CommandIdempotencyResult::Conflict)
228                }
229            } else {
230                match operation().await {
231                    Ok(response) => {
232                        let created_at = self.clock.now();
233                        self.records.lock().map_err(lock_error)?.insert(
234                            key.clone(),
235                            CommandIdempotencyRecord {
236                                input,
237                                response: response.clone(),
238                                created_at,
239                            },
240                        );
241                        Ok(CommandIdempotencyResult::Created(response))
242                    }
243                    Err(error) => Err(error),
244                }
245            }
246        };
247        self.release_command_lock(&key, &command_lock)?;
248        result
249    }
250}
251
252fn lock_error<T>(_error: std::sync::PoisonError<T>) -> ServiceError {
253    ServiceError::Store("AEP Service memory store lock is poisoned".to_owned())
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    #[test]
261    fn retains_an_idempotency_lock_while_another_request_holds_it() {
262        let store = MemoryCommandIdempotencyStore::default();
263        let key = ("did:web:agent.example".to_owned(), "request-1".to_owned());
264        let first = store.command_lock(&key).expect("first lock");
265        let waiting = store.command_lock(&key).expect("waiting lock");
266
267        store
268            .release_command_lock(&key, &first)
269            .expect("retain lock");
270        let concurrent = store.command_lock(&key).expect("concurrent lock");
271        assert!(Arc::ptr_eq(&first, &waiting));
272        assert!(Arc::ptr_eq(&first, &concurrent));
273
274        drop(waiting);
275        drop(concurrent);
276        store
277            .release_command_lock(&key, &first)
278            .expect("release lock");
279        assert!(!store.locks.lock().expect("locks").contains_key(&key));
280    }
281}