flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/apps/api/src/audit.rs

259 lines8,577 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API1//! Least privilege and the audit trail, around every operation.
2//!
3//! An agent's token is checked against its run's scope and the person it
4//! acts for before anything runs (see `g1t_contracts::credentials`); a
5//! runner's credential then acts downstream as that person. Everything an
6//! agent does is recorded, reads included, as is every change a person or
7//! a workspace token makes. Refusals are recorded with their rule.
8
9use g1t_contracts::audit::{AuditActor, AuditTarget, NewAuditEntry, RecordAuditArgs, Surface};
10use g1t_contracts::credentials::{Decision, as_person, decide_operation, is_read};
11use g1t_contracts::repos::RepoPath;
12use g1t_contracts::{FailureCode, Outcome, PrincipalKind, User, Viewer};
13use serde_json::Value;
14use worker::{Request, Result, console_error};
15
16use crate::operations::{Op, Services};
17
18/// Where a request came in, and the id it is recorded under.
19#[derive(Clone, Debug)]
20pub struct AuditContext {
21 pub request_id: String,
22 pub surface: Surface,
23}
24
25impl Default for AuditContext {
26 fn default() -> Self {
27 AuditContext {
28 request_id: String::new(),
29 surface: Surface::Rest,
30 }
31 }
32}
33
34impl AuditContext {
35 /// Cloudflare's ray id, which also finds the request in Workers logs.
36 pub fn of(request: &Request, on_mcp: bool) -> Self {
37 let ray = request.headers().get("cf-ray").ok().flatten();
38 AuditContext {
39 request_id: ray.unwrap_or_else(|| g1t_contracts::new_id("req", g1t_kit::now_ms())),
40 surface: if on_mcp { Surface::Mcp } else { Surface::Rest },
41 }
42 }
43}
44
45fn repo_path(input: &Value) -> Option<RepoPath> {
46 let mut parts = input["repo"].as_str()?.split('/');
47 match (parts.next(), parts.next(), parts.next()) {
48 (Some(namespace), Some(name), None) if !namespace.is_empty() && !name.is_empty() => {
49 Some(RepoPath {
50 namespace: namespace.to_owned(),
51 name: name.to_owned(),
52 })
53 }
54 _ => None,
55 }
56}
57
58fn number(input: &Value) -> Option<u32> {
59 match &input["number"] {
60 Value::Number(number) => number.as_u64().and_then(|n| u32::try_from(n).ok()),
61 Value::String(digits) => digits.parse().ok(),
62 _ => None,
63 }
64}
65
66fn some_text(input: &Value, key: &str) -> Option<String> {
67 input[key]
68 .as_str()
69 .filter(|value| !value.is_empty())
70 .map(str::to_owned)
71}
72
73/// What an operation named, for its entry.
74pub fn target(user: &User, input: &Value) -> AuditTarget {
75 let repo = repo_path(input);
76 let workspace = repo
77 .as_ref()
78 .map(|repo| repo.namespace.clone())
79 .or_else(|| some_text(input, "workspace"))
80 .or_else(|| some_text(input, "slug"))
81 .or_else(|| {
82 user.acting
83 .as_ref()
84 .map(|acting| acting.scope.repo.namespace.clone())
85 })
86 .unwrap_or_default()
87 .to_lowercase();
88 AuditTarget {
89 workspace,
90 repo: repo.map(|repo| format!("{}/{}", repo.namespace, repo.name)),
91 number: number(input),
92 git_ref: some_text(input, "ref").or_else(|| some_text(input, "branch")),
93 path: some_text(input, "path"),
94 }
95}
96
97/// The rule that let a person or a workspace token through: theirs is
98/// decided by the service that owns what they asked about.
99fn principal_rule(user: &User) -> &'static str {
100 match user.kind {
101 PrincipalKind::Workspace => "workspace-token",
102 _ => "person",
103 }
104}
105
106/// Whether the API refused an agent before anything ran, and why.
107pub fn decide(op: Op, services: &Services, viewer: &Viewer, input: &Value) -> Option<Decision> {
108 let user = viewer
109 .as_ref()
110 .filter(|user| user.kind == PrincipalKind::Agent)?;
111 let scope = services.scope.as_ref()?;
112 Some(decide_operation(
113 user,
114 scope,
115 op.name(),
116 repo_path(input).as_ref(),
117 op.needs_repo(),
118 number(input),
119 ))
120}
121
122/// Runs `op` for `viewer`: enforcing an agent's scope first, and recording
123/// what happened.
124pub async fn run(
125 op: Op,
126 services: &Services,
127 viewer: &Viewer,
128 input: &Value,
129) -> Result<Outcome<Value>> {
130 let decision = decide(op, services, viewer, input);
131 if let Some(decision) = decision.as_ref().filter(|decision| !decision.allowed) {
132 record(op, services, viewer, input, decision, None).await;
133 return Ok(Outcome::fail(
134 FailureCode::Forbidden,
135 decision.reason.as_deref().unwrap_or("Not allowed."),
136 ));
137 }
138 // A runner's credential acts as the person, within the run's scope.
139 let downstream: Viewer = viewer
140 .as_ref()
141 .and_then(as_person)
142 .or_else(|| viewer.clone());
143 let outcome = op.run(services, &downstream, input).await?;
144 let decision = decision.unwrap_or_else(|| match viewer {
145 Some(user) => Decision::allow(principal_rule(user)),
146 None => Decision::allow("anonymous"),
147 });
148 record(op, services, viewer, input, &decision, Some(&outcome)).await;
149 Ok(outcome)
150}
151
152/// Appends the entry, if this is something the log keeps. A failure to
153/// record is logged, never passed on to the caller.
154async fn record(
155 op: Op,
156 services: &Services,
157 viewer: &Viewer,
158 input: &Value,
159 decision: &Decision,
160 outcome: Option<&Outcome<Value>>,
161) {
162 let Some(user) = viewer.as_ref() else {
163 return;
164 };
165 let actor = AuditActor::of(user);
166 if !actor.records_reads() && is_read(op.name()) {
167 return;
168 }
169 let mut entry = NewAuditEntry::new(
170 actor,
171 op.name(),
172 services.audit.surface,
173 target(user, input),
174 decision,
175 services.audit.request_id.clone(),
176 );
177 match outcome {
178 Some(Outcome::Ok(_)) => entry.result = Some("ok".to_owned()),
179 Some(Outcome::Fail(failure)) => {
180 let code = serde_json::to_value(failure.code)
181 .ok()
182 .and_then(|code| code.as_str().map(str::to_owned))
183 .unwrap_or_else(|| "failed".to_owned());
184 // Refused by the service that owns it: still a denial.
185 if matches!(
186 failure.code,
187 FailureCode::Forbidden | FailureCode::Unauthenticated
188 ) {
189 entry.outcome = g1t_contracts::audit::AuditOutcome::Denied;
190 entry.rule = "service".to_owned();
191 }
192 entry.message = Some(failure.message.clone());
193 entry.result = Some(code);
194 }
195 None => entry.result = Some("forbidden".to_owned()),
196 }
197 let recorded: Result<u32> = g1t_kit::call(
198 &services.events,
199 "audit_record",
200 &RecordAuditArgs {
201 entries: vec![entry],
202 },
203 )
204 .await;
205 if let Err(error) = recorded {
206 console_error!("audit entry not recorded for {}: {error}", op.name());
207 }
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213 use g1t_contracts::credentials::{Acting, Principal};
214 use g1t_contracts::identity::AgentScope;
215 use serde_json::json;
216
217 #[test]
218 fn the_target_comes_from_the_input() {
219 let person = User {
220 id: "usr_1".to_owned(),
221 username: "syntaqx".to_owned(),
222 ..User::default()
223 };
224 let target = target(
225 &person,
226 &json!({ "repo": "Acme/rocket", "number": "12", "path": "src/a.rs" }),
227 );
228 assert_eq!(target.workspace, "acme");
229 assert_eq!(target.repo.as_deref(), Some("Acme/rocket"));
230 assert_eq!(target.number, Some(12));
231 assert_eq!(target.path.as_deref(), Some("src/a.rs"));
232 assert_eq!(
233 super::target(&person, &json!({ "workspace": "Ops" })).workspace,
234 "ops"
235 );
236 }
237
238 #[test]
239 fn an_agent_with_no_repository_is_logged_in_its_run_s_workspace() {
240 let agent = User {
241 kind: PrincipalKind::Agent,
242 acting: Some(Box::new(Acting {
243 credential_id: "tok_1".to_owned(),
244 agent: "g1t-agent".to_owned(),
245 on_behalf_of: Principal::default(),
246 scope: AgentScope {
247 repo: RepoPath {
248 namespace: "acme".to_owned(),
249 name: "rocket".to_owned(),
250 },
251 operations: vec![],
252 run: None,
253 },
254 })),
255 ..User::default()
256 };
257 assert_eq!(target(&agent, &json!({})).workspace, "acme");
258 }
259}