This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / knot2 / crates / knot-acl / src / lib.rs
24 kB 701 lines
1use std::collections::BTreeSet; 2 3use knot_index::{Index, Resolved}; 4use knot_types::{AccountDid, AdmissionPolicy, OwnerDid, RepoDid}; 5 6#[derive(Debug, Clone, Copy, PartialEq, Eq)] 7#[must_use] 8pub enum Decision { 9 Allow, 10 Deny, 11} 12 13impl Decision { 14 pub fn is_allowed(self) -> bool { 15 matches!(self, Decision::Allow) 16 } 17 18 fn allow_if(granted: bool) -> Self { 19 if granted { 20 Decision::Allow 21 } else { 22 Decision::Deny 23 } 24 } 25} 26 27pub trait Acl { 28 fn is_admin(&self, who: &AccountDid) -> bool; 29 fn admission(&self) -> AdmissionPolicy; 30 fn is_member(&self, who: &AccountDid) -> Resolved<bool>; 31 fn is_blocked(&self, who: &AccountDid) -> Resolved<bool>; 32 fn is_collaborator(&self, repo: &RepoDid, who: &AccountDid) -> Resolved<bool>; 33 fn repo_owner(&self, repo: &RepoDid) -> Resolved<Option<OwnerDid>>; 34} 35 36fn confirmed(resolved: Resolved<bool>) -> bool { 37 matches!(resolved, Resolved::Ready(true)) 38} 39 40fn owns_repo(acl: &impl Acl, who: &AccountDid, repo: &RepoDid) -> bool { 41 confirmed( 42 acl.repo_owner(repo) 43 .map(|owner| owner.is_some_and(|owner| owner.is(who))), 44 ) 45} 46 47fn not_blocked(acl: &impl Acl, who: &AccountDid) -> bool { 48 acl.is_admin(who) || matches!(acl.is_blocked(who), Resolved::Ready(false)) 49} 50 51pub fn can_admin_knot(acl: &impl Acl, who: &AccountDid) -> Decision { 52 Decision::allow_if(acl.is_admin(who)) 53} 54 55pub fn can_create_repo(acl: &impl Acl, who: &AccountDid) -> Decision { 56 Decision::allow_if( 57 acl.is_admin(who) 58 || (not_blocked(acl, who) 59 && match acl.admission() { 60 AdmissionPolicy::Open => true, 61 AdmissionPolicy::Closed => confirmed(acl.is_member(who)), 62 }), 63 ) 64} 65 66pub fn can_push(acl: &impl Acl, who: &AccountDid, repo: &RepoDid) -> Decision { 67 Decision::allow_if( 68 not_blocked(acl, who) 69 && (owns_repo(acl, who, repo) || confirmed(acl.is_collaborator(repo, who))), 70 ) 71} 72 73pub fn can_manage_collaborators(acl: &impl Acl, who: &AccountDid, repo: &RepoDid) -> Decision { 74 Decision::allow_if(not_blocked(acl, who) && owns_repo(acl, who, repo)) 75} 76 77pub fn can_delete_repo(acl: &impl Acl, who: &AccountDid, repo: &RepoDid) -> Decision { 78 Decision::allow_if(acl.is_admin(who) || owns_repo(acl, who, repo)) 79} 80 81pub struct KnotAcl<'a> { 82 admins: &'a BTreeSet<AccountDid>, 83 policy: AdmissionPolicy, 84 index: &'a Index, 85} 86 87impl<'a> KnotAcl<'a> { 88 pub fn new( 89 admins: &'a BTreeSet<AccountDid>, 90 policy: AdmissionPolicy, 91 index: &'a Index, 92 ) -> Self { 93 Self { 94 admins, 95 policy, 96 index, 97 } 98 } 99} 100 101impl Acl for KnotAcl<'_> { 102 fn is_admin(&self, who: &AccountDid) -> bool { 103 self.admins.contains(who) 104 } 105 106 fn admission(&self) -> AdmissionPolicy { 107 self.policy 108 } 109 110 fn is_member(&self, who: &AccountDid) -> Resolved<bool> { 111 self.index.is_member(who) 112 } 113 114 fn is_blocked(&self, who: &AccountDid) -> Resolved<bool> { 115 self.index.is_blocked(who) 116 } 117 118 fn is_collaborator(&self, repo: &RepoDid, who: &AccountDid) -> Resolved<bool> { 119 self.index.is_collaborator(repo, who) 120 } 121 122 fn repo_owner(&self, repo: &RepoDid) -> Resolved<Option<OwnerDid>> { 123 self.index.owner_of(repo) 124 } 125} 126 127#[cfg(test)] 128mod tests { 129 use super::*; 130 131 fn acc(suffix: &str) -> AccountDid { 132 AccountDid::new(format!("did:plc:{suffix}")).unwrap() 133 } 134 135 fn owner(suffix: &str) -> OwnerDid { 136 OwnerDid::new(format!("did:plc:{suffix}")).unwrap() 137 } 138 139 fn repo(suffix: &str) -> RepoDid { 140 RepoDid::new(format!("did:plc:{suffix}")).unwrap() 141 } 142 143 struct Fake { 144 admins: BTreeSet<AccountDid>, 145 admission: AdmissionPolicy, 146 member: Resolved<bool>, 147 blocked: Resolved<bool>, 148 collaborator: Resolved<bool>, 149 owner: Resolved<Option<OwnerDid>>, 150 } 151 152 impl Fake { 153 fn new() -> Self { 154 Self { 155 admins: BTreeSet::new(), 156 admission: AdmissionPolicy::Closed, 157 member: Resolved::Warming, 158 blocked: Resolved::Ready(false), 159 collaborator: Resolved::Warming, 160 owner: Resolved::Warming, 161 } 162 } 163 164 fn admin(mut self, who: &str) -> Self { 165 self.admins.insert(acc(who)); 166 self 167 } 168 169 fn open(mut self) -> Self { 170 self.admission = AdmissionPolicy::Open; 171 self 172 } 173 174 fn member(mut self, resolved: Resolved<bool>) -> Self { 175 self.member = resolved; 176 self 177 } 178 179 fn blocked(mut self, resolved: Resolved<bool>) -> Self { 180 self.blocked = resolved; 181 self 182 } 183 184 fn collaborator(mut self, resolved: Resolved<bool>) -> Self { 185 self.collaborator = resolved; 186 self 187 } 188 189 fn owner(mut self, resolved: Resolved<Option<OwnerDid>>) -> Self { 190 self.owner = resolved; 191 self 192 } 193 } 194 195 impl Acl for Fake { 196 fn is_admin(&self, who: &AccountDid) -> bool { 197 self.admins.contains(who) 198 } 199 200 fn admission(&self) -> AdmissionPolicy { 201 self.admission 202 } 203 204 fn is_member(&self, _who: &AccountDid) -> Resolved<bool> { 205 self.member.clone() 206 } 207 208 fn is_blocked(&self, _who: &AccountDid) -> Resolved<bool> { 209 self.blocked.clone() 210 } 211 212 fn is_collaborator(&self, _repo: &RepoDid, _who: &AccountDid) -> Resolved<bool> { 213 self.collaborator.clone() 214 } 215 216 fn repo_owner(&self, _repo: &RepoDid) -> Resolved<Option<OwnerDid>> { 217 self.owner.clone() 218 } 219 } 220 221 #[test] 222 fn an_admin_administers_and_creates_but_does_not_push_arbitrary_repos() { 223 let acl = Fake::new() 224 .admin("nel") 225 .owner(Resolved::Ready(Some(owner("olaren")))) 226 .collaborator(Resolved::Ready(false)); 227 assert_eq!(can_admin_knot(&acl, &acc("nel")), Decision::Allow); 228 assert_eq!(can_create_repo(&acl, &acc("nel")), Decision::Allow); 229 assert_eq!( 230 can_push(&acl, &acc("nel"), &repo("squid")), 231 Decision::Deny, 232 "knot admin has no push on repo it neither owns nor collaborates on" 233 ); 234 } 235 236 #[test] 237 fn decisions() { 238 type Case = (&'static str, Fake, fn(&Fake) -> Decision, Decision); 239 let cases: Vec<Case> = vec![ 240 ( 241 "a_member_creates_repos", 242 Fake::new().member(Resolved::Ready(true)), 243 |acl| can_create_repo(acl, &acc("olaren")), 244 Decision::Allow, 245 ), 246 ( 247 "a_member_cannot_administer_the_knot", 248 Fake::new().member(Resolved::Ready(true)), 249 |acl| can_admin_knot(acl, &acc("olaren")), 250 Decision::Deny, 251 ), 252 ( 253 "an_open_knot_admits_a_non_member", 254 Fake::new().open().member(Resolved::Ready(false)), 255 |acl| can_create_repo(acl, &acc("teq")), 256 Decision::Allow, 257 ), 258 ( 259 "an_open_knot_does_not_widen_push", 260 Fake::new() 261 .open() 262 .owner(Resolved::Ready(Some(owner("nel")))) 263 .collaborator(Resolved::Ready(false)), 264 |acl| can_push(acl, &acc("teq"), &repo("squid")), 265 Decision::Deny, 266 ), 267 ( 268 "a_blocked_account_cannot_create", 269 Fake::new() 270 .open() 271 .member(Resolved::Ready(true)) 272 .blocked(Resolved::Ready(true)), 273 |acl| can_create_repo(acl, &acc("squid")), 274 Decision::Deny, 275 ), 276 ( 277 "an_admin_is_immune_to_the_blocklist", 278 Fake::new() 279 .open() 280 .admin("nel") 281 .blocked(Resolved::Ready(true)), 282 |acl| can_create_repo(acl, &acc("nel")), 283 Decision::Allow, 284 ), 285 ( 286 "the_repo_owner_pushes", 287 Fake::new() 288 .owner(Resolved::Ready(Some(owner("nel")))) 289 .collaborator(Resolved::Ready(false)), 290 |acl| can_push(acl, &acc("nel"), &repo("squid")), 291 Decision::Allow, 292 ), 293 ( 294 "a_collaborator_pushes_without_owning", 295 Fake::new() 296 .owner(Resolved::Ready(Some(owner("nel")))) 297 .collaborator(Resolved::Ready(true)), 298 |acl| can_push(acl, &acc("olaren"), &repo("squid")), 299 Decision::Allow, 300 ), 301 ( 302 "push_allows_on_a_confirmed_collaborator_while_the_registry_warms", 303 Fake::new() 304 .owner(Resolved::Warming) 305 .collaborator(Resolved::Ready(true)), 306 |acl| can_push(acl, &acc("olaren"), &repo("squid")), 307 Decision::Allow, 308 ), 309 ( 310 "push_allows_a_confirmed_owner_while_collaborators_warm", 311 Fake::new() 312 .owner(Resolved::Ready(Some(owner("nel")))) 313 .collaborator(Resolved::Warming), 314 |acl| can_push(acl, &acc("nel"), &repo("squid")), 315 Decision::Allow, 316 ), 317 ( 318 "push_denies_when_ownership_is_warming_and_not_a_collaborator", 319 Fake::new() 320 .owner(Resolved::Warming) 321 .collaborator(Resolved::Ready(false)), 322 |acl| can_push(acl, &acc("nel"), &repo("squid")), 323 Decision::Deny, 324 ), 325 ( 326 "push_denies_an_unregistered_repo", 327 Fake::new() 328 .owner(Resolved::Ready(None)) 329 .collaborator(Resolved::Ready(false)), 330 |acl| can_push(acl, &acc("nel"), &repo("squid")), 331 Decision::Deny, 332 ), 333 ( 334 "push_matches_a_did_web_owner_across_authority_case", 335 Fake::new() 336 .owner(Resolved::Ready(Some( 337 OwnerDid::new("did:web:OYSTER.cafe").unwrap(), 338 ))) 339 .collaborator(Resolved::Ready(false)), 340 |acl| { 341 can_push( 342 acl, 343 &AccountDid::new("did:web:oyster.cafe").unwrap(), 344 &repo("squid"), 345 ) 346 }, 347 Decision::Allow, 348 ), 349 ( 350 "push_denies_a_did_plc_owner_whose_case_differs", 351 Fake::new() 352 .owner(Resolved::Ready(Some(OwnerDid::new("did:plc:ABC").unwrap()))) 353 .collaborator(Resolved::Ready(false)), 354 |acl| { 355 can_push( 356 acl, 357 &AccountDid::new("did:plc:abc").unwrap(), 358 &repo("squid"), 359 ) 360 }, 361 Decision::Deny, 362 ), 363 ( 364 "manage_collaborators_fails_closed_while_ownership_is_warming", 365 Fake::new().owner(Resolved::Warming), 366 |acl| can_manage_collaborators(acl, &acc("olaren"), &repo("squid")), 367 Decision::Deny, 368 ), 369 ( 370 "an_admin_is_authorized_before_the_projection_warms", 371 Fake::new().admin("nel"), 372 |acl| can_admin_knot(acl, &acc("nel")), 373 Decision::Allow, 374 ), 375 ( 376 "an_admin_creates_before_the_projection_warms", 377 Fake::new().admin("nel"), 378 |acl| can_create_repo(acl, &acc("nel")), 379 Decision::Allow, 380 ), 381 ( 382 "an_admin_deletes_a_repo_before_the_registry_warms", 383 Fake::new().admin("nel").owner(Resolved::Warming), 384 |acl| can_delete_repo(acl, &acc("nel"), &repo("squid")), 385 Decision::Allow, 386 ), 387 ]; 388 cases.iter().for_each(|(label, acl, eval, expected)| { 389 assert_eq!(eval(acl), *expected, "{label}"); 390 }); 391 } 392 393 #[test] 394 fn a_blocked_owner_cannot_push_or_invite() { 395 let acl = Fake::new() 396 .owner(Resolved::Ready(Some(owner("squid")))) 397 .collaborator(Resolved::Ready(false)) 398 .blocked(Resolved::Ready(true)); 399 assert_eq!( 400 can_push(&acl, &acc("squid"), &repo("anemone")), 401 Decision::Deny, 402 "ban overrides ownership on write path" 403 ); 404 assert_eq!( 405 can_manage_collaborators(&acl, &acc("squid"), &repo("anemone")), 406 Decision::Deny 407 ); 408 } 409 410 #[test] 411 fn a_warming_blocklist_fails_create_and_push_closed() { 412 let acl = Fake::new() 413 .open() 414 .blocked(Resolved::Warming) 415 .owner(Resolved::Ready(Some(owner("squid")))); 416 assert_eq!( 417 can_create_repo(&acl, &acc("squid")), 418 Decision::Deny, 419 "unresolved blocklist must not admit, ban could be hiding in it" 420 ); 421 assert_eq!( 422 can_push(&acl, &acc("squid"), &repo("anemone")), 423 Decision::Deny 424 ); 425 } 426 427 #[test] 428 fn a_stranger_is_denied_everything() { 429 let acl = Fake::new() 430 .member(Resolved::Ready(false)) 431 .collaborator(Resolved::Ready(false)) 432 .owner(Resolved::Ready(Some(owner("nel")))); 433 assert_eq!(can_admin_knot(&acl, &acc("teq")), Decision::Deny); 434 assert_eq!(can_create_repo(&acl, &acc("teq")), Decision::Deny); 435 assert_eq!(can_push(&acl, &acc("teq"), &repo("squid")), Decision::Deny); 436 } 437 438 #[test] 439 fn a_fully_warming_index_denies_every_index_backed_decision() { 440 let acl = Fake::new(); 441 assert_eq!(can_create_repo(&acl, &acc("olaren")), Decision::Deny); 442 assert_eq!(can_push(&acl, &acc("nel"), &repo("squid")), Decision::Deny); 443 } 444 445 #[test] 446 fn is_allowed_reports_the_verdict() { 447 assert!(Decision::Allow.is_allowed()); 448 assert!(!Decision::Deny.is_allowed()); 449 } 450 451 #[test] 452 fn only_the_repo_owner_manages_collaborators() { 453 let acl = Fake::new() 454 .admin("nel") 455 .owner(Resolved::Ready(Some(owner("olaren")))) 456 .collaborator(Resolved::Ready(true)); 457 assert_eq!( 458 can_manage_collaborators(&acl, &acc("olaren"), &repo("squid")), 459 Decision::Allow, 460 "repo owner manages its own collaborators" 461 ); 462 assert_eq!( 463 can_manage_collaborators(&acl, &acc("lyna"), &repo("squid")), 464 Decision::Deny, 465 "collaborator cannot manage collaborator set" 466 ); 467 assert_eq!( 468 can_manage_collaborators(&acl, &acc("nel"), &repo("squid")), 469 Decision::Deny, 470 "knot admin has no collaborator-invite right on repo it does not own" 471 ); 472 } 473 474 #[test] 475 fn repo_deletion_is_the_owner_or_a_knot_admin() { 476 let acl = Fake::new() 477 .admin("nel") 478 .owner(Resolved::Ready(Some(owner("olaren")))) 479 .collaborator(Resolved::Ready(true)); 480 assert_eq!( 481 can_delete_repo(&acl, &acc("olaren"), &repo("squid")), 482 Decision::Allow, 483 "repo owner deletes its own repo" 484 ); 485 assert_eq!( 486 can_delete_repo(&acl, &acc("nel"), &repo("squid")), 487 Decision::Allow, 488 "knot admin deletes any repo" 489 ); 490 assert_eq!( 491 can_delete_repo(&acl, &acc("lyna"), &repo("squid")), 492 Decision::Deny, 493 "collaborator cannot delete the repo" 494 ); 495 } 496 497 mod integration { 498 use super::*; 499 use knot_cob::{ChangePayload, CobHome, CobStore}; 500 use knot_cobs::{CollaboratorsChange, Grant, MembersChange, Registration, RegistryChange}; 501 use knot_git::{Layout, Repo}; 502 use knot_runtime::{K256Signer, SeededEntropy}; 503 use knot_types::{KnotId, RepoName, RepoRkey, UnixSeconds}; 504 use std::path::PathBuf; 505 506 fn knot_home() -> CobHome { 507 CobHome::from(&KnotId::new("did:web:knot.nel.pet").unwrap()) 508 } 509 510 fn grant(subject: &str, at: i64) -> Grant { 511 Grant { 512 subject: acc(subject), 513 added_by: acc("nel"), 514 created_at: UnixSeconds::new(at), 515 } 516 } 517 518 fn registration( 519 owner_id: &str, 520 key: &str, 521 repo_did: &knot_types::RepoDid, 522 at: i64, 523 ) -> Registration { 524 Registration { 525 owner: owner(owner_id), 526 rkey: RepoRkey::new(key).unwrap(), 527 name: RepoName::new(key).unwrap(), 528 repo: repo_did.clone(), 529 created_at: UnixSeconds::new(at), 530 } 531 } 532 533 fn world() -> (tempfile::TempDir, PathBuf, Layout, K256Signer) { 534 let dir = tempfile::tempdir().unwrap(); 535 let meta_path = dir.path().join("meta"); 536 Repo::create(&meta_path).unwrap(); 537 let layout = Layout::new(dir.path().join("repos")); 538 let signer = K256Signer::generate(&SeededEntropy::new(1)); 539 (dir, meta_path, layout, signer) 540 } 541 542 fn seed<P: ChangePayload>( 543 store: &CobStore, 544 home: &CobHome, 545 change: &P, 546 signer: &K256Signer, 547 at: UnixSeconds, 548 ) { 549 store.create(home, change, signer, at).unwrap(); 550 } 551 552 #[test] 553 fn the_enforcer_decides_over_a_real_rebuilt_index() { 554 let (_dir, meta_path, layout, signer) = world(); 555 let at = UnixSeconds::new; 556 557 let meta = Repo::open(&meta_path).unwrap(); 558 let store = CobStore::new(&meta); 559 let squid = repo("squid"); 560 seed( 561 &store, 562 &knot_home(), 563 &MembersChange::Add(grant("olaren", 1)), 564 &signer, 565 at(1), 566 ); 567 seed( 568 &store, 569 &knot_home(), 570 &RegistryChange::Register(registration("nel", "anemone", &squid, 1)), 571 &signer, 572 at(1), 573 ); 574 let git = layout.create(&squid).unwrap(); 575 seed( 576 &CobStore::new(&git), 577 &CobHome::from(&squid), 578 &CollaboratorsChange::Add(grant("lyna", 1)), 579 &signer, 580 at(1), 581 ); 582 583 let index = Index::new(&meta_path, layout.clone()); 584 index.rebuild().unwrap(); 585 index.ensure_collaborators(&squid).unwrap(); 586 let admins = BTreeSet::from([acc("nel")]); 587 let acl = KnotAcl::new(&admins, AdmissionPolicy::Closed, &index); 588 589 assert_eq!(can_admin_knot(&acl, &acc("nel")), Decision::Allow); 590 assert_eq!(can_create_repo(&acl, &acc("nel")), Decision::Allow); 591 assert_eq!( 592 can_push(&acl, &acc("nel"), &squid), 593 Decision::Allow, 594 "nel owns squid in the registry" 595 ); 596 597 assert_eq!(can_admin_knot(&acl, &acc("olaren")), Decision::Deny); 598 assert_eq!(can_create_repo(&acl, &acc("olaren")), Decision::Allow); 599 assert_eq!( 600 can_push(&acl, &acc("olaren"), &squid), 601 Decision::Deny, 602 "member who is neither owner nor collaborator cannot push" 603 ); 604 605 assert_eq!( 606 can_push(&acl, &acc("lyna"), &squid), 607 Decision::Allow, 608 "lyna collaborates on squid" 609 ); 610 assert_eq!(can_create_repo(&acl, &acc("lyna")), Decision::Deny); 611 612 assert_eq!(can_push(&acl, &acc("teq"), &squid), Decision::Deny); 613 assert_eq!(can_create_repo(&acl, &acc("teq")), Decision::Deny); 614 615 let cold = Index::new(&meta_path, layout); 616 let cold_acl = KnotAcl::new(&admins, AdmissionPolicy::Closed, &cold); 617 assert_eq!( 618 can_admin_knot(&cold_acl, &acc("nel")), 619 Decision::Allow, 620 "admin is config, answered before any rebuild" 621 ); 622 assert_eq!( 623 can_push(&cold_acl, &acc("nel"), &squid), 624 Decision::Deny, 625 "before rebuild owner lookup is warming, so push fails closed" 626 ); 627 assert_eq!(can_create_repo(&cold_acl, &acc("olaren")), Decision::Deny); 628 } 629 630 #[test] 631 fn a_repo_re_registered_under_a_second_owner_grants_push_only_to_the_later_owner() { 632 let (_dir, meta_path, layout, signer) = world(); 633 let at = UnixSeconds::new; 634 635 let squid = repo("squid"); 636 layout.create(&squid).unwrap(); 637 638 let meta = Repo::open(&meta_path).unwrap(); 639 let store = CobStore::new(&meta); 640 let created = store 641 .create( 642 &knot_home(), 643 &RegistryChange::Register(registration("nel", "anemone", &squid, 1)), 644 &signer, 645 at(1), 646 ) 647 .unwrap(); 648 store 649 .update( 650 &knot_home(), 651 created.object, 652 &RegistryChange::Register(registration("olaren", "fork", &squid, 2)), 653 &signer, 654 at(2), 655 ) 656 .unwrap(); 657 658 let index = Index::new(&meta_path, layout); 659 index.rebuild().unwrap(); 660 let admins = BTreeSet::new(); 661 let acl = KnotAcl::new(&admins, AdmissionPolicy::Closed, &index); 662 663 assert_eq!( 664 can_push(&acl, &acc("nel"), &squid), 665 Decision::Deny, 666 "re-register moves repo wholesale, so displaced owner loses push" 667 ); 668 assert_eq!( 669 can_push(&acl, &acc("olaren"), &squid), 670 Decision::Allow, 671 "linear causal order gives later registrant deterministic ownership" 672 ); 673 } 674 675 #[test] 676 fn a_collaborator_on_an_unregistered_repo_cannot_push_after_a_real_rebuild() { 677 let (_dir, meta_path, layout, signer) = world(); 678 let at = UnixSeconds::new; 679 680 let squid = repo("squid"); 681 let git = layout.create(&squid).unwrap(); 682 seed( 683 &CobStore::new(&git), 684 &CobHome::from(&squid), 685 &CollaboratorsChange::Add(grant("lyna", 1)), 686 &signer, 687 at(1), 688 ); 689 690 let index = Index::new(&meta_path, layout); 691 index.rebuild().unwrap(); 692 let admins = BTreeSet::new(); 693 let acl = KnotAcl::new(&admins, AdmissionPolicy::Closed, &index); 694 assert_eq!( 695 can_push(&acl, &acc("lyna"), &squid), 696 Decision::Deny, 697 "rebuild folds collaborators only for registered repos, so collaborator COB on unregistered repo never warms and grants no push" 698 ); 699 } 700 } 701}