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

nordisk/gunnar

crates/gunnar-grpc/tests/entitlement_scope.rs

//! **`Entitlement.Get` is namespace-scoped: a tenant may read their own paid
//! state, and nobody else's.**
//!
//! # The ruling, and what it changed
//!
//! `Get` authorised `Access::Server` at `GrantLevel::Read` — the provider's
//! own question about a tenant, answerable only by the provider. So a
//! customer's own key was refused their own renewal date, and the console had
//! nothing to render a billing page from: the one screen whose entire content
//! is "what you are on, and until when" could not be built without handing the
//! browser session a server-wide read over every customer on the box.
//!
//! The owner ruled: scope it to the namespace the request names.
//!
//! # This is backwards-compatible, and that is a property of the model rather
//! than a hope
//!
//! `gunnar_core::Scope::covers_namespace` answers `true` for `Scope::Server`
//! against every name. A server-scoped `Read` grant therefore satisfies
//! `Access::namespace(..)` exactly as it satisfied `Access::Server`, so every
//! caller that could read an entitlement yesterday reads it today. The change
//! adds exactly ONE capability and takes none away: a tenant may read their
//! own. [`a_server_scoped_grant_still_reads_any_namespace`] is the guard on
//! that sentence, so "backwards-compatible" is executed rather than asserted in
//! a commit message.
//!
//! # What was NOT widened
//!
//! `Entitlement.Set` is untouched — server `Admin`, as it was. `Set` is
//! monetize (or an operator with the server's admin key) telling this box what
//! a tenant has paid for, and a tenant who could write their own fact would be
//! a tenant who could sell themselves a plan.
//! [`entitlement_set_is_unchanged_and_a_namespace_admin_is_not_a_server_admin`]
//! holds that line, in this file, next to the widening — because the risk in a
//! change like this one is that the next person reads "Get was scoped to the
//! namespace" and makes the neighbouring verb match.
//!
//! `List` is untouched too: it enumerates every tenant on
//! the box, which is not a question a tenant has a scoped version of.
//!
//! # Every test here drives the real gate
//!
//! The caller is produced by `AuthGate::authenticate` over a real signature
//! from a really-enrolled key, and the decision is `MemoryControlPlane::may`,
//! whose shape is `AuthTable::may`'s — `owns_account` included, which is what
//! makes "a principal's id IS the namespace it owns" the thing being tested
//! rather than a string comparison written here.

use std::sync::Arc;

use gunnar_grpc::{
    sign_credential_for, AuthGate, AuthMode, Caller, ControlPlane, EntitlementService, EventBus,
    MemoryControlPlane, ServerConfig, ServiceContext, CREDENTIAL_METADATA_KEY,
};
use gunnar_proto::entitlement_server::Entitlement as _;
use gunnar_proto::{
    EntitlementFact, EntitlementState, GetEntitlementRequest, GrantLevel, PrincipalKind,
    SetEntitlementRequest,
};
use tonic::{Code, Request};

const ALICE: &str = "alice";
const BETA: &str = "beta";

fn now_ms() -> i64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .expect("after 1970")
        .as_millis() as i64
}

fn mint_key() -> ssh_key::PrivateKey {
    ssh_key::PrivateKey::random(
        &mut rand::rand_core::UnwrapErr(rand::rngs::SysRng),
        ssh_key::Algorithm::Ed25519,
    )
    .expect("mint an ed25519 key")
}

struct Harness {
    plane: Arc<MemoryControlPlane>,
    ctx: ServiceContext,
}

