Skip to content

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.sh
358 linesCodeBlameRaw
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
16use std::collections::{HashMap, HashSet};
17
18use g1t_contracts::Viewer;
19use g1t_contracts::access::{self, RepoRef};
20use g1t_contracts::repos::Repo;
21
22/// Who is reading, as far as private repositories go.
23#[derive(Clone, Debug, Default)]
24pub 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
35impl 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.
84pub 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)]
92pub 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)]
100pub 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)]
108pub 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.
121pub 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)]
165mod 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 is_private: private,
194 owner_id: "usr_0".into(),
195 default_branch: "main".into(),
196 fork_of: None,
197 parent_id: None,
198 forked_from: None,
199 parent: None,
200 allow_forking: false,
201 max_ref_updates: None,
202 protected: false,
203 created_at: String::new(),
204 topics: Vec::new(),
205 website: None,
206 archived_at: None,
207 }
208 }
209
210 #[test]
211 fn anyone_sees_public_rows() {
212 assert!(Reader::of(&None).may_see("rep_1", "acme", false));
213 assert!(Reader::of(&viewer(&["other"])).may_see("rep_1", "acme", false));
214 }
215
216 #[test]
217 fn private_rows_are_for_members_only() {
218 assert!(!Reader::of(&None).may_see("rep_1", "acme", true));
219 assert!(!Reader::of(&viewer(&["other"])).may_see("rep_1", "acme", true));
220 assert!(Reader::of(&viewer(&["acme"])).may_see("rep_1", "acme", true));
221 assert!(Reader::of(&viewer(&["acme"])).may_see("rep_1", "ACME", true));
222 }
223
224 /// A member of acme whose base permission is `base`, given `grants`
225 /// (repository id, role) there.
226 fn member_with(base: BasePermission, grants: &[(&str, RepoRole)]) -> Viewer {
227 Some(User {
228 id: "usr_1".into(),
229 username: "ana".into(),
230 workspaces: vec![Membership { base_permission: Some(base), ..Membership::member("acme") }],
231 grants: grants
232 .iter()
233 .map(|(id, role)| RepoGrant { repo_id: (*id).into(), workspace: "acme".into(), role: *role, team: None })
234 .collect(),
235 ..User::default()
236 })
237 }
238
239 #[test]
240 fn members_without_a_base_permission_see_only_what_they_were_given() {
241 let reader = Reader::of(&member_with(BasePermission::None, &[("rep_2", RepoRole::Read)]));
242 assert!(!reader.may_see("rep_1", "acme", true));
243 assert!(reader.may_see("rep_2", "acme", true));
244 assert!(reader.may_see("rep_1", "acme", false));
245 assert_eq!(reader.namespaces_json(), "[]");
246 assert_eq!(reader.granted_json(), r#"["rep_2"]"#);
247 // Read as a base is enough for every repository.
248 let reader = Reader::of(&member_with(BasePermission::Read, &[]));
249 assert!(reader.may_see("rep_1", "acme", true));
250 // An owner reads everything, whatever the base.
251 let owner = Some(User {
252 workspaces: vec![Membership {
253 role: Role::Owner,
254 base_permission: Some(BasePermission::None),
255 ..Membership::member("acme")
256 }],
257 ..User::default()
258 });
259 assert!(Reader::of(&owner).may_see("rep_1", "acme", true));
260 }
261
262 #[test]
263 fn outside_collaborators_see_their_repositories_and_no_others() {
264 let outsider = Some(User {
265 id: "usr_2".into(),
266 username: "bo".into(),
267 grants: vec![RepoGrant { repo_id: "rep_1".into(), workspace: "acme".into(), role: RepoRole::Triage, team: None }],
268 ..User::default()
269 });
270 let reader = Reader::of(&outsider);
271 assert!(reader.may_see("rep_1", "acme", true));
272 assert!(!reader.may_see("rep_2", "acme", true));
273 // Kept by the second check when the repos service agrees, and
274 // without it, since the grant is theirs.
275 let rows = [row("rep_1", "acme", true), row("rep_2", "acme", true)];
276 let verdict = check(&reader, &rows, Some(&[repo("rep_1", "acme", true)]));
277 assert_eq!(verdict.keep, HashSet::from(["rep_1".to_owned()]));
278 let verdict = check(&reader, &rows, Some(&[repo("rep_1", "acme", true), repo("rep_2", "acme", true)]));
279 assert_eq!(verdict.keep, HashSet::from(["rep_1".to_owned()]));
280 let verdict = check(&reader, &rows, None);
281 assert_eq!(verdict.keep, HashSet::from(["rep_1".to_owned()]));
282 }
283
284 #[test]
285 fn the_query_clause_binds_workspaces_and_grants() {
286 assert_eq!(
287 clause("r"),
288 "(r.private = 0 OR r.namespace IN (SELECT value FROM json_each(?)) OR r.repo_id IN (SELECT value FROM json_each(?)))"
289 );
290 assert_eq!(Reader::of(&None).namespaces_json(), "[]");
291 assert_eq!(Reader::of(&None).granted_json(), "[]");
292 // Identity gives slugs in lowercase, as access::granted expects.
293 assert_eq!(Reader::of(&viewer(&["acme", "beta"])).namespaces_json(), r#"["acme","beta"]"#);
294 }
295
296 #[test]
297 fn a_repository_made_private_disappears_before_its_event() {
298 // The index still says public; the repos service no longer lets
299 // this outsider read it.
300 let reader = Reader::of(&viewer(&["other"]));
301 let verdict = check(&reader, &[row("rep_1", "acme", false)], Some(&[]));
302 assert!(verdict.keep.is_empty());
303 assert_eq!(
304 verdict.corrections,
305 vec![Correction { repo_id: "rep_1".into(), private: true, path: None }]
306 );
307 // Signed out, the same.
308 let verdict = check(&Reader::of(&None), &[row("rep_1", "acme", false)], Some(&[]));
309 assert!(verdict.keep.is_empty());
310 }
311
312 #[test]
313 fn members_still_see_a_repository_made_private() {
314 let reader = Reader::of(&viewer(&["acme"]));
315 let verdict = check(&reader, &[row("rep_1", "acme", false)], Some(&[repo("rep_1", "acme", true)]));
316 assert!(verdict.keep.contains("rep_1"));
317 assert!(verdict.corrections[0].private);
318 }
319
320 #[test]
321 fn a_repository_made_public_is_shown_and_corrected() {
322 // The first check let a member's row through; the repos service
323 // says it is public now, so the index is told.
324 let reader = Reader::of(&viewer(&["acme"]));
325 let verdict = check(&reader, &[row("rep_2", "acme", true)], Some(&[repo("rep_2", "acme", false)]));
326 assert!(verdict.keep.contains("rep_2"));
327 assert!(!verdict.corrections[0].private);
328 // Once corrected, an outsider's first check lets it through too.
329 assert!(Reader::of(&None).may_see("rep_2", "acme", false));
330 }
331
332 #[test]
333 fn private_rows_never_reach_outsiders_whatever_the_index_says() {
334 // Even if a private row slipped through the first check, and the
335 // repos service somehow answered with it, the reader is no member.
336 let reader = Reader::of(&viewer(&["other"]));
337 let verdict = check(&reader, &[row("rep_3", "acme", false)], Some(&[repo("rep_3", "acme", true)]));
338 assert!(verdict.keep.is_empty());
339 }
340
341 #[test]
342 fn without_the_repos_service_only_members_rows_are_kept() {
343 let reader = Reader::of(&viewer(&["acme"]));
344 let verdict = check(&reader, &[row("rep_1", "acme", true), row("rep_2", "public-co", false)], None);
345 assert_eq!(verdict.keep, HashSet::from(["rep_1".to_owned()]));
346 assert!(verdict.corrections.is_empty());
347 }
348
349 #[test]
350 fn a_moved_repository_is_corrected() {
351 let reader = Reader::of(&None);
352 let mut moved = repo("rep_4", "newname", false);
353 moved.name = "Web".into();
354 let verdict = check(&reader, &[row("rep_4", "oldname", false)], Some(&[moved]));
355 assert!(verdict.keep.contains("rep_4"));
356 assert_eq!(verdict.corrections[0].path, Some(("newname".into(), "web".into())));
357 }
358}