1use 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 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 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 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 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 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 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}