Skip to main content

kcode_k1_access_projection/
lib.rs

1use std::{
2    collections::HashMap,
3    path::Path,
4    sync::{LockResult, Mutex, RwLock, RwLockReadGuard},
5};
6
7pub use kcode_k1_access_format::{AccessAction, OwnerWitness};
8pub use kcode_k1_access_types::{
9    AccessCheck, AccessId, AccessRevision, Authorizations, GroupId, ModelId, OwnerSubject,
10    RequestPrincipal, SubsystemId, Target, TxId, UserId, ViewerSubject,
11};
12pub use kcode_k1_txn_ordering::K1TxnOrdering;
13
14use kcode_k1_access_discovery_store::DiscoveryStore;
15use kcode_k1_access_store::{Store, StoreMutation, StoredAccess};
16use kcode_k1_transaction::Transaction;
17
18#[derive(Clone, Debug, Eq, PartialEq)]
19pub enum ApplyOutcome {
20    Applied(AccessRevision),
21    Unchanged(AccessRevision),
22    Rejected(String),
23}
24
25pub struct Projection {
26    store: Store,
27    discovery: DiscoveryStore,
28    state: RwLock<State>,
29    apply: Mutex<()>,
30}
31struct State {
32    available: bool,
33    objects: HashMap<AccessId, StoredAccess>,
34    targets: HashMap<Target, AccessId>,
35}
36struct Prepared {
37    outcome: ApplyOutcome,
38    mutation: StoreMutation,
39    delta: Delta,
40    discovery: Option<(AccessId, Authorizations)>,
41}
42enum Delta {
43    None,
44    Create(AccessId, Target, StoredAccess),
45    Replace(AccessId, StoredAccess),
46}
47
48fn locked<T>(result: LockResult<T>, message: &str) -> Result<T, String> {
49    result.map_err(|_| message.to_owned())
50}
51fn subjects(authorizations: &Authorizations) -> (Vec<UserId>, Vec<GroupId>) {
52    let mut users = Vec::new();
53    let mut groups = Vec::new();
54    for owner in authorizations.owners() {
55        match owner {
56            OwnerSubject::User(user) => users.push(*user),
57            OwnerSubject::Group(group) => groups.push(*group),
58        }
59    }
60    for viewer in authorizations.viewers() {
61        match viewer {
62            ViewerSubject::User(user) => users.push(*user),
63            ViewerSubject::Group(group) => groups.push(*group),
64            ViewerSubject::Model(_) => {}
65        }
66    }
67    (users, groups)
68}
69
70impl State {
71    fn finish(&mut self, delta: Delta) -> Result<(), String> {
72        let failure = match delta {
73            Delta::None => None,
74            Delta::Create(access_id, target, stored) => {
75                if self.objects.contains_key(&access_id) || self.targets.contains_key(&target) {
76                    Some("projection create contradiction after commit")
77                } else {
78                    self.targets.insert(target, access_id);
79                    self.objects.insert(access_id, stored);
80                    None
81                }
82            }
83            Delta::Replace(access_id, stored) => {
84                let consistent = self.targets.get(stored.target()) == Some(&access_id)
85                    && self
86                        .objects
87                        .get(&access_id)
88                        .is_some_and(|row| row.target() == stored.target());
89                if !consistent {
90                    Some("projection replace contradiction after commit")
91                } else if let Some(row) = self.objects.get_mut(&access_id) {
92                    *row = stored;
93                    None
94                } else {
95                    Some("projection replace disappeared after commit")
96                }
97            }
98        };
99        if let Some(failure) = failure {
100            self.available = false;
101            return Err(failure.to_owned());
102        }
103        Ok(())
104    }
105}
106
107impl Projection {
108    pub fn open(root: &Path, ordering: &K1TxnOrdering) -> Result<(Self, Option<TxId>), String> {
109        let store = Store::open(root)?;
110        let discovery = DiscoveryStore::open(root)?;
111        let (cursor, rows) = store.snapshot()?.into_parts();
112        let subsystem = SubsystemId::from_str("k1-access-subsystem")?;
113        if !Self::valid_snapshot(ordering, subsystem, cursor, &rows)?
114            || discovery.checkpoint()? != cursor
115        {
116            return Self::reset(store, discovery, root);
117        }
118        for row in &rows {
119            let (users, groups) = subjects(row.authorizations());
120            if discovery.contains_missing(&users, &groups, row.access_id())? {
121                return Self::reset(store, discovery, root);
122            }
123        }
124        let mut objects = HashMap::new();
125        let mut targets = HashMap::new();
126        objects
127            .try_reserve(rows.len())
128            .map_err(|error| format!("unable to reserve access index: {error}"))?;
129        targets
130            .try_reserve(rows.len())
131            .map_err(|error| format!("unable to reserve target index: {error}"))?;
132        for row in rows {
133            let access_id = row.access_id();
134            let target = row.target().clone();
135            if objects.insert(access_id, row).is_some()
136                || targets.insert(target, access_id).is_some()
137            {
138                return Self::reset(store, discovery, root);
139            }
140        }
141        Ok((Self::new(store, discovery, objects, targets), cursor))
142    }
143    fn new(
144        store: Store,
145        discovery: DiscoveryStore,
146        objects: HashMap<AccessId, StoredAccess>,
147        targets: HashMap<Target, AccessId>,
148    ) -> Self {
149        Self {
150            store,
151            discovery,
152            state: RwLock::new(State {
153                available: true,
154                objects,
155                targets,
156            }),
157            apply: Mutex::new(()),
158        }
159    }
160    fn reset(
161        store: Store,
162        discovery: DiscoveryStore,
163        root: &Path,
164    ) -> Result<(Self, Option<TxId>), String> {
165        store.clear()?;
166        discovery.discard()?;
167        let discovery = DiscoveryStore::open(root)?;
168        Ok((
169            Self::new(store, discovery, HashMap::new(), HashMap::new()),
170            None,
171        ))
172    }
173    fn valid_snapshot(
174        ordering: &K1TxnOrdering,
175        subsystem: SubsystemId,
176        cursor: Option<TxId>,
177        rows: &[StoredAccess],
178    ) -> Result<bool, String> {
179        if !rows.is_empty() && cursor.is_none() {
180            return Ok(false);
181        }
182        if let Some(cursor) = cursor
183            && Self::load_action(ordering, subsystem, cursor)?.is_none()
184        {
185            return Ok(false);
186        }
187        for row in rows {
188            let access_id = row.access_id();
189            let Some(create) = Self::load_action(ordering, subsystem, access_id.txid())? else {
190                return Ok(false);
191            };
192            let valid = match create {
193                AccessAction::Create {
194                    target,
195                    authorizations,
196                } if &target == row.target() => {
197                    if row.revision() == access_id.txid() {
198                        &authorizations == row.authorizations()
199                    } else {
200                        matches!(Self::load_action(ordering, subsystem, row.revision())?, Some(AccessAction::Replace { access_id: replaced, authorizations, .. }) if replaced == access_id && &authorizations == row.authorizations())
201                    }
202                }
203                _ => false,
204            };
205            if !valid {
206                return Ok(false);
207            }
208        }
209        Ok(true)
210    }
211    fn load_action(
212        ordering: &K1TxnOrdering,
213        subsystem: SubsystemId,
214        txid: TxId,
215    ) -> Result<Option<AccessAction>, String> {
216        let Some(bytes) = ordering
217            .get_txn(txid)
218            .map_err(|error| format!("KTO transaction lookup failed: {error}"))?
219        else {
220            return Ok(None);
221        };
222        let Ok(transaction) = Transaction::parse(&bytes) else {
223            return Ok(None);
224        };
225        if transaction.subsystem() != subsystem {
226            return Ok(None);
227        }
228        Ok(kcode_k1_access_format::decode(transaction.payload())
229            .ok()
230            .map(|(_, action)| action))
231    }
232    pub fn apply(&self, callback_txid: TxId, action: AccessAction) -> Result<ApplyOutcome, String> {
233        let _lane = locked(self.apply.lock(), "projection apply lock poisoned")?;
234        let prepared = {
235            let mut state = locked(self.state.write(), "projection state lock poisoned")?;
236            if !state.available {
237                return Err("projection unavailable".to_owned());
238            }
239            Self::prepare(&mut state, callback_txid, action)?
240        };
241        if let Err(error) = self.store.commit(callback_txid, &prepared.mutation) {
242            self.unavailable();
243            return Err(error);
244        }
245        let committed = if let Some((access_id, authorizations)) = &prepared.discovery {
246            let (users, groups) = subjects(authorizations);
247            self.discovery
248                .commit(callback_txid, &users, &groups, *access_id)
249        } else {
250            self.discovery
251                .commit(callback_txid, &[], &[], AccessId::new(callback_txid))
252        };
253        if let Err(error) = committed {
254            self.unavailable();
255            return Err(error);
256        }
257        let mut state = locked(
258            self.state.write(),
259            "projection state lock poisoned after commit",
260        )?;
261        if !state.available {
262            return Err("projection became unavailable after commit".to_owned());
263        }
264        state.finish(prepared.delta)?;
265        Ok(prepared.outcome)
266    }
267    fn unavailable(&self) {
268        if let Ok(mut state) = self.state.write() {
269            state.available = false;
270        }
271    }
272    fn prepare(state: &mut State, txid: TxId, action: AccessAction) -> Result<Prepared, String> {
273        match action {
274            AccessAction::Create {
275                target,
276                authorizations,
277            } => {
278                let access_id = AccessId::new(txid);
279                if state.objects.contains_key(&access_id) {
280                    return Ok(Self::rejected("access ID already exists"));
281                }
282                if state.targets.contains_key(&target) {
283                    return Ok(Self::rejected("target already exists"));
284                }
285                state
286                    .objects
287                    .try_reserve(1)
288                    .map_err(|error| format!("unable to reserve access index: {error}"))?;
289                state
290                    .targets
291                    .try_reserve(1)
292                    .map_err(|error| format!("unable to reserve target index: {error}"))?;
293                let stored =
294                    StoredAccess::new(access_id, target.clone(), txid, authorizations.clone());
295                Ok(Prepared {
296                    outcome: ApplyOutcome::Applied(AccessRevision::new(access_id, txid)),
297                    mutation: StoreMutation::Create(stored.clone()),
298                    delta: Delta::Create(access_id, target, stored),
299                    discovery: Some((access_id, authorizations)),
300                })
301            }
302            AccessAction::Replace {
303                access_id,
304                actor,
305                witness,
306                authorizations,
307                ..
308            } => {
309                let Some(current) = state.objects.get(&access_id) else {
310                    return Ok(Self::rejected("unknown access ID"));
311                };
312                if !Self::valid_witness(current.authorizations(), actor, &witness) {
313                    return Ok(Self::rejected("owner witness is invalid"));
314                }
315                if current.authorizations() == &authorizations {
316                    return Ok(Prepared {
317                        outcome: ApplyOutcome::Unchanged(AccessRevision::new(
318                            access_id,
319                            current.revision(),
320                        )),
321                        mutation: StoreMutation::CursorOnly,
322                        delta: Delta::None,
323                        discovery: Some((access_id, authorizations)),
324                    });
325                }
326                let stored = StoredAccess::new(
327                    access_id,
328                    current.target().clone(),
329                    txid,
330                    authorizations.clone(),
331                );
332                Ok(Prepared {
333                    outcome: ApplyOutcome::Applied(AccessRevision::new(access_id, txid)),
334                    mutation: StoreMutation::Replace {
335                        access_id,
336                        revision: txid,
337                        authorizations: authorizations.clone(),
338                    },
339                    delta: Delta::Replace(access_id, stored),
340                    discovery: Some((access_id, authorizations)),
341                })
342            }
343            AccessAction::EnsureDiscovery { access_id } => {
344                let Some(stored) = state.objects.get(&access_id) else {
345                    return Ok(Self::rejected("unknown access ID"));
346                };
347                Ok(Prepared {
348                    outcome: ApplyOutcome::Unchanged(AccessRevision::new(
349                        access_id,
350                        stored.revision(),
351                    )),
352                    mutation: StoreMutation::CursorOnly,
353                    delta: Delta::None,
354                    discovery: Some((access_id, stored.authorizations().clone())),
355                })
356            }
357        }
358    }
359    fn rejected(reason: &str) -> Prepared {
360        Prepared {
361            outcome: ApplyOutcome::Rejected(reason.to_owned()),
362            mutation: StoreMutation::CursorOnly,
363            delta: Delta::None,
364            discovery: None,
365        }
366    }
367    fn valid_witness(auth: &Authorizations, actor: UserId, witness: &OwnerWitness) -> bool {
368        auth.owners().iter().any(|owner| match (owner, witness) {
369            (OwnerSubject::User(owner), OwnerWitness::User) => *owner == actor,
370            (OwnerSubject::Group(owner), OwnerWitness::Group(group)) => owner == group,
371            _ => false,
372        })
373    }
374    fn readable(&self) -> Result<RwLockReadGuard<'_, State>, String> {
375        let state = locked(self.state.read(), "projection state lock poisoned")?;
376        if state.available {
377            Ok(state)
378        } else {
379            Err("projection unavailable".to_owned())
380        }
381    }
382    pub fn discovery_missing(
383        &self,
384        access_id: AccessId,
385        expected_subsystem: SubsystemId,
386    ) -> Result<bool, String> {
387        let state = self.readable()?;
388        let Some(stored) = state.objects.get(&access_id) else {
389            return Ok(false);
390        };
391        if stored.target().subsystem() != expected_subsystem {
392            return Ok(false);
393        }
394        let (users, groups) = subjects(stored.authorizations());
395        self.discovery.contains_missing(&users, &groups, access_id)
396    }
397    pub fn discovered_for_user(
398        &self,
399        user: UserId,
400        subsystem: SubsystemId,
401    ) -> Result<Vec<AccessId>, String> {
402        self.discovered(self.discovery.list_user(user)?, subsystem)
403    }
404    pub fn discovered_for_group(
405        &self,
406        group: GroupId,
407        subsystem: SubsystemId,
408    ) -> Result<Vec<AccessId>, String> {
409        self.discovered(self.discovery.list_group(group)?, subsystem)
410    }
411    fn discovered(
412        &self,
413        ids: Vec<AccessId>,
414        subsystem: SubsystemId,
415    ) -> Result<Vec<AccessId>, String> {
416        let state = self.readable()?;
417        Ok(ids
418            .into_iter()
419            .filter(|id| {
420                state
421                    .objects
422                    .get(id)
423                    .is_some_and(|stored| stored.target().subsystem() == subsystem)
424            })
425            .collect())
426    }
427    pub fn owner_witness(
428        &self,
429        access_id: AccessId,
430        user: UserId,
431        user_groups: &[GroupId],
432    ) -> Result<Option<OwnerWitness>, String> {
433        let state = self.readable()?;
434        let Some(stored) = state.objects.get(&access_id) else {
435            return Ok(None);
436        };
437        let owners = stored.authorizations().owners();
438        if owners
439            .iter()
440            .any(|owner| matches!(owner, OwnerSubject::User(owner) if *owner == user))
441        {
442            return Ok(Some(OwnerWitness::User));
443        }
444        Ok(owners
445            .iter()
446            .filter_map(|owner| match owner {
447                OwnerSubject::Group(group) if user_groups.contains(group) => Some(*group),
448                _ => None,
449            })
450            .min()
451            .map(OwnerWitness::Group))
452    }
453    pub fn check_user(
454        &self,
455        user: UserId,
456        access_id: AccessId,
457        expected_subsystem: SubsystemId,
458        user_groups: &[GroupId],
459        groups_revision: Option<TxId>,
460    ) -> Result<AccessCheck, String> {
461        let state = self.readable()?;
462        let Some(stored) = state.objects.get(&access_id) else {
463            return Self::hidden_check();
464        };
465        if stored.target().subsystem() != expected_subsystem {
466            return Self::hidden_check();
467        }
468        let auth = stored.authorizations();
469        let can_manage = Self::user_owner(auth, user, user_groups);
470        let can_view = can_manage
471            || auth.viewers().iter().any(|viewer| match viewer {
472                ViewerSubject::User(viewer) => *viewer == user,
473                ViewerSubject::Group(group) => user_groups.contains(group),
474                ViewerSubject::Model(_) => false,
475            });
476        AccessCheck::new(
477            can_view,
478            can_manage,
479            can_view.then(|| stored.target().clone()),
480            can_view.then_some(stored.revision()),
481            can_view.then_some(groups_revision).flatten(),
482        )
483    }
484    pub fn check(
485        &self,
486        principal: RequestPrincipal,
487        access_id: AccessId,
488        expected_subsystem: SubsystemId,
489        user_groups: &[GroupId],
490        model_groups: &[GroupId],
491        groups_revision: Option<TxId>,
492    ) -> Result<AccessCheck, String> {
493        let state = self.readable()?;
494        let Some(stored) = state.objects.get(&access_id) else {
495            return Self::hidden_check();
496        };
497        if stored.target().subsystem() != expected_subsystem {
498            return Self::hidden_check();
499        }
500        let auth = stored.authorizations();
501        let user_owner = Self::user_owner(auth, principal.user(), user_groups);
502        let user_view = user_owner
503            || auth.viewers().iter().any(|viewer| match viewer {
504                ViewerSubject::User(user) => *user == principal.user(),
505                ViewerSubject::Group(group) => user_groups.contains(group),
506                ViewerSubject::Model(_) => false,
507            });
508        let model_view = auth.owners().iter().any(|owner| match owner {
509            OwnerSubject::Group(group) => model_groups.contains(group),
510            OwnerSubject::User(_) => false,
511        }) || auth.viewers().iter().any(|viewer| match viewer {
512            ViewerSubject::Model(model) => *model == principal.model(),
513            ViewerSubject::Group(group) => model_groups.contains(group),
514            ViewerSubject::User(_) => false,
515        });
516        let can_manage = user_owner;
517        let can_view = user_view && model_view;
518        let evidence = can_view || can_manage;
519        AccessCheck::new(
520            can_view,
521            can_manage,
522            can_view.then(|| stored.target().clone()),
523            evidence.then_some(stored.revision()),
524            evidence.then_some(groups_revision).flatten(),
525        )
526    }
527    fn user_owner(auth: &Authorizations, user: UserId, groups: &[GroupId]) -> bool {
528        auth.owners().iter().any(|owner| match owner {
529            OwnerSubject::User(owner) => *owner == user,
530            OwnerSubject::Group(group) => groups.contains(group),
531        })
532    }
533    fn hidden_check() -> Result<AccessCheck, String> {
534        AccessCheck::new(false, false, None, None, None)
535    }
536    pub fn clear(&self) -> Result<(), String> {
537        let _lane = locked(self.apply.lock(), "projection apply lock poisoned")?;
538        self.unavailable();
539        self.store.clear().and(self.discovery.discard())
540    }
541}
542
543#[cfg(test)]
544mod tests {
545    use super::*;
546    use std::{
547        fs,
548        sync::atomic::{AtomicU64, Ordering},
549    };
550    static NEXT: AtomicU64 = AtomicU64::new(0);
551    fn root() -> std::path::PathBuf {
552        let root = std::env::temp_dir().join(format!(
553            "access-projection-{}-{}",
554            std::process::id(),
555            NEXT.fetch_add(1, Ordering::Relaxed)
556        ));
557        fs::create_dir(&root).unwrap();
558        root
559    }
560    fn tx(value: u8) -> TxId {
561        TxId::from_bytes([value; 12])
562    }
563    fn user(value: u8) -> UserId {
564        UserId::from_tx_id(tx(value))
565    }
566    fn group(value: u8) -> GroupId {
567        GroupId::new(tx(value))
568    }
569    fn target(name: &str) -> Target {
570        Target::new(SubsystemId::from_str(name).unwrap(), vec![1])
571    }
572    fn auth() -> Authorizations {
573        Authorizations::new(
574            vec![OwnerSubject::User(user(1)), OwnerSubject::Group(group(2))],
575            vec![
576                ViewerSubject::User(user(3)),
577                ViewerSubject::Group(group(4)),
578                ViewerSubject::Model(ModelId::from_bytes([5; 32])),
579            ],
580        )
581        .unwrap()
582    }
583    #[test]
584    fn create_replace_ensure_queries_and_authorization_fan_out_without_duplicates()
585    -> Result<(), String> {
586        let root = root();
587        let ordering = K1TxnOrdering::open(&root.join("ordering"))?;
588        let (projection, _) = Projection::open(&root.join("projection"), &ordering)?;
589        let access_id = AccessId::new(tx(6));
590        let subsystem = SubsystemId::from_str("one")?;
591        assert_eq!(
592            projection.apply(
593                tx(6),
594                AccessAction::Create {
595                    target: target("one"),
596                    authorizations: auth()
597                }
598            )?,
599            ApplyOutcome::Applied(AccessRevision::new(access_id, tx(6)))
600        );
601        assert_eq!(
602            projection.discovered_for_user(user(1), subsystem)?,
603            vec![access_id]
604        );
605        assert_eq!(
606            projection.discovered_for_group(group(4), subsystem)?,
607            vec![access_id]
608        );
609        assert!(!projection.discovery_missing(access_id, subsystem)?);
610        assert_eq!(
611            projection.apply(tx(7), AccessAction::EnsureDiscovery { access_id })?,
612            ApplyOutcome::Unchanged(AccessRevision::new(access_id, tx(6)))
613        );
614        assert_eq!(
615            projection.discovered_for_user(user(1), subsystem)?,
616            vec![access_id]
617        );
618        let replacement = Authorizations::new(
619            vec![OwnerSubject::User(user(3))],
620            vec![ViewerSubject::Group(group(2))],
621        )?;
622        assert_eq!(
623            projection.apply(
624                tx(8),
625                AccessAction::Replace {
626                    access_id,
627                    actor: user(1),
628                    groups_revision: None,
629                    witness: OwnerWitness::User,
630                    authorizations: replacement
631                }
632            )?,
633            ApplyOutcome::Applied(AccessRevision::new(access_id, tx(8)))
634        );
635        assert_eq!(
636            projection.discovered_for_user(user(3), subsystem)?,
637            vec![access_id]
638        );
639        assert_eq!(
640            projection.discovered_for_group(group(2), subsystem)?,
641            vec![access_id]
642        );
643        assert!(
644            projection
645                .discovered_for_user(user(1), SubsystemId::from_str("two")?)?
646                .is_empty()
647        );
648        assert_eq!(
649            projection.apply(
650                tx(9),
651                AccessAction::EnsureDiscovery {
652                    access_id: AccessId::new(tx(9))
653                }
654            )?,
655            ApplyOutcome::Rejected("unknown access ID".to_owned())
656        );
657        let view = projection.check(
658            RequestPrincipal::new(user(1), ModelId::from_bytes([5; 32])),
659            access_id,
660            subsystem,
661            &[group(2)],
662            &[group(2)],
663            None,
664        )?;
665        assert!(view.can_view() && !view.can_manage());
666        assert_eq!(
667            projection.owner_witness(access_id, user(3), &[group(2)])?,
668            Some(OwnerWitness::User)
669        );
670        fs::remove_dir_all(root).map_err(|error| error.to_string())
671    }
672    #[test]
673    fn check_user_grants_human_subjects_and_hides_model_only_access() -> Result<(), String> {
674        let root = root();
675        let ordering = K1TxnOrdering::open(&root.join("ordering"))?;
676        let (projection, _) = Projection::open(&root.join("projection"), &ordering)?;
677        let subsystem = SubsystemId::from_str("http")?;
678        let groups_revision = Some(tx(99));
679        let model_only = AccessId::new(tx(10));
680        projection.apply(
681            tx(10),
682            AccessAction::Create {
683                target: target("http"),
684                authorizations: Authorizations::new(
685                    vec![OwnerSubject::User(user(9))],
686                    vec![ViewerSubject::Model(ModelId::from_bytes([8; 32]))],
687                )?,
688            },
689        )?;
690        assert_eq!(
691            projection.check_user(user(1), model_only, subsystem, &[], groups_revision)?,
692            AccessCheck::new(false, false, None, None, None)?
693        );
694        let model_group_only = AccessId::new(tx(11));
695        projection.apply(
696            tx(11),
697            AccessAction::Create {
698                target: target("http-model-group"),
699                authorizations: Authorizations::new(
700                    vec![OwnerSubject::User(user(9))],
701                    vec![ViewerSubject::Group(group(8))],
702                )?,
703            },
704        )?;
705        assert_eq!(
706            projection.check_user(
707                user(1),
708                model_group_only,
709                SubsystemId::from_str("http-model-group")?,
710                &[],
711                groups_revision
712            )?,
713            AccessCheck::new(false, false, None, None, None)?
714        );
715        let user_owner = AccessId::new(tx(12));
716        projection.apply(
717            tx(12),
718            AccessAction::Create {
719                target: target("http-user-owner"),
720                authorizations: Authorizations::new(
721                    vec![OwnerSubject::User(user(1))],
722                    vec![ViewerSubject::Model(ModelId::from_bytes([8; 32]))],
723                )?,
724            },
725        )?;
726        assert_eq!(
727            projection.check_user(
728                user(1),
729                user_owner,
730                SubsystemId::from_str("http-user-owner")?,
731                &[],
732                groups_revision
733            )?,
734            AccessCheck::new(
735                true,
736                true,
737                Some(target("http-user-owner")),
738                Some(tx(12)),
739                groups_revision
740            )?
741        );
742        let group_viewer = AccessId::new(tx(13));
743        projection.apply(
744            tx(13),
745            AccessAction::Create {
746                target: target("http-group-viewer"),
747                authorizations: Authorizations::new(
748                    vec![OwnerSubject::User(user(9))],
749                    vec![ViewerSubject::Group(group(2))],
750                )?,
751            },
752        )?;
753        assert_eq!(
754            projection.check_user(
755                user(1),
756                group_viewer,
757                SubsystemId::from_str("http-group-viewer")?,
758                &[group(2)],
759                groups_revision
760            )?,
761            AccessCheck::new(
762                true,
763                false,
764                Some(target("http-group-viewer")),
765                Some(tx(13)),
766                groups_revision
767            )?
768        );
769        assert_eq!(
770            projection.check_user(
771                user(1),
772                group_viewer,
773                SubsystemId::from_str("wrong")?,
774                &[group(2)],
775                groups_revision
776            )?,
777            AccessCheck::new(false, false, None, None, None)?
778        );
779        fs::remove_dir_all(root).map_err(|error| error.to_string())
780    }
781    #[test]
782    fn mismatched_discovery_checkpoint_resets_both_derived_stores() -> Result<(), String> {
783        let root = root();
784        let projection_root = root.join("projection");
785        let ordering = K1TxnOrdering::open(&root.join("ordering"))?;
786        kcode_k1_access_discovery_store::DiscoveryStore::open(&projection_root)?.commit(
787            tx(1),
788            &[user(2)],
789            &[],
790            AccessId::new(tx(3)),
791        )?;
792        let (projection, cursor) = Projection::open(&projection_root, &ordering)?;
793        assert_eq!(cursor, None);
794        assert!(
795            projection
796                .discovered_for_user(user(2), SubsystemId::from_str("one")?)?
797                .is_empty()
798        );
799        projection.clear()?;
800        fs::remove_dir_all(root).map_err(|error| error.to_string())
801    }
802}