diff --git a/knot2/crates/knot-acl/src/lib.rs b/knot2/crates/knot-acl/src/lib.rs index 4e4c3edb0..50896727d 100644 --- a/knot2/crates/knot-acl/src/lib.rs +++ b/knot2/crates/knot-acl/src/lib.rs @@ -1,28 +1,12 @@ use std::collections::BTreeSet; use knot_index::{CollaborationConsent, Index, MemberConsent, Resolved}; -use knot_types::{AccountDid, AdmissionPolicy, OwnerDid, RepoDid}; +use knot_types::{ + AccountDid, AdmissionPolicy, Blocked, ContributionPermission, ContributionPolicy, Membership, + OwnerDid, RepoDid, RepoPolicy, RepoRole, +}; -#[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 use knot_types::Decision; pub trait Acl { fn is_admin(&self, who: &AccountDid) -> bool; @@ -35,11 +19,13 @@ fn confirmed(resolved: Resolved) -> bool { matches!(resolved, Resolved::Ready(true)) } +fn ownership(acl: &impl Acl, who: &AccountDid, repo: &RepoDid) -> Resolved { + acl.repo_owner(repo) + .map(|owner| owner.is_some_and(|owner| owner.is(who))) +} + 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))), - ) + confirmed(ownership(acl, who, repo)) } fn not_blocked(acl: &impl Acl, who: &AccountDid) -> bool { @@ -60,12 +46,10 @@ pub fn needs_membership(acl: &impl Acl, who: &AccountDid) -> bool { pub fn can_create_repo(acl: &impl Acl, consent: &MemberConsent<'_>) -> Decision { let who = consent.subject(); - match acl.is_admin(who) { - true => Decision::Allow, - false => Decision::allow_if( - not_blocked(acl, who) && (!closed_to_outsiders(acl) || consent.granted()), - ), - } + Decision::allow_if( + acl.is_admin(who) + || (not_blocked(acl, who) && (!closed_to_outsiders(acl) || consent.granted())), + ) } pub fn can_push(acl: &impl Acl, consent: &CollaborationConsent<'_>) -> Decision { @@ -79,8 +63,81 @@ pub fn can_manage_collaborators(acl: &impl Acl, who: &AccountDid, repo: &RepoDid Decision::allow_if(not_blocked(acl, who) && owns_repo(acl, who, repo)) } +pub fn can_set_contribution_policy(acl: &impl Acl, who: &AccountDid, repo: &RepoDid) -> Decision { + Decision::allow_if(not_blocked(acl, who) && (acl.is_admin(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)) + Decision::allow_if(not_blocked(acl, who) && (acl.is_admin(who) || owns_repo(acl, who, repo))) +} + +pub struct RepoAccess<'a> { + collaboration: &'a CollaborationConsent<'a>, + membership: &'a MemberConsent<'a>, + policy: &'a RepoPolicy, +} + +impl<'a> RepoAccess<'a> { + pub fn of( + collaboration: &'a CollaborationConsent<'a>, + membership: &'a MemberConsent<'a>, + policy: &'a RepoPolicy, + ) -> Option { + let one_subject = collaboration.subject() == membership.subject(); + let one_repo = collaboration.repo() == policy.repo(); + (one_subject && one_repo).then_some(Self { + collaboration, + membership, + policy, + }) + } + + pub const fn subject(&self) -> &'a AccountDid { + self.collaboration.subject() + } + + pub const fn repo(&self) -> &'a RepoDid { + self.collaboration.repo() + } + + pub const fn policy(&self) -> ContributionPolicy { + self.policy.policy() + } +} + +pub fn repo_role(acl: &impl Acl, access: &RepoAccess) -> Resolved { + let who = access.subject(); + ownership(acl, who, access.repo()).map(|owns| { + match ( + owns, + access.collaboration.granted(), + acl.is_admin(who), + access.membership.granted(), + ) { + (true, ..) => RepoRole::Owner, + (_, true, ..) => RepoRole::Collaborator, + (.., true, member) => RepoRole::KnotAdmin(Membership::of(member)), + (.., true) => RepoRole::Member, + _ => RepoRole::Stranger, + } + }) +} + +pub fn permission_of(acl: &impl Acl, access: &RepoAccess) -> Resolved { + let blocked = if not_blocked(acl, access.subject()) { + Blocked::No + } else { + Blocked::Yes + }; + repo_role(acl, access).map(|role| ContributionPermission::new(blocked, role, access.policy())) +} + +pub fn can_contribute(acl: &impl Acl, access: &RepoAccess) -> Resolved { + permission_of(acl, access).map(ContributionPermission::contributes) +} + +pub fn can_moderate(acl: &impl Acl, access: &RepoAccess) -> Resolved { + permission_of(acl, access).map(ContributionPermission::moderates) } pub struct KnotAcl<'a> { @@ -149,7 +206,7 @@ mod tests { CollaborationConsent::assumed(repo, who, true) } - fn bystanding<'a>(repo: &'a RepoDid, who: &'a AccountDid) -> CollaborationConsent<'a> { + fn ungranted<'a>(repo: &'a RepoDid, who: &'a AccountDid) -> CollaborationConsent<'a> { CollaborationConsent::assumed(repo, who, false) } @@ -209,8 +266,183 @@ mod tests { } } + fn access_of<'a>( + collaboration: &'a CollaborationConsent<'a>, + membership: &'a MemberConsent<'a>, + policy: &'a RepoPolicy, + ) -> RepoAccess<'a> { + RepoAccess::of(collaboration, membership, policy) + .expect("one access is one subject on one repo") + } + + fn policy_for(repo: &RepoDid, policy: ContributionPolicy) -> RepoPolicy { + RepoPolicy::new(repo.clone(), policy) + } + + fn allowed(decision: Resolved) -> bool { + matches!(decision, Resolved::Ready(Decision::Allow)) + } + + #[test] + fn an_admin_who_doesnt_own_and_doesnt_collaborate_gets_exactly_the_moderation_verbs() { + let acl = Fake::new() + .admin("nel") + .owner(Resolved::Ready(Some(owner("olaren")))); + let who = acc("nel"); + let target = repo("anemone"); + let collaboration = ungranted(&target, &who); + let membership = outside(&who); + let anyone = policy_for(&target, ContributionPolicy::Anyone); + let members = policy_for(&target, ContributionPolicy::Members); + let collaborators = policy_for(&target, ContributionPolicy::Collaborators); + + assert_eq!( + repo_role(&acl, &access_of(&collaboration, &membership, &anyone)), + Resolved::Ready(RepoRole::KnotAdmin(Membership::Outsider)) + ); + assert!( + allowed(can_moderate( + &acl, + &access_of(&collaboration, &membership, &anyone) + )), + "Operator may take down what it publishes" + ); + assert!( + !allowed(can_contribute( + &acl, + &access_of(&collaboration, &membership, &collaborators) + )), + "moderating somebody's repo doesn't make admin one of its authors" + ); + assert!(!allowed(can_contribute( + &acl, + &access_of(&collaboration, &membership, &members) + ))); + assert!( + allowed(can_contribute( + &acl, + &access_of(&collaboration, &membership, &anyone) + )), + "under Anyone admin contributes on the same footing as strangers" + ); + assert!( + !can_push(&acl, &collaboration).is_allowed(), + "Admin carve-out stops at moderation. It never reaches git" + ); + assert!(!can_manage_collaborators(&acl, &who, &target).is_allowed()); + + let membership = joined(&who); + assert_eq!( + repo_role(&acl, &access_of(&collaboration, &membership, &members)), + Resolved::Ready(RepoRole::KnotAdmin(Membership::Member)), + "Admin set doesn't strip somebody of membership they also have" + ); + } + + #[test] + fn ownership_outranks_collaboration_outranks_the_admin_set_outranks_membership() { + let target = repo("anemone"); + let who = acc("nel"); + type Case = (&'static str, Fake, bool, bool, Resolved); + let cases: Vec = vec![ + ( + "an_owner_who_is_also_a_collaborator_reads_as_the_owner", + Fake::new() + .admin("nel") + .owner(Resolved::Ready(Some(owner("nel")))), + true, + true, + Resolved::Ready(RepoRole::Owner), + ), + ( + "a_collaborator_in_the_admin_set_reads_as_a_collaborator", + Fake::new() + .admin("nel") + .owner(Resolved::Ready(Some(owner("olaren")))), + true, + false, + Resolved::Ready(RepoRole::Collaborator), + ), + ( + "a_member_who_never_collaborated_reads_as_a_member", + Fake::new().owner(Resolved::Ready(Some(owner("olaren")))), + false, + true, + Resolved::Ready(RepoRole::Member), + ), + ( + "nobody_at_all_reads_as_a_stranger", + Fake::new().owner(Resolved::Ready(Some(owner("olaren")))), + false, + false, + Resolved::Ready(RepoRole::Stranger), + ), + ( + "warming_ownership_answers_warming_rather_than_a_demoted_role", + Fake::new(), + false, + false, + Resolved::Warming, + ), + ]; + let anyone = policy_for(&target, ContributionPolicy::Anyone); + cases + .iter() + .for_each(|(label, acl, collaborates, member, expected)| { + let collaboration = CollaborationConsent::assumed(&target, &who, *collaborates); + let membership = MemberConsent::assumed((), &who, *member); + let access = access_of(&collaboration, &membership, &anyone); + assert_eq!(repo_role(acl, &access), *expected, "{label}"); + }); + } + #[test] - fn an_admin_administers_and_creates_but_does_not_push_arbitrary_repos() { + fn the_blocklist_decides_admission_not_identity() { + let target = repo("anemone"); + let who = acc("nel"); + let collaboration = collaborating(&target, &who); + let membership = joined(&who); + [Resolved::Ready(true), Resolved::Warming] + .into_iter() + .for_each(|blocked| { + let acl = Fake::new() + .blocked(blocked) + .owner(Resolved::Ready(Some(owner("olaren")))); + let anyone = policy_for(&target, ContributionPolicy::Anyone); + let access = access_of(&collaboration, &membership, &anyone); + assert_eq!( + repo_role(&acl, &access), + Resolved::Ready(RepoRole::Collaborator), + "Blocklist decides admission, not who somebody is" + ); + assert!( + !allowed(can_contribute(&acl, &access)), + "Blocked subject doesn't write, whatever policy runs underneath" + ); + }); + } + + #[test] + fn one_access_is_one_subject_on_one_repo() { + let target = repo("anemone"); + let elsewhere = repo("barnacle"); + let (nel, olaren) = (acc("nel"), acc("olaren")); + let collaboration = collaborating(&target, &nel); + let here = policy_for(&target, ContributionPolicy::Anyone); + let there = policy_for(&elsewhere, ContributionPolicy::Anyone); + assert!( + RepoAccess::of(&collaboration, &joined(&olaren), &here).is_none(), + "Role read off two subjects' consents would authorize account nobody consented to" + ); + assert!( + RepoAccess::of(&collaboration, &joined(&nel), &there).is_none(), + "Access built from another repo's policy would authorize repository policy never named" + ); + assert!(RepoAccess::of(&collaboration, &joined(&nel), &here).is_some()); + } + + #[test] + fn an_admin_administers_and_creates_but_doesnt_push_arbitrary_repos() { let acl = Fake::new() .admin("nel") .owner(Resolved::Ready(Some(owner("olaren")))); @@ -218,9 +450,9 @@ mod tests { assert_eq!(can_admin_knot(&acl, &nel), Decision::Allow); assert_eq!(can_create_repo(&acl, &outside(&nel)), Decision::Allow); assert_eq!( - can_push(&acl, &bystanding(&squid, &nel)), + can_push(&acl, &ungranted(&squid, &nel)), Decision::Deny, - "knot admin has no push on repo it neither owns nor collaborates on" + "Knot admin can't push to somebody else's repo" ); } @@ -235,27 +467,27 @@ mod tests { Decision::Allow, ), ( - "a_member_cannot_administer_the_knot", + "a_member_cant_administer_the_knot", Fake::new(), |acl| can_admin_knot(acl, &acc("olaren")), Decision::Deny, ), ( - "an_open_knot_admits_a_non_member", + "an_open_knot_lets_an_outsider_create", Fake::new().open(), |acl| can_create_repo(acl, &outside(&acc("teq"))), Decision::Allow, ), ( - "an_open_knot_does_not_widen_push", + "an_open_knot_doesnt_widen_push", Fake::new() .open() .owner(Resolved::Ready(Some(owner("nel")))), - |acl| can_push(acl, &bystanding(&repo("squid"), &acc("teq"))), + |acl| can_push(acl, &ungranted(&repo("squid"), &acc("teq"))), Decision::Deny, ), ( - "a_blocked_account_cannot_create", + "a_blocked_account_cant_create", Fake::new().open().blocked(Resolved::Ready(true)), |acl| can_create_repo(acl, &joined(&acc("squid"))), Decision::Deny, @@ -272,7 +504,7 @@ mod tests { ( "the_repo_owner_pushes", Fake::new().owner(Resolved::Ready(Some(owner("nel")))), - |acl| can_push(acl, &bystanding(&repo("squid"), &acc("nel"))), + |acl| can_push(acl, &ungranted(&repo("squid"), &acc("nel"))), Decision::Allow, ), ( @@ -290,19 +522,19 @@ mod tests { ( "push_allows_a_confirmed_owner_with_no_acceptance_behind_them", Fake::new().owner(Resolved::Ready(Some(owner("nel")))), - |acl| can_push(acl, &bystanding(&repo("squid"), &acc("nel"))), + |acl| can_push(acl, &ungranted(&repo("squid"), &acc("nel"))), Decision::Allow, ), ( "push_denies_when_ownership_is_warming_and_not_a_collaborator", Fake::new().owner(Resolved::Warming), - |acl| can_push(acl, &bystanding(&repo("squid"), &acc("nel"))), + |acl| can_push(acl, &ungranted(&repo("squid"), &acc("nel"))), Decision::Deny, ), ( "push_denies_an_unregistered_repo", Fake::new().owner(Resolved::Ready(None)), - |acl| can_push(acl, &bystanding(&repo("squid"), &acc("nel"))), + |acl| can_push(acl, &ungranted(&repo("squid"), &acc("nel"))), Decision::Deny, ), ( @@ -313,7 +545,7 @@ mod tests { |acl| { can_push( acl, - &bystanding( + &ungranted( &repo("squid"), &AccountDid::new("did:web:oyster.cafe").unwrap(), ), @@ -327,7 +559,7 @@ mod tests { |acl| { can_push( acl, - &bystanding(&repo("squid"), &AccountDid::new("did:plc:abc").unwrap()), + &ungranted(&repo("squid"), &AccountDid::new("did:plc:abc").unwrap()), ) }, Decision::Deny, @@ -369,7 +601,7 @@ mod tests { .blocked(Resolved::Ready(true)); let (squid, anemone) = (acc("squid"), repo("anemone")); assert_eq!( - can_push(&acl, &bystanding(&anemone, &squid)), + can_push(&acl, &ungranted(&anemone, &squid)), Decision::Deny, "ban overrides ownership on write path" ); @@ -391,10 +623,7 @@ mod tests { Decision::Deny, "an unresolved blocklist could contain the ban, so create must fail closed" ); - assert_eq!( - can_push(&acl, &bystanding(&anemone, &squid)), - Decision::Deny - ); + assert_eq!(can_push(&acl, &ungranted(&anemone, &squid)), Decision::Deny); } #[test] @@ -403,7 +632,7 @@ mod tests { let (teq, squid) = (acc("teq"), repo("squid")); assert_eq!(can_admin_knot(&acl, &teq), Decision::Deny); assert_eq!(can_create_repo(&acl, &outside(&teq)), Decision::Deny); - assert_eq!(can_push(&acl, &bystanding(&squid, &teq)), Decision::Deny); + assert_eq!(can_push(&acl, &ungranted(&squid, &teq)), Decision::Deny); } #[test] @@ -411,7 +640,7 @@ mod tests { let acl = Fake::new(); let (olaren, nel, squid) = (acc("olaren"), acc("nel"), repo("squid")); assert_eq!(can_create_repo(&acl, &outside(&olaren)), Decision::Deny); - assert_eq!(can_push(&acl, &bystanding(&squid, &nel)), Decision::Deny); + assert_eq!(can_push(&acl, &ungranted(&squid, &nel)), Decision::Deny); } #[test] @@ -466,7 +695,7 @@ mod tests { mod integration { use super::*; - use knot_cob::{ChangePayload, CobHome, CobStore}; + use knot_cob::{CobHome, CobStore, KnotAuthored}; use knot_cobs::{CollaboratorsChange, Grant, MembersChange, Registration, RegistryChange}; use knot_git::{Layout, Repo}; use knot_runtime::{K256Signer, SeededEntropy}; @@ -511,6 +740,7 @@ mod tests { name: RepoName::new(key).unwrap(), repo: repo_did.clone(), created_at: UnixSeconds::new(at), + policy: ContributionPolicy::default(), } } @@ -523,7 +753,7 @@ mod tests { (dir, meta_path, layout, signer) } - fn seed( + fn seed( store: &CobStore, home: &CobHome, change: &P, diff --git a/knot2/crates/knot-types/src/policy.rs b/knot2/crates/knot-types/src/policy.rs index 13c40764e..83a1972ed 100644 --- a/knot2/crates/knot-types/src/policy.rs +++ b/knot2/crates/knot-types/src/policy.rs @@ -1,5 +1,9 @@ +use std::fmt; + use serde::{Deserialize, Serialize}; +use crate::ids::RepoDid; + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum AdmissionPolicy { @@ -7,3 +11,290 @@ pub enum AdmissionPolicy { Closed, Open, } + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ContributionPolicy { + Anyone, + #[default] + Collaborators, + Members, +} + +impl ContributionPolicy { + pub const fn allows(self, role: RepoRole) -> bool { + match (self, role) { + (_, RepoRole::Owner | RepoRole::Collaborator) => true, + (Self::Anyone, _) => true, + (Self::Members, RepoRole::Member | RepoRole::KnotAdmin(Membership::Member)) => true, + (Self::Members, RepoRole::KnotAdmin(Membership::Outsider) | RepoRole::Stranger) => { + false + } + (Self::Collaborators, _) => false, + } + } + + pub const fn name(self) -> &'static str { + match self { + Self::Anyone => "anyone", + Self::Collaborators => "collaborators", + Self::Members => "members", + } + } +} + +impl fmt::Display for ContributionPolicy { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.pad(self.name()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Blocked { + No, + Yes, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ContributionPermission { + blocked: Blocked, + role: RepoRole, + policy: ContributionPolicy, +} + +impl ContributionPermission { + pub const fn new(blocked: Blocked, role: RepoRole, policy: ContributionPolicy) -> Self { + Self { + blocked, + role, + policy, + } + } + + pub const fn role(self) -> RepoRole { + self.role + } + + pub const fn policy(self) -> ContributionPolicy { + self.policy + } + + pub const fn blocked(self) -> Blocked { + self.blocked + } + + pub const fn contributes(self) -> Decision { + match self.blocked { + Blocked::Yes => Decision::Deny, + Blocked::No => Decision::allow_if(self.policy.allows(self.role)), + } + } + + pub const fn moderates(self) -> Decision { + match self.blocked { + Blocked::Yes => Decision::Deny, + Blocked::No => Decision::allow_if(self.role.moderates()), + } + } +} + +impl fmt::Display for ContributionPermission { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let listed = match self.blocked { + Blocked::Yes => " on the blocklist", + Blocked::No => "", + }; + write!( + f, + "{}{listed}, where contribution policy is {}", + self.role, self.policy + ) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RepoPolicy { + repo: RepoDid, + policy: ContributionPolicy, +} + +impl RepoPolicy { + pub const fn new(repo: RepoDid, policy: ContributionPolicy) -> Self { + Self { repo, policy } + } + + pub const fn repo(&self) -> &RepoDid { + &self.repo + } + + pub const fn policy(&self) -> ContributionPolicy { + self.policy + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Membership { + Member, + Outsider, +} + +impl Membership { + pub const fn of(member: bool) -> Self { + if member { Self::Member } else { Self::Outsider } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum RepoRole { + Owner, + Collaborator, + KnotAdmin(Membership), + Member, + Stranger, +} + +impl RepoRole { + pub const fn moderates(self) -> bool { + matches!(self, Self::Owner | Self::Collaborator | Self::KnotAdmin(_)) + } + + pub const fn name(self) -> &'static str { + match self { + Self::Owner => "owner", + Self::Collaborator => "collaborator", + Self::KnotAdmin(Membership::Member) => "knot admin on member roll", + Self::KnotAdmin(Membership::Outsider) => "knot admin", + Self::Member => "member", + Self::Stranger => "stranger", + } + } +} + +impl fmt::Display for RepoRole { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.pad(self.name()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[must_use] +pub enum Decision { + Allow, + Deny, +} + +impl Decision { + pub const fn is_allowed(self) -> bool { + matches!(self, Decision::Allow) + } + + pub const fn allow_if(granted: bool) -> Self { + if granted { + Decision::Allow + } else { + Decision::Deny + } + } +} + +#[cfg(test)] +mod tests { + use super::{Blocked, ContributionPermission, ContributionPolicy, Membership, RepoRole}; + + const EVERY_ROLE: [RepoRole; 6] = [ + RepoRole::Owner, + RepoRole::Collaborator, + RepoRole::KnotAdmin(Membership::Member), + RepoRole::KnotAdmin(Membership::Outsider), + RepoRole::Member, + RepoRole::Stranger, + ]; + + #[test] + fn a_knot_admin_moderates_without_being_a_maintainer_of_somebody_else_s_repo() { + let outsider = RepoRole::KnotAdmin(Membership::Outsider); + let enrolled = RepoRole::KnotAdmin(Membership::Member); + assert!( + outsider.moderates() && enrolled.moderates(), + "admin carve-out exists for operator to moderate knot's text" + ); + assert!( + !matches!(outsider, RepoRole::Owner | RepoRole::Collaborator) + && !matches!(enrolled, RepoRole::Owner | RepoRole::Collaborator), + "Admin's authority stops at knot, and maintenance answers to owner" + ); + } + + #[test] + fn each_policy_allows_exactly_the_roles_beneath_it() { + let cases: &[(ContributionPolicy, &[RepoRole])] = &[ + (ContributionPolicy::Anyone, &EVERY_ROLE), + ( + ContributionPolicy::Members, + &[ + RepoRole::Owner, + RepoRole::Collaborator, + RepoRole::KnotAdmin(Membership::Member), + RepoRole::Member, + ], + ), + ( + ContributionPolicy::Collaborators, + &[RepoRole::Owner, RepoRole::Collaborator], + ), + ]; + let every = EVERY_ROLE; + cases.iter().for_each(|(policy, admitted)| { + every.iter().for_each(|role| { + assert_eq!( + policy.allows(*role), + admitted.contains(role), + "{policy:?} disagrees about {role:?}" + ); + }); + }); + } + + #[test] + fn the_default_policy_declines_strangers_text() { + assert_eq!( + ContributionPolicy::default(), + ContributionPolicy::Collaborators, + "Self-hoster mustn't become publisher of strangers' text by accident" + ); + } + + #[test] + fn a_blocked_subject_doesnt_contribute_or_moderate_whatever_it_would_otherwise_be() { + let every_role = EVERY_ROLE; + let every_policy = [ + ContributionPolicy::Anyone, + ContributionPolicy::Members, + ContributionPolicy::Collaborators, + ]; + every_policy.iter().for_each(|policy| { + every_role.iter().for_each(|role| { + let banned = ContributionPermission::new(Blocked::Yes, *role, *policy); + assert!( + !banned.contributes().is_allowed(), + "Banned {role:?} doesn't write anything under {policy:?}" + ); + assert!( + !banned.moderates().is_allowed(), + "Banned {role:?} doesn't moderate anything under {policy:?}" + ); + let permission = ContributionPermission::new(Blocked::No, *role, *policy); + assert_eq!( + permission.contributes().is_allowed(), + policy.allows(*role), + "Unbanned {role:?} contributes exactly as far as {policy:?} allows" + ); + assert_eq!( + permission.moderates().is_allowed(), + role.moderates(), + "Unbanned {role:?} moderates exactly as its role allows" + ); + }); + }); + } +}