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 => {
124                OperatorAccessState::Expired
125            }
126            OperatorAccessState::Approved if now >= self.granted_until_unix_ms => {
127                OperatorAccessState::Expired
128            }
129            state => state,
130        }
131    }
132
133    /// The row with [`OperatorAccess::state_at`] written in.
134    pub fn as_at(mut self, now: i64) -> OperatorAccess {
135        self.state = self.state_at(now);
136        self
137    }
138}
139
140/// **How far a server-scope grant reaches into a namespace**, for one principal.
141#[derive(Clone, Copy, PartialEq, Eq, Debug)]
142pub enum Shield {
143    /// Not a customer's account, or the principal is inside it: grants count as always.
144    Open,
145    /// The account admin approved a look that is live: server grants count up to `Read`.
146    ReadOnly,
147    /// A customer's account: server grants count for nothing.
148    Closed,
149}
150
151fn key(account: &str, operator: &str) -> String {
152    format!("{account}\u{1f}{operator}")
153}
154
155fn refused(reason: impl Into<String>) -> Error {
156    Error::InvalidInput {
157        what: "operator access",
158        reason: reason.into(),
159    }
160}
161
162/// The alphabet of a code's letters: no I or O, which read as 1 and 0.
163const LETTERS: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ";
164
165fn code_of(bytes: &[u8; 16]) -> String {
166    let letter = |b: u8| LETTERS[usize::from(b) % LETTERS.len()] as char;
167    let digit = |b: u8| char::from(b'0' + b % 10);
168    format!(
169        "{}{}{}-{}{}{}",
170        letter(bytes[0]),
171        letter(bytes[1]),
172        letter(bytes[2]),
173        digit(bytes[3]),
174        digit(bytes[4]),
175        digit(bytes[5])
176    )
177}
178
179impl AuthTable {
180    /// The account `namespace` belongs to, read off its first component: a
181    /// person's id (not an employee's). `None` for a namespace no person owns.
182    pub(super) fn account_in(doc: &AuthDoc, namespace: &str) -> Result<Option<String>> {
183        let account = namespace.split('/').next().unwrap_or("");
184        if account.is_empty() || Self::is_box_identity(doc, account) {
185            return Ok(None);
186        }
187        // **A CLAN's namespace is an account too** (clans + the enterprise
188        // list, 2026-10-05): what its members make there is theirs, behind
189        // the operator's shield, in the clan's own log, counted inside the
190        // accounts — never the operator's. A clan's name is no principal's.
191        if !doc.clans.is_empty() && doc.clans.contains_key(account)? {
192            return Ok(Some(account.to_owned()));
193        }
194        match doc.principals.get(account)? {
195            Some(owner)
196                if owner.kind == PrincipalKind::User && !doc.employers.contains_key(account)? =>
197            {
198                Ok(Some(account.to_owned()))
199            }
200            _ => Ok(None),
201        }
202    }
203
204    /// **Is `principal_id` the box's own first identity** — the one bootstrap
205    /// enrolled (the installer's admin key, `--admin-key`, `control
206    /// bootstrap`)? It is the estate itself, as a machine is: its server
207    /// grant keeps its reach, and its namespace is the estate's own, like one
208    /// no person owns. Every OTHER person is an account.
209    pub(super) fn is_box_identity(doc: &AuthDoc, principal_id: &str) -> bool {
210        doc.bootstrap
211            .as_ref()
212            .is_some_and(|b| !b.principal_id.is_empty() && b.principal_id == principal_id)
213    }
214
215    /// **How far `principal_id`'s server grants reach into `namespace` at `now`.**
216    pub(super) fn shield_in(
217        doc: &AuthDoc,
218        principal_id: &str,
219        namespace: &str,
220        now: i64,
221    ) -> Result<Shield> {
222        let Some(account) = Self::account_in(doc, namespace)? else {
223            return Ok(Shield::Open);
224        };
225        // **A machine is the estate's plumbing, not a person looking**: the
226        // replicator pushing a lapsed tenant's data to its standby, the
227        // console's relay, monetize. Its key is held by a box, and the shield
228        // binds the PEOPLE who hold server grants (the operator, directly or
229        // through a group). Whoever holds a machine key can still use it —
230        // `.nornir/account-audit-operator-privacy.md` says so.
231        if doc
232            .principals
233            .get(principal_id)?
234            .is_some_and(|p| p.kind == PrincipalKind::Machine)
235            || Self::is_box_identity(doc, principal_id)
236        {
237            return Ok(Shield::Open);
238        }
239        if account == principal_id
240            || doc.employers.get(principal_id)?.as_deref() == Some(account.as_str())
241        {
242            return Ok(Shield::Open);
243        }
244        // A clan's own member is inside it (its clan grants decide its
245        // reach); an invited account is not yet.
246        if Self::clan_member(doc, principal_id, &account)? {
247            return Ok(Shield::Open);
248        }
249        match doc.operator_access.get(&key(&account, principal_id))? {
250            Some(row) if row.is_live_at(now) => Ok(Shield::ReadOnly),
251            _ => Ok(Shield::Closed),
252        }
253    }
254
255    /// The best level `grants` give on a scope, a server-scope grant capped by `shield`.
256    pub(super) fn best_under(
257        grants: impl Iterator<Item = crate::model::Grant>,
258        shield: impl Fn() -> Result<Shield>,
259    ) -> Result<Option<Level>> {
260        let mut best: Option<Level> = None;
261        let mut server: Option<Level> = None;
262        for grant in grants {
263            if grant.scope == Scope::Server {
264                server = server.max(Some(grant.level));
265            } else {
266                best = best.max(Some(grant.level));
267            }
268        }
269        if let Some(level) = server {
270            let capped = match shield()? {
271                Shield::Open => Some(level),
272                Shield::ReadOnly => Some(level.min(Level::Read)),
273                Shield::Closed => None,
274            };
275            best = best.max(capped);
276        }
277        Ok(best)
278    }
279
280    /// **Is `namespace` closed to `principal_id`'s server grants right now?**
281    /// What a listing asks per row: a closed row is not shown.
282    pub fn shielded(&self, principal_id: &str, namespace: &str) -> Result<bool> {
283        self.shielded_at(principal_id, namespace, crate::model::now_ms())
284    }
285
286    /// [`AuthTable::shielded`] at a given instant.
287    pub fn shielded_at(&self, principal_id: &str, namespace: &str, now: i64) -> Result<bool> {
288        let doc = self.doc.load()?;
289        Ok(Self::shield_in(&doc, principal_id, namespace, now)? == Shield::Closed)
290    }
291
292    /// The account `namespace` belongs to, if a person owns it.
293    pub fn account_of_namespace(&self, namespace: &str) -> Result<Option<String>> {
294        let doc = self.doc.load()?;
295        Self::account_in(&doc, namespace)
296    }
297
298    /// The account `principal_id` belongs to: its employer, or its own when
299    /// it is a person. `None` for machines, groups and nobody.
300    pub fn account_of_principal(&self, principal_id: &str) -> Result<Option<String>> {
301        let doc = self.doc.load()?;
302        if let Some(employer) = doc.employers.get(principal_id)? {
303            return Ok(Some(employer));
304        }
305        Self::account_in(&doc, principal_id).map(|a| a.filter(|a| a == principal_id))
306    }
307
308    /// Is `principal_id` a member (or admin) of the clan `account`? False
309    /// when `account` is no clan.
310    fn clan_member(doc: &AuthDoc, principal_id: &str, account: &str) -> Result<bool> {
311        if doc.clans.is_empty() {
312            return Ok(false);
313        }
314        Ok(doc.clans.get(account)?.is_some_and(|clan| {
315            matches!(
316                clan.role_of(principal_id),
317                Some(super::clan_table::ClanRole::Admin | super::clan_table::ClanRole::Member)
318            )
319        }))
320    }
321
322    fn admin_in(doc: &AuthDoc, principal_id: &str, account: &str) -> Result<bool> {
323        // A clan's admins are its account admins (its log, its name queue,
324        // the operator's looks into it).
325        if !doc.clans.is_empty() {
326            if let Some(clan) = doc.clans.get(account)? {
327                return Ok(clan.role_of(principal_id) == Some(super::clan_table::ClanRole::Admin));
328            }
329        }
330        if principal_id == account {
331            return doc.principals.contains_key(account);
332        }
333        if !doc.principals.contains_key(principal_id)? {
334            return Ok(false);
335        }
336        Ok(Self::holder_grants(doc, principal_id)?
337            .into_iter()
338            .any(|g| g.level == Level::Admin && g.scope == Scope::Namespace(account.to_owned())))
339    }
340
341    /// **What `actor` is to `account`**, as its audit log says it: `admin`
342    /// (its person, or one it made admin), `member` (its employee), `machine`,
343    /// `operator` (a person with a server grant), `outsider` — or `system`
344    /// for nobody.
345    pub fn role_in_account(&self, actor: &str, account: &str) -> Result<&'static str> {
346        if actor.is_empty() {
347            return Ok("system");
348        }
349        let doc = self.doc.load()?;
350        if Self::admin_in(&doc, actor, account)? {
351            return Ok("admin");
352        }
353        if doc.employers.get(actor)?.as_deref() == Some(account)
354            || Self::clan_member(&doc, actor, account)?
355        {
356            return Ok("member");
357        }
358        if doc
359            .principals
360            .get(actor)?
361            .is_some_and(|p| p.kind == PrincipalKind::Machine)
362        {
363            return Ok("machine");
364        }
365        let server = Self::holder_grants(&doc, actor)?
366            .into_iter()
367            .any(|g| g.scope == Scope::Server);
368        Ok(if server { "operator" } else { "outsider" })
369    }
370
371    /// **Is `principal_id` an admin of `account`?** The account's own person,
372    /// or one its admin made admin over the namespace. A SERVER grant is not:
373    /// the operator is no account's admin.
374    pub fn is_account_admin(&self, principal_id: &str, account: &str) -> Result<bool> {
375        let doc = self.doc.load()?;
376        Self::admin_in(&doc, principal_id, account)
377    }
378
379    /// **The operator asks to look inside `account`.** Pending until the
380    /// account admin decides; a request already waiting is answered as is.
381    pub fn request_operator_access(
382        &self,
383        account: &str,
384        operator: &str,
385        reason: &str,
386        minutes: u32,
387        now: i64,
388    ) -> Result<OperatorAccess> {
389        let reason = reason.trim();
390        if reason.is_empty() {
391            return Err(refused(
392                "a reason is required: the account admin reads it before deciding",
393            ));
394        }
395        if reason.chars().count() > MAX_REASON_CHARS {
396            return Err(refused(format!(
397                "the reason is longer than {MAX_REASON_CHARS} characters"
398            )));
399        }
400        let minutes = if minutes == 0 {
401            OPERATOR_ACCESS_DEFAULT_MINUTES
402        } else {
403            minutes
404        };
405        if minutes > OPERATOR_ACCESS_MAX_MINUTES {
406            return Err(refused(format!(
407                "a look lasts at most {OPERATOR_ACCESS_MAX_MINUTES} minutes (a week); ask for less"
408            )));
409        }
410        self.doc.update(|doc| {
411            if Self::account_in(doc, account)?.as_deref() != Some(account) {
412                return Err(refused(format!(
413                    "{account:?} is not an account: no person owns it"
414                )));
415            }
416            if account == operator
417                || doc.employers.get(operator)?.as_deref() == Some(account)
418                || Self::clan_member(doc, operator, account)?
419            {
420                return Err(refused(format!(
421                    "{operator} is inside {account} already; there is nothing to ask"
422                )));
423            }
424            if !doc.principals.contains_key(operator)? {
425                return Err(Error::NoSuchPrincipal(operator.to_owned()));
426            }
427            let k = key(account, operator);
428            if let Some(row) = doc.operator_access.get(&k)? {
429                if row.state_at(now) == OperatorAccessState::Pending {
430                    return Ok(row);
431                }
432            }
433            let mut bytes = [0u8; 16];
434            {
435                use rand::Rng as _;
436                crate::os_rng().fill_bytes(&mut bytes);
437            }
438            let row = OperatorAccess {
439                id: hex::encode(bytes),
440                code: code_of(&bytes),
441                account: account.to_owned(),
442                operator: operator.to_owned(),
443                reason: reason.to_owned(),
444                minutes,
445                requested_at_unix_ms: now,
446                request_expires_at_unix_ms: now + OPERATOR_REQUEST_TTL_MS,
447                state: OperatorAccessState::Pending,
448                granted_until_unix_ms: 0,
449                decided_by: String::new(),
450                decided_at_unix_ms: 0,
451            };
452            doc.operator_access.insert(k, row.clone())?;
453            Ok(row)
454        })
455    }
456
457    fn find(doc: &AuthDoc, id: &str) -> Result<Option<(String, OperatorAccess)>> {
458        Ok(doc
459            .operator_access
460            .entries()?
461            .into_iter()
462            .find(|(_, row)| row.id == id))
463    }
464
465    /// **The account admin decides a pending request.** Approved, the look
466    /// lasts the minutes asked for (or `minutes`, if the admin set fewer or
467    /// more, up to a week).
468    pub fn decide_operator_access(
469        &self,
470        id: &str,
471        approver: &str,
472        approve: bool,
473        minutes: Option<u32>,
474        now: i64,
475    ) -> Result<OperatorAccess> {
476        self.decide_operator_access_stepped(id, approver, approve, minutes, None, now)
477    }
478
479    /// [`AuthTable::decide_operator_access`] with the approver's STEP-UP
480    /// stored in the same update: `step_up` = the passkey (its id as the
481    /// table keys it, base64url) and the counter it presented — the
482    /// passkey must be the approver's, and its counter must move as a
483    /// login's does.
484    pub fn decide_operator_access_stepped(
485        &self,
486        id: &str,
487        approver: &str,
488        approve: bool,
489        minutes: Option<u32>,
490        step_up: Option<(&str, u32)>,
491        now: i64,
492    ) -> Result<OperatorAccess> {
493        if let Some(m) = minutes {
494            if m == 0 || m > OPERATOR_ACCESS_MAX_MINUTES {
495                return Err(refused(format!(
496                    "a look lasts 1 to {OPERATOR_ACCESS_MAX_MINUTES} minutes"
497                )));
498            }
499        }
500        self.doc.update(|doc| {
501            let Some((k, mut row)) = Self::find(doc, id)? else {
502                return Err(refused(format!("no operator access request {id}")));
503            };
504            if !Self::admin_in(doc, approver, &row.account)? {
505                return Err(refused(format!(
506                    "{approver} is not an admin of account {}: only the account's admin decides an operator's look",
507                    row.account
508                )));
509            }
510            match row.state_at(now) {
511                OperatorAccessState::Pending => {}
512                other => return Err(refused(format!("request {id} is {}: it cannot be decided now", other.as_str()))),
513            }
514            if let Some((credential_id, presented)) = step_up {
515                let mut credential = doc
516                    .credentials
517                    .get(credential_id)?
518                    .ok_or_else(|| Error::NoSuchCredential(credential_id.to_owned()))?;
519                if credential.principal_id != approver {
520                    return Err(refused(format!("the step-up's passkey is not {approver}'s")));
521                }
522                if credential.sign_count != 0 && presented != 0 && presented <= credential.sign_count {
523                    return Err(Error::CounterRollback {
524                        id: credential_id.to_owned(),
525                        stored: credential.sign_count,
526                        presented,
527                    });
528                }
529                if presented != 0 {
530                    credential.sign_count = presented;
531                }
532                credential.last_used = Some(now);
533                doc.credentials.insert(credential_id.to_owned(), credential)?;
534            }
535            row.decided_by = approver.to_owned();
536            row.decided_at_unix_ms = now;
537            if approve {
538                let minutes = minutes.unwrap_or(row.minutes);
539                row.minutes = minutes;
540                row.state = OperatorAccessState::Approved;
541                row.granted_until_unix_ms = now + i64::from(minutes) * 60_000;
542            } else {
543                row.state = OperatorAccessState::Refused;
544            }
545            doc.operator_access.insert(k, row.clone())?;
546            Ok(row)
547        })
548    }
549
550    /// **End a look (or withdraw a request) early**: the account's admin, or
551    /// the operator that asked.
552    pub fn revoke_operator_access(&self, id: &str, by: &str, now: i64) -> Result<OperatorAccess> {
553        self.doc.update(|doc| {
554            let Some((k, mut row)) = Self::find(doc, id)? else {
555                return Err(refused(format!("no operator access request {id}")));
556            };
557            if by != row.operator && !Self::admin_in(doc, by, &row.account)? {
558                return Err(refused(format!(
559                    "{by} is not an admin of account {}: only the account's admin (or the operator that asked) ends a look",
560                    row.account
561                )));
562            }
563            match row.state_at(now) {
564                OperatorAccessState::Pending | OperatorAccessState::Approved => {}
565                other => return Err(refused(format!("request {id} is {} already", other.as_str()))),
566            }
567            row.state = OperatorAccessState::Revoked;
568            if row.granted_until_unix_ms > now {
569                row.granted_until_unix_ms = now;
570            }
571            row.decided_by = by.to_owned();
572            row.decided_at_unix_ms = now;
573            doc.operator_access.insert(k, row.clone())?;
574            Ok(row)
575        })
576    }
577
578    /// The looks asked into `account` (or into every account, `None`),
579    /// newest first, each as it stands now.
580    pub fn operator_access(&self, account: Option<&str>) -> Result<Vec<OperatorAccess>> {
581        self.operator_access_at(account, crate::model::now_ms())
582    }
583
584    /// [`AuthTable::operator_access`] at a given instant.
585    pub fn operator_access_at(
586        &self,
587        account: Option<&str>,
588        now: i64,
589    ) -> Result<Vec<OperatorAccess>> {
590        let mut rows: Vec<OperatorAccess> = self
591            .doc
592            .load()?
593            .operator_access
594            .values()?
595            .into_iter()
596            .filter(|row| account.is_none_or(|a| row.account == a))
597            .map(|row| row.as_at(now))
598            .collect();
599        rows.sort_by_key(|row| std::cmp::Reverse(row.requested_at_unix_ms));
600        Ok(rows)
601    }
602
603    /// One request by its id, as it stands now.
604    pub fn operator_access_by_id(&self, id: &str) -> Result<Option<OperatorAccess>> {
605        let now = crate::model::now_ms();
606        let doc = self.doc.load()?;
607        Ok(Self::find(&doc, id)?.map(|(_, row)| row.as_at(now)))
608    }
609}