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

nordisk/gunnar

crates/gunnar-core/src/auth/operator_access.rs

//! **The operator's look into an account, and the door it stops at** (owner's
//! enterprise list, 2026-10-05).
//!
//! # The shield
//!
//! The estate's operator holds a SERVER-scope grant: it runs the box. Until
//! this, that grant reached every repository on it, customers' included. Now
//! it stops at an account's door ([`Shield`]): inside a customer's account a
//! PERSON's server-scope grant — held directly or through a group — counts
//! for NOTHING (a machine's — replication, the console's relay, monetize —
//! is the estate's plumbing and keeps its reach), and the operator sees the account's totals
//! (`Enterprise.Aggregates`), never its names or its events. What still
//! reaches inside is everything the ACCOUNT decided: its own principal, its
//! employees, a namespace or repository grant its admin made, and a public
//! repository.
//!
//! "A customer's account" is a namespace whose first component is the id of
//! a PERSON (a user principal that is not itself an employee, and not the
//! box's own first identity — the installer's admin key bootstrap enrolled,
//! which is the estate itself). Namespaces no principal owns (`nordisk`, the
//! operator's forge), machines' and the bootstrap identity's are the
//! operator's, as before.
//!
//! # The look ([`OperatorAccess`])
//!
//! When the operator needs to see inside (a support case), it ASKS
//! ([`AuthTable::request_operator_access`]): an account, a reason, how many
//! minutes. Nothing changes until the ACCOUNT's admin approves it
//! ([`AuthTable::decide_operator_access`]; the gRPC door also wants a fresh
//! step-up of the approver's own passkey, as a machine enrolment does). An
//! approved look reads — never writes — until it ends on its own or the admin
//! ends it ([`AuthTable::revoke_operator_access`]). Every step is the
//! account's audit log's (`gunnar-grpc`'s `Enterprise` service writes it).
//!
//! Rows live in the identity table, in the same compare-and-swap as the
//! grants they widen, for the reason the account gate does: `may` reads them
//! on the path it reads grants on, from the same version of the table.

use super::{AuthDoc, AuthTable};
use crate::error::{Error, Result};
use crate::model::{Level, PrincipalKind, Scope};

/// How long a request waits for the account admin: three days.
pub const OPERATOR_REQUEST_TTL_MS: i64 = 3 * 24 * 3_600_000;
/// The longest look an admin can approve: a week.
pub const OPERATOR_ACCESS_MAX_MINUTES: u32 = 7 * 24 * 60;
/// A look nobody named the length of: an hour.
pub const OPERATOR_ACCESS_DEFAULT_MINUTES: u32 = 60;
/// The longest reason kept.
const MAX_REASON_CHARS: usize = 500;

/// Where an operator's look is.
#[derive(Clone, Copy, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub enum OperatorAccessState {
    /// Asked, waiting for the account admin.
    Pending,
    /// Approved: the operator reads inside until `granted_until_unix_ms`.
    Approved,
    /// The account admin said no.
    Refused,
    /// The account admin (or the operator) ended it early.
    Revoked,
    /// Its time ran out, unanswered or used.
    Expired,
}

impl OperatorAccessState {
    /// `pending`, `approved`, `refused`, `revoked`, `expired`.
    pub fn as_str(self) -> &'static str {
        match self {
            OperatorAccessState::Pending => "pending",
            OperatorAccessState::Approved => "approved",
            OperatorAccessState::Refused => "refused",
            OperatorAccessState::Revoked => "revoked",
            OperatorAccessState::Expired => "expired",
        }
    }
}

/// **One operator's look into one account** — the latest per pair.
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct OperatorAccess {
    /// Random, 32 hex digits.
    pub id: String,
    /// For the eye, `ABC-123`: shown to the operator and beside the request,
    /// so the admin approves the request the operator made.
    pub code: String,
    /// The account looked into.
    pub account: String,
    /// The principal that asked (a person or machine with a server grant).
    pub operator: String,
    /// Why, in the operator's words: the account admin reads it first.
    pub reason: String,
    /// How long the look lasts once approved.
    pub minutes: u32,
    /// When it was asked for.
    pub requested_at_unix_ms: i64,
    /// When an unanswered request lapses.
    pub request_expires_at_unix_ms: i64,
    /// Where it is (as stored; [`OperatorAccess::state_at`] for now).
    pub state: OperatorAccessState,
    /// When an approved look ends; 0 until approved.
    #[serde(default)]
    pub granted_until_unix_ms: i64,
    /// Who decided (or ended) it.
    #[serde(default)]
    pub decided_by: String,
    /// When.
    #[serde(default)]
    pub decided_at_unix_ms: i64,
}

