Skip to content
344 linesCodeBlameRaw
1//! 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::scopes;
13use 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
107/// 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.
112pub fn decide(op: Op, services: &Services, viewer: &Viewer, input: &Value) -> Option<Decision> {
113 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))
133}
134
135/// 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
143/// 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 // Every person in the answer with the case they chose (people.rs).
171 Ok(match outcome {
172 Outcome::Ok(value) => Outcome::Ok(crate::people::name_people(services, value).await),
173 failed => failed,
174 })
175}
176
177/// Appends the entry, if this is something the log keeps. A failure to
178/// record is logged, never passed on to the caller.
179async fn record(
180 op: Op,
181 services: &Services,
182 viewer: &Viewer,
183 input: &Value,
184 decision: &Decision,
185 outcome: Option<&Outcome<Value>>,
186) {
187 let Some(user) = viewer.as_ref() else {
188 return;
189 };
190 let actor = AuditActor::of(user);
191 if !actor.records_reads() && is_read(op.name()) {
192 return;
193 }
194 // A person's own inbox is nobody else's business.
195 if op.personal() {
196 return;
197 }
198 let mut entry = NewAuditEntry::new(
199 actor,
200 op.name(),
201 services.audit.surface,
202 target(user, input),
203 decision,
204 services.audit.request_id.clone(),
205 );
206 match outcome {
207 Some(Outcome::Ok(_)) => entry.result = Some("ok".to_owned()),
208 Some(Outcome::Fail(failure)) => {
209 let code = serde_json::to_value(failure.code)
210 .ok()
211 .and_then(|code| code.as_str().map(str::to_owned))
212 .unwrap_or_else(|| "failed".to_owned());
213 // Refused by the service that owns it: still a denial.
214 if matches!(
215 failure.code,
216 FailureCode::Forbidden | FailureCode::Unauthenticated
217 ) {
218 entry.outcome = g1t_contracts::audit::AuditOutcome::Denied;
219 entry.rule = "service".to_owned();
220 }
221 entry.message = Some(failure.message.clone());
222 entry.result = Some(code);
223 }
224 None => entry.result = Some("forbidden".to_owned()),
225 }
226 let recorded: Result<u32> = g1t_kit::call(
227 &services.events,
228 "audit_record",
229 &RecordAuditArgs {
230 entries: vec![entry],
231 },
232 )
233 .await;
234 if let Err(error) = recorded {
235 console_error!("audit entry not recorded for {}: {error}", op.name());
236 }
237}
238
239#[cfg(test)]
240mod tests {
241 use super::*;
242 use g1t_contracts::credentials::{Acting, Principal};
243 use g1t_contracts::identity::AgentScope;
244 use g1t_contracts::scopes::{Scope, TokenAccess};
245 use serde_json::json;
246
247 #[test]
248 fn the_target_comes_from_the_input() {
249 let person = User {
250 id: "usr_1".to_owned(),
251 username: "syntaqx".to_owned(),
252 ..User::default()
253 };
254 let target = target(
255 &person,
256 &json!({ "repo": "Acme/rocket", "number": "12", "path": "src/a.rs" }),
257 );
258 assert_eq!(target.workspace, "acme");
259 assert_eq!(target.repo.as_deref(), Some("Acme/rocket"));
260 assert_eq!(target.number, Some(12));
261 assert_eq!(target.path.as_deref(), Some("src/a.rs"));
262 assert_eq!(
263 super::target(&person, &json!({ "workspace": "Ops" })).workspace,
264 "ops"
265 );
266 }
267
268 fn with_token(scopes: Option<&[Scope]>, legacy: bool) -> User {
269 User {
270 id: "usr_1".to_owned(),
271 username: "syntaqx".to_owned(),
272 token: Some(Box::new(TokenAccess {
273 token_id: "tok_1".to_owned(),
274 scopes: scopes.map(|scopes| scopes.iter().map(|scope| scope.as_str().to_owned()).collect()),
275 legacy,
276 name: None,
277 ..TokenAccess::default()
278 })),
279 ..User::default()
280 }
281 }
282
283 #[test]
284 fn a_session_is_limited_only_by_the_person_s_role() {
285 let person = User { id: "usr_1".to_owned(), ..User::default() };
286 assert!(token_decision(Op::DeleteRepo, &person, &json!({})).is_none());
287 }
288
289 #[test]
290 fn a_legacy_token_still_does_everything() {
291 let legacy = with_token(None, true);
292 for op in Op::ALL {
293 let decision = token_decision(op, &legacy, &json!({ "repo": "acme/rocket" })).unwrap();
294 assert!(decision.allowed, "{}", op.name());
295 assert_eq!(decision.rule, "token:legacy");
296 }
297 }
298
299 #[test]
300 fn a_token_without_the_scope_is_refused_and_told_which() {
301 let reader = with_token(Some(&[Scope::IssuesRead]), false);
302 let input = json!({ "repo": "acme/rocket", "title": "x" });
303 assert!(token_decision(Op::GetIssue, &reader, &input).unwrap().allowed);
304 let refused = token_decision(Op::CreateIssue, &reader, &input).unwrap();
305 assert!(!refused.allowed);
306 assert!(refused.reason.unwrap().contains("issues:write"));
307 assert_eq!(missing_scope(Op::CreateIssue, &Some(reader.clone()), &input), Some(Scope::IssuesWrite));
308 assert_eq!(missing_scope(Op::GetIssue, &Some(reader), &input), None);
309 }
310
311 #[test]
312 fn a_token_reaches_what_its_owner_can() {
313 // Scopes are the only limit a token adds: which workspaces and
314 // repositories it reaches is the owner's, decided downstream.
315 let admin = with_token(Some(&[Scope::RepoAdmin]), false);
316 let moved = token_decision(Op::TransferRepo, &admin, &json!({ "repo": "acme/rocket", "to": "elsewhere" })).unwrap();
317 assert!(moved.allowed);
318 assert_eq!(moved.rule, "token:scope");
319 let full = with_token(None, false);
320 assert!(token_decision(Op::ListWebhooks, &full, &json!({ "workspace": "other" })).unwrap().allowed);
321 }
322
323 #[test]
324 fn an_agent_with_no_repository_is_logged_in_its_run_s_workspace() {
325 let agent = User {
326 kind: PrincipalKind::Agent,
327 acting: Some(Box::new(Acting {
328 credential_id: "tok_1".to_owned(),
329 agent: "g1t".to_owned(),
330 on_behalf_of: Principal::default(),
331 scope: AgentScope {
332 repo: RepoPath {
333 namespace: "acme".to_owned(),
334 name: "rocket".to_owned(),
335 },
336 operations: vec![],
337 run: None,
338 },
339 })),
340 ..User::default()
341 };
342 assert_eq!(target(&agent, &json!({})).workspace, "acme");
343 }
344}