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 {
215215 pub path: String,
216216 /// Optional description.
217217 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,
218223 /// Creation time.
219224 pub created_at: Timestamp,
220225 }
crates/store/src/groups.rs +397 −32
@@ -2,28 +2,39 @@
22 // License, v. 2.0. If a copy of the MPL was not distributed with this
33 // 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
7-use model::Group;
9+use model::{Group, Visibility};
810 use sqlx::any::AnyRow;
911 use sqlx::{AssertSqlSafe, Row};
1012
13+use crate::repos::Collaborator;
1114 use crate::{Store, StoreError, map_conflict, new_id, now_ms};
1215
1316 /// The columns of `groups` in a fixed order, shared by every `SELECT`.
14-const GROUP_COLUMNS: &str = "id, owner_id, parent_id, name, path, description, created_at";
17+const 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.
21+fn perm_rank(perm: &str) -> i32 {
22+ match perm {
23+ "admin" => 3,
24+ "write" => 2,
25+ "read" => 1,
26+ _ => 0,
27+ }
28+}
1529
1630 impl Store {
1731 /// Ensure a group exists at `owner_id`/`path`, creating it and every missing
18- /// intermediate group, and return the leaf group. Existing groups along the
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.
32+ /// intermediate group (all auto), and return the leaf group.
2333 ///
2434 /// # Errors
2535 ///
2636 /// * [`StoreError::Name`] if any segment is an invalid name.
37+ /// * [`StoreError::GroupTooDeep`] past [`model::MAX_GROUP_DEPTH`].
2738 /// * [`StoreError::Query`] on a database failure.
2839 pub async fn ensure_group_path(&self, owner_id: &str, path: &str) -> Result<Group, StoreError> {
2940 let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
@@ -59,11 +70,28 @@ impl Store {
5970 parent_id = Some(group.id.clone());
6071 leaf = Some(group);
6172 }
62- // `leaf` is always set: `segments` was non-empty.
6373 leaf.ok_or(StoreError::Name(model::NameError::Empty))
6474 }
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.
6795 async fn insert_group(
6896 &self,
6997 owner_id: &str,
@@ -78,10 +106,13 @@ impl Store {
78106 name: name.to_string(),
79107 path: path.to_string(),
80108 description: None,
109+ manual: false,
110+ visibility: Visibility::Private,
81111 created_at: now_ms(),
82112 };
83- let sql = "INSERT INTO groups (id, owner_id, parent_id, name, path, description, created_at) \
84- VALUES ($1, $2, $3, $4, $5, $6, $7)";
113+ let sql = "INSERT INTO groups \
114+ (id, owner_id, parent_id, name, path, description, manual, visibility, created_at) \
115+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)";
85116 sqlx::query(sql)
86117 .bind(group.id.clone())
87118 .bind(group.owner_id.clone())
@@ -89,6 +120,8 @@ impl Store {
89120 .bind(group.name.clone())
90121 .bind(group.path.clone())
91122 .bind(group.description.clone())
123+ .bind(group.manual)
124+ .bind(group.visibility.as_str())
92125 .bind(group.created_at)
93126 .execute(&self.pool)
94127 .await
@@ -96,7 +129,7 @@ impl Store {
96129 Ok(group)
97130 }
98131
99- /// Resolve a group by `(owner_id, path)`, or `None` if absent.
132+ /// Resolve a group by `(owner_id, path)`, or `None`.
100133 ///
101134 /// # Errors
102135 ///
@@ -112,7 +145,21 @@ impl Store {
112145 .bind(path)
113146 .fetch_optional(&self.pool)
114147 .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()?)
116163 }
117164
118165 /// List every group owned by `owner_id`, ordered by path.
@@ -128,7 +175,7 @@ impl Store {
128175 .fetch_all(&self.pool)
129176 .await?;
130177 rows.iter()
131- .map(map_group)
178+ .map(|r| self.map_group(r))
132179 .collect::<Result<_, _>>()
133180 .map_err(Into::into)
134181 }
@@ -144,7 +191,7 @@ impl Store {
144191 .fetch_all(&self.pool)
145192 .await?;
146193 rows.iter()
147- .map(map_group)
194+ .map(|r| self.map_group(r))
148195 .collect::<Result<_, _>>()
149196 .map_err(Into::into)
150197 }
@@ -168,14 +215,168 @@ impl Store {
168215 .fetch_all(&self.pool)
169216 .await?;
170217 rows.iter()
171- .map(map_group)
218+ .map(|r| self.map_group(r))
172219 .collect::<Result<_, _>>()
173220 .map_err(Into::into)
174221 }
175222
176- /// Delete a group by id. Child groups cascade (`ON DELETE CASCADE`); repos in
177- /// the group have their `group_id` set null by the schema, so they survive as
178- /// ungrouped repos. Returns `true` if a row was deleted.
223+ /// Set a group's visibility.
224+ ///
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.
179380 ///
180381 /// # Errors
181382 ///
@@ -188,17 +389,181 @@ impl Store {
188389 .rows_affected();
189390 Ok(deleted > 0)
190391 }
191-}
192392
193-/// Map a `groups` row (selected via [`GROUP_COLUMNS`]) to a [`Group`].
194-fn map_group(row: &AnyRow) -> Result<Group, sqlx::Error> {
195- Ok(Group {
196- id: row.try_get("id")?,
197- owner_id: row.try_get("owner_id")?,
198- parent_id: row.try_get("parent_id")?,
199- name: row.try_get("name")?,
200- path: row.try_get("path")?,
201- description: row.try_get("description")?,
202- created_at: row.try_get("created_at")?,
203- })
393+ // ---- Group collaborators ----
394+
395+ /// Add or update a group collaborator's permission.
396+ ///
397+ /// # Errors
398+ ///
399+ /// Returns [`StoreError::Query`] on a database failure.
400+ pub async fn add_group_collaborator(
401+ &self,
402+ group_id: &str,
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+ }
204569 }
crates/store/src/repos.rs +7 −1
@@ -379,17 +379,23 @@ impl Store {
379379
380380 /// Delete a repository row (cascading to collaborators). The caller is
381381 /// 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.
383384 ///
384385 /// # Errors
385386 ///
386387 /// Returns [`StoreError::Query`] on a database failure.
387388 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);
388391 let deleted = sqlx::query("DELETE FROM repos WHERE id = $1")
389392 .bind(repo_id)
390393 .execute(&self.pool)
391394 .await?
392395 .rows_affected();
396+ if deleted > 0 {
397+ self.gc_empty_groups(group_id).await?;
398+ }
393399 Ok(deleted > 0)
394400 }
395401
crates/store/src/tests.rs +224 −0
@@ -1370,3 +1370,227 @@ async fn postgres_round_trip() {
13701370 .await
13711371 .unwrap();
13721372 }
1373+
1374+/// Helper: create a repo at `path`, auto-creating its group chain, and return it.
1375+async 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]
1399+async 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]
1451+async 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]
1484+async 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]
1561+async 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.
4+ALTER TABLE groups ADD COLUMN manual BOOLEAN NOT NULL DEFAULT FALSE;
5+ALTER TABLE groups ADD COLUMN visibility TEXT NOT NULL DEFAULT 'private';
6+
7+CREATE 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+);
14+CREATE 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.
4+ALTER TABLE groups ADD COLUMN manual INTEGER NOT NULL DEFAULT 0;
5+ALTER TABLE groups ADD COLUMN visibility TEXT NOT NULL DEFAULT 'private';
6+
7+CREATE 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+);
14+CREATE UNIQUE INDEX idx_group_collab ON group_collaborators (group_id, user_id);