fix(connections): hide subfolders the user cannot access

Closes #147.

ab_list_subfolders checked access to the parent folder but then returned
every subfolder unfiltered, so a user who could open a parent saw all of
its children regardless of per-child group ACLs - clicking one they
weren't entitled to gave "no access". (Top-level folders were already
filtered; this was the subfolder gap.)

Subfolders are now filtered per child. A folder is shown if the user can
access it directly OR can access any descendant of it, so a deeper grant
(child with its own allowed_groups and inherit_from_parent=false under a
denied folder) is never orphaned out of the tree. Admins still see all.

New folder_or_descendant_accessible helper does the recursive (boxed
async) OR over resolve_folder_access, short-circuiting on the first
accessible folder. No Vault mock harness exists to unit-test the
Vault-backed path; verified via build + the logic being a thin recursive
wrapper over the already-shipping resolve_folder_access.

docs/roles-and-access-control.md gains explicit notes that inaccessible
folders are hidden (not shown-then-denied) at every level, the
descendant-visibility rule, and how inheritance interacts.
This commit is contained in:
Dave Kempe
2026-05-28 06:43:43 +10:00
parent 2095c7f2fb
commit 89129a54b5
2 changed files with 62 additions and 1 deletions
+6
View File
@@ -130,6 +130,12 @@ Connections folders have group-based access control. Each folder has an `allowed
- **Admins** bypass group checks and see all folders
- **Operators and powerusers** see only folders where their OIDC groups intersect with the folder's `allowed_groups`
- If `allowed_groups` is empty, all authenticated users can see the folder
- Folders the user cannot access are **hidden** from the tree, not shown-then-denied. This applies at every level, including subfolders.
- A folder the user cannot access directly is still shown if they can access one of its descendants, so a deeper grant is never orphaned out of the tree. Access of a child can be granted independently of its parent (see Inheritance below).
### Inheritance
A subfolder created with `inherit_from_parent: true` (the default for new subfolders) grants access to anyone who can access its parent. A subfolder with its own non-empty `allowed_groups` and `inherit_from_parent: false` is gated solely by its own list, independent of the parent.
### Example
+56 -1
View File
@@ -1779,6 +1779,42 @@ async fn check_folder_access(
}
}
/// Returns true if the user can access this folder directly, or can access
/// any descendant of it. Used to decide folder visibility: a folder the user
/// cannot enter is still shown if it is on the path to something they can
/// reach, so deeper grants are never orphaned out of the tree.
///
/// Recursion is boxed (async). The walk short-circuits on the first
/// accessible folder and is bounded by the actual subtree, which is small in
/// practice; folders the user CAN access return immediately without
/// descending.
fn folder_or_descendant_accessible<'a>(
vault: &'a VaultClient,
scope: &'a str,
path: &'a str,
user_groups: &'a [String],
) -> std::pin::Pin<Box<dyn std::future::Future<Output = bool> + Send + 'a>> {
Box::pin(async move {
if vault
.resolve_folder_access(scope, path, user_groups)
.await
.unwrap_or(false)
{
return true;
}
// Not directly accessible — keep it only if some descendant is.
if let Ok(subs) = vault.list_subfolders(scope, path).await {
for sub in subs {
let child = sub.path.unwrap_or(sub.name);
if folder_or_descendant_accessible(vault, scope, &child, user_groups).await {
return true;
}
}
}
false
})
}
/// GET /api/addressbook/folders — List folders visible to the current user.
pub async fn ab_list_folders(
identity: Option<Extension<AuthIdentity>>,
@@ -1856,7 +1892,26 @@ pub async fn ab_list_subfolders(
}
match vault.list_subfolders(&scope, &folder).await {
Ok(subfolders) => Json(json!(subfolders)).into_response(),
Ok(subfolders) => {
// Hide subfolders the user cannot reach. A folder is shown if the
// user can access it directly or can access any descendant of it
// (so deeper grants aren't orphaned out of the tree). Admins see
// everything. Fixes #147: previously every subfolder of an
// accessible parent was returned, so users saw folders they could
// only click into and get "no access".
if id.has_role("admin") {
return Json(json!(subfolders)).into_response();
}
let user_groups = id.groups();
let mut visible = Vec::with_capacity(subfolders.len());
for sf in subfolders {
let path = sf.path.clone().unwrap_or_else(|| sf.name.clone());
if folder_or_descendant_accessible(&vault, &scope, &path, user_groups).await {
visible.push(sf);
}
}
Json(json!(visible)).into_response()
}
Err(crate::vault::VaultError::NotFound) => Json(json!([])).into_response(),
Err(e) => (
StatusCode::BAD_GATEWAY,