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/principal_identity_plane.rs

#![cfg(all(feature = "server", feature = "control"))]

//! **A principal's id is not its name — on the REAL plane** (2026-09-12).
//!
//! `gunnar-core`'s `principal_identity` proves the table; `gunnar-grpc`'s
//! services test proves the RPC shape on the in-memory double. This proves
//! the row the shipped `CoreControlPlane` writes: the account namespace is
//! created under the ID, not the name; the name filter and the rename reach
//! the real identity table; and the refusals come back as the codes a client
//! is documented to expect.

mod common;

use std::sync::Arc;

use common::Harness;
use gunnar_cli::server::CoreControlPlane;
use gunnar_core::{HashKind, Level};
use gunnar_grpc::{AuthMode, Caller, ControlPlane, EventBus, ServerConfig, ServiceContext};
use gunnar_proto::access_server::Access as _;
use gunnar_proto::{
    AddPrincipalRequest, ListPrincipalsRequest, PrincipalKind, RenamePrincipalRequest,
};
use tonic::{Code, Request};

fn now<F: std::future::Future>(future: F) -> F::Output {
    tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()
        .expect("runtime")
        .block_on(future)
}

fn as_admin<T>(message: T) -> Request<T> {
    let mut request = Request::new(message);
    request.extensions_mut().insert(Caller {
        principal_id: "root".to_owned(),
        fingerprint: String::new(),
        machine: false,
        authenticated: true,
    });
    request
}

fn access(plane: &Arc<CoreControlPlane>) -> gunnar_grpc::AccessService {
    let control: Arc<dyn ControlPlane> = plane.clone();
    gunnar_grpc::AccessService::new(ServiceContext::new(
        control,
        Arc::new(EventBus::new(16)),
        ServerConfig {
            auth: AuthMode::Required,
            ..ServerConfig::default()
        },
    ))
}

#[test]
fn the_account_is_the_id_and_the_name_is_free_to_move() {
    let harness = Harness::start("team", "proj", HashKind::Sha1);
    let auth = harness.stores.auth();
    auth.add_principal(
        gunnar_core::Principal::new("root", "root", gunnar_core::PrincipalKind::User).unwrap(),
    )
    .expect("root");
    auth.grant(gunnar_core::Grant {
        principal: "root".into(),
        scope: gunnar_core::Scope::Server,
        level: Level::Admin,
    })
    .expect("root is admin");
    let plane = Arc::new(CoreControlPlane::new(Arc::clone(&harness.stores)));
    let access = access(&plane);

    let bob = now(access.add_principal(as_admin(AddPrincipalRequest {
        name: "Bob Builder".into(),
        kind: PrincipalKind::User as i32,
        id: "bob".into(),
    })))
    .expect("created")
    .into_inner()
    .principal
    .unwrap();
    assert_eq!(bob.id, "bob");
    assert_eq!(bob.name, "Bob Builder");
    assert!(
        bob.created_at_unix_ms > 0,
        "the real plane must report the stamp"
    );

    // The namespace is the ID's. Applied output: the catalog, and the ACL.
    assert!(
        harness
            .stores
            .catalog()
            .namespace("bob")
            .expect("read")
            .is_some(),
        "the account namespace must be created under the id"
    );
    assert!(
        harness
            .stores
            .catalog()
            .namespace("Bob Builder")
            .expect("read")
            .is_none(),
        "nothing may be created under the display name"
    );
    assert!(
        auth.may_namespace("bob", "bob", Level::Admin).expect("acl"),
        "the principal owns the namespace named after its id"
    );

    // The name filter reaches the real index.
    let found = now(access.list_principals(as_admin(ListPrincipalsRequest {
        name: "Bob Builder".into(),
        ..Default::default()
    })))
    .expect("listed")
    .into_inner()
    .principals;
    assert_eq!(found.len(), 1);
    assert_eq!(found[0].id, "bob");

    // RED first: the name is taken (as a name), and the id is taken.
    let status = now(access.add_principal(as_admin(AddPrincipalRequest {
        name: "Bob Builder".into(),
        kind: PrincipalKind::User as i32,
        id: "bob2".into(),
    })))
    .expect_err("a taken name is refused by the real table");
    assert_eq!(status.code(), Code::AlreadyExists, "{status}");
    assert!(
        harness
            .stores
            .catalog()
            .namespace("bob2")
            .expect("read")
            .is_none(),
        "a refused principal must not leave a namespace behind"
    );

    // Rename on the real plane: label moves, account and grant stay.
    let renamed = now(access.rename_principal(as_admin(RenamePrincipalRequest {
        principal_id: "bob".into(),
        name: "Robert".into(),
    })))
    .expect("renamed")
    .into_inner()
    .principal
    .unwrap();
    assert_eq!(renamed.id, "bob");
    assert_eq!(renamed.name, "Robert");
    assert_eq!(renamed.created_at_unix_ms, bob.created_at_unix_ms);
    assert!(auth.may_namespace("bob", "bob", Level::Admin).expect("acl"));
    assert_eq!(
        auth.principal_by_name("Robert")
            .expect("read")
            .map(|p| p.id),
        Some("bob".to_owned())
    );
    assert!(auth
        .principal_by_name("Bob Builder")
        .expect("read")
        .is_none());
}