This repository has no description
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}