impl Harness {
    /// Two namespaces, each with a stored fact, and no grants. A fact per
    /// namespace so that "alice read beta's" would be a visible wrong ANSWER
    /// and not merely an absent one.
    fn new() -> Harness {
        let plane = Arc::new(MemoryControlPlane::new());
        for (name, plan) in [(ALICE, "small"), (BETA, "large")] {
            plane
                .create_namespace(name, "a tenant")
                .expect("namespace created");
            plane.set_entitlement_fact(EntitlementFact {
                namespace: name.to_owned(),
                plan: plan.to_owned(),
                state: EntitlementState::Paid as i32,
                paid_until_unix_ms: Some((now_ms() + 30 * 24 * 60 * 60 * 1000) as u64),
                caps: [("pack_bytes".to_owned(), 7_000_u64)].into_iter().collect(),
                source: "operator".to_owned(),
                signature: Vec::new(),
                // Unstamped: this file is about WHO may read a fact, not about
                // replay. The double's seam verifies nothing, so a fact with no
                // issue time is a legitimate thing to seed it with — and it is
                // what a fact from before the stamp existed looks like.
                issued_unix_ms: None,
                issued_signature: Vec::new(),
            });
        }
        let control: Arc<dyn ControlPlane> = plane.clone();
        let ctx = ServiceContext::new(
            control,
            Arc::new(EventBus::new(16)),
            ServerConfig::default(),
        );
        assert_eq!(
            ctx.config.auth,
            AuthMode::Required,
            "the fixture must run the SHIPPED default; a suite that quietly ran the \
             allow-all escape hatch would prove nothing about a grant check"
        );
        Harness { plane, ctx }
    }

    /// Enrol a principal whose id IS `name` — the shape `gunnar-ui`'s
    /// `TenantKeys` produces: one ed25519 key per console user, on that
    /// person's own gunnar principal.
    fn enrol(&self, name: &str) -> ssh_key::PrivateKey {
        let key = mint_key();
        let principal = self
            .plane
            .add_principal(name, name, PrincipalKind::User)
            .expect("principal added");
        assert_eq!(
            principal.id, name,
            "the whole scope check rests on a principal's id being the namespace it owns"
        );
        let openssh = key.public_key().to_openssh().expect("render public key");
        self.plane
            .add_key(&principal.id, &openssh, name, None, "", None)
            .expect("key registered");
        key
    }

    fn caller_for(&self, key: &ssh_key::PrivateKey, nonce: &str) -> Caller {
        let credential = sign_credential_for(key, now_ms(), nonce, "Admin.Health").expect("sign");
        let mut request = Request::new(());
        request.metadata_mut().insert(
            CREDENTIAL_METADATA_KEY,
            credential.to_header_value().parse().expect("ascii"),
        );
        AuthGate::new(Arc::clone(&self.ctx.control), self.ctx.config.auth)
            .authenticate_bound(request.metadata(), now_ms(), Some("Admin.Health"))
            .expect("a freshly signed credential from an enrolled key must verify")
    }

    fn authed<T>(&self, caller: &Caller, message: T) -> Request<T> {
        let mut request = Request::new(message);
        request.extensions_mut().insert(caller.clone());
        request
    }

    fn entitlement(&self) -> EntitlementService {
        EntitlementService::new(self.ctx.clone())
    }
}

fn get(namespace: &str) -> GetEntitlementRequest {
    GetEntitlementRequest {
        namespace: namespace.to_owned(),
    }
}

// ───────────────────────────────────────────────────────────────────────────
// The one capability this adds
// ───────────────────────────────────────────────────────────────────────────

/// **A tenant reads their own entitlement with their own key.**
///
/// The whole point of the ruling: before it, this was `PERMISSION_DENIED` and
/// the console could not show a customer their own renewal date without a
/// server-wide grant standing in for them.
///
/// `Read` and not `Admin`, deliberately: an organisation member who may read
/// the namespace may see what the namespace is paid up to. This is the state
/// they already feel every time a push is refused for want of room.
#[tokio::test]
async fn a_tenant_reads_their_own_entitlement() {
    let harness = Harness::new();
    let alice = harness.enrol(ALICE);
    let caller = harness.caller_for(&alice, "nonce-alice-reads-alice");

    let answer = harness
        .entitlement()
        .get(harness.authed(&caller, get(ALICE)))
        .await
        .expect("a tenant reads their own entitlement")
        .into_inner();

    let fact = answer.fact.expect("alice has a fact");
    assert_eq!(fact.namespace, ALICE);
    assert_eq!(
        fact.plan, "small",
        "alice was handed somebody else's plan; the fixture gives the two namespaces \
         different ones so a mixed-up read is visible"
    );
    assert_eq!(fact.state, EntitlementState::Paid as i32);
}

