crates/gunnar-cli/tests/public_listing.rs
#![cfg(all(feature = "server", feature = "control"))]
//! **`Repos.List` narrows to the public repositories instead of refusing** —
//! the last thing standing between anonymous read and a git host a stranger can browse.
//!
//! # The hole this closes
//!
//! Anonymous read taught `AuthTable::may` that a `Visibility::Public` repository is
//! readable with no grant, and `gunnar-cli/tests/anonymous_access.rs` proves a
//! stranger can clone one over stock `git`. But there was no way to *find* one.
//! Every read RPC on the control plane is scoped to the repository
//! (`Access::repo`) and therefore already went through that rule — except the
//! one RPC that has no repository to name:
//!
//! ```text
//! Repos.List -> Access::namespace(ns) | Access::Server
//! require(..., GrantLevel::Read)
//! └─ no grant -> PERMISSION_DENIED
//! ```
//!
//! So a principal holding no grants was *refused outright* rather than being
//! **narrowed to the subset it is allowed to see**. A visitor could read a
//! public repository whose path somebody had told them, and could not discover
//! that it existed. The console's login page has a panel for exactly that list
//! and it has been hidden since it was written, because the call behind it
//! answered `PERMISSION_DENIED`.
//!
//! # What is asserted here, and why the whole set every time
//!
//! Every arm asserts the **entire** listing, sorted, never that a particular
//! row is present. A narrowing that returns the one public repository and also
//! three private ones passes every `contains` assertion that could be written
//! about it — and this is the file where that mistake would be a disclosure of
//! other tenants' repository names.
//!
//! # Red first
//!
//! Against `ServiceContext::require`, which is what `Repos.List` called before
//! this change, `a_grantless_principal_lists_only_the_public_repositories`
//! fails with:
//!
//! ```text
//! called `Result::unwrap()` on an `Err` value: Status { code: PermissionDenied,
//! message: "principal guest has no read grant on server" }
//! ```
//!
//! …which is the bug in one line: not "here are none of them", but "you may not
//! ask".
//!
//! # Why a real control plane and not `MemoryControlPlane`
//!
//! The narrowing asks `AuthTable::may` once per candidate row, and the whole
//! point of routing it through that function rather than reading
//! `Repo.visibility` here is that `may` also enforces the **account gate** and
//! account ownership. A double that answers `may` out of a `HashMap` of grants
//! would be green on a narrowing that had quietly re-implemented the visibility
//! rule and dropped the gate — which is precisely the second-ACL failure the
//! twinned sandboxes record. `CoreControlPlane` over a real `AuthTable` is the only fixture that
//! cannot be fooled that way, and it is what
//! `a_gated_account_is_absent_even_when_its_repository_is_public` needs.
use std::sync::Arc;
use gunnar_cli::server::{CoreControlPlane, ServerStores};
use gunnar_grpc::{Caller, ControlPlane, EventBus, ReposService, ServerConfig, ServiceContext};
use gunnar_proto::repos_server::Repos as _;
use gunnar_proto::{GrantLevel, HashKind, ListReposRequest, RepoPath, Visibility};
use tonic::{Code, Request};
/// A server holding four repositories across two accounts, two of them public.
///
/// Two namespaces on purpose: a narrowing that filtered correctly *within* one
/// namespace but leaked across them would be green on a one-namespace fixture,
/// and `Repos.List` with no `namespace` set is the call the login page makes.
struct Rig {
_dir: tempfile::TempDir,
stores: Arc<ServerStores>,
service: ReposService,
}
fn rig() -> Rig {
let dir = tempfile::tempdir().expect("temporary directory");
let stores = Arc::new(ServerStores::open(dir.path()).expect("open the stores"));
let plane = CoreControlPlane::new(Arc::clone(&stores));
for namespace in ["nordisk", "vetra"] {
plane
.create_namespace(namespace, "")
.expect("create the namespace");
}
for (namespace, name, visibility) in [
("nordisk", "gunnar", Visibility::Public),
("nordisk", "payroll", Visibility::Private),
("vetra", "njord", Visibility::Public),
("vetra", "secrets", Visibility::Private),
] {
plane
.create_repo(gunnar_grpc::NewRepo {
path: RepoPath::new(namespace, name),
hash_kind: HashKind::Sha256,
default_branch: String::new(),
visibility,
description: String::new(),
adopt: false,
})
.expect("create the repository");
}
let control: Arc<dyn ControlPlane> = Arc::new(plane);
let ctx = ServiceContext::new(
control,
Arc::new(EventBus::new(64)),
ServerConfig::default(),
);
Rig {
_dir: dir,
stores,
service: ReposService::new(ctx),
}
}
impl Rig {
/// Register a principal that holds **no grants at all**. This is the shape
/// of the console's guest identity: enrolled, so it is a caller the control
/// plane will authenticate, and granted nothing, so every ACL question
/// about it is answered by the visibility rule or not at all.
fn grantless(&self, id: &str) -> Caller {
let principal = gunnar_core::Principal::new(id, id, gunnar_core::PrincipalKind::Machine)
.expect("a legal principal id");
self.stores
.auth()
.add_principal(principal)
.expect("register the principal");
Caller {
principal_id: id.to_owned(),
fingerprint: format!("SHA256:{id}"),
machine: true,
authenticated: true,
}
}
/// The same principal, plus a server-wide `Read` grant: an operator.
fn reader(&self, id: &str) -> Caller {
let caller = self.grantless(id);
self.stores
.auth()
.grant(gunnar_core::Grant {
principal: id.to_owned(),
scope: gunnar_core::Scope::Server,
level: gunnar_core::Level::Read,
})
.expect("grant server read");
caller
}
fn gate(&self, account: &str) {
self.stores
.auth()
.gate(gunnar_core::AccountGate::administrative(
account,
"operator",
"suspended for the duration of this test",
))
.expect("gate the account");
}
/// `Repos.ListNamespaces`, as names sorted. The whole answer, always.
fn namespaces(&self, caller: &Caller) -> Result<Vec<String>, tonic::Status> {
let response = runtime().block_on(self.service.list_namespaces(authed(
caller,
gunnar_proto::ListNamespacesRequest::default(),
)))?;
let mut names: Vec<String> = response
.into_inner()
.namespaces
.into_iter()
.map(|namespace| namespace.name)
.collect();
names.sort();
Ok(names)
}
/// `Repos.List`, as `<namespace>/<name>` sorted. The whole answer, always.
fn list(&self, caller: &Caller, namespace: Option<&str>) -> Result<Vec<String>, tonic::Status> {
let mut request = Request::new(ListReposRequest {
namespace: namespace.unwrap_or_default().to_owned(),
..ListReposRequest::default()
});
request.extensions_mut().insert(caller.clone());
let response = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("a runtime")
.block_on(self.service.list(request))?;
let mut paths: Vec<String> = response
.into_inner()
.repos
.into_iter()
.map(|repo| {
let path = repo.path.unwrap_or_default();
format!("{}/{}", path.namespace, path.repo)
})
.collect();
paths.sort();
Ok(paths)
}
}
/// **The listing a stranger gets.** Two public repositories out of four, and
/// the two private ones are not merely unreadable — they are not named.
///
/// A repository *name* is disclosure on its own: `vetra/secrets` tells a reader
/// that vetra has something called secrets, which is why this asserts the exact
/// set rather than that the private paths are absent from a set it did not
/// otherwise constrain.
#[test]
fn a_grantless_principal_lists_only_the_public_repositories() {
let rig = rig();
let guest = rig.grantless("guest");
assert_eq!(
rig.list(&guest, None).expect("the listing is served"),
vec!["nordisk/gunnar".to_owned(), "vetra/njord".to_owned()],
"a caller with no grant must be NARROWED to the public subset, not refused"
);
}
/// The same narrowing, asked about one namespace.
///
/// The login page asks the server-wide question, but the console asks this one,
/// and a narrowing wired into only the `Access::Server` arm would leave the
/// per-namespace call refusing exactly as before — green above, broken in the
/// console.
#[test]
fn the_narrowing_applies_to_a_single_namespace_too() {
let rig = rig();
let guest = rig.grantless("guest");
assert_eq!(
rig.list(&guest, Some("nordisk"))
.expect("the listing is served"),
vec!["nordisk/gunnar".to_owned()]
);
assert_eq!(
rig.list(&guest, Some("vetra"))
.expect("the listing is served"),
vec!["vetra/njord".to_owned()]
);
}
/// **Nothing changes for a caller that HAS a grant.** A server-wide
/// `Read` still lists everything, private repositories included.
///
/// This is the half a narrowing can break by accident: filtering every listing
/// through the visibility rule would silently take private repositories away
/// from the operators whose whole job is to see them.
#[test]
fn a_server_read_grant_still_lists_everything() {
let rig = rig();
let operator = rig.reader("operator");
assert_eq!(
rig.list(&operator, None).expect("the listing is served"),
vec![
"nordisk/gunnar".to_owned(),
"nordisk/payroll".to_owned(),
"vetra/njord".to_owned(),
"vetra/secrets".to_owned(),
]
);
}
/// **A principal is admin of its own account, and the narrowing must not take
/// that away.** `vetra` the principal owns `vetra` the namespace intrinsically
/// (`AuthTable::owns_account`), with no grant anywhere, so it sees both of its
/// own repositories and only the public one of the other account.
///
/// This is the arm that proves the narrowing asks `AuthTable::may` rather than
/// reading `Repo.visibility` for itself: a filter that tested visibility
/// directly would hide `vetra/secrets` from vetra, who owns it.
#[test]
fn account_ownership_survives_the_narrowing() {
let rig = rig();
let owner = rig.grantless("vetra");
assert_eq!(
rig.list(&owner, None).expect("the listing is served"),
vec![
"nordisk/gunnar".to_owned(),
"vetra/njord".to_owned(),
"vetra/secrets".to_owned(),
]
);
}
/// **The account gate outranks the visibility rule, in the listing as well as
/// in the read.**
///
/// `AuthTable::may` checks the gate above everything, anonymous rule included,
/// because a stranger cloning a public repository out of an account that is
/// being emptied puts bytes back behind a purge. A narrowing that re-derived
/// "public means visible" instead of asking `may` would put `vetra/njord` back
/// on this list while the purge worker was walking it.
#[test]
fn a_gated_account_is_absent_even_when_its_repository_is_public() {
let rig = rig();
let guest = rig.grantless("guest");
rig.gate("vetra");
assert_eq!(
rig.list(&guest, None).expect("the listing is served"),
vec!["nordisk/gunnar".to_owned()],
"a gated account's public repository must not be listed"
);
}
/// The other direction of the gate: a **gated caller** lists nothing at all,
/// not even repositories that are public to everyone else.
#[test]
fn a_gated_caller_lists_nothing() {
let rig = rig();
let guest = rig.grantless("guest");
rig.gate("guest");
assert!(
rig.list(&guest, None)
.expect("the listing is served")
.is_empty(),
"a locked-out principal must see nothing, public or not"
);
}
/// A request carrying `caller`'s verified identity, as [`crate::Rig`]'s helpers
/// build one. A free function rather than a closure because a closure is
/// monomorphic in its argument and these tests drive several message types.
fn authed<T>(caller: &Caller, message: T) -> Request<T> {
let mut request = Request::new(message);
request.extensions_mut().insert(caller.clone());
request
}
/// A current-thread runtime, for driving one handler to completion.
fn runtime() -> tokio::runtime::Runtime {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("a runtime")
}
/// **The narrowing is `Repos.List` and `Repos.ListNamespaces`, and nothing
/// else.** The other two grantless-refusable listings on this service stay
/// refused, because neither has a public subset to narrow to: a deleted
/// repository's visibility is whatever it was when it was deleted, and an
/// orphan has no catalog row and therefore no visibility at all. (A namespace
/// does have one now — the account admin's `public` setting, owner 2026-10-03 —
/// see [`a_grantless_principal_lists_only_the_namespaces_their_admins_opened`].)
#[test]
fn the_neighbouring_listings_are_still_refused() {
let rig = rig();
let guest = rig.grantless("guest");
let runtime = runtime();
let deleted = runtime.block_on(
rig.service
.list_deleted(authed(&guest, gunnar_proto::ListDeletedRequest::default())),
);
assert_eq!(
deleted.expect_err("ListDeleted is refused").code(),
Code::PermissionDenied
);
let orphans = runtime.block_on(
rig.service
.list_orphans(authed(&guest, gunnar_proto::ListOrphansRequest::default())),
);
assert_eq!(
orphans.expect_err("ListOrphans is refused").code(),
Code::PermissionDenied
);
}
/// **`Repos.ListNamespaces` narrows to the namespaces their account admins
/// opened** (owner, 2026-10-03: every account starts NOT public; making it
/// public is an explicit setting only the account admin changes).
///
/// The whole answer every time, as above. `nordisk` holds a public repository
/// and is still not listed until its admin opens the namespace itself: that
/// repository is reachable by its path, and the namespace's page shows it (and
/// only it) to whoever asks by name — a listing of every account that happens
/// to hold one public repository would be the account directory the default
/// promises not to publish. `events_public` alone lists nothing either: it
/// opens the events, not the namespace.
#[test]
fn a_grantless_principal_lists_only_the_namespaces_their_admins_opened() {
let rig = rig();
let guest = rig.grantless("guest");
assert_eq!(
rig.namespaces(&guest).expect("the listing is served"),
Vec::<String>::new(),
"no namespace is public by default, a public repository inside one included"
);
let catalog = rig.stores.catalog();
catalog
.set_namespace_events_public("vetra", true)
.expect("open vetra's events");
assert_eq!(
rig.namespaces(&guest).expect("the listing is served"),
Vec::<String>::new(),
"public events do not make the namespace public"
);
catalog
.set_namespace_public("nordisk", true)
.expect("open nordisk");
assert_eq!(
rig.namespaces(&guest).expect("the listing is served"),
vec!["nordisk".to_owned()]
);
// Inside an account (its owner here) is inside, public or not; a server
// reader sees every account.
let owner = rig.grantless("vetra");
assert_eq!(
rig.namespaces(&owner).expect("the listing is served"),
vec!["nordisk".to_owned(), "vetra".to_owned()]
);
let operator = rig.reader("operator");
assert_eq!(
rig.namespaces(&operator).expect("the listing is served"),
vec!["nordisk".to_owned(), "vetra".to_owned()]
);
}
/// **The gate outranks the admin's setting too.** A public namespace whose
/// account is gated is absent from an outsider's listing, exactly as its public
/// repository is absent from `Repos.List`
/// ([`a_gated_account_is_absent_even_when_its_repository_is_public`]) — and a
/// gated caller is shown no public namespace at all.
#[test]
fn a_gated_account_is_absent_even_when_its_namespace_is_public() {
let rig = rig();
let guest = rig.grantless("guest");
let catalog = rig.stores.catalog();
for namespace in ["nordisk", "vetra"] {
catalog
.set_namespace_public(namespace, true)
.expect("open the namespace");
}
assert_eq!(
rig.namespaces(&guest).expect("the listing is served"),
vec!["nordisk".to_owned(), "vetra".to_owned()]
);
rig.gate("vetra");
assert_eq!(
rig.namespaces(&guest).expect("the listing is served"),
vec!["nordisk".to_owned()],
"a gated account's public namespace must not be listed"
);
rig.gate("guest");
assert_eq!(
rig.namespaces(&guest).expect("the listing is served"),
Vec::<String>::new(),
"a locked-out principal must be shown no public namespace"
);
}
/// **A narrowed listing is a read and stays a read.** The grantless principal
/// that can now see `nordisk/gunnar` still cannot create, delete or publish
/// anything, and the refusal is a grant refusal rather than a narrowing to an
/// empty set — a write that "succeeded on nothing" is the worse failure.
#[test]
fn the_narrowed_caller_still_writes_nothing() {
let rig = rig();
let guest = rig.grantless("guest");
let runtime = runtime();
let created = runtime.block_on(rig.service.create(authed(
&guest,
gunnar_proto::CreateRepoRequest {
path: Some(RepoPath::new("nordisk", "intruder")),
hash_kind: HashKind::Sha256 as i32,
..gunnar_proto::CreateRepoRequest::default()
},
)));
assert_eq!(
created.expect_err("Create is refused").code(),
Code::PermissionDenied
);
let published = runtime.block_on(rig.service.set_visibility(authed(
&guest,
gunnar_proto::SetVisibilityRequest {
path: Some(RepoPath::new("nordisk", "payroll")),
visibility: Visibility::Public as i32,
},
)));
assert_eq!(
published.expect_err("SetVisibility is refused").code(),
Code::PermissionDenied
);
let deleted = runtime.block_on(rig.service.delete(authed(
&guest,
gunnar_proto::DeleteRepoRequest {
path: Some(RepoPath::new("nordisk", "gunnar")),
..gunnar_proto::DeleteRepoRequest::default()
},
)));
assert_eq!(
deleted.expect_err("Delete is refused").code(),
Code::PermissionDenied
);
// …and the catalogue is untouched: a refusal that had already written
// would be green above and wrong on the disk.
let operator = rig.reader("operator");
assert_eq!(
rig.list(&operator, None).expect("the listing is served"),
vec![
"nordisk/gunnar".to_owned(),
"nordisk/payroll".to_owned(),
"vetra/njord".to_owned(),
"vetra/secrets".to_owned(),
]
);
let _ = GrantLevel::Read;
}