Something went wrong. Try again.
Monorepo for Tangled
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702use std::collections::BTreeSet;
use knot_index::{Index, Resolved};use knot_types::{AccountDid, AdmissionPolicy, OwnerDid, RepoDid};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]#[must_use]pub enum Decision { Allow, Deny,}
impl Decision { pub fn is_allowed(self) -> bool { matches!(self, Decision::Allow) }
fn allow_if(granted: bool) -> Self { if granted { Decision::Allow } else { Decision::Deny } }}
pub trait Acl { fn is_admin(&self, who: &AccountDid) -> bool; fn admission(&self) -> AdmissionPolicy; fn is_member(&self, who: &AccountDid) -> Resolved<bool>; fn is_blocked(&self, who: &AccountDid) -> Resolved<bool>; fn is_collaborator(&self, repo: &RepoDid, who: &AccountDid) -> Resolved<bool>; fn repo_owner(&self, repo: &RepoDid) -> Resolved<Option<OwnerDid>>;}
fn confirmed(resolved: Resolved<bool>) -> bool { matches!(resolved, Resolved::Ready(true))}
fn owns_repo(acl: &impl Acl, who: &AccountDid, repo: &RepoDid) -> bool { confirmed( acl.repo_owner(repo) .map(|owner| owner.is_some_and(|owner| owner.is(who))), )}
fn not_blocked(acl: &impl Acl, who: &AccountDid) -> bool { acl.is_admin(who) || matches!(acl.is_blocked(who), Resolved::Ready(false))}
pub fn can_admin_knot(acl: &impl Acl, who: &AccountDid) -> Decision { Decision::allow_if(acl.is_admin(who))}
pub fn can_create_repo(acl: &impl Acl, who: &AccountDid) -> Decision { Decision::allow_if( acl.is_admin(who) || (not_blocked(acl, who) && match acl.admission() { AdmissionPolicy::Open => true, AdmissionPolicy::Closed => confirmed(acl.is_member(who)), }), )}
pub fn can_push(acl: &impl Acl, who: &AccountDid, repo: &RepoDid) -> Decision { Decision::allow_if( not_blocked(acl, who) && (owns_repo(acl, who, repo) || confirmed(acl.is_collaborator(repo, who))), )}
pub fn can_manage_collaborators(acl: &impl Acl, who: &AccountDid, repo: &RepoDid) -> Decision { Decision::allow_if(not_blocked(acl, who) && owns_repo(acl, who, repo))}
pub fn can_delete_repo(acl: &impl Acl, who: &AccountDid, repo: &RepoDid) -> Decision { Decision::allow_if(acl.is_admin(who) || owns_repo(acl, who, repo))}
pub struct KnotAcl<'a> { admins: &'a BTreeSet<AccountDid>, policy: AdmissionPolicy, index: &'a Index,}
impl<'a> KnotAcl<'a> { pub fn new( admins: &'a BTreeSet<AccountDid>, policy: AdmissionPolicy, index: &'a Index, ) -> Self { Self { admins, policy, index, } }}
impl Acl for KnotAcl<'_> { fn is_admin(&self, who: &AccountDid) -> bool { self.admins.contains(who) }
fn admission(&self) -> AdmissionPolicy { self.policy }
fn is_member(&self, who: &AccountDid) -> Resolved<bool> { self.index.is_member(who) }
fn is_blocked(&self, who: &AccountDid) -> Resolved<bool> { self.index.is_blocked(who) }
fn is_collaborator(&self, repo: &RepoDid, who: &AccountDid) -> Resolved<bool> { self.index.is_collaborator(repo, who) }
fn repo_owner(&self, repo: &RepoDid) -> Resolved<Option<OwnerDid>> { self.index.owner_of(repo) }}
#[cfg(test)]mod tests { use super::*;
fn acc(suffix: &str) -> AccountDid { AccountDid::new(format!("did:plc:{suffix}")).unwrap() }
fn owner(suffix: &str) -> OwnerDid { OwnerDid::new(format!("did:plc:{suffix}")).unwrap() }
fn repo(suffix: &str) -> RepoDid { RepoDid::new(format!("did:plc:{suffix}")).unwrap() }
struct Fake { admins: BTreeSet<AccountDid>, admission: AdmissionPolicy, member: Resolved<bool>, blocked: Resolved<bool>, collaborator: Resolved<bool>, owner: Resolved<Option<OwnerDid>>, }
impl Fake { fn new() -> Self { Self { admins: BTreeSet::new(), admission: AdmissionPolicy::Closed, member: Resolved::Warming, blocked: Resolved::Ready(false), collaborator: Resolved::Warming, owner: Resolved::Warming, } }
fn admin(mut self, who: &str) -> Self { self.admins.insert(acc(who)); self }
fn open(mut self) -> Self { self.admission = AdmissionPolicy::Open; self }
fn member(mut self, resolved: Resolved<bool>) -> Self { self.member = resolved; self }
fn blocked(mut self, resolved: Resolved<bool>) -> Self { self.blocked = resolved; self }
fn collaborator(mut self, resolved: Resolved<bool>) -> Self { self.collaborator = resolved; self }
fn owner(mut self, resolved: Resolved<Option<OwnerDid>>) -> Self { self.owner = resolved; self } }
impl Acl for Fake { fn is_admin(&self, who: &AccountDid) -> bool { self.admins.contains(who) }
fn admission(&self) -> AdmissionPolicy { self.admission }
fn is_member(&self, _who: &AccountDid) -> Resolved<bool> { self.member.clone() }
fn is_blocked(&self, _who: &AccountDid) -> Resolved<bool> { self.blocked.clone() }
fn is_collaborator(&self, _repo: &RepoDid, _who: &AccountDid) -> Resolved<bool> { self.collaborator.clone() }
fn repo_owner(&self, _repo: &RepoDid) -> Resolved<Option<OwnerDid>> { self.owner.clone() } }
#[test] fn an_admin_administers_and_creates_but_does_not_push_arbitrary_repos() { let acl = Fake::new() .admin("nel") .owner(Resolved::Ready(Some(owner("olaren")))) .collaborator(Resolved::Ready(false)); assert_eq!(can_admin_knot(&acl, &acc("nel")), Decision::Allow); assert_eq!(can_create_repo(&acl, &acc("nel")), Decision::Allow); assert_eq!( can_push(&acl, &acc("nel"), &repo("squid")), Decision::Deny, "knot admin has no push on repo it neither owns nor collaborates on" ); }
#[test] fn decisions() { type Case = (&'static str, Fake, fn(&Fake) -> Decision, Decision); let cases: Vec<Case> = vec![ ( "a_member_creates_repos", Fake::new().member(Resolved::Ready(true)), |acl| can_create_repo(acl, &acc("olaren")), Decision::Allow, ), ( "a_member_cannot_administer_the_knot", Fake::new().member(Resolved::Ready(true)), |acl| can_admin_knot(acl, &acc("olaren")), Decision::Deny, ), ( "an_open_knot_admits_a_non_member", Fake::new().open().member(Resolved::Ready(false)), |acl| can_create_repo(acl, &acc("teq")), Decision::Allow, ), ( "an_open_knot_does_not_widen_push", Fake::new() .open() .owner(Resolved::Ready(Some(owner("nel")))) .collaborator(Resolved::Ready(false)), |acl| can_push(acl, &acc("teq"), &repo("squid")), Decision::Deny, ), ( "a_blocked_account_cannot_create", Fake::new() .open() .member(Resolved::Ready(true)) .blocked(Resolved::Ready(true)), |acl| can_create_repo(acl, &acc("squid")), Decision::Deny, ), ( "an_admin_is_immune_to_the_blocklist", Fake::new() .open() .admin("nel") .blocked(Resolved::Ready(true)), |acl| can_create_repo(acl, &acc("nel")), Decision::Allow, ), ( "the_repo_owner_pushes", Fake::new() .owner(Resolved::Ready(Some(owner("nel")))) .collaborator(Resolved::Ready(false)), |acl| can_push(acl, &acc("nel"), &repo("squid")), Decision::Allow, ), ( "a_collaborator_pushes_without_owning", Fake::new() .owner(Resolved::Ready(Some(owner("nel")))) .collaborator(Resolved::Ready(true)), |acl| can_push(acl, &acc("olaren"), &repo("squid")), Decision::Allow, ), ( "push_allows_on_a_confirmed_collaborator_while_the_registry_warms", Fake::new() .owner(Resolved::Warming) .collaborator(Resolved::Ready(true)), |acl| can_push(acl, &acc("olaren"), &repo("squid")), Decision::Allow, ), ( "push_allows_a_confirmed_owner_while_collaborators_warm", Fake::new() .owner(Resolved::Ready(Some(owner("nel")))) .collaborator(Resolved::Warming), |acl| can_push(acl, &acc("nel"), &repo("squid")), Decision::Allow, ), ( "push_denies_when_ownership_is_warming_and_not_a_collaborator", Fake::new() .owner(Resolved::Warming) .collaborator(Resolved::Ready(false)), |acl| can_push(acl, &acc("nel"), &repo("squid")), Decision::Deny, ), ( "push_denies_an_unregistered_repo", Fake::new() .owner(Resolved::Ready(None)) .collaborator(Resolved::Ready(false)), |acl| can_push(acl, &acc("nel"), &repo("squid")), Decision::Deny, ), ( "push_matches_a_did_web_owner_across_authority_case", Fake::new() .owner(Resolved::Ready(Some( OwnerDid::new("did:web:OYSTER.cafe").unwrap(), ))) .collaborator(Resolved::Ready(false)), |acl| { can_push( acl, &AccountDid::new("did:web:oyster.cafe").unwrap(), &repo("squid"), ) }, Decision::Allow, ), ( "push_denies_a_did_plc_owner_whose_case_differs", Fake::new() .owner(Resolved::Ready(Some(OwnerDid::new("did:plc:ABC").unwrap()))) .collaborator(Resolved::Ready(false)), |acl| { can_push( acl, &AccountDid::new("did:plc:abc").unwrap(), &repo("squid"), ) }, Decision::Deny, ), ( "manage_collaborators_fails_closed_while_ownership_is_warming", Fake::new().owner(Resolved::Warming), |acl| can_manage_collaborators(acl, &acc("olaren"), &repo("squid")), Decision::Deny, ), ( "an_admin_is_authorized_before_the_projection_warms", Fake::new().admin("nel"), |acl| can_admin_knot(acl, &acc("nel")), Decision::Allow, ), ( "an_admin_creates_before_the_projection_warms", Fake::new().admin("nel"), |acl| can_create_repo(acl, &acc("nel")), Decision::Allow, ), ( "an_admin_deletes_a_repo_before_the_registry_warms", Fake::new().admin("nel").owner(Resolved::Warming), |acl| can_delete_repo(acl, &acc("nel"), &repo("squid")), Decision::Allow, ), ]; cases.iter().for_each(|(label, acl, eval, expected)| { assert_eq!(eval(acl), *expected, "{label}"); }); }
#[test] fn a_blocked_owner_cannot_push_or_invite() { let acl = Fake::new() .owner(Resolved::Ready(Some(owner("squid")))) .collaborator(Resolved::Ready(false)) .blocked(Resolved::Ready(true)); assert_eq!( can_push(&acl, &acc("squid"), &repo("anemone")), Decision::Deny, "ban overrides ownership on write path" ); assert_eq!( can_manage_collaborators(&acl, &acc("squid"), &repo("anemone")), Decision::Deny ); }
#[test] fn a_warming_blocklist_fails_create_and_push_closed() { let acl = Fake::new() .open() .blocked(Resolved::Warming) .owner(Resolved::Ready(Some(owner("squid")))); assert_eq!( can_create_repo(&acl, &acc("squid")), Decision::Deny, "unresolved blocklist must not admit, ban could be hiding in it" ); assert_eq!( can_push(&acl, &acc("squid"), &repo("anemone")), Decision::Deny ); }
#[test] fn a_stranger_is_denied_everything() { let acl = Fake::new() .member(Resolved::Ready(false)) .collaborator(Resolved::Ready(false)) .owner(Resolved::Ready(Some(owner("nel")))); assert_eq!(can_admin_knot(&acl, &acc("teq")), Decision::Deny); assert_eq!(can_create_repo(&acl, &acc("teq")), Decision::Deny); assert_eq!(can_push(&acl, &acc("teq"), &repo("squid")), Decision::Deny); }
#[test] fn a_fully_warming_index_denies_every_index_backed_decision() { let acl = Fake::new(); assert_eq!(can_create_repo(&acl, &acc("olaren")), Decision::Deny); assert_eq!(can_push(&acl, &acc("nel"), &repo("squid")), Decision::Deny); }
#[test] fn is_allowed_reports_the_verdict() { assert!(Decision::Allow.is_allowed()); assert!(!Decision::Deny.is_allowed()); }
#[test] fn only_the_repo_owner_manages_collaborators() { let acl = Fake::new() .admin("nel") .owner(Resolved::Ready(Some(owner("olaren")))) .collaborator(Resolved::Ready(true)); assert_eq!( can_manage_collaborators(&acl, &acc("olaren"), &repo("squid")), Decision::Allow, "repo owner manages its own collaborators" ); assert_eq!( can_manage_collaborators(&acl, &acc("lyna"), &repo("squid")), Decision::Deny, "collaborator cannot manage collaborator set" ); assert_eq!( can_manage_collaborators(&acl, &acc("nel"), &repo("squid")), Decision::Deny, "knot admin has no collaborator-invite right on repo it does not own" ); }
#[test] fn repo_deletion_is_the_owner_or_a_knot_admin() { let acl = Fake::new() .admin("nel") .owner(Resolved::Ready(Some(owner("olaren")))) .collaborator(Resolved::Ready(true)); assert_eq!( can_delete_repo(&acl, &acc("olaren"), &repo("squid")), Decision::Allow, "repo owner deletes its own repo" ); assert_eq!( can_delete_repo(&acl, &acc("nel"), &repo("squid")), Decision::Allow, "knot admin deletes any repo" ); assert_eq!( can_delete_repo(&acl, &acc("lyna"), &repo("squid")), Decision::Deny, "collaborator cannot delete the repo" ); }
mod integration { use super::*; use knot_cob::{ChangePayload, CobHome, CobStore}; use knot_cobs::{CollaboratorsChange, Grant, MembersChange, Registration, RegistryChange}; use knot_git::{Layout, Repo}; use knot_runtime::{K256Signer, SeededEntropy}; use knot_types::{KnotId, RepoName, RepoRkey, UnixSeconds}; use std::path::PathBuf;
fn knot_home() -> CobHome { CobHome::from(&KnotId::new("did:web:knot.nel.pet").unwrap()) }
fn grant(subject: &str, at: i64) -> Grant { Grant { subject: acc(subject), added_by: acc("nel"), created_at: UnixSeconds::new(at), } }
fn registration( owner_id: &str, key: &str, repo_did: &knot_types::RepoDid, at: i64, ) -> Registration { Registration { owner: owner(owner_id), rkey: RepoRkey::new(key).unwrap(), name: RepoName::new(key).unwrap(), repo: repo_did.clone(), created_at: UnixSeconds::new(at), } }
fn world() -> (tempfile::TempDir, PathBuf, Layout, K256Signer) { let dir = tempfile::tempdir().unwrap(); let meta_path = dir.path().join("meta"); Repo::create(&meta_path).unwrap(); let layout = Layout::new(dir.path().join("repos")); let signer = K256Signer::generate(&SeededEntropy::new(1)); (dir, meta_path, layout, signer) }
fn seed<P: ChangePayload>( store: &CobStore, home: &CobHome, change: &P, signer: &K256Signer, at: UnixSeconds, ) { store.create(home, change, signer, at).unwrap(); }
#[test] fn the_enforcer_decides_over_a_real_rebuilt_index() { let (_dir, meta_path, layout, signer) = world(); let at = UnixSeconds::new;
let meta = Repo::open(&meta_path).unwrap(); let store = CobStore::new(&meta); let squid = repo("squid"); seed( &store, &knot_home(), &MembersChange::Add(grant("olaren", 1)), &signer, at(1), ); seed( &store, &knot_home(), &RegistryChange::Register(registration("nel", "anemone", &squid, 1)), &signer, at(1), ); let git = layout.create(&squid).unwrap(); seed( &CobStore::new(&git), &CobHome::from(&squid), &CollaboratorsChange::Add(grant("lyna", 1)), &signer, at(1), );
let index = Index::new(&meta_path, layout.clone()); index.rebuild().unwrap(); index.ensure_collaborators(&squid).unwrap(); let admins = BTreeSet::from([acc("nel")]); let acl = KnotAcl::new(&admins, AdmissionPolicy::Closed, &index);
assert_eq!(can_admin_knot(&acl, &acc("nel")), Decision::Allow); assert_eq!(can_create_repo(&acl, &acc("nel")), Decision::Allow); assert_eq!( can_push(&acl, &acc("nel"), &squid), Decision::Allow, "nel owns squid in the registry" );
assert_eq!(can_admin_knot(&acl, &acc("olaren")), Decision::Deny); assert_eq!(can_create_repo(&acl, &acc("olaren")), Decision::Allow); assert_eq!( can_push(&acl, &acc("olaren"), &squid), Decision::Deny, "member who is neither owner nor collaborator cannot push" );
assert_eq!( can_push(&acl, &acc("lyna"), &squid), Decision::Allow, "lyna collaborates on squid" ); assert_eq!(can_create_repo(&acl, &acc("lyna")), Decision::Deny);
assert_eq!(can_push(&acl, &acc("teq"), &squid), Decision::Deny); assert_eq!(can_create_repo(&acl, &acc("teq")), Decision::Deny);
let cold = Index::new(&meta_path, layout); let cold_acl = KnotAcl::new(&admins, AdmissionPolicy::Closed, &cold); assert_eq!( can_admin_knot(&cold_acl, &acc("nel")), Decision::Allow, "admin is config, answered before any rebuild" ); assert_eq!( can_push(&cold_acl, &acc("nel"), &squid), Decision::Deny, "before rebuild owner lookup is warming, so push fails closed" ); assert_eq!(can_create_repo(&cold_acl, &acc("olaren")), Decision::Deny); }
#[test] fn a_repo_re_registered_under_a_second_owner_grants_push_only_to_the_later_owner() { let (_dir, meta_path, layout, signer) = world(); let at = UnixSeconds::new;
let squid = repo("squid"); layout.create(&squid).unwrap();
let meta = Repo::open(&meta_path).unwrap(); let store = CobStore::new(&meta); let created = store .create( &knot_home(), &RegistryChange::Register(registration("nel", "anemone", &squid, 1)), &signer, at(1), ) .unwrap(); store .update( &knot_home(), created.object, &RegistryChange::Register(registration("olaren", "fork", &squid, 2)), &signer, at(2), ) .unwrap();
let index = Index::new(&meta_path, layout); index.rebuild().unwrap(); let admins = BTreeSet::new(); let acl = KnotAcl::new(&admins, AdmissionPolicy::Closed, &index);
assert_eq!( can_push(&acl, &acc("nel"), &squid), Decision::Deny, "re-register moves repo wholesale, so displaced owner loses push" ); assert_eq!( can_push(&acl, &acc("olaren"), &squid), Decision::Allow, "linear causal order gives later registrant deterministic ownership" ); }
#[test] fn a_collaborator_on_an_unregistered_repo_cannot_push_after_a_real_rebuild() { let (_dir, meta_path, layout, signer) = world(); let at = UnixSeconds::new;
let squid = repo("squid"); let git = layout.create(&squid).unwrap(); seed( &CobStore::new(&git), &CobHome::from(&squid), &CollaboratorsChange::Add(grant("lyna", 1)), &signer, at(1), );
let index = Index::new(&meta_path, layout); index.rebuild().unwrap(); let admins = BTreeSet::new(); let acl = KnotAcl::new(&admins, AdmissionPolicy::Closed, &index); assert_eq!( can_push(&acl, &acc("lyna"), &squid), Decision::Deny, "rebuild folds collaborators only for registered repos, so collaborator COB on unregistered repo never warms and grants no push" ); } }}