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

354 lines14,591 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Search across all of g1t, Explore, and a command palette1//! Who may see what. Checked twice, both times when the query runs:
2//!
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look3//! 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.
Search across all of g1t, Explore, and a command palette8//! 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
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look11//! moment ago, before its event arrived, is never shown to anyone who
12//! cannot read it.
Search across all of g1t, Explore, and a command palette13//!
14//! Pure, so the rules are tested apart from the index.
15
16use std::collections::{HashMap, HashSet};
17
18use g1t_contracts::Viewer;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look19use g1t_contracts::access::{self, RepoRef};
Search across all of g1t, Explore, and a command palette20use g1t_contracts::repos::Repo;
21
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look22/// Who is reading, as far as private repositories go.
Search across all of g1t, Explore, and a command palette23#[derive(Clone, Debug, Default)]
24pub struct Reader {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look25 /// 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>,
Search across all of g1t, Explore, and a command palette33}
34
35impl Reader {
36 pub fn of(viewer: &Viewer) -> Reader {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look37 let Some(user) = viewer else {
38 return Reader::default();
39 };
Search across all of g1t, Explore, and a command palette40 Reader {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look41 reads_in: user
42 .workspaces
Search across all of g1t, Explore, and a command palette43 .iter()
44 .map(|membership| membership.slug.to_lowercase())
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look45 // 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())
Search across all of g1t, Explore, and a command palette48 .collect(),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look49 granted: user.grants.iter().map(|grant| grant.repo_id.clone()).collect(),
Search across all of g1t, Explore, and a command palette50 }
51 }
52
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look53 /// 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)
Search across all of g1t, Explore, and a command palette67 }
68
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look69 /// [`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())
Search across all of g1t, Explore, and a command palette73 }
74
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look75 /// [`Reader::granted`] as JSON, likewise.
76 pub fn granted_json(&self) -> String {
77 serde_json::to_string(&self.granted).unwrap_or_else(|_| "[]".into())
Search across all of g1t, Explore, and a command palette78 }
79}
80
81/// The SQL the first check adds to every query, given the repository's
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look82/// table alias. Its two parameters are [`Reader::namespaces_json`] and
83/// [`Reader::granted_json`], in that order.
Search across all of g1t, Explore, and a command palette84pub fn clause(alias: &str) -> String {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look85 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 )
Search across all of g1t, Explore, and a command palette88}
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
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look117/// 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.
Search across all of g1t, Explore, and a command palette121pub 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 {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look125 if reader.reads_namespace(&row.namespace) || reader.was_granted(&row.repo_id) {
Search across all of g1t, Explore, and a command palette126 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.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look140 if reader.may_see(&repo.id, &repo.namespace, repo.is_private) {
Search across all of g1t, Explore, and a command palette141 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 {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look166 use g1t_contracts::access::{BasePermission, RepoGrant, RepoRole};
167 use g1t_contracts::{Membership, Role, User};
Search across all of g1t, Explore, and a command palette168
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(),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look201 website: None,
202 archived_at: None,
Search across all of g1t, Explore, and a command palette203 }
204 }
205
206 #[test]
207 fn anyone_sees_public_rows() {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look208 assert!(Reader::of(&None).may_see("rep_1", "acme", false));
209 assert!(Reader::of(&viewer(&["other"])).may_see("rep_1", "acme", false));
Search across all of g1t, Explore, and a command palette210 }
211
212 #[test]
213 fn private_rows_are_for_members_only() {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look214 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 })
Search across all of g1t, Explore, and a command palette233 }
234
235 #[test]
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look236 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));
Search across all of g1t, Explore, and a command palette256 }
257
258 #[test]
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look259 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]
Search across all of g1t, Explore, and a command palette293 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.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look325 assert!(Reader::of(&None).may_see("rep_2", "acme", false));
Search across all of g1t, Explore, and a command palette326 }
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}