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