Jump to…
snowinitial commitqoxwzsukwmkx1mo
Matt W1//! Authorization (spec §6).
Matt W2//!
Matt W3//! ```text
Matt W4//! Role read comment push manage-changes settings delete
Matt W5//! read ✓ ✓ · · · ·
Matt W6//! write ✓ ✓ ✓ · · ·
Matt W7//! maintain ✓ ✓ ✓ ✓ ✓ ·
Matt W8//! admin ✓ ✓ ✓ ✓ ✓ ✓
Matt W9//! ```
Matt W10//!
Matt W11//! The whole point of this module is that permission logic exists in exactly one
Matt W12//! place. Spec §6: "Do not scatter permission checks through handlers — one
Matt W13//! function, one call site per request, and a default-deny fallthrough."
Matt W14//!
Matt W15//! [`resolve`] is pure — it takes facts already loaded from the database and
Matt W16//! returns a decision. That makes the interesting cases testable without a
Matt W17//! database, which matters because a mistake here is the private-repo leakage
Matt W18//! class of bug that §9 calls "the most common forge vulnerability".
Matt W19
Matt W20use df_db::models::{OrgRole, RepoRole, Visibility};
Matt W21
Matt W22/// What a request is allowed to do with a repository.
Matt W23///
Matt W24/// Constructed only by [`resolve`]. There is deliberately no way to build one
Matt W25/// with arbitrary permissions outside this module.
Matt W26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
Matt W27pub struct RepoAccess {
Matt W28 role: Option<RepoRole>,
Matt W29}
Matt W30
Matt W31impl RepoAccess {
Matt W32 /// No access at all. The repo must be rendered as if it does not exist —
Matt W33 /// see the note on 404s below.
Matt W34 pub const DENIED: RepoAccess = RepoAccess { role: None };
Matt W35
Matt W36 pub fn role(&self) -> Option<RepoRole> {
Matt W37 self.role
Matt W38 }
Matt W39
Matt W40 /// Whether the repo may be seen at all.
Matt W41 ///
Matt W42 /// When this is false the handler must return the *same* 404 a nonexistent
Matt W43 /// repo returns (spec §9), never a 403 — a 403 confirms the repo exists,
Matt W44 /// which is exactly the leak.
Matt W45 pub fn can_read(&self) -> bool {
Matt W46 self.role.is_some()
Matt W47 }
Matt W48
Matt W49 pub fn can_comment(&self) -> bool {
Matt W50 self.at_least(RepoRole::Read)
Matt W51 }
Matt W52
Matt W53 pub fn can_push(&self) -> bool {
Matt W54 self.at_least(RepoRole::Write)
Matt W55 }
Matt W56
Matt W57 /// Merge, abandon, retarget, edit others' changes.
Matt W58 pub fn can_manage_changes(&self) -> bool {
Matt W59 self.at_least(RepoRole::Maintain)
Matt W60 }
Matt W61
Matt W62 pub fn can_change_settings(&self) -> bool {
Matt W63 self.at_least(RepoRole::Maintain)
Matt W64 }
Matt W65
Matt W66 pub fn can_delete(&self) -> bool {
Matt W67 self.at_least(RepoRole::Admin)
Matt W68 }
Matt W69
Matt W70 fn at_least(&self, needed: RepoRole) -> bool {
Matt W71 matches!(self.role, Some(r) if r >= needed)
Matt W72 }
Matt W73}
Matt W74
Matt W75/// Everything [`resolve`] needs to know, loaded once per request.
Matt W76#[derive(Debug, Clone, Copy)]
Matt W77pub struct AccessInputs {
Matt W78 pub visibility: Visibility,
Matt W79 /// Set when the viewer is signed in.
Matt W80 pub viewer: Option<Viewer>,
Matt W81 /// The repo's owning user, when owned by a user.
Matt W82 pub owner_user_id: Option<uuid::Uuid>,
Matt W83 /// Direct collaborator role, if any.
Matt W84 pub collaborator_role: Option<RepoRole>,
Matt W85 /// The viewer's role in the owning org, when the repo is org-owned.
Matt W86 pub org_role: Option<OrgRole>,
Matt W87}
Matt W88
Matt W89#[derive(Debug, Clone, Copy)]
Matt W90pub struct Viewer {
Matt W91 pub user_id: uuid::Uuid,
Matt W92 pub is_site_admin: bool,
Matt W93}
Matt W94
Matt W95/// Resolve effective access.
Matt W96///
Matt W97/// Effective role is the maximum of: direct collaborator role, org role (org
Matt W98/// admins get `admin` on org repos), repo ownership, and site admin. Public
Matt W99/// repos additionally grant implicit `read` to everyone including anonymous
Matt W100/// users; private repos grant nothing implicitly.
Matt W101pub fn resolve(input: AccessInputs) -> RepoAccess {
Matt W102 // Default deny. Every grant below is additive, and anything that falls
Matt W103 // through this function unmatched ends up denied.
Matt W104 let mut role: Option<RepoRole> = None;
Matt W105
Matt W106 /// Raise `role` to at least `candidate`.
Matt W107 fn raise(role: &mut Option<RepoRole>, candidate: RepoRole) {
Matt W108 *role = Some(match *role {
Matt W109 Some(existing) => existing.max(candidate),
Matt W110 None => candidate,
Matt W111 });
Matt W112 }
Matt W113
Matt W114 // Public repos are readable by anyone, including anonymous requests.
Matt W115 if matches!(input.visibility, Visibility::Public) {
Matt W116 raise(&mut role, RepoRole::Read);
Matt W117 }
Matt W118
Matt W119 if let Some(viewer) = input.viewer {
Matt W120 // Site admins have full access everywhere.
Matt W121 if viewer.is_site_admin {
Matt W122 raise(&mut role, RepoRole::Admin);
Matt W123 }
Matt W124
Matt W125 // Owning a repo grants admin on it.
Matt W126 if input.owner_user_id == Some(viewer.user_id) {
Matt W127 raise(&mut role, RepoRole::Admin);
Matt W128 }
Matt W129
Matt W130 if let Some(direct) = input.collaborator_role {
Matt W131 raise(&mut role, direct);
Matt W132 }
Matt W133
Matt W134 // Org membership. Org admins get repo admin; plain members get read,
Matt W135 // which is what makes private org repos visible to the org.
Matt W136 match input.org_role {
Matt W137 Some(OrgRole::Admin) => raise(&mut role, RepoRole::Admin),
Matt W138 Some(OrgRole::Member) => raise(&mut role, RepoRole::Read),
Matt W139 None => {}
Matt W140 }
Matt W141 }
Matt W142
Matt W143 RepoAccess { role }
Matt W144}
Matt W145
Matt W146#[cfg(test)]
Matt W147mod tests {
Matt W148 use super::*;
Matt W149 use uuid::Uuid;
Matt W150
Matt W151 fn viewer() -> Viewer {
Matt W152 Viewer { user_id: Uuid::from_u128(1), is_site_admin: false }
Matt W153 }
Matt W154
Matt W155 fn base() -> AccessInputs {
Matt W156 AccessInputs {
Matt W157 visibility: Visibility::Private,
Matt W158 viewer: None,
Matt W159 owner_user_id: None,
Matt W160 collaborator_role: None,
Matt W161 org_role: None,
Matt W162 }
Matt W163 }
Matt W164
Matt W165 // ─── the leakage cases (spec §9) ─────────────────────────────────────────
Matt W166
Matt W167 #[test]
Matt W168 fn anonymous_cannot_read_a_private_repo() {
Matt W169 let a = resolve(base());
Matt W170 assert!(!a.can_read(), "anonymous access to a private repo must be denied");
Matt W171 assert_eq!(a.role(), None);
Matt W172 }
Matt W173
Matt W174 #[test]
Matt W175 fn a_signed_in_stranger_cannot_read_a_private_repo() {
Matt W176 let a = resolve(AccessInputs { viewer: Some(viewer()), ..base() });
Matt W177 assert!(
Matt W178 !a.can_read(),
Matt W179 "being authenticated is not authorization — this is the private-repo leak"
Matt W180 );
Matt W181 }
Matt W182
Matt W183 #[test]
Matt W184 fn org_membership_alone_does_not_grant_access_to_a_non_org_repo() {
Matt W185 // org_role is only ever populated for org-owned repos, but if a bug ever
Matt W186 // populated it for a user-owned repo the grant would still be read-only,
Matt W187 // never write.
Matt W188 let a = resolve(AccessInputs {
Matt W189 viewer: Some(viewer()),
Matt W190 org_role: Some(OrgRole::Member),
Matt W191 ..base()
Matt W192 });
Matt W193 assert!(a.can_read());
Matt W194 assert!(!a.can_push(), "org membership must never imply push");
Matt W195 }
Matt W196
Matt W197 // ─── public repos ────────────────────────────────────────────────────────
Matt W198
Matt W199 #[test]
Matt W200 fn anonymous_can_read_but_not_write_a_public_repo() {
Matt W201 let a = resolve(AccessInputs { visibility: Visibility::Public, ..base() });
Matt W202 assert!(a.can_read());
Matt W203 assert!(a.can_comment(), "read implies comment per the §6 table");
Matt W204 assert!(!a.can_push());
Matt W205 assert!(!a.can_change_settings());
Matt W206 assert!(!a.can_delete());
Matt W207 }
Matt W208
Matt W209 // ─── the role table, exactly as specified in §6 ──────────────────────────
Matt W210
Matt W211 #[test]
Matt W212 fn role_table_matches_the_spec() {
Matt W213 let cases = [
Matt W214 // read, comment, push, manage, settings, delete
Matt W215 (RepoRole::Read, true, true, false, false, false, false),
Matt W216 (RepoRole::Write, true, true, true, false, false, false),
Matt W217 (RepoRole::Maintain, true, true, true, true, true, false),
Matt W218 (RepoRole::Admin, true, true, true, true, true, true),
Matt W219 ];
Matt W220 for (role, read, comment, push, manage, settings, delete) in cases {
Matt W221 let a = resolve(AccessInputs {
Matt W222 viewer: Some(viewer()),
Matt W223 collaborator_role: Some(role),
Matt W224 ..base()
Matt W225 });
Matt W226 assert_eq!(a.can_read(), read, "{role:?} read");
Matt W227 assert_eq!(a.can_comment(), comment, "{role:?} comment");
Matt W228 assert_eq!(a.can_push(), push, "{role:?} push");
Matt W229 assert_eq!(a.can_manage_changes(), manage, "{role:?} manage-changes");
Matt W230 assert_eq!(a.can_change_settings(), settings, "{role:?} settings");
Matt W231 assert_eq!(a.can_delete(), delete, "{role:?} delete");
Matt W232 }
Matt W233 }
Matt W234
Matt W235 // ─── the maximum rule ────────────────────────────────────────────────────
Matt W236
Matt W237 #[test]
Matt W238 fn effective_role_is_the_maximum_not_the_last_match() {
Matt W239 // A read collaborator who is also an org admin gets admin, not read.
Matt W240 let a = resolve(AccessInputs {
Matt W241 viewer: Some(viewer()),
Matt W242 collaborator_role: Some(RepoRole::Read),
Matt W243 org_role: Some(OrgRole::Admin),
Matt W244 ..base()
Matt W245 });
Matt W246 assert_eq!(a.role(), Some(RepoRole::Admin));
Matt W247 }
Matt W248
Matt W249 #[test]
Matt W250 fn a_lower_org_role_never_downgrades_a_higher_collaborator_role() {
Matt W251 let a = resolve(AccessInputs {
Matt W252 viewer: Some(viewer()),
Matt W253 collaborator_role: Some(RepoRole::Admin),
Matt W254 org_role: Some(OrgRole::Member),
Matt W255 ..base()
Matt W256 });
Matt W257 assert_eq!(
Matt W258 a.role(),
Matt W259 Some(RepoRole::Admin),
Matt W260 "org membership must not reduce an explicit collaborator grant"
Matt W261 );
Matt W262 }
Matt W263
Matt W264 #[test]
Matt W265 fn site_admin_has_full_access_to_private_repos() {
Matt W266 let a = resolve(AccessInputs {
Matt W267 viewer: Some(Viewer { user_id: Uuid::from_u128(9), is_site_admin: true }),
Matt W268 ..base()
Matt W269 });
Matt W270 assert!(a.can_delete());
Matt W271 }
Matt W272
Matt W273 #[test]
Matt W274 fn owner_has_admin_on_their_own_repo() {
Matt W275 let v = viewer();
Matt W276 let a = resolve(AccessInputs {
Matt W277 viewer: Some(v),
Matt W278 owner_user_id: Some(v.user_id),
Matt W279 ..base()
Matt W280 });
Matt W281 assert_eq!(a.role(), Some(RepoRole::Admin));
Matt W282 }
Matt W283
Matt W284 #[test]
Matt W285 fn a_different_users_repo_is_not_owned() {
Matt W286 let a = resolve(AccessInputs {
Matt W287 viewer: Some(viewer()),
Matt W288 owner_user_id: Some(Uuid::from_u128(999)),
Matt W289 ..base()
Matt W290 });
Matt W291 assert!(!a.can_read());
Matt W292 }
Matt W293
Matt W294 #[test]
Matt W295 fn denied_is_the_default() {
Matt W296 assert!(!RepoAccess::DENIED.can_read());
Matt W297 assert_eq!(RepoAccess::DENIED.role(), None);
Matt W298 }
Matt W299}

299 lines · Rust