fabrica

hanna/fabrica

22287 bytes
Raw
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Groups: the nested, user-owned repository hierarchy, with per-group settings
6//! (visibility, collaborators) and recursive garbage collection of the auto-
7//! created groups that once held a now-deleted repository.
8
9use model::{Group, Visibility};
10use sqlx::any::AnyRow;
11use sqlx::{AssertSqlSafe, Row};
12
13use crate::repos::Collaborator;
14use crate::{Store, StoreError, map_conflict, new_id, now_ms};
15
16/// The columns of `groups` in a fixed order, shared by every `SELECT`.
17const GROUP_COLUMNS: &str =
18 "id, owner_id, parent_id, name, path, description, manual, visibility, created_at";
19
20/// Rank a permission token so the highest across repo + group collaborators wins.
21fn perm_rank(perm: &str) -> i32 {
22 match perm {
23 "admin" => 3,
24 "write" => 2,
25 "read" => 1,
26 _ => 0,
27 }
28}
29
30impl Store {
31 /// Ensure a group exists at `owner_id`/`path`, creating it and every missing
32 /// intermediate group (all auto), and return the leaf group.
33 ///
34 /// # Errors
35 ///
36 /// * [`StoreError::Name`] if any segment is an invalid name.
37 /// * [`StoreError::GroupTooDeep`] past [`model::MAX_GROUP_DEPTH`].
38 /// * [`StoreError::Query`] on a database failure.
39 pub async fn ensure_group_path(&self, owner_id: &str, path: &str) -> Result<Group, StoreError> {
40 let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
41 if segments.is_empty() {
42 return Err(StoreError::Name(model::NameError::Empty));
43 }
44 if segments.len() > model::MAX_GROUP_DEPTH {
45 return Err(StoreError::GroupTooDeep {
46 max: model::MAX_GROUP_DEPTH,
47 });
48 }
49 for segment in &segments {
50 model::validate_name(segment)?;
51 }
52
53 let mut parent_id: Option<String> = None;
54 let mut prefix = String::new();
55 let mut leaf: Option<Group> = None;
56 for segment in segments {
57 if prefix.is_empty() {
58 prefix.push_str(segment);
59 } else {
60 prefix.push('/');
61 prefix.push_str(segment);
62 }
63 let group = match self.group_by_owner_path(owner_id, &prefix).await? {
64 Some(existing) => existing,
65 None => {
66 self.insert_group(owner_id, parent_id.as_deref(), segment, &prefix)
67 .await?
68 }
69 };
70 parent_id = Some(group.id.clone());
71 leaf = Some(group);
72 }
73 leaf.ok_or(StoreError::Name(model::NameError::Empty))
74 }
75
76 /// Create a group explicitly at `path`, marking its leaf as manual so it is
77 /// never garbage-collected. Intermediates are created auto.
78 ///
79 /// # Errors
80 ///
81 /// See [`Store::ensure_group_path`].
82 pub async fn create_group(&self, owner_id: &str, path: &str) -> Result<Group, StoreError> {
83 let leaf = self.ensure_group_path(owner_id, path).await?;
84 sqlx::query("UPDATE groups SET manual = $1 WHERE id = $2")
85 .bind(true)
86 .bind(&leaf.id)
87 .execute(&self.pool)
88 .await?;
89 self.group_by_id(&leaf.id)
90 .await?
91 .ok_or(StoreError::Name(model::NameError::Empty))
92 }
93
94 /// Insert a single (auto) group row.
95 async fn insert_group(
96 &self,
97 owner_id: &str,
98 parent_id: Option<&str>,
99 name: &str,
100 path: &str,
101 ) -> Result<Group, StoreError> {
102 let group = Group {
103 id: new_id(),
104 owner_id: owner_id.to_string(),
105 parent_id: parent_id.map(str::to_string),
106 name: name.to_string(),
107 path: path.to_string(),
108 description: None,
109 manual: false,
110 // Auto groups are transparent to the visibility ceiling (see migration
111 // 0021); only a deliberately-set group restricts the repos beneath it.
112 visibility: Visibility::Public,
113 created_at: now_ms(),
114 };
115 let sql = "INSERT INTO groups \
116 (id, owner_id, parent_id, name, path, description, manual, visibility, created_at) \
117 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)";
118 sqlx::query(sql)
119 .bind(group.id.clone())
120 .bind(group.owner_id.clone())
121 .bind(group.parent_id.clone())
122 .bind(group.name.clone())
123 .bind(group.path.clone())
124 .bind(group.description.clone())
125 .bind(group.manual)
126 .bind(group.visibility.as_str())
127 .bind(group.created_at)
128 .execute(&self.pool)
129 .await
130 .map_err(|e| map_conflict(e, "group", &[("path", "path")]))?;
131 Ok(group)
132 }
133
134 /// Resolve a group by `(owner_id, path)`, or `None`.
135 ///
136 /// # Errors
137 ///
138 /// Returns [`StoreError::Query`] on a database failure.
139 pub async fn group_by_owner_path(
140 &self,
141 owner_id: &str,
142 path: &str,
143 ) -> Result<Option<Group>, StoreError> {
144 let sql = format!("SELECT {GROUP_COLUMNS} FROM groups WHERE owner_id = $1 AND path = $2");
145 let row = sqlx::query(AssertSqlSafe(sql))
146 .bind(owner_id)
147 .bind(path)
148 .fetch_optional(&self.pool)
149 .await?;
150 Ok(row.as_ref().map(|r| self.map_group(r)).transpose()?)
151 }
152
153 /// Fetch a group by id.
154 ///
155 /// # Errors
156 ///
157 /// Returns [`StoreError::Query`] on a database failure.
158 pub async fn group_by_id(&self, id: &str) -> Result<Option<Group>, StoreError> {
159 let sql = format!("SELECT {GROUP_COLUMNS} FROM groups WHERE id = $1");
160 let row = sqlx::query(AssertSqlSafe(sql))
161 .bind(id)
162 .fetch_optional(&self.pool)
163 .await?;
164 Ok(row.as_ref().map(|r| self.map_group(r)).transpose()?)
165 }
166
167 /// List every group owned by `owner_id`, ordered by path.
168 ///
169 /// # Errors
170 ///
171 /// Returns [`StoreError::Query`] on a database failure.
172 pub async fn groups_by_owner(&self, owner_id: &str) -> Result<Vec<Group>, StoreError> {
173 let sql =
174 format!("SELECT {GROUP_COLUMNS} FROM groups WHERE owner_id = $1 ORDER BY path ASC");
175 let rows = sqlx::query(AssertSqlSafe(sql))
176 .bind(owner_id)
177 .fetch_all(&self.pool)
178 .await?;
179 rows.iter()
180 .map(|r| self.map_group(r))
181 .collect::<Result<_, _>>()
182 .map_err(Into::into)
183 }
184
185 /// List every group across all owners, ordered by path. For the admin view.
186 ///
187 /// # Errors
188 ///
189 /// Returns [`StoreError::Query`] on a database failure.
190 pub async fn list_groups(&self) -> Result<Vec<Group>, StoreError> {
191 let sql = format!("SELECT {GROUP_COLUMNS} FROM groups ORDER BY owner_id ASC, path ASC");
192 let rows = sqlx::query(AssertSqlSafe(sql))
193 .fetch_all(&self.pool)
194 .await?;
195 rows.iter()
196 .map(|r| self.map_group(r))
197 .collect::<Result<_, _>>()
198 .map_err(Into::into)
199 }
200
201 /// One page of groups, ordered by owner then path. For the admin view.
202 ///
203 /// # Errors
204 ///
205 /// Returns [`StoreError::Query`] on a database failure.
206 pub async fn list_groups_paged(
207 &self,
208 limit: i64,
209 offset: i64,
210 ) -> Result<Vec<Group>, StoreError> {
211 let sql = format!(
212 "SELECT {GROUP_COLUMNS} FROM groups ORDER BY owner_id ASC, path ASC LIMIT $1 OFFSET $2"
213 );
214 let rows = sqlx::query(AssertSqlSafe(sql))
215 .bind(limit)
216 .bind(offset)
217 .fetch_all(&self.pool)
218 .await?;
219 rows.iter()
220 .map(|r| self.map_group(r))
221 .collect::<Result<_, _>>()
222 .map_err(Into::into)
223 }
224
225 /// Set a group's visibility.
226 ///
227 /// # Errors
228 ///
229 /// Returns [`StoreError::Query`] on a database failure.
230 pub async fn set_group_visibility(
231 &self,
232 group_id: &str,
233 visibility: Visibility,
234 ) -> Result<(), StoreError> {
235 sqlx::query("UPDATE groups SET visibility = $1 WHERE id = $2")
236 .bind(visibility.as_str())
237 .bind(group_id)
238 .execute(&self.pool)
239 .await?;
240 Ok(())
241 }
242
243 /// Rename a group's leaf name, updating the materialized paths of the group,
244 /// its descendant groups, and every repository beneath it.
245 ///
246 /// # Errors
247 ///
248 /// * [`StoreError::Name`] if `new_name` is invalid.
249 /// * [`StoreError::Conflict`] if the new path is already taken.
250 /// * [`StoreError::Query`] for any other database failure.
251 pub async fn rename_group(&self, group_id: &str, new_name: &str) -> Result<Group, StoreError> {
252 model::validate_name(new_name)?;
253 let group = self
254 .group_by_id(group_id)
255 .await?
256 .ok_or(StoreError::Name(model::NameError::Empty))?;
257 let old_path = group.path.clone();
258 let new_path = match old_path.rsplit_once('/') {
259 Some((prefix, _)) => format!("{prefix}/{new_name}"),
260 None => new_name.to_string(),
261 };
262 if new_path != old_path
263 && (self
264 .group_by_owner_path(&group.owner_id, &new_path)
265 .await?
266 .is_some()
267 || self
268 .repo_by_owner_path(&group.owner_id, &new_path)
269 .await?
270 .is_some())
271 {
272 return Err(StoreError::Conflict {
273 entity: "group",
274 field: "path",
275 });
276 }
277 let old_prefix = format!("{old_path}/%");
278 let old_len = i64::try_from(old_path.len()).unwrap_or(i64::MAX);
279 let mut tx = self.pool.begin().await?;
280 // Descendant groups: replace the shared path prefix.
281 sqlx::query(
282 "UPDATE groups SET path = $1 || SUBSTR(path, $2) \
283 WHERE owner_id = $3 AND path LIKE $4",
284 )
285 .bind(&new_path)
286 .bind(old_len + 1)
287 .bind(&group.owner_id)
288 .bind(&old_prefix)
289 .execute(&mut *tx)
290 .await?;
291 // Repositories beneath the group.
292 sqlx::query(
293 "UPDATE repos SET path = $1 || SUBSTR(path, $2) \
294 WHERE owner_id = $3 AND path LIKE $4",
295 )
296 .bind(&new_path)
297 .bind(old_len + 1)
298 .bind(&group.owner_id)
299 .bind(&old_prefix)
300 .execute(&mut *tx)
301 .await?;
302 // The group itself.
303 sqlx::query("UPDATE groups SET name = $1, path = $2 WHERE id = $3")
304 .bind(new_name)
305 .bind(&new_path)
306 .bind(group_id)
307 .execute(&mut *tx)
308 .await?;
309 tx.commit().await?;
310 self.group_by_id(group_id)
311 .await?
312 .ok_or(StoreError::Name(model::NameError::Empty))
313 }
314
315 /// Whether a group holds any repositories directly.
316 async fn group_has_repos(&self, group_id: &str) -> Result<bool, StoreError> {
317 let row = sqlx::query("SELECT 1 AS n FROM repos WHERE group_id = $1")
318 .bind(group_id)
319 .fetch_optional(&self.pool)
320 .await?;
321 Ok(row.is_some())
322 }
323
324 /// Whether a group has any child groups.
325 async fn group_has_children(&self, group_id: &str) -> Result<bool, StoreError> {
326 let row = sqlx::query("SELECT 1 AS n FROM groups WHERE parent_id = $1")
327 .bind(group_id)
328 .fetch_optional(&self.pool)
329 .await?;
330 Ok(row.is_some())
331 }
332
333 /// The number of repositories anywhere in a group's subtree.
334 ///
335 /// # Errors
336 ///
337 /// Returns [`StoreError::Query`] on a database failure.
338 pub async fn group_subtree_repo_count(&self, group: &Group) -> Result<i64, StoreError> {
339 let prefix = format!("{}/%", group.path);
340 let n: i64 =
341 sqlx::query("SELECT COUNT(*) AS n FROM repos WHERE owner_id = $1 AND path LIKE $2")
342 .bind(&group.owner_id)
343 .bind(&prefix)
344 .fetch_one(&self.pool)
345 .await?
346 .try_get("n")?;
347 Ok(n)
348 }
349
350 /// Garbage-collect empty auto groups upward from `group_id`: while a group is
351 /// auto (not manual) and holds no repos and no child groups, delete it and
352 /// continue with its parent. Manual groups (and non-empty ones) stop the walk.
353 ///
354 /// # Errors
355 ///
356 /// Returns [`StoreError::Query`] on a database failure.
357 pub async fn gc_empty_groups(&self, mut group_id: Option<String>) -> Result<(), StoreError> {
358 let mut guard = 0;
359 while let Some(gid) = group_id {
360 guard += 1;
361 if guard > 64 {
362 break;
363 }
364 let Some(group) = self.group_by_id(&gid).await? else {
365 break;
366 };
367 if group.manual
368 || self.group_has_repos(&gid).await?
369 || self.group_has_children(&gid).await?
370 {
371 break;
372 }
373 let parent = group.parent_id.clone();
374 self.delete_group(&gid).await?;
375 group_id = parent;
376 }
377 Ok(())
378 }
379
380 /// Delete a group by id. Child groups cascade; repos have their `group_id`
381 /// set null by the schema. Returns `true` if a row was deleted.
382 ///
383 /// # Errors
384 ///
385 /// Returns [`StoreError::Query`] on a database failure.
386 pub async fn delete_group(&self, group_id: &str) -> Result<bool, StoreError> {
387 let deleted = sqlx::query("DELETE FROM groups WHERE id = $1")
388 .bind(group_id)
389 .execute(&self.pool)
390 .await?
391 .rows_affected();
392 Ok(deleted > 0)
393 }
394
395 // ---- Group collaborators ----
396
397 /// Add or update a group collaborator's permission.
398 ///
399 /// # Errors
400 ///
401 /// Returns [`StoreError::Query`] on a database failure.
402 pub async fn add_group_collaborator(
403 &self,
404 group_id: &str,
405 user_id: &str,
406 permission: &str,
407 ) -> Result<(), StoreError> {
408 sqlx::query(
409 "INSERT INTO group_collaborators (id, group_id, user_id, permission, created_at) \
410 VALUES ($1, $2, $3, $4, $5) \
411 ON CONFLICT (group_id, user_id) DO UPDATE SET permission = $4",
412 )
413 .bind(new_id())
414 .bind(group_id)
415 .bind(user_id)
416 .bind(permission)
417 .bind(now_ms())
418 .execute(&self.pool)
419 .await?;
420 Ok(())
421 }
422
423 /// Remove a group collaborator.
424 ///
425 /// # Errors
426 ///
427 /// Returns [`StoreError::Query`] on a database failure.
428 pub async fn remove_group_collaborator(
429 &self,
430 group_id: &str,
431 user_id: &str,
432 ) -> Result<(), StoreError> {
433 sqlx::query("DELETE FROM group_collaborators WHERE group_id = $1 AND user_id = $2")
434 .bind(group_id)
435 .bind(user_id)
436 .execute(&self.pool)
437 .await?;
438 Ok(())
439 }
440
441 /// List a group's collaborators with their usernames resolved.
442 ///
443 /// # Errors
444 ///
445 /// Returns [`StoreError::Query`] on a database failure.
446 pub async fn list_group_collaborators(
447 &self,
448 group_id: &str,
449 ) -> Result<Vec<Collaborator>, StoreError> {
450 let rows = sqlx::query(
451 "SELECT gc.user_id, u.username, gc.permission \
452 FROM group_collaborators gc JOIN users u ON u.id = gc.user_id \
453 WHERE gc.group_id = $1 ORDER BY u.username_lower ASC",
454 )
455 .bind(group_id)
456 .fetch_all(&self.pool)
457 .await?;
458 rows.iter()
459 .map(|r| {
460 Ok(Collaborator {
461 user_id: r.try_get("user_id")?,
462 username: r.try_get("username")?,
463 permission: r.try_get("permission")?,
464 })
465 })
466 .collect::<Result<_, sqlx::Error>>()
467 .map_err(Into::into)
468 }
469
470 /// A user's collaborator permission on a group, if any.
471 ///
472 /// # Errors
473 ///
474 /// Returns [`StoreError::Query`] on a database failure.
475 pub async fn group_collaborator_permission(
476 &self,
477 group_id: &str,
478 user_id: &str,
479 ) -> Result<Option<String>, StoreError> {
480 let row = sqlx::query(
481 "SELECT permission FROM group_collaborators WHERE group_id = $1 AND user_id = $2",
482 )
483 .bind(group_id)
484 .bind(user_id)
485 .fetch_optional(&self.pool)
486 .await?;
487 Ok(row.map(|r| r.try_get("permission")).transpose()?)
488 }
489
490 /// A user's effective collaborator permission on a repo: the strongest of its
491 /// direct collaborator row and any group-collaborator row on the repo's group
492 /// or an ancestor group.
493 ///
494 /// # Errors
495 ///
496 /// Returns [`StoreError::Query`] on a database failure.
497 pub async fn effective_permission(
498 &self,
499 repo_id: &str,
500 user_id: &str,
501 ) -> Result<Option<String>, StoreError> {
502 let mut best: Option<String> = self.collaborator_permission(repo_id, user_id).await?;
503 let mut best_rank = best.as_deref().map_or(0, perm_rank);
504
505 let mut group_id = self.repo_by_id(repo_id).await?.and_then(|r| r.group_id);
506 let mut guard = 0;
507 while let Some(gid) = group_id {
508 guard += 1;
509 if guard > 64 {
510 break;
511 }
512 if let Some(p) = self.group_collaborator_permission(&gid, user_id).await? {
513 let r = perm_rank(&p);
514 if r > best_rank {
515 best_rank = r;
516 best = Some(p);
517 }
518 }
519 group_id = self.group_by_id(&gid).await?.and_then(|g| g.parent_id);
520 }
521 Ok(best)
522 }
523
524 /// A user's effective collaborator permission on a group: the strongest
525 /// group-collaborator row on the group itself or any ancestor group.
526 ///
527 /// # Errors
528 ///
529 /// Returns [`StoreError::Query`] on a database failure.
530 pub async fn effective_group_permission(
531 &self,
532 group_id: &str,
533 user_id: &str,
534 ) -> Result<Option<String>, StoreError> {
535 let mut best: Option<String> = None;
536 let mut best_rank = 0;
537 let mut next = Some(group_id.to_string());
538 let mut guard = 0;
539 while let Some(gid) = next {
540 guard += 1;
541 if guard > 64 {
542 break;
543 }
544 if let Some(p) = self.group_collaborator_permission(&gid, user_id).await? {
545 let r = perm_rank(&p);
546 if r > best_rank {
547 best_rank = r;
548 best = Some(p);
549 }
550 }
551 next = self.group_by_id(&gid).await?.and_then(|g| g.parent_id);
552 }
553 Ok(best)
554 }
555
556 /// The most-restrictive visibility across a group and its ancestors — the
557 /// ceiling a repository in the group (or a descendant group) may not exceed.
558 /// Auto groups default to public, so they leave the ceiling untouched.
559 ///
560 /// # Errors
561 ///
562 /// Returns [`StoreError::Query`] on a database failure.
563 pub async fn group_visibility_ceiling(&self, group_id: &str) -> Result<Visibility, StoreError> {
564 let mut ceiling = Visibility::Public;
565 let mut next = Some(group_id.to_string());
566 let mut guard = 0;
567 while let Some(gid) = next {
568 guard += 1;
569 if guard > 64 {
570 break;
571 }
572 let Some(g) = self.group_by_id(&gid).await? else {
573 break;
574 };
575 if g.visibility.rank() < ceiling.rank() {
576 ceiling = g.visibility;
577 }
578 next = g.parent_id;
579 }
580 Ok(ceiling)
581 }
582
583 /// Clamp every descendant group and repository so none is more visible than
584 /// its group ceiling. Call after lowering a group's visibility.
585 ///
586 /// # Errors
587 ///
588 /// Returns [`StoreError::Query`] on a database failure.
589 pub async fn clamp_subtree_visibility(&self, group: &Group) -> Result<(), StoreError> {
590 let child_prefix = format!("{}/", group.path);
591 // Descendant groups first, top-down (path ASC) so each parent's ceiling is
592 // settled before its children are measured against it.
593 let groups = self.groups_by_owner(&group.owner_id).await?;
594 for g in groups.iter().filter(|g| g.path.starts_with(&child_prefix)) {
595 if let Some(parent_id) = &g.parent_id {
596 let ceiling = self.group_visibility_ceiling(parent_id).await?;
597 if g.visibility.rank() > ceiling.rank() {
598 self.set_group_visibility(&g.id, ceiling).await?;
599 }
600 }
601 }
602 // Then every repository anywhere in the subtree.
603 let repos = self.repos_by_owner(&group.owner_id).await?;
604 for r in repos.iter().filter(|r| r.path.starts_with(&child_prefix)) {
605 if let Some(gid) = &r.group_id {
606 let ceiling = self.group_visibility_ceiling(gid).await?;
607 if r.visibility.rank() > ceiling.rank() {
608 self.set_visibility(&r.id, ceiling).await?;
609 }
610 }
611 }
612 Ok(())
613 }
614
615 /// Map a `groups` row (selected via [`GROUP_COLUMNS`]) to a [`Group`].
616 fn map_group(&self, row: &AnyRow) -> Result<Group, sqlx::Error> {
617 Ok(Group {
618 id: row.try_get("id")?,
619 owner_id: row.try_get("owner_id")?,
620 parent_id: row.try_get("parent_id")?,
621 name: row.try_get("name")?,
622 path: row.try_get("path")?,
623 description: row.try_get("description")?,
624 manual: self.get_bool(row, "manual")?,
625 visibility: Visibility::from_token(&row.try_get::<String, _>("visibility")?)
626 .unwrap_or(Visibility::Private),
627 created_at: row.try_get("created_at")?,
628 })
629 }
630}