impl OperatorAccess {
    /// Does it let the operator read inside at `now`?
    pub fn is_live_at(&self, now: i64) -> bool {
        self.state == OperatorAccessState::Approved && now < self.granted_until_unix_ms
    }

    /// The state as it stands at `now` (a lapsed request, an ended look).
    pub fn state_at(&self, now: i64) -> OperatorAccessState {
        match self.state {
            OperatorAccessState::Pending if now >= self.request_expires_at_unix_ms => {
                OperatorAccessState::Expired
            }
            OperatorAccessState::Approved if now >= self.granted_until_unix_ms => {
                OperatorAccessState::Expired
            }
            state => state,
        }
    }

    /// The row with [`OperatorAccess::state_at`] written in.
    pub fn as_at(mut self, now: i64) -> OperatorAccess {
        self.state = self.state_at(now);
        self
    }
}

/// **How far a server-scope grant reaches into a namespace**, for one principal.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Shield {
    /// Not a customer's account, or the principal is inside it: grants count as always.
    Open,
    /// The account admin approved a look that is live: server grants count up to `Read`.
    ReadOnly,
    /// A customer's account: server grants count for nothing.
    Closed,
}

fn key(account: &str, operator: &str) -> String {
    format!("{account}\u{1f}{operator}")
}

fn refused(reason: impl Into<String>) -> Error {
    Error::InvalidInput {
        what: "operator access",
        reason: reason.into(),
    }
}

/// The alphabet of a code's letters: no I or O, which read as 1 and 0.
const LETTERS: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ";

fn code_of(bytes: &[u8; 16]) -> String {
    let letter = |b: u8| LETTERS[usize::from(b) % LETTERS.len()] as char;
    let digit = |b: u8| char::from(b'0' + b % 10);
    format!(
        "{}{}{}-{}{}{}",
        letter(bytes[0]),
        letter(bytes[1]),
        letter(bytes[2]),
        digit(bytes[3]),
        digit(bytes[4]),
        digit(bytes[5])
    )
}

impl AuthTable {
    /// The account `namespace` belongs to, read off its first component: a
    /// person's id (not an employee's). `None` for a namespace no person owns.
    pub(super) fn account_in(doc: &AuthDoc, namespace: &str) -> Result<Option<String>> {
        let account = namespace.split('/').next().unwrap_or("");
        if account.is_empty() || Self::is_box_identity(doc, account) {
            return Ok(None);
        }
        // **A CLAN's namespace is an account too** (clans + the enterprise
        // list, 2026-10-05): what its members make there is theirs, behind
        // the operator's shield, in the clan's own log, counted inside the
        // accounts — never the operator's. A clan's name is no principal's.
        if !doc.clans.is_empty() && doc.clans.contains_key(account)? {
            return Ok(Some(account.to_owned()));
        }
        match doc.principals.get(account)? {
            Some(owner)
                if owner.kind == PrincipalKind::User && !doc.employers.contains_key(account)? =>
            {
                Ok(Some(account.to_owned()))
            }
            _ => Ok(None),
        }
    }

    /// **Is `principal_id` the box's own first identity** — the one bootstrap
    /// enrolled (the installer's admin key, `--admin-key`, `control
    /// bootstrap`)? It is the estate itself, as a machine is: its server
    /// grant keeps its reach, and its namespace is the estate's own, like one
    /// no person owns. Every OTHER person is an account.
    pub(super) fn is_box_identity(doc: &AuthDoc, principal_id: &str) -> bool {
        doc.bootstrap
            .as_ref()
            .is_some_and(|b| !b.principal_id.is_empty() && b.principal_id == principal_id)
    }

    /// **How far `principal_id`'s server grants reach into `namespace` at `now`.**
    pub(super) fn shield_in(
        doc: &AuthDoc,
        principal_id: &str,
        namespace: &str,
        now: i64,
    ) -> Result<Shield> {
        let Some(account) = Self::account_in(doc, namespace)? else {
            return Ok(Shield::Open);
        };
        // **A machine is the estate's plumbing, not a person looking**: the
        // replicator pushing a lapsed tenant's data to its standby, the
        // console's relay, monetize. Its key is held by a box, and the shield
        // binds the PEOPLE who hold server grants (the operator, directly or
        // through a group). Whoever holds a machine key can still use it —
        // `.nornir/account-audit-operator-privacy.md` says so.
        if doc
            .principals
            .get(principal_id)?
            .is_some_and(|p| p.kind == PrincipalKind::Machine)
            || Self::is_box_identity(doc, principal_id)
        {
            return Ok(Shield::Open);
        }
        if account == principal_id
            || doc.employers.get(principal_id)?.as_deref() == Some(account.as_str())
        {
            return Ok(Shield::Open);
        }
        // A clan's own member is inside it (its clan grants decide its
        // reach); an invited account is not yet.
        if Self::clan_member(doc, principal_id, &account)? {
            return Ok(Shield::Open);
        }
        match doc.operator_access.get(&key(&account, principal_id))? {
            Some(row) if row.is_live_at(now) => Ok(Shield::ReadOnly),
            _ => Ok(Shield::Closed),
        }
    }

