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