/// ★ **Another tenant's key is refused.**
///
/// The test the owner asked for by name, and the one that says the widening is
/// a scoping and not an opening. alice is a real, enrolled, fully
/// authenticated caller with a fact of her own; beta's paid state is beta's
/// business. Nothing about her credential is wrong — what refuses her is
/// `Access::namespace("beta")` reaching `Identity::may`, the same call
/// `Repos.Delete` makes about the same namespace.
///
/// A plan name, a state and a renewal date are commercial facts about another
/// customer of this appliance. On a hosted box, "which of your tenants is in
/// Grace" is precisely the question a competitor would like answered.
#[tokio::test]
async fn another_tenants_key_is_refused() {
    let harness = Harness::new();
    let alice = harness.enrol(ALICE);
    let caller = harness.caller_for(&alice, "nonce-alice-reads-beta1");

    let refused = harness
        .entitlement()
        .get(harness.authed(&caller, get(BETA)))
        .await
        .expect_err("alice must not read beta's entitlement");
    assert_eq!(refused.code(), Code::PermissionDenied);

    // The same caller, her own namespace: the refusal above is about the
    // namespace and not about alice, her key, or the verb being shut.
    let caller = harness.caller_for(&alice, "nonce-alice-reads-own-02");
    harness
        .entitlement()
        .get(harness.authed(&caller, get(ALICE)))
        .await
        .expect("alice holds alice");
}

/// **The backwards-compatibility guard.**
///
/// `Scope::Server` covers every namespace (`Scope::covers_namespace` returns
/// `true` for it), so a server-scoped `Read` satisfies `Access::namespace(..)`
/// exactly as it satisfied `Access::Server`. Every caller that could read an
/// entitlement before the ruling reads one after it — the operator's own key,
/// the console's BFF grant, monetize's machine principal.
///
/// Without this test, "backwards-compatible" is a sentence in a commit message.
/// With it, narrowing the scope in a way that dropped the server path is red
/// here rather than on an operator's dashboard.
#[tokio::test]
async fn a_server_scoped_grant_still_reads_any_namespace() {
    let harness = Harness::new();
    let operator = harness.enrol("operator");
    harness.plane.grant_server("operator", GrantLevel::Read);

    for namespace in [ALICE, BETA] {
        let caller = harness.caller_for(&operator, &format!("nonce-op-reads-{namespace:0<8}"));
        let answer = harness
            .entitlement()
            .get(harness.authed(&caller, get(namespace)))
            .await
            .unwrap_or_else(|err| {
                panic!(
                    "a server-scoped Read no longer reads {namespace}: {err}. Everything that \
                     worked before the namespace scoping must keep working"
                )
            })
            .into_inner();
        assert_eq!(answer.fact.expect("a fact").namespace, namespace);
    }
}

/// **An explicit `Read` grant on a namespace the caller does not own reads it
/// too.**
///
/// `owns_account` is one of `may`'s two answers and a grant row is the other.
/// Pinning both means "scoped to the namespace" cannot quietly narrow to
/// "compares the principal id with the namespace" — an organisation's
/// bookkeeper is a real case, and a billing page that only ever worked for a
/// principal whose id happened to equal the namespace would fail it.
#[tokio::test]
async fn an_explicit_namespace_read_grant_is_enough() {
    let harness = Harness::new();
    let bookkeeper = harness.enrol("bookkeeper");
    harness
        .plane
        .grant("bookkeeper", BETA, "", GrantLevel::Read)
        .expect("granted");

    let caller = harness.caller_for(&bookkeeper, "nonce-bookkeeper-beta1");
    let answer = harness
        .entitlement()
        .get(harness.authed(&caller, get(BETA)))
        .await
        .expect("a Read grant on beta is what this RPC asks for")
        .into_inner();
    assert_eq!(answer.fact.expect("a fact").plan, "large");
}

// ───────────────────────────────────────────────────────────────────────────
// The ordering trap, and the verb that did NOT move
// ───────────────────────────────────────────────────────────────────────────