    /// The best level `grants` give on a scope, a server-scope grant capped by `shield`.
    pub(super) fn best_under(
        grants: impl Iterator<Item = crate::model::Grant>,
        shield: impl Fn() -> Result<Shield>,
    ) -> Result<Option<Level>> {
        let mut best: Option<Level> = None;
        let mut server: Option<Level> = None;
        for grant in grants {
            if grant.scope == Scope::Server {
                server = server.max(Some(grant.level));
            } else {
                best = best.max(Some(grant.level));
            }
        }
        if let Some(level) = server {
            let capped = match shield()? {
                Shield::Open => Some(level),
                Shield::ReadOnly => Some(level.min(Level::Read)),
                Shield::Closed => None,
            };
            best = best.max(capped);
        }
        Ok(best)
    }

    /// **Is `namespace` closed to `principal_id`'s server grants right now?**
    /// What a listing asks per row: a closed row is not shown.
    pub fn shielded(&self, principal_id: &str, namespace: &str) -> Result<bool> {
        self.shielded_at(principal_id, namespace, crate::model::now_ms())
    }

    /// [`AuthTable::shielded`] at a given instant.
    pub fn shielded_at(&self, principal_id: &str, namespace: &str, now: i64) -> Result<bool> {
        let doc = self.doc.load()?;
        Ok(Self::shield_in(&doc, principal_id, namespace, now)? == Shield::Closed)
    }

    /// The account `namespace` belongs to, if a person owns it.
    pub fn account_of_namespace(&self, namespace: &str) -> Result<Option<String>> {
        let doc = self.doc.load()?;
        Self::account_in(&doc, namespace)
    }

    /// The account `principal_id` belongs to: its employer, or its own when
    /// it is a person. `None` for machines, groups and nobody.
    pub fn account_of_principal(&self, principal_id: &str) -> Result<Option<String>> {
        let doc = self.doc.load()?;
        if let Some(employer) = doc.employers.get(principal_id)? {
            return Ok(Some(employer));
        }
        Self::account_in(&doc, principal_id).map(|a| a.filter(|a| a == principal_id))
    }

    /// Is `principal_id` a member (or admin) of the clan `account`? False
    /// when `account` is no clan.
    fn clan_member(doc: &AuthDoc, principal_id: &str, account: &str) -> Result<bool> {
        if doc.clans.is_empty() {
            return Ok(false);
        }
        Ok(doc.clans.get(account)?.is_some_and(|clan| {
            matches!(
                clan.role_of(principal_id),
                Some(super::clan_table::ClanRole::Admin | super::clan_table::ClanRole::Member)
            )
        }))
    }

    fn admin_in(doc: &AuthDoc, principal_id: &str, account: &str) -> Result<bool> {
        // A clan's admins are its account admins (its log, its name queue,
        // the operator's looks into it).
        if !doc.clans.is_empty() {
            if let Some(clan) = doc.clans.get(account)? {
                return Ok(clan.role_of(principal_id) == Some(super::clan_table::ClanRole::Admin));
            }
        }
        if principal_id == account {
            return doc.principals.contains_key(account);
        }
        if !doc.principals.contains_key(principal_id)? {
            return Ok(false);
        }
        Ok(Self::holder_grants(doc, principal_id)?
            .into_iter()
            .any(|g| g.level == Level::Admin && g.scope == Scope::Namespace(account.to_owned())))
    }

