flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/services/search/src/visibility.rs

253 lines9,602 bytesCodeBlame
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
14use std::collections::{HashMap, HashSet};
15
16use g1t_contracts::Viewer;
17use g1t_contracts::repos::Repo;
18
19/// Who is reading: the workspaces they belong to, by slug.
20#[derive(Clone, Debug, Default)]
21pub struct Reader {
22 pub member_of: Vec<String>,
23}
24
25impl 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`].
55pub 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)]
61pub 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)]
69pub 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)]
77pub 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.
88pub 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)]
132mod 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}