Skip to main content

gunnar_server/control_plane/
enterprise_plane.rs

1//! **The owner's enterprise list on the real stores** (2026-10-05):
2//! `gunnar_grpc::enterprise::EnterprisePlane` over `gunnar_core`'s account
3//! audit logs, its name filter and the identity table's shield and looks.
4
5use gunnar_core::account_audit::{
6    to_csv, to_json, AccountEntry, AccountEvent, AuditQuery, Outcome,
7};
8use gunnar_core::name_filter::{NameKind, NameReview, Verdict};
9use gunnar_core::{OperatorAccess, OperatorAccessState};
10use gunnar_grpc::enterprise::{
11    AccountAuditEvent, AccountAuditQuery, EnterprisePlane, NameVerdict, OperatorAccessDecision,
12};
13use gunnar_grpc::{ControlError, ControlResult};
14
15use super::{internal, CoreControlPlane};
16
17fn entry_out(e: AccountEntry) -> gunnar_proto::AuditEntry {
18    gunnar_proto::AuditEntry {
19        seq: e.seq,
20        at_unix_ms: e.at_unix_ms,
21        kind: e.kind,
22        actor: e.actor,
23        actor_role: e.actor_role,
24        target: e.target,
25        detail: e.detail,
26        outcome: e.outcome,
27        prev: e.prev,
28        hash: e.hash,
29    }
30}
31
32fn query_in(q: &AccountAuditQuery) -> AuditQuery {
33    AuditQuery {
34        kinds: q.kinds.clone(),
35        actor: q.actor.clone(),
36        since_unix_ms: q.since_unix_ms,
37        until_unix_ms: q.until_unix_ms,
38        text: q.text.clone(),
39        before_seq: q.before_seq,
40        limit: q.limit as usize,
41    }
42}
43
44fn look_out(row: OperatorAccess) -> gunnar_proto::OperatorAccess {
45    let state = match row.state {
46        OperatorAccessState::Pending => gunnar_proto::OperatorAccessState::Pending,
47        OperatorAccessState::Approved => gunnar_proto::OperatorAccessState::Approved,
48        OperatorAccessState::Refused => gunnar_proto::OperatorAccessState::Refused,
49        OperatorAccessState::Revoked => gunnar_proto::OperatorAccessState::Revoked,
50        OperatorAccessState::Expired => gunnar_proto::OperatorAccessState::Expired,
51    };
52    gunnar_proto::OperatorAccess {
53        request_id: row.id,
54        code: row.code,
55        account: row.account,
56        operator: row.operator,
57        reason: row.reason,
58        minutes: row.minutes,
59        requested_at_unix_ms: row.requested_at_unix_ms,
60        request_expires_at_unix_ms: row.request_expires_at_unix_ms,
61        state: state as i32,
62        granted_until_unix_ms: row.granted_until_unix_ms,
63        decided_by: row.decided_by,
64        decided_at_unix_ms: row.decided_at_unix_ms,
65    }
66}
67
68fn review_out(r: NameReview) -> gunnar_proto::NameReview {
69    gunnar_proto::NameReview {
70        id: r.id,
71        account: r.account,
72        kind: r.kind,
73        name: r.name,
74        actor: r.actor,
75        verdict: r.verdict,
76        state: r.state.as_str().to_owned(),
77        at_unix_ms: r.at_unix_ms,
78        decided_by: r.decided_by,
79        decided_at_unix_ms: r.decided_at_unix_ms,
80    }
81}
82
83fn kind_in(kind: &str) -> ControlResult<NameKind> {
84    NameKind::parse(kind).ok_or_else(|| {
85        ControlError::InvalidArgument(format!(
86            "{kind:?} is not a kind of name: account, member, namespace or repo"
87        ))
88    })
89}
90
91fn lists_out(l: gunnar_core::name_filter::NameLists) -> gunnar_proto::NameLists {
92    gunnar_proto::NameLists {
93        blocked: l.blocked,
94        borderline: l.borderline,
95        reserved: l.reserved,
96        reserved_prefixes: l.reserved_prefixes,
97        allowed: l.allowed,
98    }
99}
100
101impl CoreControlPlane {
102    /// What `actor` is to `account` (`AuthTable::role_in_account`); a machine
103    /// that authenticated as one is named so even where the table says less.
104    fn role_in(&self, actor: &str, machine: bool, account: &str) -> &'static str {
105        match self.stores.auth().role_in_account(actor, account) {
106            Ok("outsider" | "operator") if machine => "machine",
107            Ok(role) => role,
108            Err(_) => "unknown",
109        }
110    }
111}
112
113impl EnterprisePlane for CoreControlPlane {
114    fn account_of_namespace(&self, namespace: &str) -> ControlResult<Option<String>> {
115        self.stores
116            .auth()
117            .account_of_namespace(namespace)
118            .map_err(internal)
119    }
120
121    fn account_of_principal(&self, principal_id: &str) -> ControlResult<Option<String>> {
122        self.stores
123            .auth()
124            .account_of_principal(principal_id)
125            .map_err(internal)
126    }
127
128    fn is_account_admin(&self, principal_id: &str, account: &str) -> ControlResult<bool> {
129        self.stores
130            .auth()
131            .is_account_admin(principal_id, account)
132            .map_err(internal)
133    }
134
135    fn shielded(&self, principal_id: &str, namespace: &str) -> ControlResult<bool> {
136        self.stores
137            .auth()
138            .shielded(principal_id, namespace)
139            .map_err(internal)
140    }
141
142    fn account_audit_record(&self, account: &str, event: &AccountAuditEvent) -> ControlResult<()> {
143        let role = self.role_in(&event.actor, event.actor_machine, account);
144        self.stores
145            .account_audit()
146            .append(
147                account,
148                AccountEvent {
149                    kind: &event.kind,
150                    actor: &event.actor,
151                    actor_role: role,
152                    target: &event.target,
153                    detail: &event.detail,
154                    outcome: if event.refused {
155                        Outcome::Refused
156                    } else {
157                        Outcome::Ok
158                    },
159                },
160                gunnar_core::now_ms(),
161            )
162            .map(|_| ())
163            .map_err(internal)
164    }
165
166    fn account_audit_log(
167        &self,
168        account: &str,
169        query: &AccountAuditQuery,
170    ) -> ControlResult<gunnar_proto::AuditLogResponse> {
171        let log = self.stores.account_audit();
172        let page = log.query(account, &query_in(query)).map_err(internal)?;
173        // The chain is checked on the FIRST page: every entry hashed once,
174        // what an admin sees beside the newest entries.
175        let chain = if query.before_seq.is_none() {
176            let report = log.verify(account).map_err(internal)?;
177            Some(gunnar_proto::AuditChain {
178                holds: report.holds,
179                entries: report.entries,
180                tip_hash: report.tip_hash,
181                anchor_seq: report.anchor_seq,
182                broken_at_seq: report.broken_at_seq,
183                what: report.what,
184            })
185        } else {
186            None
187        };
188        let (written, _) = log.head(account).map_err(internal)?;
189        Ok(gunnar_proto::AuditLogResponse {
190            account: account.to_owned(),
191            entries: page.entries.into_iter().map(entry_out).collect(),
192            next_before_seq: page.next_before_seq,
193            chain,
194            retention_days: log.retention_days(),
195            written,
196        })
197    }
198
199    fn account_audit_export(
200        &self,
201        account: &str,
202        query: &AccountAuditQuery,
203        format: gunnar_proto::AuditExportFormat,
204    ) -> ControlResult<gunnar_proto::ExportAuditLogResponse> {
205        let entries = self
206            .stores
207            .account_audit()
208            .all(account, &query_in(query))
209            .map_err(internal)?;
210        let day = gunnar_core::iso8601_utc(gunnar_core::now_ms());
211        let day = &day[..10];
212        let (body, content_type, ext) = match format {
213            gunnar_proto::AuditExportFormat::Json => {
214                (to_json(&entries), "application/json", "json")
215            }
216            _ => (to_csv(&entries), "text/csv; charset=utf-8", "csv"),
217        };
218        Ok(gunnar_proto::ExportAuditLogResponse {
219            body: body.into_bytes(),
220            content_type: content_type.to_owned(),
221            file_name: format!("audit-{account}-{day}.{ext}"),
222            entries: entries.len() as u64,
223        })
224    }
225
226    fn aggregates(&self) -> ControlResult<gunnar_proto::AggregatesResponse> {
227        let auth = self.stores.auth();
228        let mut out = gunnar_proto::AggregatesResponse {
229            at_unix_ms: gunnar_core::now_ms(),
230            ..Default::default()
231        };
232        let mut accounts = std::collections::BTreeSet::new();
233        let mut employers = std::collections::BTreeSet::new();
234        for p in auth.principals().map_err(internal)? {
235            if p.kind != gunnar_core::PrincipalKind::User {
236                continue;
237            }
238            match auth.employer_of(&p.id).map_err(internal)? {
239                Some(employer) => {
240                    out.members += 1;
241                    employers.insert(employer);
242                }
243                // A person who is an account — not the box's own first
244                // identity, whose namespace is the estate's.
245                None if auth
246                    .account_of_principal(&p.id)
247                    .map_err(internal)?
248                    .as_deref()
249                    == Some(p.id.as_str()) =>
250                {
251                    accounts.insert(p.id);
252                }
253                None => {}
254            }
255        }
256        out.accounts = accounts.len() as u64;
257        out.enterprise_accounts = employers.iter().filter(|a| accounts.contains(*a)).count() as u64;
258        // A CLAN's namespace is inside the accounts too (clans + the
259        // enterprise list, 2026-10-05): its repositories are its members',
260        // never the operator's. A clan is no account of its own in the count.
261        let in_account = |ns: &str| {
262            let first = ns.split('/').next().unwrap_or("");
263            accounts.contains(first) || auth.is_clan(first).unwrap_or(false)
264        };
265        // ★ **The WHOLE estate** (round 5.3; the owner on test2 2026-10-08:
266        // "Repositories 0 (0 public) · Storage used 0 B · Room held —" over
267        // the seven repositories, ~205 MB, the release functest seeded into
268        // the operator's `nordisk`). Repositories, the public ones among them,
269        // the stored bytes and the room held are every namespace's — the
270        // operator's own forge included — as numbers, never names.
271        // `operator_repos` is the operator's SHARE of `repos`: what is not
272        // inside an account.
273        for repo in self.stores.catalog().list(None).map_err(internal)? {
274            out.repos += 1;
275            if repo.visibility == gunnar_core::Visibility::Public {
276                out.public_repos += 1;
277            }
278            if !in_account(&repo.namespace) {
279                out.operator_repos += 1;
280            }
281        }
282        if let Ok(rows) = gunnar_grpc::ControlPlane::usage(self, None) {
283            out.stored_bytes = rows
284                .iter()
285                .map(|row| row.pack_bytes.saturating_add(row.lfs_bytes))
286                .sum();
287        }
288        #[cfg(feature = "metered")]
289        if let Ok(facts) = gunnar_grpc::ControlPlane::entitlement_list(self) {
290            for fact in &facts {
291                out.sold_bytes = out
292                    .sold_bytes
293                    .saturating_add(fact.caps.get("pack_bytes").copied().unwrap_or(0));
294                // Accounts holding a paid plan are ACCOUNTS: the operator's
295                // forge is none.
296                let state =
297                    gunnar_proto::EntitlementState::try_from(fact.state).unwrap_or_default();
298                if in_account(&fact.namespace)
299                    && matches!(
300                        state,
301                        gunnar_proto::EntitlementState::Paid
302                            | gunnar_proto::EntitlementState::Grace
303                    )
304                {
305                    out.paid_accounts += 1;
306                }
307            }
308        }
309        for look in auth.operator_access(None).map_err(internal)? {
310            match look.state {
311                OperatorAccessState::Approved => out.looks_live += 1,
312                OperatorAccessState::Pending => out.looks_pending += 1,
313                _ => {}
314            }
315        }
316        let alarms = gunnar_grpc::ControlPlane::health_alarms(self);
317        out.health_alarms = alarms.len() as u32;
318        out.health = if alarms.is_empty() {
319            "ok".to_owned()
320        } else {
321            "alarm".to_owned()
322        };
323        Ok(out)
324    }
325
326    fn operator_access_request(
327        &self,
328        account: &str,
329        operator: &str,
330        reason: &str,
331        minutes: u32,
332    ) -> ControlResult<gunnar_proto::OperatorAccess> {
333        let row = self
334            .stores
335            .auth()
336            .request_operator_access(account, operator, reason, minutes, gunnar_core::now_ms())
337            .map_err(internal)?;
338        Ok(look_out(row))
339    }
340
341    fn operator_access_list(
342        &self,
343        account: Option<&str>,
344    ) -> ControlResult<Vec<gunnar_proto::OperatorAccess>> {
345        Ok(self
346            .stores
347            .auth()
348            .operator_access(account)
349            .map_err(internal)?
350            .into_iter()
351            .map(look_out)
352            .collect())
353    }
354
355    fn operator_access_get(
356        &self,
357        request_id: &str,
358    ) -> ControlResult<Option<gunnar_proto::OperatorAccess>> {
359        Ok(self
360            .stores
361            .auth()
362            .operator_access_by_id(request_id)
363            .map_err(internal)?
364            .map(look_out))
365    }
366
367    fn operator_access_decide(
368        &self,
369        decision: &OperatorAccessDecision,
370    ) -> ControlResult<gunnar_proto::OperatorAccess> {
371        let step_up = decision
372            .step_up
373            .as_ref()
374            .map(|(id, count)| (gunnar_grpc::webauthn::b64url(id), *count));
375        let row = self
376            .stores
377            .auth()
378            .decide_operator_access_stepped(
379                &decision.request_id,
380                &decision.approver,
381                decision.approve,
382                decision.minutes,
383                step_up.as_ref().map(|(id, count)| (id.as_str(), *count)),
384                gunnar_core::now_ms(),
385            )
386            .map_err(internal)?;
387        Ok(look_out(row))
388    }
389
390    fn operator_access_revoke(
391        &self,
392        request_id: &str,
393        by: &str,
394    ) -> ControlResult<gunnar_proto::OperatorAccess> {
395        let row = self
396            .stores
397            .auth()
398            .revoke_operator_access(request_id, by, gunnar_core::now_ms())
399            .map_err(internal)?;
400        Ok(look_out(row))
401    }
402
403    fn name_check(&self, kind: &str, name: &str, account: &str) -> ControlResult<NameVerdict> {
404        let kind = kind_in(kind)?;
405        let verdict = self
406            .stores
407            .names()
408            .check(kind, name, account)
409            .map_err(internal)?;
410        Ok(NameVerdict {
411            verdict: verdict.as_str().to_owned(),
412            sentence: if verdict == Verdict::Clean {
413                String::new()
414            } else {
415                verdict.sentence(kind, name)
416            },
417        })
418    }
419
420    fn name_review_add(
421        &self,
422        kind: &str,
423        name: &str,
424        account: &str,
425        actor: &str,
426        hold: bool,
427    ) -> ControlResult<gunnar_proto::NameReview> {
428        let kind = kind_in(kind)?;
429        let names = self.stores.names();
430        let verdict = names.check(kind, name, account).map_err(internal)?;
431        let now = gunnar_core::now_ms();
432        let row = if hold {
433            names.hold(kind, name, account, actor, &verdict, now)
434        } else {
435            names.note(kind, name, account, actor, &verdict, now)
436        }
437        .map_err(internal)?;
438        Ok(review_out(row))
439    }
440
441    fn name_lists(&self) -> ControlResult<gunnar_proto::NameLists> {
442        Ok(lists_out(self.stores.names().lists().map_err(internal)?))
443    }
444
445    fn set_name_lists(
446        &self,
447        lists: gunnar_proto::NameLists,
448    ) -> ControlResult<gunnar_proto::NameLists> {
449        let stored = self
450            .stores
451            .names()
452            .set_lists(gunnar_core::name_filter::NameLists {
453                blocked: lists.blocked,
454                borderline: lists.borderline,
455                reserved: lists.reserved,
456                reserved_prefixes: lists.reserved_prefixes,
457                allowed: lists.allowed,
458            })
459            .map_err(internal)?;
460        Ok(lists_out(stored))
461    }
462
463    fn name_reviews(&self, account: &str) -> ControlResult<Vec<gunnar_proto::NameReview>> {
464        Ok(self
465            .stores
466            .names()
467            .reviews(account)
468            .map_err(internal)?
469            .into_iter()
470            .map(review_out)
471            .collect())
472    }
473
474    fn name_review_decide(
475        &self,
476        account: &str,
477        id: &str,
478        allow: bool,
479        by: &str,
480    ) -> ControlResult<gunnar_proto::NameReview> {
481        let row = self
482            .stores
483            .names()
484            .decide(id, account, allow, by, gunnar_core::now_ms())
485            .map_err(internal)?;
486        Ok(review_out(row))
487    }
488}