fabrica

hanna/fabrica

feat(store): group settings, collaborators, and recursive GC

fc737e6 · hanna committed on 2026-07-26

Add a `manual` flag and `visibility` to groups plus a `group_collaborators`
table (migration 0021). Manual groups are created explicitly and never
collected; auto groups created to hold a repo are garbage-collected up the
tree by `delete_repo` once their last repo and child leave.

Group collaborators cascade to every repo in the subtree: `effective_permission`
maxes a repo's direct collaborator row against any ancestor group grant, and
`effective_group_permission` resolves the same for a group itself. `rename_group`
rewrites the materialized paths of the group, its descendant groups, and every
repo beneath it in one transaction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
6 files changed · +661 −33UnifiedSplit
crates/model/src/entity.rs +5 −0
@@ -215,6 +215,11 @@ pub struct Group {
215 pub path: String,215 pub path: String,
216 /// Optional description.216 /// Optional description.
217 pub description: Option<String>,217 pub description: Option<String>,
218 /// Whether the group was created explicitly (persists) rather than
219 /// auto-created to hold a repo (garbage-collected when empty).
220 pub manual: bool,
221 /// The group's visibility (gates its listing page).
222 pub visibility: Visibility,
218 /// Creation time.223 /// Creation time.
219 pub created_at: Timestamp,224 pub created_at: Timestamp,
220}225}
crates/store/src/groups.rs +397 −32
@@ -2,28 +2,39 @@
2// License, v. 2.0. If a copy of the MPL was not distributed with this2// 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/.3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
44
5//! Groups: the nested, user-owned repository hierarchy.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.
68
7use model::Group;9use model::{Group, Visibility};
8use sqlx::any::AnyRow;10use sqlx::any::AnyRow;
9use sqlx::{AssertSqlSafe, Row};11use sqlx::{AssertSqlSafe, Row};
1012
13use crate::repos::Collaborator;
11use crate::{Store, StoreError, map_conflict, new_id, now_ms};14use crate::{Store, StoreError, map_conflict, new_id, now_ms};
1215
13/// The columns of `groups` in a fixed order, shared by every `SELECT`.16/// The columns of `groups` in a fixed order, shared by every `SELECT`.
14const GROUP_COLUMNS: &str = "id, owner_id, parent_id, name, path, description, created_at";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}
1529
16impl Store {30impl Store {
17 /// Ensure a group exists at `owner_id`/`path`, creating it and every missing31 /// Ensure a group exists at `owner_id`/`path`, creating it and every missing
18 /// intermediate group, and return the leaf group. Existing groups along the32 /// intermediate group (all auto), and return the leaf group.
19 /// path are reused. Each segment name is validated.
20 ///
21 /// This powers both `fabrica group add` and the `repo add owner group/sub/repo`
22 /// convenience that auto-creates the groups a repo is filed under.
23 ///33 ///
24 /// # Errors34 /// # Errors
25 ///35 ///
26 /// * [`StoreError::Name`] if any segment is an invalid name.36 /// * [`StoreError::Name`] if any segment is an invalid name.
37 /// * [`StoreError::GroupTooDeep`] past [`model::MAX_GROUP_DEPTH`].
27 /// * [`StoreError::Query`] on a database failure.38 /// * [`StoreError::Query`] on a database failure.
28 pub async fn ensure_group_path(&self, owner_id: &str, path: &str) -> Result<Group, StoreError> {39 pub async fn ensure_group_path(&self, owner_id: &str, path: &str) -> Result<Group, StoreError> {
29 let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();40 let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
@@ -59,11 +70,28 @@ impl Store {
59 parent_id = Some(group.id.clone());70 parent_id = Some(group.id.clone());
60 leaf = Some(group);71 leaf = Some(group);
61 }72 }
62 // `leaf` is always set: `segments` was non-empty.
63 leaf.ok_or(StoreError::Name(model::NameError::Empty))73 leaf.ok_or(StoreError::Name(model::NameError::Empty))
64 }74 }
6575
66 /// Insert a single group row.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.
67 async fn insert_group(95 async fn insert_group(
68 &self,96 &self,
69 owner_id: &str,97 owner_id: &str,
@@ -78,10 +106,13 @@ impl Store {
78 name: name.to_string(),106 name: name.to_string(),
79 path: path.to_string(),107 path: path.to_string(),
80 description: None,108 description: None,
109 manual: false,
110 visibility: Visibility::Private,
81 created_at: now_ms(),111 created_at: now_ms(),
82 };112 };
83 let sql = "INSERT INTO groups (id, owner_id, parent_id, name, path, description, created_at) \113 let sql = "INSERT INTO groups \
84 VALUES ($1, $2, $3, $4, $5, $6, $7)";114 (id, owner_id, parent_id, name, path, description, manual, visibility, created_at) \
115 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)";
85 sqlx::query(sql)116 sqlx::query(sql)
86 .bind(group.id.clone())117 .bind(group.id.clone())
87 .bind(group.owner_id.clone())118 .bind(group.owner_id.clone())
@@ -89,6 +120,8 @@ impl Store {
89 .bind(group.name.clone())120 .bind(group.name.clone())
90 .bind(group.path.clone())121 .bind(group.path.clone())
91 .bind(group.description.clone())122 .bind(group.description.clone())
123 .bind(group.manual)
124 .bind(group.visibility.as_str())
92 .bind(group.created_at)125 .bind(group.created_at)
93 .execute(&self.pool)126 .execute(&self.pool)
94 .await127 .await
@@ -96,7 +129,7 @@ impl Store {
96 Ok(group)129 Ok(group)
97 }130 }
98131
99 /// Resolve a group by `(owner_id, path)`, or `None` if absent.132 /// Resolve a group by `(owner_id, path)`, or `None`.
100 ///133 ///
101 /// # Errors134 /// # Errors
102 ///135 ///
@@ -112,7 +145,21 @@ impl Store {
112 .bind(path)145 .bind(path)
113 .fetch_optional(&self.pool)146 .fetch_optional(&self.pool)
114 .await?;147 .await?;
115 Ok(row.as_ref().map(map_group).transpose()?)148 Ok(row.as_ref().map(|r| self.map_group(r)).transpose()?)
149 }
150
151 /// Fetch a group by id.
152 ///
153 /// # Errors
154 ///
155 /// Returns [`StoreError::Query`] on a database failure.
156 pub async fn group_by_id(&self, id: &str) -> Result<Option<Group>, StoreError> {
157 let sql = format!("SELECT {GROUP_COLUMNS} FROM groups WHERE id = $1");
158 let row = sqlx::query(AssertSqlSafe(sql))
159 .bind(id)
160 .fetch_optional(&self.pool)
161 .await?;
162 Ok(row.as_ref().map(|r| self.map_group(r)).transpose()?)
116 }163 }
117164
118 /// List every group owned by `owner_id`, ordered by path.165 /// List every group owned by `owner_id`, ordered by path.
@@ -128,7 +175,7 @@ impl Store {
128 .fetch_all(&self.pool)175 .fetch_all(&self.pool)
129 .await?;176 .await?;
130 rows.iter()177 rows.iter()
131 .map(map_group)178 .map(|r| self.map_group(r))
132 .collect::<Result<_, _>>()179 .collect::<Result<_, _>>()
133 .map_err(Into::into)180 .map_err(Into::into)
134 }181 }
@@ -144,7 +191,7 @@ impl Store {
144 .fetch_all(&self.pool)191 .fetch_all(&self.pool)
145 .await?;192 .await?;
146 rows.iter()193 rows.iter()
147 .map(map_group)194 .map(|r| self.map_group(r))
148 .collect::<Result<_, _>>()195 .collect::<Result<_, _>>()
149 .map_err(Into::into)196 .map_err(Into::into)
150 }197 }
@@ -168,14 +215,168 @@ impl Store {
168 .fetch_all(&self.pool)215 .fetch_all(&self.pool)
169 .await?;216 .await?;
170 rows.iter()217 rows.iter()
171 .map(map_group)218 .map(|r| self.map_group(r))
172 .collect::<Result<_, _>>()219 .collect::<Result<_, _>>()
173 .map_err(Into::into)220 .map_err(Into::into)
174 }221 }
175222
176 /// Delete a group by id. Child groups cascade (`ON DELETE CASCADE`); repos in223 /// Set a group's visibility.
177 /// the group have their `group_id` set null by the schema, so they survive as224 ///
178 /// ungrouped repos. Returns `true` if a row was deleted.225 /// # Errors
226 ///
227 /// Returns [`StoreError::Query`] on a database failure.
228 pub async fn set_group_visibility(
229 &self,
230 group_id: &str,
231 visibility: Visibility,
232 ) -> Result<(), StoreError> {
233 sqlx::query("UPDATE groups SET visibility = $1 WHERE id = $2")
234 .bind(visibility.as_str())
235 .bind(group_id)
236 .execute(&self.pool)
237 .await?;
238 Ok(())
239 }
240
241 /// Rename a group's leaf name, updating the materialized paths of the group,
242 /// its descendant groups, and every repository beneath it.
243 ///
244 /// # Errors
245 ///
246 /// * [`StoreError::Name`] if `new_name` is invalid.
247 /// * [`StoreError::Conflict`] if the new path is already taken.
248 /// * [`StoreError::Query`] for any other database failure.
249 pub async fn rename_group(&self, group_id: &str, new_name: &str) -> Result<Group, StoreError> {
250 model::validate_name(new_name)?;
251 let group = self
252 .group_by_id(group_id)
253 .await?
254 .ok_or(StoreError::Name(model::NameError::Empty))?;
255 let old_path = group.path.clone();
256 let new_path = match old_path.rsplit_once('/') {
257 Some((prefix, _)) => format!("{prefix}/{new_name}"),
258 None => new_name.to_string(),
259 };
260 if new_path != old_path
261 && (self
262 .group_by_owner_path(&group.owner_id, &new_path)
263 .await?
264 .is_some()
265 || self
266 .repo_by_owner_path(&group.owner_id, &new_path)
267 .await?
268 .is_some())
269 {
270 return Err(StoreError::Conflict {
271 entity: "group",
272 field: "path",
273 });
274 }
275 let old_prefix = format!("{old_path}/%");
276 let old_len = i64::try_from(old_path.len()).unwrap_or(i64::MAX);
277 let mut tx = self.pool.begin().await?;
278 // Descendant groups: replace the shared path prefix.
279 sqlx::query(
280 "UPDATE groups SET path = $1 || SUBSTR(path, $2) \
281 WHERE owner_id = $3 AND path LIKE $4",
282 )
283 .bind(&new_path)
284 .bind(old_len + 1)
285 .bind(&group.owner_id)
286 .bind(&old_prefix)
287 .execute(&mut *tx)
288 .await?;
289 // Repositories beneath the group.
290 sqlx::query(
291 "UPDATE repos SET path = $1 || SUBSTR(path, $2) \
292 WHERE owner_id = $3 AND path LIKE $4",
293 )
294 .bind(&new_path)
295 .bind(old_len + 1)
296 .bind(&group.owner_id)
297 .bind(&old_prefix)
298 .execute(&mut *tx)
299 .await?;
300 // The group itself.
301 sqlx::query("UPDATE groups SET name = $1, path = $2 WHERE id = $3")
302 .bind(new_name)
303 .bind(&new_path)
304 .bind(group_id)
305 .execute(&mut *tx)
306 .await?;
307 tx.commit().await?;
308 self.group_by_id(group_id)
309 .await?
310 .ok_or(StoreError::Name(model::NameError::Empty))
311 }
312
313 /// Whether a group holds any repositories directly.
314 async fn group_has_repos(&self, group_id: &str) -> Result<bool, StoreError> {
315 let row = sqlx::query("SELECT 1 AS n FROM repos WHERE group_id = $1")
316 .bind(group_id)
317 .fetch_optional(&self.pool)
318 .await?;
319 Ok(row.is_some())
320 }
321
322 /// Whether a group has any child groups.
323 async fn group_has_children(&self, group_id: &str) -> Result<bool, StoreError> {
324 let row = sqlx::query("SELECT 1 AS n FROM groups WHERE parent_id = $1")
325 .bind(group_id)
326 .fetch_optional(&self.pool)
327 .await?;
328 Ok(row.is_some())
329 }
330
331 /// The number of repositories anywhere in a group's subtree.
332 ///
333 /// # Errors
334 ///
335 /// Returns [`StoreError::Query`] on a database failure.
336 pub async fn group_subtree_repo_count(&self, group: &Group) -> Result<i64, StoreError> {
337 let prefix = format!("{}/%", group.path);
338 let n: i64 =
339 sqlx::query("SELECT COUNT(*) AS n FROM repos WHERE owner_id = $1 AND path LIKE $2")
340 .bind(&group.owner_id)
341 .bind(&prefix)
342 .fetch_one(&self.pool)
343 .await?
344 .try_get("n")?;
345 Ok(n)
346 }
347
348 /// Garbage-collect empty auto groups upward from `group_id`: while a group is
349 /// auto (not manual) and holds no repos and no child groups, delete it and
350 /// continue with its parent. Manual groups (and non-empty ones) stop the walk.
351 ///
352 /// # Errors
353 ///
354 /// Returns [`StoreError::Query`] on a database failure.
355 pub async fn gc_empty_groups(&self, mut group_id: Option<String>) -> Result<(), StoreError> {
356 let mut guard = 0;
357 while let Some(gid) = group_id {
358 guard += 1;
359 if guard > 64 {
360 break;
361 }
362 let Some(group) = self.group_by_id(&gid).await? else {
363 break;
364 };
365 if group.manual
366 || self.group_has_repos(&gid).await?
367 || self.group_has_children(&gid).await?
368 {
369 break;
370 }
371 let parent = group.parent_id.clone();
372 self.delete_group(&gid).await?;
373 group_id = parent;
374 }
375 Ok(())
376 }
377
378 /// Delete a group by id. Child groups cascade; repos have their `group_id`
379 /// set null by the schema. Returns `true` if a row was deleted.
179 ///380 ///
180 /// # Errors381 /// # Errors
181 ///382 ///
@@ -188,17 +389,181 @@ impl Store {
188 .rows_affected();389 .rows_affected();
189 Ok(deleted > 0)390 Ok(deleted > 0)
190 }391 }
191}
192392
193/// Map a `groups` row (selected via [`GROUP_COLUMNS`]) to a [`Group`].393 // ---- Group collaborators ----
194fn map_group(row: &AnyRow) -> Result<Group, sqlx::Error> {394
195 Ok(Group {395 /// Add or update a group collaborator's permission.
196 id: row.try_get("id")?,396 ///
197 owner_id: row.try_get("owner_id")?,397 /// # Errors
198 parent_id: row.try_get("parent_id")?,398 ///
199 name: row.try_get("name")?,399 /// Returns [`StoreError::Query`] on a database failure.
200 path: row.try_get("path")?,400 pub async fn add_group_collaborator(
201 description: row.try_get("description")?,401 &self,
202 created_at: row.try_get("created_at")?,402 group_id: &str,
203 })403 user_id: &str,
404 permission: &str,
405 ) -> Result<(), StoreError> {
406 sqlx::query(
407 "INSERT INTO group_collaborators (id, group_id, user_id, permission, created_at) \
408 VALUES ($1, $2, $3, $4, $5) \
409 ON CONFLICT (group_id, user_id) DO UPDATE SET permission = $4",
410 )
411 .bind(new_id())
412 .bind(group_id)
413 .bind(user_id)
414 .bind(permission)
415 .bind(now_ms())
416 .execute(&self.pool)
417 .await?;
418 Ok(())
419 }
420
421 /// Remove a group collaborator.
422 ///
423 /// # Errors
424 ///
425 /// Returns [`StoreError::Query`] on a database failure.
426 pub async fn remove_group_collaborator(
427 &self,
428 group_id: &str,
429 user_id: &str,
430 ) -> Result<(), StoreError> {
431 sqlx::query("DELETE FROM group_collaborators WHERE group_id = $1 AND user_id = $2")
432 .bind(group_id)
433 .bind(user_id)
434 .execute(&self.pool)
435 .await?;
436 Ok(())
437 }
438
439 /// List a group's collaborators with their usernames resolved.
440 ///
441 /// # Errors
442 ///
443 /// Returns [`StoreError::Query`] on a database failure.
444 pub async fn list_group_collaborators(
445 &self,
446 group_id: &str,
447 ) -> Result<Vec<Collaborator>, StoreError> {
448 let rows = sqlx::query(
449 "SELECT gc.user_id, u.username, gc.permission \
450 FROM group_collaborators gc JOIN users u ON u.id = gc.user_id \
451 WHERE gc.group_id = $1 ORDER BY u.username_lower ASC",
452 )
453 .bind(group_id)
454 .fetch_all(&self.pool)
455 .await?;
456 rows.iter()
457 .map(|r| {
458 Ok(Collaborator {
459 user_id: r.try_get("user_id")?,
460 username: r.try_get("username")?,
461 permission: r.try_get("permission")?,
462 })
463 })
464 .collect::<Result<_, sqlx::Error>>()
465 .map_err(Into::into)
466 }
467
468 /// A user's collaborator permission on a group, if any.
469 ///
470 /// # Errors
471 ///
472 /// Returns [`StoreError::Query`] on a database failure.
473 pub async fn group_collaborator_permission(
474 &self,
475 group_id: &str,
476 user_id: &str,
477 ) -> Result<Option<String>, StoreError> {
478 let row = sqlx::query(
479 "SELECT permission FROM group_collaborators WHERE group_id = $1 AND user_id = $2",
480 )
481 .bind(group_id)
482 .bind(user_id)
483 .fetch_optional(&self.pool)
484 .await?;
485 Ok(row.map(|r| r.try_get("permission")).transpose()?)
486 }
487
488 /// A user's effective collaborator permission on a repo: the strongest of its
489 /// direct collaborator row and any group-collaborator row on the repo's group
490 /// or an ancestor group.
491 ///
492 /// # Errors
493 ///
494 /// Returns [`StoreError::Query`] on a database failure.
495 pub async fn effective_permission(
496 &self,
497 repo_id: &str,
498 user_id: &str,
499 ) -> Result<Option<String>, StoreError> {
500 let mut best: Option<String> = self.collaborator_permission(repo_id, user_id).await?;
501 let mut best_rank = best.as_deref().map_or(0, perm_rank);
502
503 let mut group_id = self.repo_by_id(repo_id).await?.and_then(|r| r.group_id);
504 let mut guard = 0;
505 while let Some(gid) = group_id {
506 guard += 1;
507 if guard > 64 {
508 break;
509 }
510 if let Some(p) = self.group_collaborator_permission(&gid, user_id).await? {
511 let r = perm_rank(&p);
512 if r > best_rank {
513 best_rank = r;
514 best = Some(p);
515 }
516 }
517 group_id = self.group_by_id(&gid).await?.and_then(|g| g.parent_id);
518 }
519 Ok(best)
520 }
521
522 /// A user's effective collaborator permission on a group: the strongest
523 /// group-collaborator row on the group itself or any ancestor group.
524 ///
525 /// # Errors
526 ///
527 /// Returns [`StoreError::Query`] on a database failure.
528 pub async fn effective_group_permission(
529 &self,
530 group_id: &str,
531 user_id: &str,
532 ) -> Result<Option<String>, StoreError> {
533 let mut best: Option<String> = None;
534 let mut best_rank = 0;
535 let mut next = Some(group_id.to_string());
536 let mut guard = 0;
537 while let Some(gid) = next {
538 guard += 1;
539 if guard > 64 {
540 break;
541 }
542 if let Some(p) = self.group_collaborator_permission(&gid, user_id).await? {
543 let r = perm_rank(&p);
544 if r > best_rank {
545 best_rank = r;
546 best = Some(p);
547 }
548 }
549 next = self.group_by_id(&gid).await?.and_then(|g| g.parent_id);
550 }
551 Ok(best)
552 }
553
554 /// Map a `groups` row (selected via [`GROUP_COLUMNS`]) to a [`Group`].
555 fn map_group(&self, row: &AnyRow) -> Result<Group, sqlx::Error> {
556 Ok(Group {
557 id: row.try_get("id")?,
558 owner_id: row.try_get("owner_id")?,
559 parent_id: row.try_get("parent_id")?,
560 name: row.try_get("name")?,
561 path: row.try_get("path")?,
562 description: row.try_get("description")?,
563 manual: self.get_bool(row, "manual")?,
564 visibility: Visibility::from_token(&row.try_get::<String, _>("visibility")?)
565 .unwrap_or(Visibility::Private),
566 created_at: row.try_get("created_at")?,
567 })
568 }
204}569}
crates/store/src/repos.rs +7 −1
@@ -379,17 +379,23 @@ impl Store {
379379
380 /// Delete a repository row (cascading to collaborators). The caller is380 /// Delete a repository row (cascading to collaborators). The caller is
381 /// responsible for the on-disk directory — moving it to trash, or purging it.381 /// responsible for the on-disk directory — moving it to trash, or purging it.
382 /// Returns `true` if a row was removed.382 /// Returns `true` if a row was removed. Empty auto-created groups the repo
383 /// left behind are garbage-collected up the tree.
383 ///384 ///
384 /// # Errors385 /// # Errors
385 ///386 ///
386 /// Returns [`StoreError::Query`] on a database failure.387 /// Returns [`StoreError::Query`] on a database failure.
387 pub async fn delete_repo(&self, repo_id: &str) -> Result<bool, StoreError> {388 pub async fn delete_repo(&self, repo_id: &str) -> Result<bool, StoreError> {
389 // Remember the group before the row disappears, to garbage-collect it.
390 let group_id = self.repo_by_id(repo_id).await?.and_then(|r| r.group_id);
388 let deleted = sqlx::query("DELETE FROM repos WHERE id = $1")391 let deleted = sqlx::query("DELETE FROM repos WHERE id = $1")
389 .bind(repo_id)392 .bind(repo_id)
390 .execute(&self.pool)393 .execute(&self.pool)
391 .await?394 .await?
392 .rows_affected();395 .rows_affected();
396 if deleted > 0 {
397 self.gc_empty_groups(group_id).await?;
398 }
393 Ok(deleted > 0)399 Ok(deleted > 0)
394 }400 }
395401
crates/store/src/tests.rs +224 −0
@@ -1370,3 +1370,227 @@ async fn postgres_round_trip() {
1370 .await1370 .await
1371 .unwrap();1371 .unwrap();
1372}1372}
1373
1374/// Helper: create a repo at `path`, auto-creating its group chain, and return it.
1375async fn repo_in_group(store: &Store, owner_id: &str, path: &str) -> model::Repo {
1376 let (group, leaf) = match path.rsplit_once('/') {
1377 Some((g, l)) => (Some(g), l),
1378 None => (None, path),
1379 };
1380 let group_id = match group {
1381 Some(g) => Some(store.ensure_group_path(owner_id, g).await.unwrap().id),
1382 None => None,
1383 };
1384 store
1385 .create_repo(NewRepo {
1386 owner_id: owner_id.to_string(),
1387 group_id,
1388 name: leaf.to_string(),
1389 path: path.to_string(),
1390 description: None,
1391 visibility: model::Visibility::Private,
1392 default_branch: "main".to_string(),
1393 })
1394 .await
1395 .unwrap()
1396}
1397
1398#[tokio::test]
1399async fn deleting_last_repo_gcs_empty_auto_groups() {
1400 for fx in sqlite_fixtures().await {
1401 let owner = fx
1402 .store
1403 .create_user(sample_user("gc", "gc@example.com"))
1404 .await
1405 .unwrap();
1406 // what/the/fuck where `fuck` is a repo; siblings keep their branch alive.
1407 let repo = repo_in_group(&fx.store, &owner.id, "what/the/fuck").await;
1408 let sibling = repo_in_group(&fx.store, &owner.id, "what/keep").await;
1409
1410 assert!(
1411 fx.store
1412 .group_by_owner_path(&owner.id, "what/the")
1413 .await
1414 .unwrap()
1415 .is_some()
1416 );
1417
1418 // Deleting `fuck` empties `the` (deleted), but `what` still holds `keep`.
1419 assert!(fx.store.delete_repo(&repo.id).await.unwrap());
1420 assert!(
1421 fx.store
1422 .group_by_owner_path(&owner.id, "what/the")
1423 .await
1424 .unwrap()
1425 .is_none(),
1426 "empty auto group `what/the` should be collected"
1427 );
1428 assert!(
1429 fx.store
1430 .group_by_owner_path(&owner.id, "what")
1431 .await
1432 .unwrap()
1433 .is_some(),
1434 "`what` still holds the `keep` repo"
1435 );
1436
1437 // Removing the last repo collects the whole chain up to the root.
1438 assert!(fx.store.delete_repo(&sibling.id).await.unwrap());
1439 assert!(
1440 fx.store
1441 .group_by_owner_path(&owner.id, "what")
1442 .await
1443 .unwrap()
1444 .is_none(),
1445 "the now-empty root auto group is collected too"
1446 );
1447 }
1448}
1449
1450#[tokio::test]
1451async fn manual_groups_survive_gc() {
1452 for fx in sqlite_fixtures().await {
1453 let owner = fx
1454 .store
1455 .create_user(sample_user("man", "man@example.com"))
1456 .await
1457 .unwrap();
1458 // Manually create `team`, then a repo under an auto subgroup.
1459 let team = fx.store.create_group(&owner.id, "team").await.unwrap();
1460 assert!(team.manual);
1461 let repo = repo_in_group(&fx.store, &owner.id, "team/auto/proj").await;
1462
1463 assert!(fx.store.delete_repo(&repo.id).await.unwrap());
1464 assert!(
1465 fx.store
1466 .group_by_owner_path(&owner.id, "team/auto")
1467 .await
1468 .unwrap()
1469 .is_none(),
1470 "auto subgroup collected"
1471 );
1472 assert!(
1473 fx.store
1474 .group_by_owner_path(&owner.id, "team")
1475 .await
1476 .unwrap()
1477 .is_some(),
1478 "manual group is never collected"
1479 );
1480 }
1481}
1482
1483#[tokio::test]
1484async fn group_collaborator_grants_effective_permission() {
1485 for fx in sqlite_fixtures().await {
1486 let owner = fx
1487 .store
1488 .create_user(sample_user("own", "own@example.com"))
1489 .await
1490 .unwrap();
1491 let collab = fx
1492 .store
1493 .create_user(sample_user("col", "col@example.com"))
1494 .await
1495 .unwrap();
1496 let repo = repo_in_group(&fx.store, &owner.id, "acme/deep/svc").await;
1497
1498 // No access before any grant.
1499 assert!(
1500 fx.store
1501 .effective_permission(&repo.id, &collab.id)
1502 .await
1503 .unwrap()
1504 .is_none()
1505 );
1506
1507 // Grant write on the top group; it cascades down to the nested repo.
1508 let acme = fx
1509 .store
1510 .group_by_owner_path(&owner.id, "acme")
1511 .await
1512 .unwrap()
1513 .unwrap();
1514 fx.store
1515 .add_group_collaborator(&acme.id, &collab.id, "write")
1516 .await
1517 .unwrap();
1518 assert_eq!(
1519 fx.store
1520 .effective_permission(&repo.id, &collab.id)
1521 .await
1522 .unwrap()
1523 .as_deref(),
1524 Some("write")
1525 );
1526
1527 // A stronger repo-level grant wins over the weaker group grant.
1528 fx.store
1529 .set_collaborator(&repo.id, &collab.id, "admin")
1530 .await
1531 .unwrap();
1532 assert_eq!(
1533 fx.store
1534 .effective_permission(&repo.id, &collab.id)
1535 .await
1536 .unwrap()
1537 .as_deref(),
1538 Some("admin")
1539 );
1540
1541 // The group-level effective permission also resolves for the group itself.
1542 let svc_group = fx
1543 .store
1544 .group_by_owner_path(&owner.id, "acme/deep")
1545 .await
1546 .unwrap()
1547 .unwrap();
1548 assert_eq!(
1549 fx.store
1550 .effective_group_permission(&svc_group.id, &collab.id)
1551 .await
1552 .unwrap()
1553 .as_deref(),
1554 Some("write"),
1555 "ancestor group grant is inherited"
1556 );
1557 }
1558}
1559
1560#[tokio::test]
1561async fn rename_group_moves_subtree() {
1562 for fx in sqlite_fixtures().await {
1563 let owner = fx
1564 .store
1565 .create_user(sample_user("rn", "rn@example.com"))
1566 .await
1567 .unwrap();
1568 let repo = repo_in_group(&fx.store, &owner.id, "old/sub/proj").await;
1569 let top = fx
1570 .store
1571 .group_by_owner_path(&owner.id, "old")
1572 .await
1573 .unwrap()
1574 .unwrap();
1575
1576 fx.store.rename_group(&top.id, "new").await.unwrap();
1577
1578 // The repo and descendant group paths are rewritten under the new prefix.
1579 let moved = fx.store.repo_by_id(&repo.id).await.unwrap().unwrap();
1580 assert_eq!(moved.path, "new/sub/proj");
1581 assert!(
1582 fx.store
1583 .group_by_owner_path(&owner.id, "new/sub")
1584 .await
1585 .unwrap()
1586 .is_some()
1587 );
1588 assert!(
1589 fx.store
1590 .group_by_owner_path(&owner.id, "old/sub")
1591 .await
1592 .unwrap()
1593 .is_none()
1594 );
1595 }
1596}
migrations/postgres/0021_group_settings.sql +14 −0
@@ -0,0 +1,14 @@
1-- Groups gain their own settings: a manual flag (manual groups persist; auto
2-- groups are garbage-collected when empty), a visibility, and collaborators
3-- whose access cascades to every repository in the group's subtree.
4ALTER TABLE groups ADD COLUMN manual BOOLEAN NOT NULL DEFAULT FALSE;
5ALTER TABLE groups ADD COLUMN visibility TEXT NOT NULL DEFAULT 'private';
6
7CREATE TABLE group_collaborators (
8 id TEXT PRIMARY KEY,
9 group_id TEXT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
10 user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
11 permission TEXT NOT NULL,
12 created_at BIGINT NOT NULL
13);
14CREATE UNIQUE INDEX idx_group_collab ON group_collaborators (group_id, user_id);
migrations/sqlite/0021_group_settings.sql +14 −0
@@ -0,0 +1,14 @@
1-- Groups gain their own settings: a manual flag (manual groups persist; auto
2-- groups are garbage-collected when empty), a visibility, and collaborators
3-- whose access cascades to every repository in the group's subtree.
4ALTER TABLE groups ADD COLUMN manual INTEGER NOT NULL DEFAULT 0;
5ALTER TABLE groups ADD COLUMN visibility TEXT NOT NULL DEFAULT 'private';
6
7CREATE TABLE group_collaborators (
8 id TEXT PRIMARY KEY,
9 group_id TEXT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
10 user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
11 permission TEXT NOT NULL,
12 created_at BIGINT NOT NULL
13);
14CREATE UNIQUE INDEX idx_group_collab ON group_collaborators (group_id, user_id);