Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

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;
}