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