crates/gunnar-grpc/tests/who_am_i.rs
//! **Who am I, and may I?** — `Access.WhoAmI` and `Access.Check` (owner,
//! 2026-10-04: the console is a thin layer; it calls gunnar AS THE PERSON and
//! draws only the controls gunnar would honour).
//!
//! Until these two existed the console kept its own copy of gunnar's grant
//! rule (`mirror_gate::Standing`, the account-admin rule in
//! `api::visibility`) to decide which buttons to draw, and its own role ladder
//! for whether a session "may write". Both RPCs answer for the CALLER only —
//! there is no principal to name, so neither is a reconnaissance tool — and
//! neither needs any grant beyond being authenticated.
//!
//! `Check` is the same decision the verbs make: the rows below ask it and then
//! ask the verb, and the two must agree.
use std::sync::{Arc, Mutex};
use gunnar_grpc::auth::AuthGate;
use gunnar_grpc::{
sign_credential_for, AccessService, AuthMode, Caller, ControlPlane, EventBus,
MemoryControlPlane, ReposService, ServerConfig, ServiceContext, AUDIT_TARGET,
CREDENTIAL_METADATA_KEY,
};
use gunnar_proto::access_server::Access as _;
use gunnar_proto::repos_server::Repos as _;
use gunnar_proto::*;
use tonic::{Code, Request};
use tracing_subscriber::layer::SubscriberExt;
/// The account: a namespace named after the principal that owns it.
const ACCOUNT: &str = "acme";
const REPO: &str = "app";
fn mint() -> ssh_key::PrivateKey {
ssh_key::PrivateKey::random(
&mut rand::rand_core::UnwrapErr(rand::rngs::SysRng),
ssh_key::Algorithm::Ed25519,
)
.expect("mint an ed25519 key")
}
fn now_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("after 1970")
.as_millis() as i64
}
struct Harness {
plane: Arc<MemoryControlPlane>,
ctx: ServiceContext,
}
impl Harness {
fn new() -> Harness {
let plane = Arc::new(MemoryControlPlane::with_repo(ACCOUNT, REPO));
let control: Arc<dyn ControlPlane> = plane.clone();
let ctx = ServiceContext::new(
control,
Arc::new(EventBus::new(64)),
ServerConfig::default(),
);
assert_eq!(ctx.config.auth, AuthMode::Required, "the shipped default");
Harness { plane, ctx }
}
/// A principal with one key, signed in through the real gate.
fn person(&self, name: &str, nonce: &str) -> (Caller, ssh_key::PrivateKey) {
let key = mint();
self.plane
.add_principal(name, name, PrincipalKind::User)
.expect("principal added");
self.plane
.add_key(
name,
&key.public_key().to_openssh().expect("openssh"),
name,
None,
"",
None,
)
.expect("key registered");
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"),
);
let caller = 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 a registered key must verify");
(caller, key)
}
fn authed<T>(&self, caller: &Caller, message: T) -> Request<T> {
let mut request = Request::new(message);
request.extensions_mut().insert(caller.clone());
request
}
fn access(&self) -> AccessService {
AccessService::new(self.ctx.clone())
}
async fn who(&self, caller: &Caller) -> WhoAmIResponse {
self.access()
.who_am_i(self.authed(caller, WhoAmIRequest {}))
.await
.expect("an authenticated caller is told who it is")
.into_inner()
}
async fn check(
&self,
caller: &Caller,
namespace: &str,
repo: &str,
level: GrantLevel,
) -> Result<CheckResponse, tonic::Status> {
self.access()
.check(self.authed(
caller,
CheckRequest {
namespace: namespace.into(),
repo: repo.into(),
level: level as i32,
},
))
.await
.map(|r| r.into_inner())
}
async fn set_visibility(&self, caller: &Caller) -> Result<(), tonic::Status> {
ReposService::new(self.ctx.clone())
.set_visibility(self.authed(
caller,
SetVisibilityRequest {
path: Some(RepoPath::new(ACCOUNT, REPO)),
visibility: Visibility::Public as i32,
},
))
.await
.map(|_| ())
}
}
/// ★ **WhoAmI names the caller, its key, and the highest server-wide level it
/// holds** — nothing for a person with no server grant.
#[tokio::test]
async fn who_am_i_names_the_caller_its_key_and_its_server_level() {
let harness = Harness::new();
let (kim, kim_key) = harness.person("kim", "nonce-who-kim-0001");
let (boss, _) = harness.person("boss", "nonce-who-boss-001");
harness.plane.grant_server("boss", GrantLevel::Admin);
let (ops, _) = harness.person("ops", "nonce-who-ops-0001");
harness.plane.grant_server("ops", GrantLevel::Write);
let me = harness.who(&kim).await;
let principal = me.principal.expect("the caller's principal");
assert_eq!(principal.id, "kim");
assert_eq!(principal.name, "kim");
assert_eq!(
me.fingerprint,
kim_key
.public_key()
.fingerprint(ssh_key::HashAlg::Sha256)
.to_string()
);
assert_eq!(me.server_level, GrantLevel::Unspecified as i32);
assert_eq!(
harness.who(&boss).await.server_level,
GrantLevel::Admin as i32
);
assert_eq!(
harness.who(&ops).await.server_level,
GrantLevel::Write as i32
);
}
/// ★ **WhoAmI names the account the caller acts in and what it is to it**
/// (2026-10-07, the members model: a customer is an ACCOUNT that pays; its
/// members use its space and never buy). A console draws the buying doors
/// from this — for an individual and an account admin, never a member — and
/// a member's storage from the ACCOUNT's room, never a phantom of his own.
#[tokio::test]
async fn who_am_i_names_the_account_and_what_the_caller_is_to_it() {
let harness = Harness::new();
let (acme, _) = harness.person(ACCOUNT, "nonce-who-acme-0001");
harness
.plane
.create_namespace("kim", "kim's own")
.expect("kim's account");
let (kim, _) = harness.person("kim", "nonce-who-kim-00002");
// A principal with no namespace and no employer: nothing to buy for.
let (relay, _) = harness.person("relay", "nonce-who-relay-001");
// Before acme has any member, it is an individual like kim.
let me = harness.who(&acme).await;
assert_eq!(
(me.account.as_str(), me.account_role.as_str()),
(ACCOUNT, "individual"),
"{me:?}"
);
harness
.plane
.enroll_employee(
ACCOUNT,
&gunnar_grpc::EmployeeAsk {
id: "ben".into(),
name: "Ben".into(),
..Default::default()
},
)
.expect("acme enrols ben");
let ben_key = mint();
harness
.plane
.add_key(
"ben",
&ben_key.public_key().to_openssh().expect("openssh"),
"ben",
None,
"",
None,
)
.expect("ben's key");
let credential = sign_credential_for(&ben_key, now_ms(), "nonce-who-ben-00001", "Admin.Health")
.expect("sign");
let mut request = Request::new(());
request.metadata_mut().insert(
CREDENTIAL_METADATA_KEY,
credential.to_header_value().parse().expect("ascii"),
);
let ben = AuthGate::new(Arc::clone(&harness.ctx.control), harness.ctx.config.auth)
.authenticate_bound(request.metadata(), now_ms(), Some("Admin.Health"))
.expect("ben's key verifies");
let admin = harness.who(&acme).await;
assert_eq!(
(
admin.account.as_str(),
admin.account_role.as_str(),
admin.account_admin.as_str()
),
(ACCOUNT, "admin", ACCOUNT),
"an account with a member is its admin's: {admin:?}"
);
let member = harness.who(&ben).await;
assert_eq!(
(member.account.as_str(), member.account_role.as_str(), member.account_admin.as_str()),
(ACCOUNT, "employee", ACCOUNT),
"a member acts in the account that enrolled him, never in a namespace of his own: {member:?}"
);
assert_eq!(member.principal.expect("ben").id, "ben");
// ★ An APPOINTED account admin (owner 2026-10-08): acme gives ben Admin over its namespace.
// He decides for the account's repositories — he may publish — and WhoAmI says so; the
// account, and whom it is, stay acme's. He never buys (members_never_buy).
harness
.plane
.grant("ben", ACCOUNT, "", GrantLevel::Admin)
.expect("acme appoints ben");
let appointed = harness.who(&ben).await;
assert_eq!(
(
appointed.account.as_str(),
appointed.account_role.as_str(),
appointed.account_admin.as_str()
),
(ACCOUNT, "admin-appointed", ACCOUNT),
"an appointed admin acts for the account that enrolled him: {appointed:?}"
);
harness
.set_visibility(&ben)
.await
.expect("an appointed admin publishes for the account");
let individual = harness.who(&kim).await;
assert_eq!(
(
individual.account.as_str(),
individual.account_role.as_str(),
individual.account_admin.as_str()
),
("kim", "individual", "kim"),
"{individual:?}"
);
let none = harness.who(&relay).await;
assert_eq!(
(
none.account.as_str(),
none.account_role.as_str(),
none.account_admin.as_str()
),
("", "", ""),
"no namespace and no employer: no account to speak for: {none:?}"
);
}
/// ★ **Check is the account-admin rule, for the caller, and it agrees with the
/// verb.** The account's owner may set a repository's visibility, and Check
/// says so; a repository Admin who is not the account admin may not, and
/// Check says so in the verb's own kind of sentence — while the same person
/// is allowed what their grant does reach.
#[tokio::test]
async fn check_answers_the_account_admin_rule_and_agrees_with_the_verb() {
let harness = Harness::new();
let (owner, _) = harness.person(ACCOUNT, "nonce-check-owner-1");
let (kim, _) = harness.person("kim", "nonce-check-kim-001");
harness
.plane
.grant("kim", ACCOUNT, REPO, GrantLevel::Admin)
.expect("kim is admin of one repository");
let yes = harness
.check(&owner, ACCOUNT, "", GrantLevel::Admin)
.await
.expect("an answer");
assert!(yes.allowed, "{yes:?}");
assert!(yes.reason.is_empty(), "{yes:?}");
harness
.set_visibility(&owner)
.await
.expect("the verb agrees: the owner sets visibility");
let no = harness
.check(&kim, ACCOUNT, "", GrantLevel::Admin)
.await
.expect("an answer");
assert!(!no.allowed, "{no:?}");
assert!(
no.reason.contains("kim"),
"the refusal names who was refused: {no:?}"
);
let verb = harness
.set_visibility(&kim)
.await
.expect_err("the verb agrees: a repository admin is not the account admin");
assert_eq!(verb.code(), Code::PermissionDenied);
let repo = harness
.check(&kim, ACCOUNT, REPO, GrantLevel::Admin)
.await
.expect("an answer");
assert!(repo.allowed, "kim's grant reaches the repository: {repo:?}");
let server = harness
.check(&kim, "", "", GrantLevel::Read)
.await
.expect("an answer");
assert!(!server.allowed, "{server:?}");
}
/// A question gunnar cannot ask is refused as one, not answered `false`.
#[tokio::test]
async fn check_refuses_a_question_it_cannot_ask() {
let harness = Harness::new();
let (kim, _) = harness.person("kim", "nonce-check-bad-001");
let no_namespace = harness
.check(&kim, "", REPO, GrantLevel::Read)
.await
.expect_err("a repository needs its namespace");
assert_eq!(
no_namespace.code(),
Code::InvalidArgument,
"{no_namespace:?}"
);
let no_level = harness
.check(&kim, ACCOUNT, "", GrantLevel::Unspecified)
.await
.expect_err("a level is required");
assert_eq!(no_level.code(), Code::InvalidArgument, "{no_level:?}");
}
/// **A `false` from Check is not a refused call**: asking "may I?" before
/// drawing a button must not put a `decision="deny"` WARN line in the audit
/// trail an operator greps for real refusals.
#[test]
fn a_check_answered_no_leaves_no_refusal_in_the_audit_trail() {
#[derive(Clone, Default)]
struct Log(Arc<Mutex<Vec<String>>>);
struct Layer(Log);
impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for Layer {
fn on_event(
&self,
event: &tracing::Event<'_>,
_: tracing_subscriber::layer::Context<'_, S>,
) {
if event.metadata().target() != AUDIT_TARGET {
return;
}
struct Visit(String);
impl tracing::field::Visit for Visit {
fn record_debug(
&mut self,
field: &tracing::field::Field,
value: &dyn std::fmt::Debug,
) {
self.0.push_str(&format!("{}={:?} ", field.name(), value));
}
}
let mut visit = Visit(String::new());
event.record(&mut visit);
self.0 .0.lock().unwrap().push(visit.0);
}
}
let log = Log::default();
let subscriber = tracing_subscriber::registry().with(Layer(log.clone()));
let runtime = tokio::runtime::Builder::new_current_thread()
.build()
.expect("runtime");
tracing::subscriber::with_default(subscriber, || {
let harness = Harness::new();
let (kim, _) = harness.person("kim", "nonce-check-audit-1");
log.0.lock().unwrap().clear();
let no = runtime
.block_on(harness.check(&kim, ACCOUNT, "", GrantLevel::Admin))
.expect("an answer");
assert!(!no.allowed, "{no:?}");
let lines = log.0.lock().unwrap().clone();
assert!(
!lines.iter().any(|l| l.contains("decision=\"deny\"")),
"a check wrote a refusal into the audit trail: {lines:#?}"
);
});
}