g1t/apps/api/src/audit.rs

334 lines11,914 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;
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step12use g1t_contracts::scopes;
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API13use g1t_contracts::{FailureCode, Outcome, PrincipalKind, User, Viewer};
14use serde_json::Value;
15use worker::{Request, Result, console_error};
16
17use crate::operations::{Op, Services};
18
19/// Where a request came in, and the id it is recorded under.
20#[derive(Clone, Debug)]
21pub struct AuditContext {
22 pub request_id: String,
23 pub surface: Surface,
24}
25
26impl Default for AuditContext {
27 fn default() -> Self {
28 AuditContext {
29 request_id: String::new(),
30 surface: Surface::Rest,
31 }
32 }
33}
34
35impl AuditContext {
36 /// Cloudflare's ray id, which also finds the request in Workers logs.
37 pub fn of(request: &Request, on_mcp: bool) -> Self {
38 let ray = request.headers().get("cf-ray").ok().flatten();
39 AuditContext {
40 request_id: ray.unwrap_or_else(|| g1t_contracts::new_id("req", g1t_kit::now_ms())),
41 surface: if on_mcp { Surface::Mcp } else { Surface::Rest },
42 }
43 }
44}
45
46fn repo_path(input: &Value) -> Option<RepoPath> {
47 let mut parts = input["repo"].as_str()?.split('/');
48 match (parts.next(), parts.next(), parts.next()) {
49 (Some(namespace), Some(name), None) if !namespace.is_empty() && !name.is_empty() => {
50 Some(RepoPath {
51 namespace: namespace.to_owned(),
52 name: name.to_owned(),
53 })
54 }
55 _ => None,
56 }
57}
58
59fn number(input: &Value) -> Option<u32> {
60 match &input["number"] {
61 Value::Number(number) => number.as_u64().and_then(|n| u32::try_from(n).ok()),
62 Value::String(digits) => digits.parse().ok(),
63 _ => None,
64 }
65}
66
67fn some_text(input: &Value, key: &str) -> Option<String> {
68 input[key]
69 .as_str()
70 .filter(|value| !value.is_empty())
71 .map(str::to_owned)
72}
73
74/// What an operation named, for its entry.
75pub fn target(user: &User, input: &Value) -> AuditTarget {
76 let repo = repo_path(input);
77 let workspace = repo
78 .as_ref()
79 .map(|repo| repo.namespace.clone())
80 .or_else(|| some_text(input, "workspace"))
81 .or_else(|| some_text(input, "slug"))
82 .or_else(|| {
83 user.acting
84 .as_ref()
85 .map(|acting| acting.scope.repo.namespace.clone())
86 })
87 .unwrap_or_default()
88 .to_lowercase();
89 AuditTarget {
90 workspace,
91 repo: repo.map(|repo| format!("{}/{}", repo.namespace, repo.name)),
92 number: number(input),
93 git_ref: some_text(input, "ref").or_else(|| some_text(input, "branch")),
94 path: some_text(input, "path"),
95 }
96}
97
98/// The rule that let a person or a workspace token through: theirs is
99/// decided by the service that owns what they asked about.
100fn principal_rule(user: &User) -> &'static str {
101 match user.kind {
102 PrincipalKind::Workspace => "workspace-token",
103 _ => "person",
104 }
105}
106
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step107/// Whether the API refused a caller before anything ran, and why: an
108/// agent against its run's scope, or an access token against its scopes.
109/// `None` for a signed-in session, which only the person's role limits.
110/// A token reaches whatever the one it acts as can reach; the service
111/// that owns what was asked about checks that.
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API112pub fn decide(op: Op, services: &Services, viewer: &Viewer, input: &Value) -> Option<Decision> {
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step113 let user = viewer.as_ref()?;
114 if user.kind == PrincipalKind::Agent {
115 let scope = services.scope.as_ref()?;
116 return Some(decide_operation(
117 user,
118 scope,
119 op.name(),
120 repo_path(input).as_ref(),
121 op.needs_repo(),
122 number(input),
123 ));
124 }
125 token_decision(op, user, input)
126}
127
128/// What an access token's scopes decide about a call; `None` for a
129/// caller without one.
130fn token_decision(op: Op, user: &User, input: &Value) -> Option<Decision> {
131 let access = user.token.as_deref()?;
132 Some(scopes::decide(access, op.name(), input))
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API133}
134
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step135/// The scope a refused call lacked, for the error the caller is sent.
136pub fn missing_scope(op: Op, viewer: &Viewer, input: &Value) -> Option<scopes::Scope> {
137 let access = viewer.as_ref()?.token.as_deref()?;
138 scopes::needed(op.name(), input)
139 .into_iter()
140 .find(|scope| !access.allows(*scope))
141}
142
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API143/// Runs `op` for `viewer`: enforcing an agent's scope first, and recording
144/// what happened.
145pub async fn run(
146 op: Op,
147 services: &Services,
148 viewer: &Viewer,
149 input: &Value,
150) -> Result<Outcome<Value>> {
151 let decision = decide(op, services, viewer, input);
152 if let Some(decision) = decision.as_ref().filter(|decision| !decision.allowed) {
153 record(op, services, viewer, input, decision, None).await;
154 return Ok(Outcome::fail(
155 FailureCode::Forbidden,
156 decision.reason.as_deref().unwrap_or("Not allowed."),
157 ));
158 }
159 // A runner's credential acts as the person, within the run's scope.
160 let downstream: Viewer = viewer
161 .as_ref()
162 .and_then(as_person)
163 .or_else(|| viewer.clone());
164 let outcome = op.run(services, &downstream, input).await?;
165 let decision = decision.unwrap_or_else(|| match viewer {
166 Some(user) => Decision::allow(principal_rule(user)),
167 None => Decision::allow("anonymous"),
168 });
169 record(op, services, viewer, input, &decision, Some(&outcome)).await;
170 Ok(outcome)
171}
172
173/// Appends the entry, if this is something the log keeps. A failure to
174/// record is logged, never passed on to the caller.
175async fn record(
176 op: Op,
177 services: &Services,
178 viewer: &Viewer,
179 input: &Value,
180 decision: &Decision,
181 outcome: Option<&Outcome<Value>>,
182) {
183 let Some(user) = viewer.as_ref() else {
184 return;
185 };
186 let actor = AuditActor::of(user);
187 if !actor.records_reads() && is_read(op.name()) {
188 return;
189 }
190 let mut entry = NewAuditEntry::new(
191 actor,
192 op.name(),
193 services.audit.surface,
194 target(user, input),
195 decision,
196 services.audit.request_id.clone(),
197 );
198 match outcome {
199 Some(Outcome::Ok(_)) => entry.result = Some("ok".to_owned()),
200 Some(Outcome::Fail(failure)) => {
201 let code = serde_json::to_value(failure.code)
202 .ok()
203 .and_then(|code| code.as_str().map(str::to_owned))
204 .unwrap_or_else(|| "failed".to_owned());
205 // Refused by the service that owns it: still a denial.
206 if matches!(
207 failure.code,
208 FailureCode::Forbidden | FailureCode::Unauthenticated
209 ) {
210 entry.outcome = g1t_contracts::audit::AuditOutcome::Denied;
211 entry.rule = "service".to_owned();
212 }
213 entry.message = Some(failure.message.clone());
214 entry.result = Some(code);
215 }
216 None => entry.result = Some("forbidden".to_owned()),
217 }
218 let recorded: Result<u32> = g1t_kit::call(
219 &services.events,
220 "audit_record",
221 &RecordAuditArgs {
222 entries: vec![entry],
223 },
224 )
225 .await;
226 if let Err(error) = recorded {
227 console_error!("audit entry not recorded for {}: {error}", op.name());
228 }
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234 use g1t_contracts::credentials::{Acting, Principal};
235 use g1t_contracts::identity::AgentScope;
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step236 use g1t_contracts::scopes::{Scope, TokenAccess};
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API237 use serde_json::json;
238
239 #[test]
240 fn the_target_comes_from_the_input() {
241 let person = User {
242 id: "usr_1".to_owned(),
243 username: "syntaqx".to_owned(),
244 ..User::default()
245 };
246 let target = target(
247 &person,
248 &json!({ "repo": "Acme/rocket", "number": "12", "path": "src/a.rs" }),
249 );
250 assert_eq!(target.workspace, "acme");
251 assert_eq!(target.repo.as_deref(), Some("Acme/rocket"));
252 assert_eq!(target.number, Some(12));
253 assert_eq!(target.path.as_deref(), Some("src/a.rs"));
254 assert_eq!(
255 super::target(&person, &json!({ "workspace": "Ops" })).workspace,
256 "ops"
257 );
258 }
259
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step260 fn with_token(scopes: Option<&[Scope]>, legacy: bool) -> User {
261 User {
262 id: "usr_1".to_owned(),
263 username: "syntaqx".to_owned(),
264 token: Some(Box::new(TokenAccess {
265 token_id: "tok_1".to_owned(),
266 scopes: scopes.map(|scopes| scopes.iter().map(|scope| scope.as_str().to_owned()).collect()),
267 legacy,
268 })),
269 ..User::default()
270 }
271 }
272
273 #[test]
274 fn a_session_is_limited_only_by_the_person_s_role() {
275 let person = User { id: "usr_1".to_owned(), ..User::default() };
276 assert!(token_decision(Op::DeleteRepo, &person, &json!({})).is_none());
277 }
278
279 #[test]
280 fn a_legacy_token_still_does_everything() {
281 let legacy = with_token(None, true);
282 for op in Op::ALL {
283 let decision = token_decision(op, &legacy, &json!({ "repo": "acme/rocket" })).unwrap();
284 assert!(decision.allowed, "{}", op.name());
285 assert_eq!(decision.rule, "token:legacy");
286 }
287 }
288
289 #[test]
290 fn a_token_without_the_scope_is_refused_and_told_which() {
291 let reader = with_token(Some(&[Scope::IssuesRead]), false);
292 let input = json!({ "repo": "acme/rocket", "title": "x" });
293 assert!(token_decision(Op::GetIssue, &reader, &input).unwrap().allowed);
294 let refused = token_decision(Op::CreateIssue, &reader, &input).unwrap();
295 assert!(!refused.allowed);
296 assert!(refused.reason.unwrap().contains("issues:write"));
297 assert_eq!(missing_scope(Op::CreateIssue, &Some(reader.clone()), &input), Some(Scope::IssuesWrite));
298 assert_eq!(missing_scope(Op::GetIssue, &Some(reader), &input), None);
299 }
300
301 #[test]
302 fn a_token_reaches_what_its_owner_can() {
303 // Scopes are the only limit a token adds: which workspaces and
304 // repositories it reaches is the owner's, decided downstream.
305 let admin = with_token(Some(&[Scope::RepoAdmin]), false);
306 let moved = token_decision(Op::TransferRepo, &admin, &json!({ "repo": "acme/rocket", "to": "elsewhere" })).unwrap();
307 assert!(moved.allowed);
308 assert_eq!(moved.rule, "token:scope");
309 let full = with_token(None, false);
310 assert!(token_decision(Op::ListWebhooks, &full, &json!({ "workspace": "other" })).unwrap().allowed);
311 }
312
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API313 #[test]
314 fn an_agent_with_no_repository_is_logged_in_its_run_s_workspace() {
315 let agent = User {
316 kind: PrincipalKind::Agent,
317 acting: Some(Box::new(Acting {
318 credential_id: "tok_1".to_owned(),
319 agent: "g1t-agent".to_owned(),
320 on_behalf_of: Principal::default(),
321 scope: AgentScope {
322 repo: RepoPath {
323 namespace: "acme".to_owned(),
324 name: "rocket".to_owned(),
325 },
326 operations: vec![],
327 run: None,
328 },
329 })),
330 ..User::default()
331 };
332 assert_eq!(target(&agent, &json!({})).workspace, "acme");
333 }
334}