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 memberships as they are |
| 4 | //! now: a row is read if its repository is public, as the index last |
| 5 | //! heard, or in a workspace the viewer belongs to. |
| 6 | //! 2. On the page about to be returned, against the repos service, which |
| 7 | //! owns visibility: anything it does not say the viewer may read now is |
| 8 | //! dropped, and the index is corrected. A repository made private a |
| 9 | //! moment ago, before its event arrived, is never shown to anyone |
| 10 | //! outside its workspace. |
| 11 | //! |
| 12 | //! Pure, so the rules are tested apart from the index. |
| 13 | |
| 14 | use std::collections::{HashMap, HashSet}; |
| 15 | |
| 16 | use g1t_contracts::Viewer; |
| 17 | use g1t_contracts::repos::Repo; |
| 18 | |
| 19 | /// Who is reading: the workspaces they belong to, by slug. |
| 20 | #[derive(Clone, Debug, Default)] |
| 21 | pub struct Reader { |
| 22 | pub member_of: Vec<String>, |
| 23 | } |
| 24 | |
| 25 | impl Reader { |
| 26 | pub fn of(viewer: &Viewer) -> Reader { |
| 27 | Reader { |
| 28 | member_of: viewer |
| 29 | .iter() |
| 30 | .flat_map(|user| &user.workspaces) |
| 31 | .map(|membership| membership.slug.to_lowercase()) |
| 32 | .collect(), |
| 33 | } |
| 34 | } |
| 35 | |
| 36 | pub fn is_member(&self, namespace: &str) -> bool { |
| 37 | self.member_of.iter().any(|slug| slug.eq_ignore_ascii_case(namespace)) |
| 38 | } |
| 39 | |
| 40 | /// The first check: may the reader see a row of a repository in |
| 41 | /// `namespace` that is `private` or not? |
| 42 | pub fn may_see(&self, namespace: &str, private: bool) -> bool { |
| 43 | !private || self.is_member(namespace) |
| 44 | } |
| 45 | |
| 46 | /// The reader's workspaces as JSON, for `json_each` in a query. Never |
| 47 | /// empty SQL: no workspaces is `[]`, which matches nothing. |
| 48 | pub fn members_json(&self) -> String { |
| 49 | serde_json::to_string(&self.member_of).unwrap_or_else(|_| "[]".into()) |
| 50 | } |
| 51 | } |
| 52 | |
| 53 | /// The SQL the first check adds to every query, given the repository's |
| 54 | /// table alias. Its one parameter is [`Reader::members_json`]. |
| 55 | pub fn clause(alias: &str) -> String { |
| 56 | format!("({alias}.private = 0 OR {alias}.namespace IN (SELECT value FROM json_each(?)))") |
| 57 | } |
| 58 | |
| 59 | /// A repository as the index holds it. |
| 60 | #[derive(Clone, Debug, PartialEq, Eq)] |
| 61 | pub struct Indexed { |
| 62 | pub repo_id: String, |
| 63 | pub namespace: String, |
| 64 | pub private: bool, |
| 65 | } |
| 66 | |
| 67 | /// What the index should be told about a repository it holds wrongly. |
| 68 | #[derive(Clone, Debug, PartialEq, Eq)] |
| 69 | pub struct Correction { |
| 70 | pub repo_id: String, |
| 71 | pub private: bool, |
| 72 | /// Its current path, when the repos service said. |
| 73 | pub path: Option<(String, String)>, |
| 74 | } |
| 75 | |
| 76 | #[derive(Debug, Default, PartialEq, Eq)] |
| 77 | pub struct Verdict { |
| 78 | /// The repositories whose rows may be returned. |
| 79 | pub keep: HashSet<String>, |
| 80 | pub corrections: Vec<Correction>, |
| 81 | } |
| 82 | |
| 83 | /// The second check. `readable` is what the repos service says, for this |
| 84 | /// viewer, of the page's repositories (`readable` leaves out what they may |
| 85 | /// not read, and repositories that are gone). `None` when it could not be |
| 86 | /// asked: then nothing private is kept, and nothing public of a workspace |
| 87 | /// the reader is not in either, since it may have just gone private. |
| 88 | pub fn check(reader: &Reader, indexed: &[Indexed], readable: Option<&[Repo]>) -> Verdict { |
| 89 | let mut verdict = Verdict::default(); |
| 90 | let Some(readable) = readable else { |
| 91 | for row in indexed { |
| 92 | if reader.is_member(&row.namespace) { |
| 93 | verdict.keep.insert(row.repo_id.clone()); |
| 94 | } |
| 95 | } |
| 96 | return verdict; |
| 97 | }; |
| 98 | let now: HashMap<&str, &Repo> = readable.iter().map(|repo| (repo.id.as_str(), repo)).collect(); |
| 99 | let mut seen = HashSet::new(); |
| 100 | for row in indexed { |
| 101 | if !seen.insert(row.repo_id.as_str()) { |
| 102 | continue; |
| 103 | } |
| 104 | match now.get(row.repo_id.as_str()) { |
| 105 | Some(repo) => { |
| 106 | // Readable now, and the first check agrees with what is true now. |
| 107 | if reader.may_see(&repo.namespace, repo.is_private) { |
| 108 | verdict.keep.insert(row.repo_id.clone()); |
| 109 | } |
| 110 | if repo.is_private != row.private || !repo.namespace.eq_ignore_ascii_case(&row.namespace) { |
| 111 | verdict.corrections.push(Correction { |
| 112 | repo_id: row.repo_id.clone(), |
| 113 | private: repo.is_private, |
| 114 | path: Some((repo.namespace.to_lowercase(), repo.name.to_lowercase())), |
| 115 | }); |
| 116 | } |
| 117 | } |
| 118 | // Not readable: private now (or gone). The index learns at once, |
| 119 | // so counts stop including it before its event arrives. |
| 120 | None if !row.private => verdict.corrections.push(Correction { |
| 121 | repo_id: row.repo_id.clone(), |
| 122 | private: true, |
| 123 | path: None, |
| 124 | }), |
| 125 | None => {} |
| 126 | } |
| 127 | } |
| 128 | verdict |
| 129 | } |
| 130 | |
| 131 | #[cfg(test)] |
| 132 | mod tests { |
| 133 | use g1t_contracts::{Membership, User}; |
| 134 | |
| 135 | use super::*; |
| 136 | |
| 137 | fn viewer(workspaces: &[&str]) -> Viewer { |
| 138 | Some(User { |
| 139 | id: "usr_1".into(), |
| 140 | username: "ana".into(), |
| 141 | workspaces: workspaces.iter().map(|slug| Membership::member(*slug)).collect(), |
| 142 | ..User::default() |
| 143 | }) |
| 144 | } |
| 145 | |
| 146 | fn row(id: &str, namespace: &str, private: bool) -> Indexed { |
| 147 | Indexed { |
| 148 | repo_id: id.into(), |
| 149 | namespace: namespace.into(), |
| 150 | private, |
| 151 | } |
| 152 | } |
| 153 | |
| 154 | fn repo(id: &str, namespace: &str, private: bool) -> Repo { |
| 155 | Repo { |
| 156 | id: id.into(), |
| 157 | namespace: namespace.into(), |
| 158 | name: "web".into(), |
| 159 | description: None, |
| 160 | is_private: private, |
| 161 | owner_id: "usr_0".into(), |
| 162 | default_branch: "main".into(), |
| 163 | fork_of: None, |
| 164 | protected: false, |
| 165 | created_at: String::new(), |
| 166 | topics: Vec::new(), |
| 167 | } |
| 168 | } |
| 169 | |
| 170 | #[test] |
| 171 | fn anyone_sees_public_rows() { |
| 172 | assert!(Reader::of(&None).may_see("acme", false)); |
| 173 | assert!(Reader::of(&viewer(&["other"])).may_see("acme", false)); |
| 174 | } |
| 175 | |
| 176 | #[test] |
| 177 | fn private_rows_are_for_members_only() { |
| 178 | assert!(!Reader::of(&None).may_see("acme", true)); |
| 179 | assert!(!Reader::of(&viewer(&["other"])).may_see("acme", true)); |
| 180 | assert!(Reader::of(&viewer(&["acme"])).may_see("acme", true)); |
| 181 | assert!(Reader::of(&viewer(&["acme"])).may_see("ACME", true)); |
| 182 | } |
| 183 | |
| 184 | #[test] |
| 185 | fn the_query_clause_binds_memberships() { |
| 186 | assert_eq!(clause("r"), "(r.private = 0 OR r.namespace IN (SELECT value FROM json_each(?)))"); |
| 187 | assert_eq!(Reader::of(&None).members_json(), "[]"); |
| 188 | assert_eq!(Reader::of(&viewer(&["Acme", "beta"])).members_json(), r#"["acme","beta"]"#); |
| 189 | } |
| 190 | |
| 191 | #[test] |
| 192 | fn a_repository_made_private_disappears_before_its_event() { |
| 193 | // The index still says public; the repos service no longer lets |
| 194 | // this outsider read it. |
| 195 | let reader = Reader::of(&viewer(&["other"])); |
| 196 | let verdict = check(&reader, &[row("rep_1", "acme", false)], Some(&[])); |
| 197 | assert!(verdict.keep.is_empty()); |
| 198 | assert_eq!( |
| 199 | verdict.corrections, |
| 200 | vec![Correction { repo_id: "rep_1".into(), private: true, path: None }] |
| 201 | ); |
| 202 | // Signed out, the same. |
| 203 | let verdict = check(&Reader::of(&None), &[row("rep_1", "acme", false)], Some(&[])); |
| 204 | assert!(verdict.keep.is_empty()); |
| 205 | } |
| 206 | |
| 207 | #[test] |
| 208 | fn members_still_see_a_repository_made_private() { |
| 209 | let reader = Reader::of(&viewer(&["acme"])); |
| 210 | let verdict = check(&reader, &[row("rep_1", "acme", false)], Some(&[repo("rep_1", "acme", true)])); |
| 211 | assert!(verdict.keep.contains("rep_1")); |
| 212 | assert_eq!(verdict.corrections[0].private, true); |
| 213 | } |
| 214 | |
| 215 | #[test] |
| 216 | fn a_repository_made_public_is_shown_and_corrected() { |
| 217 | // The first check let a member's row through; the repos service |
| 218 | // says it is public now, so the index is told. |
| 219 | let reader = Reader::of(&viewer(&["acme"])); |
| 220 | let verdict = check(&reader, &[row("rep_2", "acme", true)], Some(&[repo("rep_2", "acme", false)])); |
| 221 | assert!(verdict.keep.contains("rep_2")); |
| 222 | assert_eq!(verdict.corrections[0].private, false); |
| 223 | // Once corrected, an outsider's first check lets it through too. |
| 224 | assert!(Reader::of(&None).may_see("acme", false)); |
| 225 | } |
| 226 | |
| 227 | #[test] |
| 228 | fn private_rows_never_reach_outsiders_whatever_the_index_says() { |
| 229 | // Even if a private row slipped through the first check, and the |
| 230 | // repos service somehow answered with it, the reader is no member. |
| 231 | let reader = Reader::of(&viewer(&["other"])); |
| 232 | let verdict = check(&reader, &[row("rep_3", "acme", false)], Some(&[repo("rep_3", "acme", true)])); |
| 233 | assert!(verdict.keep.is_empty()); |
| 234 | } |
| 235 | |
| 236 | #[test] |
| 237 | fn without_the_repos_service_only_members_rows_are_kept() { |
| 238 | let reader = Reader::of(&viewer(&["acme"])); |
| 239 | let verdict = check(&reader, &[row("rep_1", "acme", true), row("rep_2", "public-co", false)], None); |
| 240 | assert_eq!(verdict.keep, HashSet::from(["rep_1".to_owned()])); |
| 241 | assert!(verdict.corrections.is_empty()); |
| 242 | } |
| 243 | |
| 244 | #[test] |
| 245 | fn a_moved_repository_is_corrected() { |
| 246 | let reader = Reader::of(&None); |
| 247 | let mut moved = repo("rep_4", "newname", false); |
| 248 | moved.name = "Web".into(); |
| 249 | let verdict = check(&reader, &[row("rep_4", "oldname", false)], Some(&[moved])); |
| 250 | assert!(verdict.keep.contains("rep_4")); |
| 251 | assert_eq!(verdict.corrections[0].path, Some(("newname".into(), "web".into()))); |
| 252 | } |
| 253 | } |