    /// **What `actor` is to `account`**, as its audit log says it: `admin`
    /// (its person, or one it made admin), `member` (its employee), `machine`,
    /// `operator` (a person with a server grant), `outsider` — or `system`
    /// for nobody.
    pub fn role_in_account(&self, actor: &str, account: &str) -> Result<&'static str> {
        if actor.is_empty() {
            return Ok("system");
        }
        let doc = self.doc.load()?;
        if Self::admin_in(&doc, actor, account)? {
            return Ok("admin");
        }
        if doc.employers.get(actor)?.as_deref() == Some(account)
            || Self::clan_member(&doc, actor, account)?
        {
            return Ok("member");
        }
        if doc
            .principals
            .get(actor)?
            .is_some_and(|p| p.kind == PrincipalKind::Machine)
        {
            return Ok("machine");
        }
        let server = Self::holder_grants(&doc, actor)?
            .into_iter()
            .any(|g| g.scope == Scope::Server);
        Ok(if server { "operator" } else { "outsider" })
    }

    /// **Is `principal_id` an admin of `account`?** The account's own person,
    /// or one its admin made admin over the namespace. A SERVER grant is not:
    /// the operator is no account's admin.
    pub fn is_account_admin(&self, principal_id: &str, account: &str) -> Result<bool> {
        let doc = self.doc.load()?;
        Self::admin_in(&doc, principal_id, account)
    }

    /// **The operator asks to look inside `account`.** Pending until the
    /// account admin decides; a request already waiting is answered as is.
    pub fn request_operator_access(
        &self,
        account: &str,
        operator: &str,
        reason: &str,
        minutes: u32,
        now: i64,
    ) -> Result<OperatorAccess> {
        let reason = reason.trim();
        if reason.is_empty() {
            return Err(refused(
                "a reason is required: the account admin reads it before deciding",
            ));
        }
        if reason.chars().count() > MAX_REASON_CHARS {
            return Err(refused(format!(
                "the reason is longer than {MAX_REASON_CHARS} characters"
            )));
        }
        let minutes = if minutes == 0 {
            OPERATOR_ACCESS_DEFAULT_MINUTES
        } else {
            minutes
        };
        if minutes > OPERATOR_ACCESS_MAX_MINUTES {
            return Err(refused(format!(
                "a look lasts at most {OPERATOR_ACCESS_MAX_MINUTES} minutes (a week); ask for less"
            )));
        }
        self.doc.update(|doc| {
            if Self::account_in(doc, account)?.as_deref() != Some(account) {
                return Err(refused(format!(
                    "{account:?} is not an account: no person owns it"
                )));
            }
            if account == operator
                || doc.employers.get(operator)?.as_deref() == Some(account)
                || Self::clan_member(doc, operator, account)?
            {
                return Err(refused(format!(
                    "{operator} is inside {account} already; there is nothing to ask"
                )));
            }
            if !doc.principals.contains_key(operator)? {
                return Err(Error::NoSuchPrincipal(operator.to_owned()));
            }
            let k = key(account, operator);
            if let Some(row) = doc.operator_access.get(&k)? {
                if row.state_at(now) == OperatorAccessState::Pending {
                    return Ok(row);
                }
            }
            let mut bytes = [0u8; 16];
            {
                use rand::Rng as _;
                crate::os_rng().fill_bytes(&mut bytes);
            }
            let row = OperatorAccess {
                id: hex::encode(bytes),
                code: code_of(&bytes),
                account: account.to_owned(),
                operator: operator.to_owned(),
                reason: reason.to_owned(),
                minutes,
                requested_at_unix_ms: now,
                request_expires_at_unix_ms: now + OPERATOR_REQUEST_TTL_MS,
                state: OperatorAccessState::Pending,
                granted_until_unix_ms: 0,
                decided_by: String::new(),
                decided_at_unix_ms: 0,
            };
            doc.operator_access.insert(k, row.clone())?;
            Ok(row)
        })
    }

    fn find(doc: &AuthDoc, id: &str) -> Result<Option<(String, OperatorAccess)>> {
        Ok(doc
            .operator_access
            .entries()?
            .into_iter()
            .find(|(_, row)| row.id == id))
    }

    /// **The account admin decides a pending request.** Approved, the look
    /// lasts the minutes asked for (or `minutes`, if the admin set fewer or
    /// more, up to a week).
    pub fn decide_operator_access(
        &self,
        id: &str,
        approver: &str,
        approve: bool,
        minutes: Option<u32>,
        now: i64,
    ) -> Result<OperatorAccess> {
        self.decide_operator_access_stepped(id, approver, approve, minutes, None, now)
    }

    /// [`AuthTable::decide_operator_access`] with the approver's STEP-UP
    /// stored in the same update: `step_up` = the passkey (its id as the
    /// table keys it, base64url) and the counter it presented — the
    /// passkey must be the approver's, and its counter must move as a
    /// login's does.
    pub fn decide_operator_access_stepped(
        &self,
        id: &str,
        approver: &str,
        approve: bool,
        minutes: Option<u32>,
        step_up: Option<(&str, u32)>,
        now: i64,
    ) -> Result<OperatorAccess> {
        if let Some(m) = minutes {
            if m == 0 || m > OPERATOR_ACCESS_MAX_MINUTES {
                return Err(refused(format!(
                    "a look lasts 1 to {OPERATOR_ACCESS_MAX_MINUTES} minutes"
                )));
            }
        }
        self.doc.update(|doc| {
            let Some((k, mut row)) = Self::find(doc, id)? else {
                return Err(refused(format!("no operator access request {id}")));
            };
            if !Self::admin_in(doc, approver, &row.account)? {
                return Err(refused(format!(
                    "{approver} is not an admin of account {}: only the account's admin decides an operator's look",
                    row.account
                )));
            }
            match row.state_at(now) {
                OperatorAccessState::Pending => {}
                other => return Err(refused(format!("request {id} is {}: it cannot be decided now", other.as_str()))),
            }
            if let Some((credential_id, presented)) = step_up {
                let mut credential = doc
                    .credentials
                    .get(credential_id)?
                    .ok_or_else(|| Error::NoSuchCredential(credential_id.to_owned()))?;
                if credential.principal_id != approver {
                    return Err(refused(format!("the step-up's passkey is not {approver}'s")));
                }
                if credential.sign_count != 0 && presented != 0 && presented <= credential.sign_count {
                    return Err(Error::CounterRollback {
                        id: credential_id.to_owned(),
                        stored: credential.sign_count,
                        presented,
                    });
                }
                if presented != 0 {
                    credential.sign_count = presented;
                }
                credential.last_used = Some(now);
                doc.credentials.insert(credential_id.to_owned(), credential)?;
            }
            row.decided_by = approver.to_owned();
            row.decided_at_unix_ms = now;
            if approve {
                let minutes = minutes.unwrap_or(row.minutes);
                row.minutes = minutes;
                row.state = OperatorAccessState::Approved;
                row.granted_until_unix_ms = now + i64::from(minutes) * 60_000;
            } else {
                row.state = OperatorAccessState::Refused;
            }
            doc.operator_access.insert(k, row.clone())?;
            Ok(row)
        })
    }

    /// **End a look (or withdraw a request) early**: the account's admin, or
    /// the operator that asked.
    pub fn revoke_operator_access(&self, id: &str, by: &str, now: i64) -> Result<OperatorAccess> {
        self.doc.update(|doc| {
            let Some((k, mut row)) = Self::find(doc, id)? else {
                return Err(refused(format!("no operator access request {id}")));
            };
            if by != row.operator && !Self::admin_in(doc, by, &row.account)? {
                return Err(refused(format!(
                    "{by} is not an admin of account {}: only the account's admin (or the operator that asked) ends a look",
                    row.account
                )));
            }
            match row.state_at(now) {
                OperatorAccessState::Pending | OperatorAccessState::Approved => {}
                other => return Err(refused(format!("request {id} is {} already", other.as_str()))),
            }
            row.state = OperatorAccessState::Revoked;
            if row.granted_until_unix_ms > now {
                row.granted_until_unix_ms = now;
            }
            row.decided_by = by.to_owned();
            row.decided_at_unix_ms = now;
            doc.operator_access.insert(k, row.clone())?;
            Ok(row)
        })
    }

    /// The looks asked into `account` (or into every account, `None`),
    /// newest first, each as it stands now.
    pub fn operator_access(&self, account: Option<&str>) -> Result<Vec<OperatorAccess>> {
        self.operator_access_at(account, crate::model::now_ms())
    }

    /// [`AuthTable::operator_access`] at a given instant.
    pub fn operator_access_at(
        &self,
        account: Option<&str>,
        now: i64,
    ) -> Result<Vec<OperatorAccess>> {
        let mut rows: Vec<OperatorAccess> = self
            .doc
            .load()?
            .operator_access
            .values()?
            .into_iter()
            .filter(|row| account.is_none_or(|a| row.account == a))
            .map(|row| row.as_at(now))
            .collect();
        rows.sort_by_key(|row| std::cmp::Reverse(row.requested_at_unix_ms));
        Ok(rows)
    }

    /// One request by its id, as it stands now.
    pub fn operator_access_by_id(&self, id: &str) -> Result<Option<OperatorAccess>> {
        let now = crate::model::now_ms();
        let doc = self.doc.load()?;
        Ok(Self::find(&doc, id)?.map(|(_, row)| row.as_at(now)))
    }
}