use gunnar_core::account_audit::{to_csv, to_json, AccountEntry, AccountEvent, AuditQuery, Outcome};
use gunnar_core::name_filter::{NameKind, NameReview, Verdict};
use gunnar_core::{OperatorAccess, OperatorAccessState};
use gunnar_grpc::enterprise::{
AccountAuditEvent, AccountAuditQuery, EnterprisePlane, NameVerdict, OperatorAccessDecision,
};
use gunnar_grpc::{ControlError, ControlResult};
use super::{internal, CoreControlPlane};
fn entry_out(e: AccountEntry) -> gunnar_proto::AuditEntry {
gunnar_proto::AuditEntry {
seq: e.seq,
at_unix_ms: e.at_unix_ms,
kind: e.kind,
actor: e.actor,
actor_role: e.actor_role,
target: e.target,
detail: e.detail,
outcome: e.outcome,
prev: e.prev,
hash: e.hash,
}
}
fn query_in(q: &AccountAuditQuery) -> AuditQuery {
AuditQuery {
kinds: q.kinds.clone(),
actor: q.actor.clone(),
since_unix_ms: q.since_unix_ms,
until_unix_ms: q.until_unix_ms,
text: q.text.clone(),
before_seq: q.before_seq,
limit: q.limit as usize,
}
}
fn look_out(row: OperatorAccess) -> gunnar_proto::OperatorAccess {
let state = match row.state {
OperatorAccessState::Pending => gunnar_proto::OperatorAccessState::Pending,
OperatorAccessState::Approved => gunnar_proto::OperatorAccessState::Approved,
OperatorAccessState::Refused => gunnar_proto::OperatorAccessState::Refused,
OperatorAccessState::Revoked => gunnar_proto::OperatorAccessState::Revoked,
OperatorAccessState::Expired => gunnar_proto::OperatorAccessState::Expired,
};
gunnar_proto::OperatorAccess {
request_id: row.id,
code: row.code,
account: row.account,
operator: row.operator,
reason: row.reason,
minutes: row.minutes,
requested_at_unix_ms: row.requested_at_unix_ms,
request_expires_at_unix_ms: row.request_expires_at_unix_ms,
state: state as i32,
granted_until_unix_ms: row.granted_until_unix_ms,
decided_by: row.decided_by,
decided_at_unix_ms: row.decided_at_unix_ms,
}
}
fn review_out(r: NameReview) -> gunnar_proto::NameReview {
gunnar_proto::NameReview {
id: r.id,
account: r.account,
kind: r.kind,
name: r.name,
actor: r.actor,
verdict: r.verdict,
state: r.state.as_str().to_owned(),
at_unix_ms: r.at_unix_ms,
decided_by: r.decided_by,
decided_at_unix_ms: r.decided_at_unix_ms,
}
}
fn kind_in(kind: &str) -> ControlResult<NameKind> {
NameKind::parse(kind).ok_or_else(|| {
ControlError::InvalidArgument(format!("{kind:?} is not a kind of name: account, member, namespace or repo"))
})
}
fn lists_out(l: gunnar_core::name_filter::NameLists) -> gunnar_proto::NameLists {
gunnar_proto::NameLists {
blocked: l.blocked,
borderline: l.borderline,
reserved: l.reserved,
reserved_prefixes: l.reserved_prefixes,
allowed: l.allowed,
}
}
impl CoreControlPlane {
fn role_in(&self, actor: &str, machine: bool, account: &str) -> &'static str {
match self.stores.auth().role_in_account(actor, account) {
Ok("outsider" | "operator") if machine => "machine",
Ok(role) => role,
Err(_) => "unknown",
}
}
}
impl EnterprisePlane for CoreControlPlane {
fn account_of_namespace(&self, namespace: &str) -> ControlResult<Option<String>> {
self.stores.auth().account_of_namespace(namespace).map_err(internal)
}
fn account_of_principal(&self, principal_id: &str) -> ControlResult<Option<String>> {
self.stores.auth().account_of_principal(principal_id).map_err(internal)
}
fn is_account_admin(&self, principal_id: &str, account: &str) -> ControlResult<bool> {
self.stores.auth().is_account_admin(principal_id, account).map_err(internal)
}
fn shielded(&self, principal_id: &str, namespace: &str) -> ControlResult<bool> {
self.stores.auth().shielded(principal_id, namespace).map_err(internal)
}
fn account_audit_record(&self, account: &str, event: &AccountAuditEvent) -> ControlResult<()> {
let role = self.role_in(&event.actor, event.actor_machine, account);
self.stores
.account_audit()
.append(
account,
AccountEvent {
kind: &event.kind,
actor: &event.actor,
actor_role: role,
target: &event.target,
detail: &event.detail,
outcome: if event.refused { Outcome::Refused } else { Outcome::Ok },
},
gunnar_core::now_ms(),
)
.map(|_| ())
.map_err(internal)
}
fn account_audit_log(&self, account: &str, query: &AccountAuditQuery) -> ControlResult<gunnar_proto::AuditLogResponse> {
let log = self.stores.account_audit();
let page = log.query(account, &query_in(query)).map_err(internal)?;
let chain = if query.before_seq.is_none() {
let report = log.verify(account).map_err(internal)?;
Some(gunnar_proto::AuditChain {
holds: report.holds,
entries: report.entries,
tip_hash: report.tip_hash,
anchor_seq: report.anchor_seq,
broken_at_seq: report.broken_at_seq,
what: report.what,
})
} else {
None
};
let (written, _) = log.head(account).map_err(internal)?;
Ok(gunnar_proto::AuditLogResponse {
account: account.to_owned(),
entries: page.entries.into_iter().map(entry_out).collect(),
next_before_seq: page.next_before_seq,
chain,
retention_days: log.retention_days(),
written,
})
}
fn account_audit_export(
&self,
account: &str,
query: &AccountAuditQuery,
format: gunnar_proto::AuditExportFormat,
) -> ControlResult<gunnar_proto::ExportAuditLogResponse> {
let entries = self.stores.account_audit().all(account, &query_in(query)).map_err(internal)?;
let day = gunnar_core::iso8601_utc(gunnar_core::now_ms());
let day = &day[..10];
let (body, content_type, ext) = match format {
gunnar_proto::AuditExportFormat::Json => (to_json(&entries), "application/json", "json"),
_ => (to_csv(&entries), "text/csv; charset=utf-8", "csv"),
};
Ok(gunnar_proto::ExportAuditLogResponse {
body: body.into_bytes(),
content_type: content_type.to_owned(),
file_name: format!("audit-{account}-{day}.{ext}"),
entries: entries.len() as u64,
})
}
fn aggregates(&self) -> ControlResult<gunnar_proto::AggregatesResponse> {
let auth = self.stores.auth();
let mut out = gunnar_proto::AggregatesResponse {
at_unix_ms: gunnar_core::now_ms(),
..Default::default()
};
let mut accounts = std::collections::BTreeSet::new();
let mut employers = std::collections::BTreeSet::new();
for p in auth.principals().map_err(internal)? {
if p.kind != gunnar_core::PrincipalKind::User {
continue;
}
match auth.employer_of(&p.id).map_err(internal)? {
Some(employer) => {
out.members += 1;
employers.insert(employer);
}
None if auth.account_of_principal(&p.id).map_err(internal)?.as_deref() == Some(p.id.as_str()) => {
accounts.insert(p.id);
}
None => {}
}
}
out.accounts = accounts.len() as u64;
out.enterprise_accounts = employers.iter().filter(|a| accounts.contains(*a)).count() as u64;
let in_account = |ns: &str| {
let first = ns.split('/').next().unwrap_or("");
accounts.contains(first) || auth.is_clan(first).unwrap_or(false)
};
for repo in self.stores.catalog().list(None).map_err(internal)? {
out.repos += 1;
if repo.visibility == gunnar_core::Visibility::Public {
out.public_repos += 1;
}
if !in_account(&repo.namespace) {
out.operator_repos += 1;
}
}
if let Ok(rows) = gunnar_grpc::ControlPlane::usage(self, None) {
out.stored_bytes = rows.iter().map(|row| row.pack_bytes.saturating_add(row.lfs_bytes)).sum();
}
#[cfg(feature = "metered")]
if let Ok(facts) = gunnar_grpc::ControlPlane::entitlement_list(self) {
for fact in &facts {
out.sold_bytes = out.sold_bytes.saturating_add(fact.caps.get("pack_bytes").copied().unwrap_or(0));
let state = gunnar_proto::EntitlementState::try_from(fact.state).unwrap_or_default();
if in_account(&fact.namespace) && matches!(state, gunnar_proto::EntitlementState::Paid | gunnar_proto::EntitlementState::Grace) {
out.paid_accounts += 1;
}
}
}
for look in auth.operator_access(None).map_err(internal)? {
match look.state {
OperatorAccessState::Approved => out.looks_live += 1,
OperatorAccessState::Pending => out.looks_pending += 1,
_ => {}
}
}
let alarms = gunnar_grpc::ControlPlane::health_alarms(self);
out.health_alarms = alarms.len() as u32;
out.health = if alarms.is_empty() { "ok".to_owned() } else { "alarm".to_owned() };
Ok(out)
}
fn operator_access_request(
&self,
account: &str,
operator: &str,
reason: &str,
minutes: u32,
) -> ControlResult<gunnar_proto::OperatorAccess> {
let row = self
.stores
.auth()
.request_operator_access(account, operator, reason, minutes, gunnar_core::now_ms())
.map_err(internal)?;
Ok(look_out(row))
}
fn operator_access_list(&self, account: Option<&str>) -> ControlResult<Vec<gunnar_proto::OperatorAccess>> {
Ok(self
.stores
.auth()
.operator_access(account)
.map_err(internal)?
.into_iter()
.map(look_out)
.collect())
}
fn operator_access_get(&self, request_id: &str) -> ControlResult<Option<gunnar_proto::OperatorAccess>> {
Ok(self.stores.auth().operator_access_by_id(request_id).map_err(internal)?.map(look_out))
}
fn operator_access_decide(&self, decision: &OperatorAccessDecision) -> ControlResult<gunnar_proto::OperatorAccess> {
let step_up = decision
.step_up
.as_ref()
.map(|(id, count)| (gunnar_grpc::webauthn::b64url(id), *count));
let row = self
.stores
.auth()
.decide_operator_access_stepped(
&decision.request_id,
&decision.approver,
decision.approve,
decision.minutes,
step_up.as_ref().map(|(id, count)| (id.as_str(), *count)),
gunnar_core::now_ms(),
)
.map_err(internal)?;
Ok(look_out(row))
}
fn operator_access_revoke(&self, request_id: &str, by: &str) -> ControlResult<gunnar_proto::OperatorAccess> {
let row = self
.stores
.auth()
.revoke_operator_access(request_id, by, gunnar_core::now_ms())
.map_err(internal)?;
Ok(look_out(row))
}
fn name_check(&self, kind: &str, name: &str, account: &str) -> ControlResult<NameVerdict> {
let kind = kind_in(kind)?;
let verdict = self.stores.names().check(kind, name, account).map_err(internal)?;
Ok(NameVerdict {
verdict: verdict.as_str().to_owned(),
sentence: if verdict == Verdict::Clean { String::new() } else { verdict.sentence(kind, name) },
})
}
fn name_review_add(
&self,
kind: &str,
name: &str,
account: &str,
actor: &str,
hold: bool,
) -> ControlResult<gunnar_proto::NameReview> {
let kind = kind_in(kind)?;
let names = self.stores.names();
let verdict = names.check(kind, name, account).map_err(internal)?;
let now = gunnar_core::now_ms();
let row = if hold {
names.hold(kind, name, account, actor, &verdict, now)
} else {
names.note(kind, name, account, actor, &verdict, now)
}
.map_err(internal)?;
Ok(review_out(row))
}
fn name_lists(&self) -> ControlResult<gunnar_proto::NameLists> {
Ok(lists_out(self.stores.names().lists().map_err(internal)?))
}
fn set_name_lists(&self, lists: gunnar_proto::NameLists) -> ControlResult<gunnar_proto::NameLists> {
let stored = self
.stores
.names()
.set_lists(gunnar_core::name_filter::NameLists {
blocked: lists.blocked,
borderline: lists.borderline,
reserved: lists.reserved,
reserved_prefixes: lists.reserved_prefixes,
allowed: lists.allowed,
})
.map_err(internal)?;
Ok(lists_out(stored))
}
fn name_reviews(&self, account: &str) -> ControlResult<Vec<gunnar_proto::NameReview>> {
Ok(self
.stores
.names()
.reviews(account)
.map_err(internal)?
.into_iter()
.map(review_out)
.collect())
}
fn name_review_decide(&self, account: &str, id: &str, allow: bool, by: &str) -> ControlResult<gunnar_proto::NameReview> {
let row = self
.stores
.names()
.decide(id, account, allow, by, gunnar_core::now_ms())
.map_err(internal)?;
Ok(review_out(row))
}
}