/// **An empty namespace is `INVALID_ARGUMENT`, and it is answered BEFORE the
/// grant check.**
///
/// The scope now comes out of the message, so the message has to be read before
/// the authorisation — and that is the trap. A handler that authorised
/// `Access::namespace("")` first would turn the empty name into an
/// authorisation oracle: `PERMISSION_DENIED` for a caller with no server grant,
/// `INVALID_ARGUMENT` for one who has it, which is a working credential test
/// for any stranger who can reach the port. `Billing.MintActorTicket` puts its
/// empty-namespace check in the same place for the same reason.
///
/// alice holds no server grant, so if the order were wrong this would be
/// `PERMISSION_DENIED`.
#[tokio::test]
async fn an_empty_namespace_is_refused_before_the_grant_check() {
    let harness = Harness::new();
    let alice = harness.enrol(ALICE);
    let caller = harness.caller_for(&alice, "nonce-alice-empty-ns-01");

    let refused = harness
        .entitlement()
        .get(harness.authed(&caller, get("")))
        .await
        .expect_err("an unnamed tenant is not a tenant");
    assert_eq!(
        refused.code(),
        Code::InvalidArgument,
        "an empty namespace answered the authorisation question instead of the argument one, \
         which makes it an oracle: {refused:?}"
    );
}

/// ★ **`Entitlement.Set` did not move.** Server `Admin`, as it always was.
///
/// alice is intrinsically admin of `alice` — that is what lets her read her own
/// fact above — and it is emphatically not a licence to WRITE one. A tenant who
/// could set their own entitlement could sell themselves a plan.
///
/// This test sits next to the widening on purpose. The failure mode of a change
/// like this one is not the change; it is the next person reading "Get was
/// scoped to the namespace" and making the neighbouring verb match.
#[tokio::test]
async fn entitlement_set_is_unchanged_and_a_namespace_admin_is_not_a_server_admin() {
    let harness = Harness::new();
    let alice = harness.enrol(ALICE);
    let caller = harness.caller_for(&alice, "nonce-alice-writes-fact");

    let refused = harness
        .entitlement()
        .set(harness.authed(
            &caller,
            SetEntitlementRequest {
                fact: Some(EntitlementFact {
                    namespace: ALICE.to_owned(),
                    plan: "enormous".to_owned(),
                    state: EntitlementState::Paid as i32,
                    paid_until_unix_ms: Some((now_ms() + 100 * 365 * 24 * 60 * 60 * 1000) as u64),
                    caps: Default::default(),
                    source: "operator".to_owned(),
                    signature: vec![0u8; 64],
                    issued_unix_ms: None,
                    issued_signature: Vec::new(),
                }),
            },
        ))
        .await
        .expect_err("a tenant sold themselves a century of the enormous plan");
    assert_eq!(refused.code(), Code::PermissionDenied);

    // Her fact is untouched: the refusal happened before anything was stored.
    let held = harness
        .plane
        .entitlement_get(ALICE)
        .expect("the box reads its own facts")
        .expect("alice has one");
    assert_eq!(held.plan, "small");
}

