Skip to main content

p2panda_auth/processor/
processor.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3use std::fmt::Debug;
4use std::marker::PhantomData;
5
6use p2panda_core::{Extension, Extensions, Hash, LogId, Operation, VerifyingKey};
7use p2panda_store::groups::GroupsStore;
8use p2panda_store::operations::OperationStore;
9use p2panda_store::{SqliteError, SqliteStore, Transaction};
10use p2panda_stream::ingest::{IngestError, ingest_operation};
11use p2panda_stream::orderer::CausalOrderer;
12use serde::{Deserialize, Serialize};
13use thiserror::Error;
14use tracing::{debug, trace};
15
16use crate::group;
17use crate::processor::{GroupsArgs, GroupsOperation};
18use crate::traits::{Conditions, IdentityHandle, Operation as GroupsOperationTrait, OperationId};
19
20type GroupsCrdt<C> = group::GroupCrdt<VerifyingKey, Hash, GroupsOperation<C>, C, StrongRemove<C>>;
21type GroupsCrdtError<C> =
22    group::GroupCrdtError<VerifyingKey, Hash, GroupsOperation<C>, C, StrongRemove<C>>;
23type StrongRemove<C> = group::resolver::StrongRemove<VerifyingKey, Hash, GroupsOperation<C>, C>;
24type GroupsState<C> = group::GroupCrdtState<VerifyingKey, Hash, GroupsOperation<C>, C>;
25
26impl IdentityHandle for VerifyingKey {}
27impl OperationId for Hash {}
28
29/// Processor for groups operations.
30#[derive(Clone)]
31pub struct GroupsProcessor<T, E, L, C = ()> {
32    store: SqliteStore,
33    orderer: CausalOrderer<Hash, SqliteStore>,
34    _marker: PhantomData<(T, E, L, C)>,
35}
36
37impl<T, E, L, C> GroupsProcessor<T, E, L, C>
38where
39    T: Serialize + for<'de> Deserialize<'de>,
40    E: Extensions + Extension<GroupsArgs<C>> + Extension<L>,
41    L: LogId,
42    C: Conditions + Serialize + for<'a> Deserialize<'a>,
43{
44    pub fn new(store: SqliteStore) -> Self {
45        Self {
46            store: store.clone(),
47            orderer: CausalOrderer::new(store),
48            _marker: Default::default(),
49        }
50    }
51    /// Process a groups operation.
52    ///
53    /// Processed operations are first partially ordered, and only processed on the auth groups
54    /// state if all their dependencies have been met. If other operations become "ready" by this
55    /// one, then they will be all processed in order.
56    ///
57    /// If an operation which does not contain the required groups extension is processed then it
58    /// is ignored. Groups messages which are not yet present in the operation store are inserted.
59    pub async fn process<SID>(
60        &self,
61        id: &SID,
62        topic: &T,
63        operation: &Operation<E>,
64    ) -> Result<(), GroupsProcessorError<C>>
65    where
66        SID: for<'a> Deserialize<'a> + Serialize,
67    {
68        // ===== ingest ==== //
69
70        // Extract the log id from the operation extensions.
71        let Some(log_id): Option<L> = operation.header.extension() else {
72            return Err(GroupsProcessorError::MissingLogId);
73        };
74
75        // Insert the operation to the store and form an association with the provided topic for
76        // this author+log_id pair.
77        ingest_operation(&self.store, &operation, &log_id, topic, false).await?;
78
79        // Convert this Operation<E> into a GroupsOperation. If this returns None then the groups
80        // extension was not present and so we consider this a non-groups operation which does not
81        // require processing.
82        let Some(args) = operation.header.extension::<GroupsArgs<C>>() else {
83            trace!(id = operation.hash.to_hex(), "ignore non-groups operation");
84            return Ok(());
85        };
86
87        let groups_operation = GroupsOperation {
88            id: operation.hash,
89            author: operation.header.verifying_key,
90            dependencies: args.dependencies,
91            group_id: args.group_id,
92            action: args.action,
93        };
94
95        debug!(id = operation.hash.to_hex(), "process groups operation");
96
97        // Start a transaction for all following database actions.
98        let permit = self.store.begin().await?;
99
100        // ==== ordering ==== //
101
102        // Process the operation in the orderer.
103        let operation_id = groups_operation.id();
104        let dependencies = groups_operation.dependencies();
105        self.orderer.process(operation_id, &dependencies).await?;
106
107        // ==== groups ==== //
108
109        // Retrieve the current groups state from the store.
110        let mut y = GroupsStore::<SID, GroupsState<C>>::get_groups_state(&self.store, id)
111            .await?
112            .unwrap_or_default();
113
114        debug!(
115            group_id = groups_operation.group_id().to_hex(),
116            "current group membership: {:?}",
117            y.members(groups_operation.group_id())
118        );
119
120        // For all operations that are now "ready" (their dependencies have all been processed)
121        // apply them to the groups state.
122        while let Some(id) = self.orderer.next().await? {
123            debug!(id = id.to_hex(), "apply ready operation to groups state");
124            let groups_operation = get_groups_operation::<E, L, C>(&self.store, &id).await?;
125            y = GroupsCrdt::process(y, &groups_operation)?;
126        }
127
128        // Set the groups state after processing is finished.
129        self.store.set_groups_state(id, &y).await?;
130
131        // Commit the open transaction.
132        self.store.commit(permit).await?;
133
134        debug!(
135            group_id = groups_operation.group_id().to_hex(),
136            "new group membership: {:?}",
137            y.members(groups_operation.group_id())
138        );
139
140        Ok(())
141    }
142}
143
144/// Get a groups operation from the operation store.
145async fn get_groups_operation<E, L, C>(
146    store: &SqliteStore,
147    id: &Hash,
148) -> Result<GroupsOperation<C>, GroupsProcessorError<C>>
149where
150    E: Extensions + Extension<GroupsArgs<C>> + Extension<L>,
151    L: LogId,
152    C: Conditions + Serialize + for<'a> Deserialize<'a>,
153{
154    let operation =
155        <SqliteStore as OperationStore<Operation<E>, Hash, L>>::get_operation_tx(store, id).await?;
156
157    let operation = match operation {
158        Some(operation) => {
159            let Some(args) = operation.header.extension::<GroupsArgs<C>>() else {
160                return Err(GroupsProcessorError::<C>::MissingOperation(*id));
161            };
162
163            GroupsOperation {
164                id: operation.hash,
165                author: operation.header.verifying_key,
166                dependencies: args.dependencies,
167                group_id: args.group_id,
168                action: args.action,
169            }
170        }
171        None => {
172            return Err(GroupsProcessorError::<C>::MissingOperation(*id));
173        }
174    };
175    Ok(operation)
176}
177
178/// Error types which can occur in the groups processor.
179#[allow(clippy::large_enum_variant)]
180#[derive(Debug, Error)]
181pub enum GroupsProcessorError<C>
182where
183    C: Conditions,
184{
185    #[error(transparent)]
186    Store(#[from] SqliteError),
187
188    #[error(transparent)]
189    Groups(#[from] GroupsCrdtError<C>),
190
191    #[error(transparent)]
192    Ingest(#[from] IngestError),
193
194    #[error("missing operation: {0}")]
195    MissingOperation(Hash),
196
197    #[error("operation retrieved from store missing groups arguments: {0}")]
198    MissingGroupsArgs(Hash),
199
200    #[error("missing \"log id\" operation extension")]
201    MissingLogId,
202}
203
204#[cfg(test)]
205mod tests {
206    use p2panda_core::test_utils::TestLog;
207    use p2panda_core::traits::Digest;
208    use p2panda_core::{Extension, Hash, Header, Operation, SigningKey, Topic, VerifyingKey};
209    use p2panda_store::groups::GroupsStore;
210    use p2panda_store::{SqliteStore, Transaction};
211    use serde::{Deserialize, Serialize};
212
213    use crate::Access;
214    use crate::group::{GroupAction, GroupCrdtState, GroupMember};
215    use crate::processor::{GroupsArgs, GroupsOperation};
216    use crate::test_utils::setup_logging;
217
218    type LogId = u64;
219    type GroupsState = GroupCrdtState<VerifyingKey, Hash, GroupsOperation, ()>;
220    type GroupsProcessor = crate::processor::GroupsProcessor<Topic, TestExtensions, LogId>;
221
222    const LOG_ID: u64 = 0;
223
224    #[derive(Debug, Clone, Serialize, Deserialize)]
225    struct TestExtensions {
226        log_id: LogId,
227        groups: Option<GroupsArgs>,
228    }
229
230    impl Extension<GroupsArgs> for TestExtensions {
231        fn extract(header: &Header<Self>) -> Option<GroupsArgs> {
232            header.extensions.groups.clone()
233        }
234    }
235
236    impl Extension<LogId> for TestExtensions {
237        fn extract(header: &Header<Self>) -> Option<LogId> {
238            Some(header.extensions.log_id)
239        }
240    }
241
242    impl From<GroupsArgs> for TestExtensions {
243        fn from(args: GroupsArgs) -> Self {
244            TestExtensions {
245                groups: Some(args),
246                log_id: LOG_ID,
247            }
248        }
249    }
250
251    #[tokio::test]
252    async fn ooo_operations() {
253        setup_logging();
254        let topic = Topic::random();
255
256        let alice_log = TestLog::new();
257        let alice = alice_log.author();
258        let bobby_log = TestLog::new();
259        let bobby = bobby_log.author();
260        let cathy_log = TestLog::new();
261        let cathy = cathy_log.author();
262
263        let state_id = 0;
264        let group_id = SigningKey::generate().verifying_key();
265
266        let args = GroupsArgs {
267            group_id,
268            action: GroupAction::Create {
269                initial_members: vec![
270                    (GroupMember::Individual(alice), <Access>::manage()),
271                    (GroupMember::Individual(bobby), <Access>::manage()),
272                ],
273            },
274            dependencies: vec![],
275        };
276        let op_00: Operation<TestExtensions> = alice_log.operation(&[], TestExtensions::from(args));
277
278        let args = GroupsArgs {
279            group_id,
280            action: GroupAction::Add {
281                member: GroupMember::Individual(cathy),
282                access: Access::manage(),
283            },
284            dependencies: vec![op_00.hash()],
285        };
286        let op_01 = bobby_log.operation(&[], TestExtensions::from(args));
287
288        let args = GroupsArgs {
289            group_id,
290            action: GroupAction::Remove {
291                member: GroupMember::Individual(alice),
292            },
293            dependencies: vec![op_01.hash()],
294        };
295        let op_02 = cathy_log.operation(&[], TestExtensions::from(args));
296
297        let store = SqliteStore::temporary().await;
298        let groups = GroupsProcessor::new(store.clone());
299        groups.process(&state_id, &topic, &op_02).await.unwrap();
300        groups.process(&state_id, &topic, &op_01).await.unwrap();
301        groups.process(&state_id, &topic, &op_00).await.unwrap();
302
303        let permit = store.begin().await.unwrap();
304        let y: GroupsState = store.get_groups_state(&state_id).await.unwrap().unwrap();
305        store.commit(permit).await.unwrap();
306
307        let members = y.members(group_id);
308        assert_eq!(members.len(), 2);
309        assert!(members.contains(&(bobby, Access::manage())));
310        assert!(members.contains(&(cathy, Access::manage())));
311    }
312
313    #[tokio::test]
314    async fn device_groups_single_context() {
315        let topic = Topic::random();
316
317        // All operations are processed on the same groups state context.
318        let state_id = 'S';
319
320        let alice_store = SqliteStore::temporary().await;
321        let bobby_store = SqliteStore::temporary().await;
322        let cathy_store = SqliteStore::temporary().await;
323
324        let alice_log = TestLog::new();
325        let alice = alice_log.author();
326        let bobby_log = TestLog::new();
327        let bobby = bobby_log.author();
328        let cathy_log = TestLog::new();
329        let cathy = cathy_log.author();
330
331        let alice_device_group = SigningKey::generate().verifying_key();
332        let bobby_device_group = SigningKey::generate().verifying_key();
333        let cathy_device_group = SigningKey::generate().verifying_key();
334        let ab_chat = SigningKey::generate().verifying_key();
335        let bc_chat = SigningKey::generate().verifying_key();
336
337        let alice_groups = GroupsProcessor::new(alice_store.clone());
338        let bobby_groups = GroupsProcessor::new(bobby_store.clone());
339        let cathy_groups = GroupsProcessor::new(cathy_store.clone());
340
341        // All members create their own device groups and process them on their own stores.
342
343        let args = GroupsArgs {
344            group_id: alice_device_group,
345            action: GroupAction::Create {
346                initial_members: vec![(GroupMember::Individual(alice), Access::manage())],
347            },
348            dependencies: vec![],
349        };
350        let create_alice_device_00: Operation<TestExtensions> =
351            alice_log.operation(&[], TestExtensions::from(args));
352
353        alice_groups
354            .process(&state_id, &topic, &create_alice_device_00)
355            .await
356            .unwrap();
357
358        let args = GroupsArgs {
359            group_id: bobby_device_group,
360            action: GroupAction::Create {
361                initial_members: vec![(GroupMember::Individual(bobby), Access::manage())],
362            },
363            dependencies: vec![],
364        };
365        let create_bobby_device_01: Operation<TestExtensions> =
366            bobby_log.operation(&[], TestExtensions::from(args));
367
368        bobby_groups
369            .process(&state_id, &topic, &create_bobby_device_01)
370            .await
371            .unwrap();
372
373        let args = GroupsArgs {
374            group_id: cathy_device_group,
375            action: GroupAction::Create {
376                initial_members: vec![(GroupMember::Individual(cathy), Access::manage())],
377            },
378            dependencies: vec![],
379        };
380        let create_cathy_device_02: Operation<TestExtensions> =
381            cathy_log.operation(&[], TestExtensions::from(args));
382
383        cathy_groups
384            .process(&state_id, &topic, &create_cathy_device_02)
385            .await
386            .unwrap();
387
388        // Alice creates chat with Bobby.
389        //
390        // First they process "create device group" operation from Bobby.
391        alice_groups
392            .process(&state_id, &topic, &create_bobby_device_01)
393            .await
394            .unwrap();
395
396        // Then they create the chat group.
397        let permit = alice_store.begin().await.unwrap();
398        let y: GroupsState = alice_store
399            .get_groups_state(&state_id)
400            .await
401            .unwrap()
402            .unwrap();
403        alice_store.commit(permit).await.unwrap();
404
405        let args = GroupsArgs {
406            group_id: ab_chat,
407            action: GroupAction::Create {
408                initial_members: vec![
409                    (GroupMember::Group(alice_device_group), Access::write()),
410                    (GroupMember::Group(bobby_device_group), Access::write()),
411                ],
412            },
413            dependencies: y.heads_filtered(&[alice_device_group, bobby_device_group]),
414        };
415        let create_alice_bobby_chat_03: Operation<TestExtensions> =
416            alice_log.operation(&[], TestExtensions::from(args));
417
418        alice_groups
419            .process(&state_id, &topic, &create_alice_bobby_chat_03)
420            .await
421            .unwrap();
422
423        // Bobby processes alice's "create device group" and "create ab chat".
424        for op in [create_alice_device_00.clone(), create_alice_bobby_chat_03] {
425            bobby_groups.process(&state_id, &topic, &op).await.unwrap();
426        }
427
428        // Both Alice and Bobby have the correct groups state.
429        for store in [alice_store.clone(), bobby_store.clone()] {
430            let permit = store.begin().await.unwrap();
431            let y: GroupsState = store.get_groups_state(&state_id).await.unwrap().unwrap();
432            store.commit(permit).await.unwrap();
433            let mut members = y.members(ab_chat);
434            members.sort();
435
436            assert_eq!(members.len(), 2);
437            assert!(members.contains(&(alice, Access::write())));
438            assert!(members.contains(&(bobby, Access::write())));
439        }
440
441        // Cathy now creates a chat with Bobby.
442        //
443        // First they process "create device group" for bobby.
444        cathy_groups
445            .process(&state_id, &topic, &create_bobby_device_01)
446            .await
447            .unwrap();
448
449        // Then they create the chat group.
450        let permit = cathy_store.begin().await.unwrap();
451        let y: GroupsState = cathy_store
452            .get_groups_state(&state_id)
453            .await
454            .unwrap()
455            .unwrap();
456        cathy_store.commit(permit).await.unwrap();
457        let args = GroupsArgs {
458            group_id: bc_chat,
459            action: GroupAction::Create {
460                initial_members: vec![
461                    (GroupMember::Group(bobby_device_group), Access::write()),
462                    (GroupMember::Group(cathy_device_group), Access::write()),
463                ],
464            },
465            dependencies: y.heads_filtered(&[bobby_device_group, cathy_device_group]),
466        };
467        let create_bobby_cathy_chat_04: Operation<TestExtensions> =
468            cathy_log.operation(&[], TestExtensions::from(args));
469
470        cathy_groups
471            .process(&state_id, &topic, &create_bobby_cathy_chat_04)
472            .await
473            .unwrap();
474
475        // Bobby processes cathy's "create device group" and "create bc chat".
476        for op in [create_cathy_device_02.clone(), create_bobby_cathy_chat_04] {
477            bobby_groups.process(&state_id, &topic, &op).await.unwrap();
478        }
479
480        // Both Cathy and Bobby have the correct groups state.
481        for store in [cathy_store, bobby_store] {
482            let permit = store.begin().await.unwrap();
483            let y: GroupsState = store.get_groups_state(&state_id).await.unwrap().unwrap();
484            store.commit(permit).await.unwrap();
485            let mut members = y.members(bc_chat);
486            members.sort();
487
488            assert_eq!(members.len(), 2);
489            assert!(members.contains(&(bobby, Access::write())));
490            assert!(members.contains(&(cathy, Access::write())));
491        }
492    }
493}