Skip to main content

battlecommand_forge/
enterprise.rs

1//! Enterprise features: audit logging, cost tracking, RBAC.
2//!
3//! File-based implementation. Can be upgraded to PostgreSQL later.
4
5use anyhow::Result;
6use serde::{Deserialize, Serialize};
7use std::fs;
8use std::path::Path;
9
10const AUDIT_LOG: &str = ".battlecommand/audit.jsonl";
11const COSTS_LOG: &str = ".battlecommand/costs.jsonl";
12
13// ── Audit Logging ──
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct AuditEntry {
17    pub timestamp: String,
18    pub actor: String,
19    pub action: String,
20    pub resource: String,
21    pub details: String,
22}
23
24/// Log an audit event (append-only, crash-safe).
25pub fn audit_log(action: &str, resource: &str, details: &str) -> Result<()> {
26    use std::io::Write;
27
28    let entry = AuditEntry {
29        timestamp: chrono::Utc::now().to_rfc3339(),
30        actor: whoami(),
31        action: action.to_string(),
32        resource: resource.to_string(),
33        details: details.to_string(),
34    };
35
36    let json = serde_json::to_string(&entry)?;
37
38    let path = Path::new(AUDIT_LOG);
39    crate::secrets::ensure_secret_file(path)?;
40    let mut file = fs::OpenOptions::new().append(true).open(path)?;
41    writeln!(file, "{}", json)?;
42    Ok(())
43}
44
45/// Read recent audit entries.
46pub fn read_audit_log(limit: usize) -> Result<Vec<AuditEntry>> {
47    let content = fs::read_to_string(AUDIT_LOG).unwrap_or_default();
48    let entries: Vec<AuditEntry> = content
49        .lines()
50        .filter(|l| !l.is_empty())
51        .filter_map(|l| serde_json::from_str(l).ok())
52        .collect();
53
54    Ok(entries.into_iter().rev().take(limit).collect())
55}
56
57// ── Cost Tracking ──
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct CostEntry {
61    pub timestamp: String,
62    pub mission_id: String,
63    pub model: String,
64    pub role: String,
65    pub input_tokens: u64,
66    pub output_tokens: u64,
67    pub cost_usd: f64,
68}
69
70/// Pricing per million tokens (input/output).
71fn model_pricing(model: &str) -> (f64, f64) {
72    let lower = model.to_lowercase();
73    if lower.contains("claude") && lower.contains("opus") {
74        (5.0, 25.0) // Opus 4.6: $5/MTok in, $25/MTok out
75    } else if lower.contains("claude") && lower.contains("sonnet") {
76        (3.0, 15.0) // Sonnet 4.6
77    } else if lower.contains("claude") && lower.contains("haiku") {
78        (0.25, 1.25) // Haiku 4.5
79    } else if lower.contains("grok") {
80        (3.0, 15.0) // Grok pricing (xAI)
81    } else {
82        (0.0, 0.0) // local models are free
83    }
84}
85
86/// Log a cost entry (append-only, crash-safe).
87pub fn log_cost(
88    mission_id: &str,
89    model: &str,
90    role: &str,
91    input_tokens: u64,
92    output_tokens: u64,
93) -> Result<()> {
94    use std::io::Write;
95
96    let (input_price, output_price) = model_pricing(model);
97    let cost =
98        (input_tokens as f64 * input_price + output_tokens as f64 * output_price) / 1_000_000.0;
99
100    // Skip logging for free local models
101    if cost == 0.0 {
102        return Ok(());
103    }
104
105    let entry = CostEntry {
106        timestamp: chrono::Utc::now().to_rfc3339(),
107        mission_id: mission_id.to_string(),
108        model: model.to_string(),
109        role: role.to_string(),
110        input_tokens,
111        output_tokens,
112        cost_usd: cost,
113    };
114
115    let json = serde_json::to_string(&entry)?;
116
117    let path = Path::new(COSTS_LOG);
118    crate::secrets::ensure_secret_file(path)?;
119    let mut file = fs::OpenOptions::new().append(true).open(path)?;
120    writeln!(file, "{}", json)?;
121    Ok(())
122}
123
124/// Get total cost across all missions.
125pub fn total_cost() -> Result<f64> {
126    let content = fs::read_to_string(COSTS_LOG).unwrap_or_default();
127    let total: f64 = content
128        .lines()
129        .filter(|l| !l.is_empty())
130        .filter_map(|l| serde_json::from_str::<CostEntry>(l).ok())
131        .map(|e| e.cost_usd)
132        .sum();
133    Ok(total)
134}
135
136// ── RBAC ──
137
138#[derive(Debug, Clone, PartialEq)]
139pub enum Role {
140    Admin,
141    Developer,
142    Viewer,
143}
144
145impl Role {
146    pub fn can_create_mission(&self) -> bool {
147        matches!(self, Role::Admin | Role::Developer)
148    }
149
150    pub fn can_view_audit(&self) -> bool {
151        matches!(self, Role::Admin | Role::Viewer)
152    }
153
154    pub fn can_manage_models(&self) -> bool {
155        matches!(self, Role::Admin)
156    }
157
158    pub fn cost_budget_usd(&self) -> f64 {
159        match self {
160            Role::Admin => 100.0,
161            Role::Developer => 10.0,
162            Role::Viewer => 0.0,
163        }
164    }
165}
166
167fn whoami() -> String {
168    std::env::var("USER")
169        .or_else(|_| std::env::var("USERNAME"))
170        .unwrap_or_else(|_| "unknown".into())
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    #[test]
178    fn test_model_pricing() {
179        let (inp, out) = model_pricing("claude-sonnet-4-20250514");
180        assert_eq!(inp, 3.0);
181        assert_eq!(out, 15.0);
182
183        let (inp, out) = model_pricing("claude-opus-4-6");
184        assert_eq!(inp, 5.0);
185        assert_eq!(out, 25.0);
186
187        let (inp, out) = model_pricing("grok-4.20-reasoning");
188        assert_eq!(inp, 3.0);
189        assert_eq!(out, 15.0);
190
191        let (inp, out) = model_pricing("qwen2.5-coder:32b");
192        assert_eq!(inp, 0.0);
193        assert_eq!(out, 0.0);
194    }
195
196    #[test]
197    fn test_rbac() {
198        assert!(Role::Admin.can_create_mission());
199        assert!(Role::Developer.can_create_mission());
200        assert!(!Role::Viewer.can_create_mission());
201        assert!(Role::Admin.can_manage_models());
202        assert!(!Role::Developer.can_manage_models());
203    }
204
205    #[test]
206    fn test_total_cost_empty() {
207        let cost = total_cost().unwrap();
208        assert!(cost >= 0.0);
209    }
210}