fabrica

hanna/fabrica

feat(groups): cap repo visibility at the group's visibility ceiling

84b193c · hanna committed on 2026-07-26

A repository may not be more visible than its group: an internal group holds
only internal/private repos, a private group only private repos, and a public
group imposes nothing. The ceiling walks the whole ancestor chain, so nested
groups compose.

Auto/intermediate groups now default to public (migration 0021) so they stay
transparent to the ceiling — only a deliberately-restricted group locks the
repos beneath it. Enforced at repo creation, repo settings, and group settings;
lowering a group's visibility cascades down, clamping every descendant group and
repository via `clamp_subtree_visibility`. The repo settings form hides options
above the ceiling and notes the cap, and the group listing hides subgroups the
viewer may not see.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
7 files changed · +198 −12UnifiedSplit
crates/store/src/groups.rs +62 −1
@@ -107,7 +107,9 @@ impl Store {
107107 path: path.to_string(),
108108 description: None,
109109 manual: false,
110- visibility: Visibility::Private,
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,
111113 created_at: now_ms(),
112114 };
113115 let sql = "INSERT INTO groups \
@@ -551,6 +553,65 @@ impl Store {
551553 Ok(best)
552554 }
553555
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+
554615 /// Map a `groups` row (selected via [`GROUP_COLUMNS`]) to a [`Group`].
555616 fn map_group(&self, row: &AnyRow) -> Result<Group, sqlx::Error> {
556617 Ok(Group {
crates/store/src/tests.rs +63 −0
@@ -1594,3 +1594,66 @@ async fn rename_group_moves_subtree() {
15941594 );
15951595 }
15961596 }
1597+
1598+#[tokio::test]
1599+async fn group_visibility_ceiling_clamps_subtree() {
1600+ use model::Visibility;
1601+ for fx in sqlite_fixtures().await {
1602+ let owner = fx
1603+ .store
1604+ .create_user(sample_user("vis", "vis@example.com"))
1605+ .await
1606+ .unwrap();
1607+ let org = fx.store.create_group(&owner.id, "org").await.unwrap();
1608+ let repo = repo_in_group(&fx.store, &owner.id, "org/team/app").await;
1609+ // Start everything fully public.
1610+ fx.store
1611+ .set_visibility(&repo.id, Visibility::Public)
1612+ .await
1613+ .unwrap();
1614+ let team = fx
1615+ .store
1616+ .group_by_owner_path(&owner.id, "org/team")
1617+ .await
1618+ .unwrap()
1619+ .unwrap();
1620+ fx.store
1621+ .set_group_visibility(&team.id, Visibility::Public)
1622+ .await
1623+ .unwrap();
1624+ assert_eq!(
1625+ fx.store.group_visibility_ceiling(&team.id).await.unwrap(),
1626+ Visibility::Public
1627+ );
1628+
1629+ // Lower the top group to internal and cascade the ceiling downward.
1630+ fx.store
1631+ .set_group_visibility(&org.id, Visibility::Internal)
1632+ .await
1633+ .unwrap();
1634+ let org = fx.store.group_by_id(&org.id).await.unwrap().unwrap();
1635+ fx.store.clamp_subtree_visibility(&org).await.unwrap();
1636+
1637+ let team = fx
1638+ .store
1639+ .group_by_owner_path(&owner.id, "org/team")
1640+ .await
1641+ .unwrap()
1642+ .unwrap();
1643+ assert_eq!(
1644+ team.visibility,
1645+ Visibility::Internal,
1646+ "subgroup clamped to the new ceiling"
1647+ );
1648+ let repo = fx.store.repo_by_id(&repo.id).await.unwrap().unwrap();
1649+ assert_eq!(
1650+ repo.visibility,
1651+ Visibility::Internal,
1652+ "repo clamped to the new ceiling"
1653+ );
1654+ assert_eq!(
1655+ fx.store.group_visibility_ceiling(&team.id).await.unwrap(),
1656+ Visibility::Internal
1657+ );
1658+ }
1659+}
crates/web/src/groups.rs +20 −3
@@ -180,7 +180,15 @@ async fn create_group_from_form(
180180 }
181181 other => other.to_string(),
182182 })?;
183- if let Some(vis) = Visibility::from_token(&form.visibility) {
183+ if let Some(mut vis) = Visibility::from_token(&form.visibility) {
184+ // A subgroup may not be more visible than its parent's ceiling.
185+ if let Some(parent_id) = &group.parent_id {
186+ if let Ok(ceiling) = state.store.group_visibility_ceiling(parent_id).await
187+ && vis.rank() > ceiling.rank()
188+ {
189+ vis = ceiling;
190+ }
191+ }
184192 let _ = state.store.set_group_visibility(&group.id, vis).await;
185193 }
186194 Ok(group.path)
@@ -399,8 +407,17 @@ pub async fn settings_general(
399407 }
400408 other => AppError::from(other),
401409 })?;
402- if let Some(vis) = Visibility::from_token(&form.visibility) {
403- state.store.set_group_visibility(&group.id, vis).await?;
410+ if let Some(mut vis) = Visibility::from_token(&form.visibility) {
411+ // A subgroup may not be more visible than its parent's ceiling.
412+ if let Some(parent_id) = &renamed.parent_id {
413+ let ceiling = state.store.group_visibility_ceiling(parent_id).await?;
414+ if vis.rank() > ceiling.rank() {
415+ vis = ceiling;
416+ }
417+ }
418+ state.store.set_group_visibility(&renamed.id, vis).await?;
419+ // Cascade the (possibly lowered) ceiling down to repos and subgroups.
420+ state.store.clamp_subtree_visibility(&renamed).await?;
404421 }
405422 let _ = owner;
406423 Ok::<String, AppError>(format!("/group-settings/{}", renamed.id))
crates/web/src/pages.rs +13 −1
@@ -427,10 +427,22 @@ async fn create_repo_from_form(
427427 b.to_string()
428428 }
429429 };
430- let visibility = model::Visibility::from_token(&form.visibility).unwrap_or_else(|| {
430+ let mut visibility = model::Visibility::from_token(&form.visibility).unwrap_or_else(|| {
431431 model::Visibility::from_token(&state.default_visibility_token())
432432 .unwrap_or(model::Visibility::Private)
433433 });
434+ // A repo may never be more visible than its group (the ceiling walks the
435+ // whole ancestor chain; auto groups are public and impose nothing).
436+ if let Some(gid) = &group_id {
437+ let ceiling = state
438+ .store
439+ .group_visibility_ceiling(gid)
440+ .await
441+ .map_err(|e| e.to_string())?;
442+ if visibility.rank() > ceiling.rank() {
443+ visibility = ceiling;
444+ }
445+ }
434446 let description = {
435447 let d = form.description.trim();
436448 (!d.is_empty()).then(|| d.to_string())
crates/web/src/repo.rs +34 −5
@@ -1298,9 +1298,18 @@ async fn repo_settings_view(
12981298
12991299 let id = &ctx.repo.id;
13001300 let vis = ctx.repo.visibility;
1301- let vis_option = |value: Visibility, label: &str| {
1302- html! {
1303- option value=(value.as_str()) selected[vis == value] { (label) }
1301+ // The group ceiling: a repo may not be set more visible than its group.
1302+ let vis_ceiling = match &ctx.repo.group_id {
1303+ Some(gid) => state.store.group_visibility_ceiling(gid).await?,
1304+ None => Visibility::Public,
1305+ };
1306+ let vis_option = |value: Visibility, label: &str| -> Markup {
1307+ // Hide options above the ceiling, but always keep the current value so the
1308+ // form round-trips even if the ceiling later tightened.
1309+ if value.rank() <= vis_ceiling.rank() || value == vis {
1310+ html! { option value=(value.as_str()) selected[vis == value] { (label) } }
1311+ } else {
1312+ html! {}
13041313 }
13051314 };
13061315 let body = html! {
@@ -1318,6 +1327,12 @@ async fn repo_settings_view(
13181327 (vis_option(Visibility::Internal, "Internal — any signed-in user"))
13191328 (vis_option(Visibility::Private, "Private — only you and collaborators"))
13201329 }
1330+ @if vis_ceiling.rank() < Visibility::Public.rank() {
1331+ p class="muted field-hint" {
1332+ "Capped by this repository's group, which is "
1333+ (vis_ceiling.as_str()) "."
1334+ }
1335+ }
13211336 label for="object_format" { "Object format" }
13221337 select id="object_format" disabled title="Fixed at creation" {
13231338 option { (ctx.repo.object_format.to_uppercase()) }
@@ -2152,6 +2167,13 @@ pub async fn settings_general(
21522167 {
21532168 vis = parent.visibility;
21542169 }
2170+ // Nor more visible than its group ceiling.
2171+ if let Some(gid) = &repo.group_id {
2172+ let ceiling = state.store.group_visibility_ceiling(gid).await?;
2173+ if vis.rank() > ceiling.rank() {
2174+ vis = ceiling;
2175+ }
2176+ }
21552177 state.store.set_visibility(&repo.id, vis).await?;
21562178 }
21572179 state
@@ -2802,10 +2824,17 @@ async fn group_listing(
28022824
28032825 let all_groups = state.store.groups_by_owner(&owner.id).await?;
28042826 let prefix = format!("{group_path}/");
2805- let subgroups: Vec<_> = all_groups
2827+ // Direct child groups the viewer may see (a private subgroup is hidden, not
2828+ // just 404 on visit — its very name would otherwise leak here).
2829+ let mut subgroups: Vec<&model::Group> = Vec::new();
2830+ for g in all_groups
28062831 .iter()
28072832 .filter(|g| g.path.starts_with(&prefix) && !g.path[prefix.len()..].contains('/'))
2808- .collect();
2833+ {
2834+ if crate::groups::group_access(state, viewer.as_ref(), g).await? != auth::Access::None {
2835+ subgroups.push(g);
2836+ }
2837+ }
28092838 let repos: Vec<Repo> = visible_repos(state, &owner, viewer.as_ref())
28102839 .await?
28112840 .into_iter()
migrations/postgres/0021_group_settings.sql +3 −1
@@ -2,7 +2,9 @@
22 -- groups are garbage-collected when empty), a visibility, and collaborators
33 -- whose access cascades to every repository in the group's subtree.
44 ALTER TABLE groups ADD COLUMN manual BOOLEAN NOT NULL DEFAULT FALSE;
5-ALTER TABLE groups ADD COLUMN visibility TEXT NOT NULL DEFAULT 'private';
5+-- Auto/intermediate groups default to 'public' so they impose no visibility
6+-- ceiling; only a deliberately-restricted group locks the repos beneath it.
7+ALTER TABLE groups ADD COLUMN visibility TEXT NOT NULL DEFAULT 'public';
68
79 CREATE TABLE group_collaborators (
810 id TEXT PRIMARY KEY,
migrations/sqlite/0021_group_settings.sql +3 −1
@@ -2,7 +2,9 @@
22 -- groups are garbage-collected when empty), a visibility, and collaborators
33 -- whose access cascades to every repository in the group's subtree.
44 ALTER TABLE groups ADD COLUMN manual INTEGER NOT NULL DEFAULT 0;
5-ALTER TABLE groups ADD COLUMN visibility TEXT NOT NULL DEFAULT 'private';
5+-- Auto/intermediate groups default to 'public' so they impose no visibility
6+-- ceiling; only a deliberately-restricted group locks the repos beneath it.
7+ALTER TABLE groups ADD COLUMN visibility TEXT NOT NULL DEFAULT 'public';
68
79 CREATE TABLE group_collaborators (
810 id TEXT PRIMARY KEY,