Skip to main content

gunnar_core/auth/
operator_access.rs

1//! **The operator's look into an account, and the door it stops at** (owner's
2//! enterprise list, 2026-10-05).
3//!
4//! # The shield
5//!
6//! The estate's operator holds a SERVER-scope grant: it runs the box. Until
7//! this, that grant reached every repository on it, customers' included. Now
8//! it stops at an account's door ([`Shield`]): inside a customer's account a
9//! PERSON's server-scope grant — held directly or through a group — counts
10//! for NOTHING (a machine's — replication, the console's relay, monetize —
11//! is the estate's plumbing and keeps its reach), and the operator sees the account's totals
12//! (`Enterprise.Aggregates`), never its names or its events. What still
13//! reaches inside is everything the ACCOUNT decided: its own principal, its
14//! employees, a namespace or repository grant its admin made, and a public
15//! repository.
16//!
17//! "A customer's account" is a namespace whose first component is the id of
18//! a PERSON (a user principal that is not itself an employee, and not the
19//! box's own first identity — the installer's admin key bootstrap enrolled,
20//! which is the estate itself). Namespaces no principal owns (`nordisk`, the
21//! operator's forge), machines' and the bootstrap identity's are the
22//! operator's, as before.
23//!
24//! # The look ([`OperatorAccess`])
25//!
26//! When the operator needs to see inside (a support case), it ASKS
27//! ([`AuthTable::request_operator_access`]): an account, a reason, how many
28//! minutes. Nothing changes until the ACCOUNT's admin approves it
29//! ([`AuthTable::decide_operator_access`]; the gRPC door also wants a fresh
30//! step-up of the approver's own passkey, as a machine enrolment does). An
31//! approved look reads — never writes — until it ends on its own or the admin
32//! ends it ([`AuthTable::revoke_operator_access`]). Every step is the
33//! account's audit log's (`gunnar-grpc`'s `Enterprise` service writes it).
34//!
35//! Rows live in the identity table, in the same compare-and-swap as the
36//! grants they widen, for the reason the account gate does: `may` reads them
37//! on the path it reads grants on, from the same version of the table.
38
39use super::{AuthDoc, AuthTable};
40use crate::error::{Error, Result};
41use crate::model::{Level, PrincipalKind, Scope};
42
43/// How long a request waits for the account admin: three days.
44pub const OPERATOR_REQUEST_TTL_MS: i64 = 3 * 24 * 3_600_000;
45/// The longest look an admin can approve: a week.
46pub const OPERATOR_ACCESS_MAX_MINUTES: u32 = 7 * 24 * 60;
47/// A look nobody named the length of: an hour.
48pub const OPERATOR_ACCESS_DEFAULT_MINUTES: u32 = 60;
49/// The longest reason kept.
50const MAX_REASON_CHARS: usize = 500;
51
52/// Where an operator's look is.
53#[derive(Clone, Copy, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
54pub enum OperatorAccessState {
55    /// Asked, waiting for the account admin.
56    Pending,
57    /// Approved: the operator reads inside until `granted_until_unix_ms`.
58    Approved,
59    /// The account admin said no.
60    Refused,
61    /// The account admin (or the operator) ended it early.
62    Revoked,
63    /// Its time ran out, unanswered or used.
64    Expired,
65}
66
67impl OperatorAccessState {
68    /// `pending`, `approved`, `refused`, `revoked`, `expired`.
69    pub fn as_str(self) -> &'static str {
70        match self {
71            OperatorAccessState::Pending => "pending",
72            OperatorAccessState::Approved => "approved",
73            OperatorAccessState::Refused => "refused",
74            OperatorAccessState::Revoked => "revoked",
75            OperatorAccessState::Expired => "expired",
76        }
77    }
78}
79
80/// **One operator's look into one account** — the latest per pair.
81#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
82#[serde(deny_unknown_fields)]
83pub struct OperatorAccess {
84    /// Random, 32 hex digits.
85    pub id: String,
86    /// For the eye, `ABC-123`: shown to the operator and beside the request,
87    /// so the admin approves the request the operator made.
88    pub code: String,
89    /// The account looked into.
90    pub account: String,
91    /// The principal that asked (a person or machine with a server grant).
92    pub operator: String,
93    /// Why, in the operator's words: the account admin reads it first.
94    pub reason: String,
95    /// How long the look lasts once approved.
96    pub minutes: u32,
97    /// When it was asked for.
98    pub requested_at_unix_ms: i64,
99    /// When an unanswered request lapses.
100    pub request_expires_at_unix_ms: i64,
101    /// Where it is (as stored; [`OperatorAccess::state_at`] for now).
102    pub state: OperatorAccessState,
103    /// When an approved look ends; 0 until approved.
104    #[serde(default)]
105    pub granted_until_unix_ms: i64,
106    /// Who decided (or ended) it.
107    #[serde(default)]
108    pub decided_by: String,
109    /// When.
110    #[serde(default)]
111    pub decided_at_unix_ms: i64,
112}
113
114impl OperatorAccess {
115    /// Does it let the operator read inside at `now`?
116    pub fn is_live_at(&self, now: i64) -> bool {
117        self.state == OperatorAccessState::Approved && now < self.granted_until_unix_ms
118    }
119
120    /// The state as it stands at `now` (a lapsed request, an ended look).
121    pub fn state_at(&self, now: i64) -> OperatorAccessState {
122        match self.state {
123            OperatorAccessState::Pending if now >= self.request_expires_at_unix_ms => OperatorAccessState::Expired,
124            OperatorAccessState::Approved if now >= self.granted_until_unix_ms => OperatorAccessState::Expired,
125            state => state,
126        }
127    }
128
129    /// The row with [`OperatorAccess::state_at`] written in.
130    pub fn as_at(mut self, now: i64) -> OperatorAccess {
131        self.state = self.state_at(now);
132        self
133    }
134}
135
136/// **How far a server-scope grant reaches into a namespace**, for one principal.
137#[derive(Clone, Copy, PartialEq, Eq, Debug)]
138pub enum Shield {
139    /// Not a customer's account, or the principal is inside it: grants count as always.
140    Open,
141    /// The account admin approved a look that is live: server grants count up to `Read`.
142    ReadOnly,
143    /// A customer's account: server grants count for nothing.
144    Closed,
145}
146
147fn key(account: &str, operator: &str) -> String {
148    format!("{account}\u{1f}{operator}")
149}
150
151fn refused(reason: impl Into<String>) -> Error {
152    Error::InvalidInput {
153        what: "operator access",
154        reason: reason.into(),
155    }
156}
157
158/// The alphabet of a code's letters: no I or O, which read as 1 and 0.
159const LETTERS: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ";
160
161fn code_of(bytes: &[u8; 16]) -> String {
162    let letter = |b: u8| LETTERS[usize::from(b) % LETTERS.len()] as char;
163    let digit = |b: u8| char::from(b'0' + b % 10);
164    format!(
165        "{}{}{}-{}{}{}",
166        letter(bytes[0]),
167        letter(bytes[1]),
168        letter(bytes[2]),
169        digit(bytes[3]),
170        digit(bytes[4]),
171        digit(bytes[5])
172    )
173}
174
175impl AuthTable {
176    /// The account `namespace` belongs to, read off its first component: a
177    /// person's id (not an employee's). `None` for a namespace no person owns.
178    pub(super) fn account_in(doc: &AuthDoc, namespace: &str) -> Result<Option<String>> {
179        let account = namespace.split('/').next().unwrap_or("");
180        if account.is_empty() || Self::is_box_identity(doc, account) {
181            return Ok(None);
182        }
183        // **A CLAN's namespace is an account too** (clans + the enterprise
184        // list, 2026-10-05): what its members make there is theirs, behind
185        // the operator's shield, in the clan's own log, counted inside the
186        // accounts — never the operator's. A clan's name is no principal's.
187        if !doc.clans.is_empty() && doc.clans.contains_key(account)? {
188            return Ok(Some(account.to_owned()));
189        }
190        match doc.principals.get(account)? {
191            Some(owner) if owner.kind == PrincipalKind::User && !doc.employers.contains_key(account)? => {
192                Ok(Some(account.to_owned()))
193            }
194            _ => Ok(None),
195        }
196    }
197
198    /// **Is `principal_id` the box's own first identity** — the one bootstrap
199    /// enrolled (the installer's admin key, `--admin-key`, `control
200    /// bootstrap`)? It is the estate itself, as a machine is: its server
201    /// grant keeps its reach, and its namespace is the estate's own, like one
202    /// no person owns. Every OTHER person is an account.
203    pub(super) fn is_box_identity(doc: &AuthDoc, principal_id: &str) -> bool {
204        doc.bootstrap.as_ref().is_some_and(|b| !b.principal_id.is_empty() && b.principal_id == principal_id)
205    }
206
207    /// **How far `principal_id`'s server grants reach into `namespace` at `now`.**
208    pub(super) fn shield_in(doc: &AuthDoc, principal_id: &str, namespace: &str, now: i64) -> Result<Shield> {
209        let Some(account) = Self::account_in(doc, namespace)? else {
210            return Ok(Shield::Open);
211        };
212        // **A machine is the estate's plumbing, not a person looking**: the
213        // replicator pushing a lapsed tenant's data to its standby, the
214        // console's relay, monetize. Its key is held by a box, and the shield
215        // binds the PEOPLE who hold server grants (the operator, directly or
216        // through a group). Whoever holds a machine key can still use it —
217        // `.nornir/account-audit-operator-privacy.md` says so.
218        if doc.principals.get(principal_id)?.is_some_and(|p| p.kind == PrincipalKind::Machine)
219            || Self::is_box_identity(doc, principal_id)
220        {
221            return Ok(Shield::Open);
222        }
223        if account == principal_id || doc.employers.get(principal_id)?.as_deref() == Some(account.as_str()) {
224            return Ok(Shield::Open);
225        }
226        // A clan's own member is inside it (its clan grants decide its
227        // reach); an invited account is not yet.
228        if Self::clan_member(doc, principal_id, &account)? {
229            return Ok(Shield::Open);
230        }
231        match doc.operator_access.get(&key(&account, principal_id))? {
232            Some(row) if row.is_live_at(now) => Ok(Shield::ReadOnly),
233            _ => Ok(Shield::Closed),
234        }
235    }
236
237    /// The best level `grants` give on a scope, a server-scope grant capped by `shield`.
238    pub(super) fn best_under(
239        grants: impl Iterator<Item = crate::model::Grant>,
240        shield: impl Fn() -> Result<Shield>,
241    ) -> Result<Option<Level>> {
242        let mut best: Option<Level> = None;
243        let mut server: Option<Level> = None;
244        for grant in grants {
245            if grant.scope == Scope::Server {
246                server = server.max(Some(grant.level));
247            } else {
248                best = best.max(Some(grant.level));
249            }
250        }
251        if let Some(level) = server {
252            let capped = match shield()? {
253                Shield::Open => Some(level),
254                Shield::ReadOnly => Some(level.min(Level::Read)),
255                Shield::Closed => None,
256            };
257            best = best.max(capped);
258        }
259        Ok(best)
260    }
261
262    /// **Is `namespace` closed to `principal_id`'s server grants right now?**
263    /// What a listing asks per row: a closed row is not shown.
264    pub fn shielded(&self, principal_id: &str, namespace: &str) -> Result<bool> {
265        self.shielded_at(principal_id, namespace, crate::model::now_ms())
266    }
267
268    /// [`AuthTable::shielded`] at a given instant.
269    pub fn shielded_at(&self, principal_id: &str, namespace: &str, now: i64) -> Result<bool> {
270        let doc = self.doc.load()?;
271        Ok(Self::shield_in(&doc, principal_id, namespace, now)? == Shield::Closed)
272    }
273
274    /// The account `namespace` belongs to, if a person owns it.
275    pub fn account_of_namespace(&self, namespace: &str) -> Result<Option<String>> {
276        let doc = self.doc.load()?;
277        Self::account_in(&doc, namespace)
278    }
279
280    /// The account `principal_id` belongs to: its employer, or its own when
281    /// it is a person. `None` for machines, groups and nobody.
282    pub fn account_of_principal(&self, principal_id: &str) -> Result<Option<String>> {
283        let doc = self.doc.load()?;
284        if let Some(employer) = doc.employers.get(principal_id)? {
285            return Ok(Some(employer));
286        }
287        Self::account_in(&doc, principal_id).map(|a| a.filter(|a| a == principal_id))
288    }
289
290    /// Is `principal_id` a member (or admin) of the clan `account`? False
291    /// when `account` is no clan.
292    fn clan_member(doc: &AuthDoc, principal_id: &str, account: &str) -> Result<bool> {
293        if doc.clans.is_empty() {
294            return Ok(false);
295        }
296        Ok(doc.clans.get(account)?.is_some_and(|clan| {
297            matches!(clan.role_of(principal_id), Some(super::clan_table::ClanRole::Admin | super::clan_table::ClanRole::Member))
298        }))
299    }
300
301    fn admin_in(doc: &AuthDoc, principal_id: &str, account: &str) -> Result<bool> {
302        // A clan's admins are its account admins (its log, its name queue,
303        // the operator's looks into it).
304        if !doc.clans.is_empty() {
305            if let Some(clan) = doc.clans.get(account)? {
306                return Ok(clan.role_of(principal_id) == Some(super::clan_table::ClanRole::Admin));
307            }
308        }
309        if principal_id == account {
310            return doc.principals.contains_key(account);
311        }
312        if !doc.principals.contains_key(principal_id)? {
313            return Ok(false);
314        }
315        Ok(Self::holder_grants(doc, principal_id)?
316            .into_iter()
317            .any(|g| g.level == Level::Admin && g.scope == Scope::Namespace(account.to_owned())))
318    }
319
320    /// **What `actor` is to `account`**, as its audit log says it: `admin`
321    /// (its person, or one it made admin), `member` (its employee), `machine`,
322    /// `operator` (a person with a server grant), `outsider` — or `system`
323    /// for nobody.
324    pub fn role_in_account(&self, actor: &str, account: &str) -> Result<&'static str> {
325        if actor.is_empty() {
326            return Ok("system");
327        }
328        let doc = self.doc.load()?;
329        if Self::admin_in(&doc, actor, account)? {
330            return Ok("admin");
331        }
332        if doc.employers.get(actor)?.as_deref() == Some(account) || Self::clan_member(&doc, actor, account)? {
333            return Ok("member");
334        }
335        if doc.principals.get(actor)?.is_some_and(|p| p.kind == PrincipalKind::Machine) {
336            return Ok("machine");
337        }
338        let server = Self::holder_grants(&doc, actor)?.into_iter().any(|g| g.scope == Scope::Server);
339        Ok(if server { "operator" } else { "outsider" })
340    }
341
342    /// **Is `principal_id` an admin of `account`?** The account's own person,
343    /// or one its admin made admin over the namespace. A SERVER grant is not:
344    /// the operator is no account's admin.
345    pub fn is_account_admin(&self, principal_id: &str, account: &str) -> Result<bool> {
346        let doc = self.doc.load()?;
347        Self::admin_in(&doc, principal_id, account)
348    }
349
350    /// **The operator asks to look inside `account`.** Pending until the
351    /// account admin decides; a request already waiting is answered as is.
352    pub fn request_operator_access(
353        &self,
354        account: &str,
355        operator: &str,
356        reason: &str,
357        minutes: u32,
358        now: i64,
359    ) -> Result<OperatorAccess> {
360        let reason = reason.trim();
361        if reason.is_empty() {
362            return Err(refused("a reason is required: the account admin reads it before deciding"));
363        }
364        if reason.chars().count() > MAX_REASON_CHARS {
365            return Err(refused(format!("the reason is longer than {MAX_REASON_CHARS} characters")));
366        }
367        let minutes = if minutes == 0 { OPERATOR_ACCESS_DEFAULT_MINUTES } else { minutes };
368        if minutes > OPERATOR_ACCESS_MAX_MINUTES {
369            return Err(refused(format!(
370                "a look lasts at most {OPERATOR_ACCESS_MAX_MINUTES} minutes (a week); ask for less"
371            )));
372        }
373        self.doc.update(|doc| {
374            if Self::account_in(doc, account)?.as_deref() != Some(account) {
375                return Err(refused(format!("{account:?} is not an account: no person owns it")));
376            }
377            if account == operator
378                || doc.employers.get(operator)?.as_deref() == Some(account)
379                || Self::clan_member(doc, operator, account)?
380            {
381                return Err(refused(format!("{operator} is inside {account} already; there is nothing to ask")));
382            }
383            if !doc.principals.contains_key(operator)? {
384                return Err(Error::NoSuchPrincipal(operator.to_owned()));
385            }
386            let k = key(account, operator);
387            if let Some(row) = doc.operator_access.get(&k)? {
388                if row.state_at(now) == OperatorAccessState::Pending {
389                    return Ok(row);
390                }
391            }
392            let mut bytes = [0u8; 16];
393            {
394                use rand::Rng as _;
395                crate::os_rng().fill_bytes(&mut bytes);
396            }
397            let row = OperatorAccess {
398                id: hex::encode(bytes),
399                code: code_of(&bytes),
400                account: account.to_owned(),
401                operator: operator.to_owned(),
402                reason: reason.to_owned(),
403                minutes,
404                requested_at_unix_ms: now,
405                request_expires_at_unix_ms: now + OPERATOR_REQUEST_TTL_MS,
406                state: OperatorAccessState::Pending,
407                granted_until_unix_ms: 0,
408                decided_by: String::new(),
409                decided_at_unix_ms: 0,
410            };
411            doc.operator_access.insert(k, row.clone())?;
412            Ok(row)
413        })
414    }
415
416    fn find(doc: &AuthDoc, id: &str) -> Result<Option<(String, OperatorAccess)>> {
417        Ok(doc.operator_access.entries()?.into_iter().find(|(_, row)| row.id == id))
418    }
419
420    /// **The account admin decides a pending request.** Approved, the look
421    /// lasts the minutes asked for (or `minutes`, if the admin set fewer or
422    /// more, up to a week).
423    pub fn decide_operator_access(
424        &self,
425        id: &str,
426        approver: &str,
427        approve: bool,
428        minutes: Option<u32>,
429        now: i64,
430    ) -> Result<OperatorAccess> {
431        self.decide_operator_access_stepped(id, approver, approve, minutes, None, now)
432    }
433
434    /// [`AuthTable::decide_operator_access`] with the approver's STEP-UP
435    /// stored in the same update: `step_up` = the passkey (its id as the
436    /// table keys it, base64url) and the counter it presented — the
437    /// passkey must be the approver's, and its counter must move as a
438    /// login's does.
439    pub fn decide_operator_access_stepped(
440        &self,
441        id: &str,
442        approver: &str,
443        approve: bool,
444        minutes: Option<u32>,
445        step_up: Option<(&str, u32)>,
446        now: i64,
447    ) -> Result<OperatorAccess> {
448        if let Some(m) = minutes {
449            if m == 0 || m > OPERATOR_ACCESS_MAX_MINUTES {
450                return Err(refused(format!("a look lasts 1 to {OPERATOR_ACCESS_MAX_MINUTES} minutes")));
451            }
452        }
453        self.doc.update(|doc| {
454            let Some((k, mut row)) = Self::find(doc, id)? else {
455                return Err(refused(format!("no operator access request {id}")));
456            };
457            if !Self::admin_in(doc, approver, &row.account)? {
458                return Err(refused(format!(
459                    "{approver} is not an admin of account {}: only the account's admin decides an operator's look",
460                    row.account
461                )));
462            }
463            match row.state_at(now) {
464                OperatorAccessState::Pending => {}
465                other => return Err(refused(format!("request {id} is {}: it cannot be decided now", other.as_str()))),
466            }
467            if let Some((credential_id, presented)) = step_up {
468                let mut credential = doc
469                    .credentials
470                    .get(credential_id)?
471                    .ok_or_else(|| Error::NoSuchCredential(credential_id.to_owned()))?;
472                if credential.principal_id != approver {
473                    return Err(refused(format!("the step-up's passkey is not {approver}'s")));
474                }
475                if credential.sign_count != 0 && presented != 0 && presented <= credential.sign_count {
476                    return Err(Error::CounterRollback {
477                        id: credential_id.to_owned(),
478                        stored: credential.sign_count,
479                        presented,
480                    });
481                }
482                if presented != 0 {
483                    credential.sign_count = presented;
484                }
485                credential.last_used = Some(now);
486                doc.credentials.insert(credential_id.to_owned(), credential)?;
487            }
488            row.decided_by = approver.to_owned();
489            row.decided_at_unix_ms = now;
490            if approve {
491                let minutes = minutes.unwrap_or(row.minutes);
492                row.minutes = minutes;
493                row.state = OperatorAccessState::Approved;
494                row.granted_until_unix_ms = now + i64::from(minutes) * 60_000;
495            } else {
496                row.state = OperatorAccessState::Refused;
497            }
498            doc.operator_access.insert(k, row.clone())?;
499            Ok(row)
500        })
501    }
502
503    /// **End a look (or withdraw a request) early**: the account's admin, or
504    /// the operator that asked.
505    pub fn revoke_operator_access(&self, id: &str, by: &str, now: i64) -> Result<OperatorAccess> {
506        self.doc.update(|doc| {
507            let Some((k, mut row)) = Self::find(doc, id)? else {
508                return Err(refused(format!("no operator access request {id}")));
509            };
510            if by != row.operator && !Self::admin_in(doc, by, &row.account)? {
511                return Err(refused(format!(
512                    "{by} is not an admin of account {}: only the account's admin (or the operator that asked) ends a look",
513                    row.account
514                )));
515            }
516            match row.state_at(now) {
517                OperatorAccessState::Pending | OperatorAccessState::Approved => {}
518                other => return Err(refused(format!("request {id} is {} already", other.as_str()))),
519            }
520            row.state = OperatorAccessState::Revoked;
521            if row.granted_until_unix_ms > now {
522                row.granted_until_unix_ms = now;
523            }
524            row.decided_by = by.to_owned();
525            row.decided_at_unix_ms = now;
526            doc.operator_access.insert(k, row.clone())?;
527            Ok(row)
528        })
529    }
530
531    /// The looks asked into `account` (or into every account, `None`),
532    /// newest first, each as it stands now.
533    pub fn operator_access(&self, account: Option<&str>) -> Result<Vec<OperatorAccess>> {
534        self.operator_access_at(account, crate::model::now_ms())
535    }
536
537    /// [`AuthTable::operator_access`] at a given instant.
538    pub fn operator_access_at(&self, account: Option<&str>, now: i64) -> Result<Vec<OperatorAccess>> {
539        let mut rows: Vec<OperatorAccess> = self
540            .doc
541            .load()?
542            .operator_access
543            .values()?
544            .into_iter()
545            .filter(|row| account.is_none_or(|a| row.account == a))
546            .map(|row| row.as_at(now))
547            .collect();
548        rows.sort_by_key(|row| std::cmp::Reverse(row.requested_at_unix_ms));
549        Ok(rows)
550    }
551
552    /// One request by its id, as it stands now.
553    pub fn operator_access_by_id(&self, id: &str) -> Result<Option<OperatorAccess>> {
554        let now = crate::model::now_ms();
555        let doc = self.doc.load()?;
556        Ok(Self::find(&doc, id)?.map(|(_, row)| row.as_at(now)))
557    }
558}