Now let's take a silly one
0

Configure Feed

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

at main 23 kB View raw
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 a_member_creates_repos_but_cannot_administer_the_knot() { 238 let acl = Fake::new().member(Resolved::Ready(true)); 239 assert_eq!(can_create_repo(&acl, &acc("olaren")), Decision::Allow); 240 assert_eq!(can_admin_knot(&acl, &acc("olaren")), Decision::Deny); 241 } 242 243 #[test] 244 fn an_open_knot_admits_a_non_member_stranger() { 245 let acl = Fake::new().open().member(Resolved::Ready(false)); 246 assert_eq!( 247 can_create_repo(&acl, &acc("teq")), 248 Decision::Allow, 249 "open admission lets anyone with valid identity create a repo" 250 ); 251 } 252 253 #[test] 254 fn an_open_knot_does_not_widen_push_to_strangers() { 255 let acl = Fake::new() 256 .open() 257 .owner(Resolved::Ready(Some(owner("nel")))) 258 .collaborator(Resolved::Ready(false)); 259 assert_eq!( 260 can_push(&acl, &acc("teq"), &repo("squid")), 261 Decision::Deny, 262 "open admission governs creation. Push stays owner-or-collaborator" 263 ); 264 } 265 266 #[test] 267 fn a_blocked_account_cannot_create_even_on_an_open_knot() { 268 let acl = Fake::new() 269 .open() 270 .member(Resolved::Ready(true)) 271 .blocked(Resolved::Ready(true)); 272 assert_eq!(can_create_repo(&acl, &acc("squid")), Decision::Deny); 273 } 274 275 #[test] 276 fn a_blocked_owner_cannot_push_or_invite() { 277 let acl = Fake::new() 278 .owner(Resolved::Ready(Some(owner("squid")))) 279 .collaborator(Resolved::Ready(false)) 280 .blocked(Resolved::Ready(true)); 281 assert_eq!( 282 can_push(&acl, &acc("squid"), &repo("anemone")), 283 Decision::Deny, 284 "ban overrides ownership on write path" 285 ); 286 assert_eq!( 287 can_manage_collaborators(&acl, &acc("squid"), &repo("anemone")), 288 Decision::Deny 289 ); 290 } 291 292 #[test] 293 fn an_admin_is_immune_to_the_blocklist() { 294 let acl = Fake::new() 295 .open() 296 .admin("nel") 297 .blocked(Resolved::Ready(true)); 298 assert_eq!(can_create_repo(&acl, &acc("nel")), Decision::Allow); 299 } 300 301 #[test] 302 fn a_warming_blocklist_fails_create_and_push_closed() { 303 let acl = Fake::new() 304 .open() 305 .blocked(Resolved::Warming) 306 .owner(Resolved::Ready(Some(owner("squid")))); 307 assert_eq!( 308 can_create_repo(&acl, &acc("squid")), 309 Decision::Deny, 310 "unresolved blocklist must not admit, ban could be hiding in it" 311 ); 312 assert_eq!( 313 can_push(&acl, &acc("squid"), &repo("anemone")), 314 Decision::Deny 315 ); 316 } 317 318 #[test] 319 fn the_repo_owner_pushes() { 320 let acl = Fake::new() 321 .owner(Resolved::Ready(Some(owner("nel")))) 322 .collaborator(Resolved::Ready(false)); 323 assert_eq!(can_push(&acl, &acc("nel"), &repo("squid")), Decision::Allow); 324 } 325 326 #[test] 327 fn a_collaborator_pushes_without_owning() { 328 let acl = Fake::new() 329 .owner(Resolved::Ready(Some(owner("nel")))) 330 .collaborator(Resolved::Ready(true)); 331 assert_eq!( 332 can_push(&acl, &acc("olaren"), &repo("squid")), 333 Decision::Allow 334 ); 335 } 336 337 #[test] 338 fn a_stranger_is_denied_everything() { 339 let acl = Fake::new() 340 .member(Resolved::Ready(false)) 341 .collaborator(Resolved::Ready(false)) 342 .owner(Resolved::Ready(Some(owner("nel")))); 343 assert_eq!(can_admin_knot(&acl, &acc("teq")), Decision::Deny); 344 assert_eq!(can_create_repo(&acl, &acc("teq")), Decision::Deny); 345 assert_eq!(can_push(&acl, &acc("teq"), &repo("squid")), Decision::Deny); 346 } 347 348 #[test] 349 fn a_fully_warming_index_denies_every_index_backed_decision() { 350 let acl = Fake::new(); 351 assert_eq!(can_create_repo(&acl, &acc("olaren")), Decision::Deny); 352 assert_eq!(can_push(&acl, &acc("nel"), &repo("squid")), Decision::Deny); 353 } 354 355 #[test] 356 fn an_admin_is_authorized_before_the_projection_warms() { 357 let acl = Fake::new().admin("nel"); 358 assert_eq!( 359 can_admin_knot(&acl, &acc("nel")), 360 Decision::Allow, 361 "admin set is config, so it answers while projection is still warming" 362 ); 363 assert_eq!(can_create_repo(&acl, &acc("nel")), Decision::Allow); 364 } 365 366 #[test] 367 fn push_allows_on_a_confirmed_collaborator_even_when_the_registry_is_warming() { 368 let acl = Fake::new() 369 .owner(Resolved::Warming) 370 .collaborator(Resolved::Ready(true)); 371 assert_eq!( 372 can_push(&acl, &acc("olaren"), &repo("squid")), 373 Decision::Allow, 374 "definitive collaborator grant does not depend on warming registry" 375 ); 376 } 377 378 #[test] 379 fn push_allows_a_confirmed_owner_even_when_collaborators_are_warming() { 380 let acl = Fake::new() 381 .owner(Resolved::Ready(Some(owner("nel")))) 382 .collaborator(Resolved::Warming); 383 assert_eq!(can_push(&acl, &acc("nel"), &repo("squid")), Decision::Allow); 384 } 385 386 #[test] 387 fn push_denies_when_ownership_is_warming_and_not_a_collaborator() { 388 let acl = Fake::new() 389 .owner(Resolved::Warming) 390 .collaborator(Resolved::Ready(false)); 391 assert_eq!(can_push(&acl, &acc("nel"), &repo("squid")), Decision::Deny); 392 } 393 394 #[test] 395 fn push_denies_an_unregistered_repo() { 396 let acl = Fake::new() 397 .owner(Resolved::Ready(None)) 398 .collaborator(Resolved::Ready(false)); 399 assert_eq!(can_push(&acl, &acc("nel"), &repo("squid")), Decision::Deny); 400 } 401 402 #[test] 403 fn push_matches_a_did_web_owner_across_authority_case() { 404 let acl = Fake::new() 405 .owner(Resolved::Ready(Some( 406 OwnerDid::new("did:web:OYSTER.cafe").unwrap(), 407 ))) 408 .collaborator(Resolved::Ready(false)); 409 assert_eq!( 410 can_push( 411 &acl, 412 &AccountDid::new("did:web:oyster.cafe").unwrap(), 413 &repo("squid") 414 ), 415 Decision::Allow, 416 "did:web owner registered upper-case grants push to same DID offered lower-case" 417 ); 418 } 419 420 #[test] 421 fn push_denies_a_did_plc_owner_whose_case_differs() { 422 let acl = Fake::new() 423 .owner(Resolved::Ready(Some(OwnerDid::new("did:plc:ABC").unwrap()))) 424 .collaborator(Resolved::Ready(false)); 425 assert_eq!( 426 can_push( 427 &acl, 428 &AccountDid::new("did:plc:abc").unwrap(), 429 &repo("squid") 430 ), 431 Decision::Deny, 432 "did:plc is case-sensitive, so case-mismatched pusher is not owner" 433 ); 434 } 435 436 #[test] 437 fn is_allowed_reports_the_verdict() { 438 assert!(Decision::Allow.is_allowed()); 439 assert!(!Decision::Deny.is_allowed()); 440 } 441 442 #[test] 443 fn only_the_repo_owner_manages_collaborators() { 444 let acl = Fake::new() 445 .admin("nel") 446 .owner(Resolved::Ready(Some(owner("olaren")))) 447 .collaborator(Resolved::Ready(true)); 448 assert_eq!( 449 can_manage_collaborators(&acl, &acc("olaren"), &repo("squid")), 450 Decision::Allow, 451 "repo owner manages its own collaborators" 452 ); 453 assert_eq!( 454 can_manage_collaborators(&acl, &acc("lyna"), &repo("squid")), 455 Decision::Deny, 456 "collaborator cannot manage collaborator set" 457 ); 458 assert_eq!( 459 can_manage_collaborators(&acl, &acc("nel"), &repo("squid")), 460 Decision::Deny, 461 "knot admin has no collaborator-invite right on repo it does not own" 462 ); 463 } 464 465 #[test] 466 fn manage_collaborators_fails_closed_while_ownership_is_warming() { 467 let acl = Fake::new().owner(Resolved::Warming); 468 assert_eq!( 469 can_manage_collaborators(&acl, &acc("olaren"), &repo("squid")), 470 Decision::Deny 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 #[test] 498 fn an_admin_deletes_a_repo_before_the_registry_warms() { 499 let acl = Fake::new().admin("nel").owner(Resolved::Warming); 500 assert_eq!( 501 can_delete_repo(&acl, &acc("nel"), &repo("squid")), 502 Decision::Allow, 503 "admin is static config, so deletion answers while ownership warms" 504 ); 505 } 506 507 mod integration { 508 use super::*; 509 use knot_cob::{CobHome, CobStore}; 510 use knot_cobs::{CollaboratorsChange, Grant, MembersChange, Registration, RegistryChange}; 511 use knot_git::{Layout, Repo}; 512 use knot_runtime::{K256Signer, SeededEntropy}; 513 use knot_types::{KnotId, RepoName, RepoRkey, UnixSeconds}; 514 515 fn knot_home() -> CobHome { 516 CobHome::from(&KnotId::new("did:web:knot.nel.pet").unwrap()) 517 } 518 519 fn grant(subject: &str, at: i64) -> Grant { 520 Grant { 521 subject: acc(subject), 522 added_by: acc("nel"), 523 created_at: UnixSeconds::new(at), 524 } 525 } 526 527 fn registration( 528 owner_id: &str, 529 key: &str, 530 repo_did: &knot_types::RepoDid, 531 at: i64, 532 ) -> Registration { 533 Registration { 534 owner: owner(owner_id), 535 rkey: RepoRkey::new(key).unwrap(), 536 name: RepoName::new(key).unwrap(), 537 repo: repo_did.clone(), 538 created_at: UnixSeconds::new(at), 539 } 540 } 541 542 #[test] 543 fn the_enforcer_decides_over_a_real_rebuilt_index() { 544 let dir = tempfile::tempdir().unwrap(); 545 let meta_path = dir.path().join("meta"); 546 Repo::create(&meta_path).unwrap(); 547 let layout = Layout::new(dir.path().join("repos")); 548 let signer = K256Signer::generate(&SeededEntropy::new(1)); 549 let at = UnixSeconds::new; 550 551 let meta = Repo::open(&meta_path).unwrap(); 552 let store = CobStore::new(&meta); 553 store 554 .create( 555 &knot_home(), 556 &MembersChange::Add(grant("olaren", 1)), 557 &signer, 558 at(1), 559 ) 560 .unwrap(); 561 let squid = repo("squid"); 562 store 563 .create( 564 &knot_home(), 565 &RegistryChange::Register(registration("nel", "anemone", &squid, 1)), 566 &signer, 567 at(1), 568 ) 569 .unwrap(); 570 let git = layout.create(&squid).unwrap(); 571 CobStore::new(&git) 572 .create( 573 &CobHome::from(&squid), 574 &CollaboratorsChange::Add(grant("lyna", 1)), 575 &signer, 576 at(1), 577 ) 578 .unwrap(); 579 580 let index = Index::new(&meta_path, layout.clone()); 581 index.rebuild().unwrap(); 582 index.ensure_collaborators(&squid).unwrap(); 583 let admins = BTreeSet::from([acc("nel")]); 584 let acl = KnotAcl::new(&admins, AdmissionPolicy::Closed, &index); 585 586 assert_eq!(can_admin_knot(&acl, &acc("nel")), Decision::Allow); 587 assert_eq!(can_create_repo(&acl, &acc("nel")), Decision::Allow); 588 assert_eq!( 589 can_push(&acl, &acc("nel"), &squid), 590 Decision::Allow, 591 "nel owns squid in the registry" 592 ); 593 594 assert_eq!(can_admin_knot(&acl, &acc("olaren")), Decision::Deny); 595 assert_eq!(can_create_repo(&acl, &acc("olaren")), Decision::Allow); 596 assert_eq!( 597 can_push(&acl, &acc("olaren"), &squid), 598 Decision::Deny, 599 "member who is neither owner nor collaborator cannot push" 600 ); 601 602 assert_eq!( 603 can_push(&acl, &acc("lyna"), &squid), 604 Decision::Allow, 605 "lyna collaborates on squid" 606 ); 607 assert_eq!(can_create_repo(&acl, &acc("lyna")), Decision::Deny); 608 609 assert_eq!(can_push(&acl, &acc("teq"), &squid), Decision::Deny); 610 assert_eq!(can_create_repo(&acl, &acc("teq")), Decision::Deny); 611 612 let cold = Index::new(&meta_path, layout); 613 let cold_acl = KnotAcl::new(&admins, AdmissionPolicy::Closed, &cold); 614 assert_eq!( 615 can_admin_knot(&cold_acl, &acc("nel")), 616 Decision::Allow, 617 "admin is config, answered before any rebuild" 618 ); 619 assert_eq!( 620 can_push(&cold_acl, &acc("nel"), &squid), 621 Decision::Deny, 622 "before rebuild owner lookup is warming, so push fails closed" 623 ); 624 assert_eq!(can_create_repo(&cold_acl, &acc("olaren")), Decision::Deny); 625 } 626 627 #[test] 628 fn a_repo_re_registered_under_a_second_owner_grants_push_only_to_the_later_owner() { 629 let dir = tempfile::tempdir().unwrap(); 630 let meta_path = dir.path().join("meta"); 631 Repo::create(&meta_path).unwrap(); 632 let layout = Layout::new(dir.path().join("repos")); 633 let signer = K256Signer::generate(&SeededEntropy::new(1)); 634 let at = UnixSeconds::new; 635 636 let squid = repo("squid"); 637 layout.create(&squid).unwrap(); 638 639 let meta = Repo::open(&meta_path).unwrap(); 640 let store = CobStore::new(&meta); 641 let created = store 642 .create( 643 &knot_home(), 644 &RegistryChange::Register(registration("nel", "anemone", &squid, 1)), 645 &signer, 646 at(1), 647 ) 648 .unwrap(); 649 store 650 .update( 651 &knot_home(), 652 created.object, 653 &RegistryChange::Register(registration("olaren", "fork", &squid, 2)), 654 &signer, 655 at(2), 656 ) 657 .unwrap(); 658 659 let index = Index::new(&meta_path, layout); 660 index.rebuild().unwrap(); 661 let admins = BTreeSet::new(); 662 let acl = KnotAcl::new(&admins, AdmissionPolicy::Closed, &index); 663 664 assert_eq!( 665 can_push(&acl, &acc("nel"), &squid), 666 Decision::Deny, 667 "re-register moves repo wholesale, so displaced owner loses push" 668 ); 669 assert_eq!( 670 can_push(&acl, &acc("olaren"), &squid), 671 Decision::Allow, 672 "linear causal order gives later registrant deterministic ownership" 673 ); 674 } 675 676 #[test] 677 fn a_collaborator_on_an_unregistered_repo_cannot_push_after_a_real_rebuild() { 678 let dir = tempfile::tempdir().unwrap(); 679 let meta_path = dir.path().join("meta"); 680 Repo::create(&meta_path).unwrap(); 681 let layout = Layout::new(dir.path().join("repos")); 682 let signer = K256Signer::generate(&SeededEntropy::new(1)); 683 let at = UnixSeconds::new; 684 685 let squid = repo("squid"); 686 let git = layout.create(&squid).unwrap(); 687 CobStore::new(&git) 688 .create( 689 &CobHome::from(&squid), 690 &CollaboratorsChange::Add(grant("lyna", 1)), 691 &signer, 692 at(1), 693 ) 694 .unwrap(); 695 696 let index = Index::new(&meta_path, layout); 697 index.rebuild().unwrap(); 698 let admins = BTreeSet::new(); 699 let acl = KnotAcl::new(&admins, AdmissionPolicy::Closed, &index); 700 assert_eq!( 701 can_push(&acl, &acc("lyna"), &squid), 702 Decision::Deny, 703 "rebuild folds collaborators only for registered repos, so collaborator COB on unregistered repo never warms and grants no push" 704 ); 705 } 706 } 707}