| 1 | //! Who may see what. Checked twice, both times when the query runs: |
| 2 | //! |
| 3 | //! 1. In the query itself, against the viewer's access as it is now: a |
| 4 | //! row is read if its repository is public, as the index last heard, |
| 5 | //! in a workspace whose private repositories the viewer reads (an |
| 6 | //! owner, or a member whose base permission gives a role), or one they |
| 7 | //! were given a role on directly. A work item is read by every member |
| 8 | //! of its workspace, and by anyone who reads the repository it was filed |
| 9 | //! in (`access::item_role`); one filed in no repository, by members only. |
| 10 | //! 2. On the page about to be returned, against the repos service, which |
| 11 | //! owns visibility: anything it does not say the viewer may read now is |
| 12 | //! dropped, and the index is corrected. A repository made private a |
| 13 | //! moment ago, before its event arrived, is never shown to anyone who |
| 14 | //! cannot read it. |
| 15 | //! |
| 16 | //! Pure, so the rules are tested apart from the index. |
| 17 | |
| 18 | use std::collections::{HashMap, HashSet}; |
| 19 | |
| 20 | use g1t_contracts::Viewer; |
| 21 | use g1t_contracts::access::{self, RepoRef}; |
| 22 | use g1t_contracts::repos::Repo; |
| 23 | |
| 24 | /// Who is reading, as far as private repositories go. |
| 25 | #[derive(Clone, Debug, Default)] |
| 26 | pub struct Reader { |
| 27 | /// Workspaces, by slug, whose every repository the reader can read: |
| 28 | /// those they own, and those whose base permission gives members a |
| 29 | /// role. A member of a workspace whose base permission is none is not |
| 30 | /// in this list. |
| 31 | pub reads_in: Vec<String>, |
| 32 | /// Repositories the reader was given a role on directly, by id: an |
| 33 | /// outside collaborator's, or a member's beyond the base permission. |
| 34 | pub granted: Vec<String>, |
| 35 | /// Workspaces, by slug, the reader is a member of, so sees every work |
| 36 | /// item of (`access::workspace_role`). |
| 37 | pub members_of: Vec<String>, |
| 38 | } |
| 39 | |
| 40 | impl Reader { |
| 41 | pub fn of(viewer: &Viewer) -> Reader { |
| 42 | let Some(user) = viewer else { |
| 43 | return Reader::default(); |
| 44 | }; |
| 45 | Reader { |
| 46 | reads_in: user |
| 47 | .workspaces |
| 48 | .iter() |
| 49 | .map(|membership| membership.slug.to_lowercase()) |
| 50 | // What membership alone gives, on a private repository no |
| 51 | // one was given a role on. |
| 52 | .filter(|slug| access::granted(user, RepoRef { id: "", namespace: slug, private: true }).is_some()) |
| 53 | .collect(), |
| 54 | granted: user.grants.iter().map(|grant| grant.repo_id.clone()).collect(), |
| 55 | members_of: user |
| 56 | .workspaces |
| 57 | .iter() |
| 58 | .map(|membership| membership.slug.to_lowercase()) |
| 59 | .filter(|slug| access::workspace_role(user, slug).is_some()) |
| 60 | .collect(), |
| 61 | } |
| 62 | } |
| 63 | |
| 64 | /// Whether the reader reads every repository in `namespace`. |
| 65 | pub fn reads_namespace(&self, namespace: &str) -> bool { |
| 66 | self.reads_in.iter().any(|slug| slug.eq_ignore_ascii_case(namespace)) |
| 67 | } |
| 68 | |
| 69 | /// Whether the reader was given a role on the repository `repo_id`. |
| 70 | pub fn was_granted(&self, repo_id: &str) -> bool { |
| 71 | self.granted.iter().any(|id| id == repo_id) |
| 72 | } |
| 73 | |
| 74 | /// The first check: may the reader see a row of the repository |
| 75 | /// `repo_id`, in `namespace`, that is `private` or not? |
| 76 | pub fn may_see(&self, repo_id: &str, namespace: &str, private: bool) -> bool { |
| 77 | !private || self.reads_namespace(namespace) || self.was_granted(repo_id) |
| 78 | } |
| 79 | |
| 80 | /// [`Reader::reads_in`] as JSON, for `json_each` in a query. Never |
| 81 | /// empty SQL: none is `[]`, which matches nothing. |
| 82 | pub fn namespaces_json(&self) -> String { |
| 83 | serde_json::to_string(&self.reads_in).unwrap_or_else(|_| "[]".into()) |
| 84 | } |
| 85 | |
| 86 | /// [`Reader::granted`] as JSON, likewise. |
| 87 | pub fn granted_json(&self) -> String { |
| 88 | serde_json::to_string(&self.granted).unwrap_or_else(|_| "[]".into()) |
| 89 | } |
| 90 | |
| 91 | /// Whether the reader sees every work item of `workspace`. |
| 92 | pub fn member_of(&self, workspace: &str) -> bool { |
| 93 | self.members_of.iter().any(|slug| slug.eq_ignore_ascii_case(workspace)) |
| 94 | } |
| 95 | |
| 96 | /// [`Reader::members_of`] as JSON, likewise. |
| 97 | pub fn members_json(&self) -> String { |
| 98 | serde_json::to_string(&self.members_of).unwrap_or_else(|_| "[]".into()) |
| 99 | } |
| 100 | } |
| 101 | |
| 102 | /// The SQL the first check adds to every query, given the repository's |
| 103 | /// table alias. Its two parameters are [`Reader::namespaces_json`] and |
| 104 | /// [`Reader::granted_json`], in that order. |
| 105 | pub fn clause(alias: &str) -> String { |
| 106 | format!( |
| 107 | "({alias}.private = 0 OR {alias}.namespace IN (SELECT value FROM json_each(?)) OR {alias}.repo_id IN (SELECT value FROM json_each(?)))" |
| 108 | ) |
| 109 | } |
| 110 | |
| 111 | /// The first check for work items, given the item's and its origin's table |
| 112 | /// aliases: a member of its workspace, or a reader of its origin, which is |
| 113 | /// joined with a `LEFT JOIN` (none: no origin, members only). Its three |
| 114 | /// parameters are [`Reader::members_json`], [`Reader::namespaces_json`] and |
| 115 | /// [`Reader::granted_json`], in that order. |
| 116 | pub fn item_clause(item: &str, origin: &str) -> String { |
| 117 | format!( |
| 118 | "({item}.workspace IN (SELECT value FROM json_each(?)) OR (({origin}.repo_id IS NOT NULL) AND {}))", |
| 119 | clause(origin) |
| 120 | ) |
| 121 | } |
| 122 | |
| 123 | /// A repository as the index holds it. |
| 124 | #[derive(Clone, Debug, PartialEq, Eq)] |
| 125 | pub struct Indexed { |
| 126 | pub repo_id: String, |
| 127 | pub namespace: String, |
| 128 | pub private: bool, |
| 129 | } |
| 130 | |
| 131 | /// What the index should be told about a repository it holds wrongly. |
| 132 | #[derive(Clone, Debug, PartialEq, Eq)] |
| 133 | pub struct Correction { |
| 134 | pub repo_id: String, |
| 135 | pub private: bool, |
| 136 | /// Its current path, when the repos service said. |
| 137 | pub path: Option<(String, String)>, |
| 138 | } |
| 139 | |
| 140 | #[derive(Debug, Default, PartialEq, Eq)] |
| 141 | pub struct Verdict { |
| 142 | /// The repositories whose rows may be returned. |
| 143 | pub keep: HashSet<String>, |
| 144 | pub corrections: Vec<Correction>, |
| 145 | } |
| 146 | |
| 147 | /// The second check. `readable` is what the repos service says, for this |
| 148 | /// viewer, of the page's repositories (`readable` leaves out what they may |
| 149 | /// not read, and repositories that are gone). `None` when it could not be |
| 150 | /// asked: then only what the reader's own access reaches is kept (a |
| 151 | /// workspace whose repositories they all read, or a repository they were |
| 152 | /// given a role on), and nothing else public either, since it may have |
| 153 | /// just gone private. |
| 154 | pub fn check(reader: &Reader, indexed: &[Indexed], readable: Option<&[Repo]>) -> Verdict { |
| 155 | let mut verdict = Verdict::default(); |
| 156 | let Some(readable) = readable else { |
| 157 | for row in indexed { |
| 158 | if reader.reads_namespace(&row.namespace) || reader.was_granted(&row.repo_id) { |
| 159 | verdict.keep.insert(row.repo_id.clone()); |
| 160 | } |
| 161 | } |
| 162 | return verdict; |
| 163 | }; |
| 164 | let now: HashMap<&str, &Repo> = readable.iter().map(|repo| (repo.id.as_str(), repo)).collect(); |
| 165 | let mut seen = HashSet::new(); |
| 166 | for row in indexed { |
| 167 | if !seen.insert(row.repo_id.as_str()) { |
| 168 | continue; |
| 169 | } |
| 170 | match now.get(row.repo_id.as_str()) { |
| 171 | Some(repo) => { |
| 172 | // Readable now, and the first check agrees with what is true now. |
| 173 | if reader.may_see(&repo.id, &repo.namespace, repo.is_private) { |
| 174 | verdict.keep.insert(row.repo_id.clone()); |
| 175 | } |
| 176 | if repo.is_private != row.private || !repo.namespace.eq_ignore_ascii_case(&row.namespace) { |
| 177 | verdict.corrections.push(Correction { |
| 178 | repo_id: row.repo_id.clone(), |
| 179 | private: repo.is_private, |
| 180 | path: Some((repo.namespace.to_lowercase(), repo.name.to_lowercase())), |
| 181 | }); |
| 182 | } |
| 183 | } |
| 184 | // Not readable: private now (or gone). The index learns at once, |
| 185 | // so counts stop including it before its event arrives. |
| 186 | None if !row.private => verdict.corrections.push(Correction { |
| 187 | repo_id: row.repo_id.clone(), |
| 188 | private: true, |
| 189 | path: None, |
| 190 | }), |
| 191 | None => {} |
| 192 | } |
| 193 | } |
| 194 | verdict |
| 195 | } |
| 196 | |
| 197 | #[cfg(test)] |
| 198 | mod tests { |
| 199 | use g1t_contracts::access::{BasePermission, RepoGrant, RepoRole}; |
| 200 | use g1t_contracts::{Membership, Role, User}; |
| 201 | |
| 202 | use super::*; |
| 203 | |
| 204 | fn viewer(workspaces: &[&str]) -> Viewer { |
| 205 | Some(User { |
| 206 | id: "usr_1".into(), |
| 207 | username: "ana".into(), |
| 208 | workspaces: workspaces.iter().map(|slug| Membership::member(*slug)).collect(), |
| 209 | ..User::default() |
| 210 | }) |
| 211 | } |
| 212 | |
| 213 | fn row(id: &str, namespace: &str, private: bool) -> Indexed { |
| 214 | Indexed { |
| 215 | repo_id: id.into(), |
| 216 | namespace: namespace.into(), |
| 217 | private, |
| 218 | } |
| 219 | } |
| 220 | |
| 221 | fn repo(id: &str, namespace: &str, private: bool) -> Repo { |
| 222 | Repo { |
| 223 | id: id.into(), |
| 224 | namespace: namespace.into(), |
| 225 | name: "web".into(), |
| 226 | is_private: private, |
| 227 | owner_id: "usr_0".into(), |
| 228 | default_branch: "main".into(), |
| 229 | fork_of: None, |
| 230 | parent_id: None, |
| 231 | forked_from: None, |
| 232 | parent: None, |
| 233 | allow_forking: false, |
| 234 | max_ref_updates: None, |
| 235 | protected: false, |
| 236 | created_at: String::new(), |
| 237 | topics: Vec::new(), |
| 238 | website: None, |
| 239 | archived_at: None, |
| 240 | } |
| 241 | } |
| 242 | |
| 243 | #[test] |
| 244 | fn anyone_sees_public_rows() { |
| 245 | assert!(Reader::of(&None).may_see("rep_1", "acme", false)); |
| 246 | assert!(Reader::of(&viewer(&["other"])).may_see("rep_1", "acme", false)); |
| 247 | } |
| 248 | |
| 249 | #[test] |
| 250 | fn private_rows_are_for_members_only() { |
| 251 | assert!(!Reader::of(&None).may_see("rep_1", "acme", true)); |
| 252 | assert!(!Reader::of(&viewer(&["other"])).may_see("rep_1", "acme", true)); |
| 253 | assert!(Reader::of(&viewer(&["acme"])).may_see("rep_1", "acme", true)); |
| 254 | assert!(Reader::of(&viewer(&["acme"])).may_see("rep_1", "ACME", true)); |
| 255 | } |
| 256 | |
| 257 | /// A member of acme whose base permission is `base`, given `grants` |
| 258 | /// (repository id, role) there. |
| 259 | fn member_with(base: BasePermission, grants: &[(&str, RepoRole)]) -> Viewer { |
| 260 | Some(User { |
| 261 | id: "usr_1".into(), |
| 262 | username: "ana".into(), |
| 263 | workspaces: vec![Membership { base_permission: Some(base), ..Membership::member("acme") }], |
| 264 | grants: grants |
| 265 | .iter() |
| 266 | .map(|(id, role)| RepoGrant { repo_id: (*id).into(), workspace: "acme".into(), role: *role, team: None }) |
| 267 | .collect(), |
| 268 | ..User::default() |
| 269 | }) |
| 270 | } |
| 271 | |
| 272 | #[test] |
| 273 | fn members_without_a_base_permission_see_only_what_they_were_given() { |
| 274 | let reader = Reader::of(&member_with(BasePermission::None, &[("rep_2", RepoRole::Read)])); |
| 275 | assert!(!reader.may_see("rep_1", "acme", true)); |
| 276 | assert!(reader.may_see("rep_2", "acme", true)); |
| 277 | assert!(reader.may_see("rep_1", "acme", false)); |
| 278 | assert_eq!(reader.namespaces_json(), "[]"); |
| 279 | assert_eq!(reader.granted_json(), r#"["rep_2"]"#); |
| 280 | // Read as a base is enough for every repository. |
| 281 | let reader = Reader::of(&member_with(BasePermission::Read, &[])); |
| 282 | assert!(reader.may_see("rep_1", "acme", true)); |
| 283 | // An owner reads everything, whatever the base. |
| 284 | let owner = Some(User { |
| 285 | workspaces: vec![Membership { |
| 286 | role: Role::Owner, |
| 287 | base_permission: Some(BasePermission::None), |
| 288 | ..Membership::member("acme") |
| 289 | }], |
| 290 | ..User::default() |
| 291 | }); |
| 292 | assert!(Reader::of(&owner).may_see("rep_1", "acme", true)); |
| 293 | } |
| 294 | |
| 295 | #[test] |
| 296 | fn outside_collaborators_see_their_repositories_and_no_others() { |
| 297 | let outsider = Some(User { |
| 298 | id: "usr_2".into(), |
| 299 | username: "bo".into(), |
| 300 | grants: vec![RepoGrant { repo_id: "rep_1".into(), workspace: "acme".into(), role: RepoRole::Triage, team: None }], |
| 301 | ..User::default() |
| 302 | }); |
| 303 | let reader = Reader::of(&outsider); |
| 304 | assert!(reader.may_see("rep_1", "acme", true)); |
| 305 | assert!(!reader.may_see("rep_2", "acme", true)); |
| 306 | // Kept by the second check when the repos service agrees, and |
| 307 | // without it, since the grant is theirs. |
| 308 | let rows = [row("rep_1", "acme", true), row("rep_2", "acme", true)]; |
| 309 | let verdict = check(&reader, &rows, Some(&[repo("rep_1", "acme", true)])); |
| 310 | assert_eq!(verdict.keep, HashSet::from(["rep_1".to_owned()])); |
| 311 | let verdict = check(&reader, &rows, Some(&[repo("rep_1", "acme", true), repo("rep_2", "acme", true)])); |
| 312 | assert_eq!(verdict.keep, HashSet::from(["rep_1".to_owned()])); |
| 313 | let verdict = check(&reader, &rows, None); |
| 314 | assert_eq!(verdict.keep, HashSet::from(["rep_1".to_owned()])); |
| 315 | } |
| 316 | |
| 317 | #[test] |
| 318 | fn the_query_clause_binds_workspaces_and_grants() { |
| 319 | assert_eq!( |
| 320 | clause("r"), |
| 321 | "(r.private = 0 OR r.namespace IN (SELECT value FROM json_each(?)) OR r.repo_id IN (SELECT value FROM json_each(?)))" |
| 322 | ); |
| 323 | assert_eq!(Reader::of(&None).namespaces_json(), "[]"); |
| 324 | assert_eq!(Reader::of(&None).granted_json(), "[]"); |
| 325 | // Identity gives slugs in lowercase, as access::granted expects. |
| 326 | assert_eq!(Reader::of(&viewer(&["acme", "beta"])).namespaces_json(), r#"["acme","beta"]"#); |
| 327 | } |
| 328 | |
| 329 | #[test] |
| 330 | fn a_repository_made_private_disappears_before_its_event() { |
| 331 | // The index still says public; the repos service no longer lets |
| 332 | // this outsider read it. |
| 333 | let reader = Reader::of(&viewer(&["other"])); |
| 334 | let verdict = check(&reader, &[row("rep_1", "acme", false)], Some(&[])); |
| 335 | assert!(verdict.keep.is_empty()); |
| 336 | assert_eq!( |
| 337 | verdict.corrections, |
| 338 | vec![Correction { repo_id: "rep_1".into(), private: true, path: None }] |
| 339 | ); |
| 340 | // Signed out, the same. |
| 341 | let verdict = check(&Reader::of(&None), &[row("rep_1", "acme", false)], Some(&[])); |
| 342 | assert!(verdict.keep.is_empty()); |
| 343 | } |
| 344 | |
| 345 | #[test] |
| 346 | fn members_still_see_a_repository_made_private() { |
| 347 | let reader = Reader::of(&viewer(&["acme"])); |
| 348 | let verdict = check(&reader, &[row("rep_1", "acme", false)], Some(&[repo("rep_1", "acme", true)])); |
| 349 | assert!(verdict.keep.contains("rep_1")); |
| 350 | assert!(verdict.corrections[0].private); |
| 351 | } |
| 352 | |
| 353 | #[test] |
| 354 | fn a_repository_made_public_is_shown_and_corrected() { |
| 355 | // The first check let a member's row through; the repos service |
| 356 | // says it is public now, so the index is told. |
| 357 | let reader = Reader::of(&viewer(&["acme"])); |
| 358 | let verdict = check(&reader, &[row("rep_2", "acme", true)], Some(&[repo("rep_2", "acme", false)])); |
| 359 | assert!(verdict.keep.contains("rep_2")); |
| 360 | assert!(!verdict.corrections[0].private); |
| 361 | // Once corrected, an outsider's first check lets it through too. |
| 362 | assert!(Reader::of(&None).may_see("rep_2", "acme", false)); |
| 363 | } |
| 364 | |
| 365 | #[test] |
| 366 | fn private_rows_never_reach_outsiders_whatever_the_index_says() { |
| 367 | // Even if a private row slipped through the first check, and the |
| 368 | // repos service somehow answered with it, the reader is no member. |
| 369 | let reader = Reader::of(&viewer(&["other"])); |
| 370 | let verdict = check(&reader, &[row("rep_3", "acme", false)], Some(&[repo("rep_3", "acme", true)])); |
| 371 | assert!(verdict.keep.is_empty()); |
| 372 | } |
| 373 | |
| 374 | #[test] |
| 375 | fn without_the_repos_service_only_members_rows_are_kept() { |
| 376 | let reader = Reader::of(&viewer(&["acme"])); |
| 377 | let verdict = check(&reader, &[row("rep_1", "acme", true), row("rep_2", "public-co", false)], None); |
| 378 | assert_eq!(verdict.keep, HashSet::from(["rep_1".to_owned()])); |
| 379 | assert!(verdict.corrections.is_empty()); |
| 380 | } |
| 381 | |
| 382 | #[test] |
| 383 | fn members_see_every_work_item_of_their_workspace() { |
| 384 | // A member whose base permission gives no role on its repositories |
| 385 | // still sees its work items. |
| 386 | let reader = Reader::of(&member_with(BasePermission::None, &[])); |
| 387 | assert!(reader.member_of("ACME")); |
| 388 | assert_eq!(reader.members_json(), r#"["acme"]"#); |
| 389 | assert_eq!(reader.namespaces_json(), "[]"); |
| 390 | assert!(!Reader::of(&None).member_of("acme")); |
| 391 | assert!(!Reader::of(&viewer(&["other"])).member_of("acme")); |
| 392 | assert_eq!( |
| 393 | item_clause("i", "r"), |
| 394 | "(i.workspace IN (SELECT value FROM json_each(?)) OR ((r.repo_id IS NOT NULL) AND (r.private = 0 OR r.namespace IN (SELECT value FROM json_each(?)) OR r.repo_id IN (SELECT value FROM json_each(?)))))" |
| 395 | ); |
| 396 | } |
| 397 | |
| 398 | #[test] |
| 399 | fn a_moved_repository_is_corrected() { |
| 400 | let reader = Reader::of(&None); |
| 401 | let mut moved = repo("rep_4", "newname", false); |
| 402 | moved.name = "Web".into(); |
| 403 | let verdict = check(&reader, &[row("rep_4", "oldname", false)], Some(&[moved])); |
| 404 | assert!(verdict.keep.contains("rep_4")); |
| 405 | assert_eq!(verdict.corrections[0].path, Some(("newname".into(), "web".into()))); |
| 406 | } |
| 407 | } |