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