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

//! **Invites are the identity core's, over Gunnar's people** (IDENTITY-HOST,
//! 2026-10-27; Stage L before it: "passkeys + invites + the
//! passkey↔principal pin → gunnar-server").
//!
//! Gunnar's own `Access.CreateInvite/ListInvites/RevokeInvite` are gone: an
//! invite is edda's `nornir_identity::Identity::invite` (IdentityService
//! `CreateInvite`), and Gunnar answers only who its people are
//! ([`GunnarStore`]) and who may enrol whom ([`GunnarHost::may_enrol`]). The
//! rules these tests held for the old verbs, held now through the core over
//! Gunnar's control plane:
//!
//! * a server admin mints a passkey link for anybody; the secret is shown once
//!   and never listed, and no invite makes an admin;
//! * an account admin mints one for the account's own employees, nobody else;
//! * a person mints one for themself (a passkey on another device);
//! * only a person who exists, and only an inviter withdraws an invite.

use std::sync::Arc;

use gunnar_grpc::identity::{
    system_clock, GunnarHost, GunnarIdentity, GunnarStore, IdentityTables, SERVER_ADMIN,
};
use gunnar_grpc::{
    AccountsService, Caller, ControlPlane, EventBus, MemoryControlPlane, ServerConfig,
    ServiceContext,
};
use gunnar_proto::accounts_server::Accounts as _;
use gunnar_proto::*;
use nornir_identity::conformance::config;
use nornir_identity::{Code, Identity, IdentityHost as _, InviteAsk, NoMail, Site, UserState};
use tonic::Request;

const ACCOUNT: &str = "acme";
const DAY_SECS: i64 = 86_400;

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

fn caller(id: &str) -> Caller {
    Caller {
        principal_id: id.to_owned(),
        fingerprint: format!("SHA256:{id}"),
        machine: false,
        authenticated: true,
    }
}

fn with<T>(who: &str, body: T) -> Request<T> {
    let mut request = Request::new(body);
    request.extensions_mut().insert(caller(who));
    request
}

struct Rig {
    plane: Arc<MemoryControlPlane>,
    identity: GunnarIdentity,
}

impl Rig {
    /// `boss` holds server Admin; `acme` owns the account and has enrolled
    /// `bob`; `ola` and `kim` are ordinary people; `ci` is a machine.
    async fn new() -> Rig {
        let plane = Arc::new(MemoryControlPlane::new());
        plane
            .create_namespace(ACCOUNT, "")
            .expect("acme's namespace");
        for (id, kind) in [
            ("boss", PrincipalKind::User),
            (ACCOUNT, PrincipalKind::User),
            ("ola", PrincipalKind::User),
            ("kim", PrincipalKind::User),
            ("ci", PrincipalKind::Machine),
        ] {
            plane.add_principal(id, id, kind).expect("principal");
        }
        plane.grant_server("boss", GrantLevel::Admin);
        let control: Arc<dyn ControlPlane> = plane.clone();
        let ctx = ServiceContext::new(
            Arc::clone(&control),
            Arc::new(EventBus::new(16)),
            ServerConfig::default(),
        );
        AccountsService::new(ctx)
            .enroll_employee(with(
                ACCOUNT,
                EnrollEmployeeRequest {
                    account: ACCOUNT.into(),
                    id: "bob".into(),
                    name: "bob".into(),
                    public_key: String::new(),
                    ..Default::default()
                },
            ))
            .await
            .expect("acme enrolls bob");
        let store = GunnarStore::new(control, IdentityTables::default(), None, system_clock());
        let identity = Identity::new(
            GunnarHost::new(Site::Private, store, Arc::new(NoMail), system_clock()),
            config(),
        );
        Rig { plane, identity }
    }

    /// A session of `who` (an active person, as after their first passkey).
    fn session(&self, who: &str) -> String {
        let store = self.identity.host().store();
        let mut user = store.user(who).expect("read").expect("a person");
        user.state = UserState::Active;
        store.put_user(&user).expect("active");
        self.identity
            .vouched_session(who, true)
            .expect("a session")
            .token
    }

    fn invite(
        &self,
        who: &str,
        person: &str,
    ) -> Result<nornir_identity::Invited, nornir_identity::Refused> {
        let token = self.session(who);
        self.identity.invite(
            &token,
            InviteAsk {
                person: Some(person.to_owned()),
                ..InviteAsk::default()
            },
        )
    }

    fn list(&self, who: &str) -> Vec<nornir_identity::InviteView> {
        self.identity
            .list_invites(&self.session(who))
            .expect("listed")
    }
}

fn code<T: std::fmt::Debug>(r: Result<T, nornir_identity::Refused>) -> Code {
    r.expect_err("a refusal").code
}

