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