/// ★ **`Entitlement.Set` is for an account that exists** (round 5.3; the
/// members walkthrough of 2026-10-07: a paid fact for tenant `ben` — a member
/// of acme, with no namespace of his own — was taken, and the room landed
/// nowhere). The provider itself, server `Admin`, is refused NOT_FOUND for a
/// namespace this box does not hold, by name, and nothing is stored; the same
/// caller's fact for a namespace that exists goes past that door to the
/// control plane (which, in this double, implements no `Set`).
#[tokio::test]
async fn entitlement_set_refuses_a_namespace_that_does_not_exist() {
    let harness = Harness::new();
    let monetize = harness.enrol("monetize");
    harness.plane.grant_server("monetize", GrantLevel::Admin);
    let fact = |namespace: &str| SetEntitlementRequest {
        fact: Some(EntitlementFact {
            namespace: namespace.to_owned(),
            plan: "10gb".to_owned(),
            state: EntitlementState::Paid as i32,
            paid_until_unix_ms: Some((now_ms() + 30 * 24 * 60 * 60 * 1000) as u64),
            caps: [("pack_bytes".to_owned(), 10_u64 << 30)]
                .into_iter()
                .collect(),
            source: "payment:stripe:cs_test_ben".to_owned(),
            signature: vec![0u8; 64],
            issued_unix_ms: None,
            issued_signature: Vec::new(),
        }),
    };

    let caller = harness.caller_for(&monetize, "nonce-monetize-sets-ben");
    let refused = harness
        .entitlement()
        .set(harness.authed(&caller, fact("ben")))
        .await
        .expect_err("a plan was set for a namespace nobody made");
    assert_eq!(refused.code(), Code::NotFound, "{refused:?}");
    assert!(
        refused
            .message()
            .contains("there is no namespace ben on this Gunnar"),
        "the refusal does not name the namespace: {}",
        refused.message()
    );
    assert!(
        harness
            .plane
            .entitlement_get("ben")
            .expect("the box reads its own facts")
            .is_none(),
        "a fact was stored for a namespace that does not exist"
    );

    // A namespace that exists: past this door, to the control plane.
    let caller = harness.caller_for(&monetize, "nonce-monetize-sets-alice");
    let reached = harness
        .entitlement()
        .set(harness.authed(&caller, fact(ALICE)))
        .await
        .expect_err("the double implements no Entitlement.Set");
    assert_eq!(
        reached.code(),
        Code::Unimplemented,
        "a fact for an existing namespace was stopped before the control plane: {reached:?}"
    );
}

/// ★ **On a box whose log follows (a main being restored, a twin) "no such
/// namespace" is not the answer** (wiring tracer B 2026-10-08). A namespace
/// made after the twin's snapshot is not replayed yet, so NOT_FOUND there —
/// which monetize's gunnar plugin files as FINAL — would drop a paid cap the
/// dead-main watch is re-pushing. The box answers its "try again" instead:
/// UNAVAILABLE, in the words monetize already reads as restoring.
#[tokio::test]
async fn entitlement_set_on_a_restoring_box_says_try_again_not_no_such_namespace() {
    let harness = Harness::new();
    let monetize = harness.enrol("monetize");
    harness.plane.grant_server("monetize", GrantLevel::Admin);
    harness.plane.set_log_follows(true);
    let request = SetEntitlementRequest {
        fact: Some(EntitlementFact {
            namespace: "made-after-the-snapshot".to_owned(),
            plan: "10gb".to_owned(),
            state: EntitlementState::Paid as i32,
            paid_until_unix_ms: Some((now_ms() + 30 * 24 * 60 * 60 * 1000) as u64),
            caps: [("pack_bytes".to_owned(), 10_u64 << 30)]
                .into_iter()
                .collect(),
            source: "payment:stripe:cs_test_restoring".to_owned(),
            signature: vec![0u8; 64],
            issued_unix_ms: None,
            issued_signature: Vec::new(),
        }),
    };
    let caller = harness.caller_for(&monetize, "nonce-monetize-sets-while-restoring");
    let answer = harness
        .entitlement()
        .set(harness.authed(&caller, request))
        .await
        .expect_err("a restoring box records no fact");
    assert_eq!(
        answer.code(),
        Code::Unavailable,
        "a restoring box says try again: {answer:?}"
    );
    assert!(
        answer.message().contains("(or restoring)")
            && answer.message().contains("originates no transactions"),
        "in the words monetize reads as restoring (grow/repush.rs reads_as_restoring): {}",
        answer.message()
    );
}

/// **No verified caller, no answer** — the floor under everything above.
/// `tests/auth.rs` sweeps this for every RPC; it is repeated here because this
/// file is where somebody changing `Get`'s scope will be reading, and a scope
/// change is exactly the edit that could reach for `ctx.control` directly and
/// step around `caller` altogether.
#[tokio::test]
async fn no_credential_reads_nobodys_entitlement() {
    let harness = Harness::new();

    let refused = harness
        .entitlement()
        .get(Request::new(get(ALICE)))
        .await
        .expect_err("no credential, no entitlement");
    assert_eq!(refused.code(), Code::Unauthenticated);
}