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.

407 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. 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
18use std::collections::{HashMap, HashSet};
19
20use g1t_contracts::Viewer;
21use g1t_contracts::access::{self, RepoRef};
22use g1t_contracts::repos::Repo;
23
24/// Who is reading, as far as private repositories go.
25#[derive(Clone, Debug, Default)]
26pub 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
40impl 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.
105pub 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.
116pub 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)]
125pub 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)]
133pub 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)]
141pub 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.
154pub 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)]
198mod 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}