/// ★ **A server admin mints a link for anybody, and the secret is shown
/// once**: the listing carries everything but the secret. No invite makes an
/// admin.
#[tokio::test]
async fn a_server_admin_invites_anybody_and_the_secret_is_shown_once() {
    let rig = Rig::new().await;
    let token = rig.session("boss");
    let refused = rig.identity.invite(
        &token,
        InviteAsk {
            groups: vec![SERVER_ADMIN.to_owned()],
            ..InviteAsk::default()
        },
    );
    assert_eq!(
        code(refused),
        Code::InviteWouldBeAdmin,
        "an invite never makes an admin"
    );

    let made = rig
        .invite("boss", "kim")
        .expect("a server admin mints kim's link");
    assert!(
        made.secret.len() >= 32,
        "a secret worth guessing at: {:?}",
        made.secret
    );
    assert!(
        (made.expires_at - (now_secs() + DAY_SECS)).abs() < 60,
        "a day by default: {made:?}"
    );
    let listed = rig.list("boss");
    assert_eq!(listed.len(), 1, "{listed:?}");
    assert_eq!(listed[0].person.as_deref(), Some("kim"));
    assert_eq!(listed[0].created_by, "boss");
    assert!(
        !format!("{listed:?}").contains(&made.secret),
        "the secret came back in a listing"
    );
}

/// ★ **An account admin mints links for the account's own employees, never
/// for anybody else**, and sees the ones they minted.
#[tokio::test]
async fn an_account_admin_invites_their_employees_and_no_one_else() {
    let rig = Rig::new().await;
    rig.invite(ACCOUNT, "bob")
        .expect("acme mints its employee's link");
    assert_eq!(
        code(rig.invite(ACCOUNT, "ola")),
        Code::NotAdmin,
        "ola is not acme's employee"
    );
    let token = rig.session(ACCOUNT);
    let refused = rig.identity.invite(
        &token,
        InviteAsk {
            display: "new".into(),
            ..InviteAsk::default()
        },
    );
    assert_eq!(
        code(refused),
        Code::NotAdmin,
        "an account admin makes no new person through an invite"
    );
    let acmes: Vec<_> = rig.list(ACCOUNT).into_iter().map(|i| i.person).collect();
    assert_eq!(
        acmes,
        vec![Some("bob".to_owned())],
        "acme sees the link it minted"
    );
    assert!(rig.list("ola").is_empty(), "ola sees nobody's");
}

/// ★ **A person mints a link for themself** — a passkey on another device —
/// and for nobody else.
#[tokio::test]
async fn a_person_invites_themselves_and_nobody_else() {
    let rig = Rig::new().await;
    rig.invite("kim", "kim").expect("kim's own link");
    assert_eq!(
        code(rig.invite("kim", "ola")),
        Code::NotAdmin,
        "kim may not mint ola's"
    );
    let token = rig.session("kim");
    let refused = rig.identity.invite(
        &token,
        InviteAsk {
            person: Some("kim".into()),
            groups: vec!["server-write".into()],
            ..InviteAsk::default()
        },
    );
    assert_eq!(
        code(refused),
        Code::Malformed,
        "a link onto an existing person carries no groups"
    );
}

/// Only somebody who exists is invited; a deactivated person gets no link.
#[tokio::test]
async fn an_invite_is_for_a_person_who_exists() {
    let rig = Rig::new().await;
    assert_eq!(
        code(rig.invite("boss", "ghost")),
        Code::Malformed,
        "nobody to invite"
    );
    let ola = rig.session("ola");
    rig.identity
        .deactivate("ola", "boss")
        .expect("ola deactivated");
    assert_eq!(
        code(rig.identity.session(&ola)),
        Code::NoSession,
        "her session ends with it"
    );
    assert_eq!(
        code(rig.invite("boss", "ola")),
        Code::NotActive,
        "a deactivated person gets no passkey"
    );
}

/// ★ **Withdrawn by whoever minted it** (or an admin); anybody else is
/// refused, and a withdrawn invite is not open any more.
#[tokio::test]
async fn an_invite_is_revoked_by_its_inviters() {
    let rig = Rig::new().await;
    let bobs = rig.invite(ACCOUNT, "bob").expect("bob's");
    let ola = rig.session("ola");
    assert_eq!(
        code(rig.identity.revoke_invite(&ola, &bobs.invite_id)),
        Code::NotAdmin,
        "not ola's to withdraw"
    );
    assert_eq!(
        code(rig.identity.revoke_invite(&ola, "no-such-invite")),
        Code::InviteNotOpen
    );
    rig.identity
        .revoke_invite(&rig.session(ACCOUNT), &bobs.invite_id)
        .expect("acme withdraws bob's link");
    assert!(rig.list("boss").is_empty(), "nothing open");
    assert_eq!(
        code(
            rig.identity
                .revoke_invite(&rig.session("boss"), &bobs.invite_id)
        ),
        Code::InviteNotOpen,
        "gone"
    );
    assert!(
        rig.identity
            .redeem_start(&bobs.secret, "", &nornir_identity::conformance::relay())
            .is_err(),
        "a withdrawn link opens no ceremony"
    );
    let _ = &rig.plane;
}