Skip to content
392 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/// Whether a decision refuses the call.
144fn refused(decision: &Option<Decision>) -> bool {
145 decision.as_ref().is_some_and(|decision| !decision.allowed)
146}
147
148/// Runs `op` for `viewer`: enforcing an agent's scope first, and recording
149/// what happened.
150pub async fn run(
151 op: Op,
152 services: &Services,
153 viewer: &Viewer,
154 input: &Value,
155) -> Result<Outcome<Value>> {
156 let mut input = std::borrow::Cow::Borrowed(input);
157 let mut decision = decide(op, services, viewer, &input);
158 // A token or an agent's run that reaches one repository knows it by its
159 // workspace's slug. A call that names it under the workspace's alias
160 // (`g1t/deploy` for `flagon-io/deploy`) or an old slug is decided again,
161 // and run, under the slug it resolves to; only a refusal pays for the
162 // lookup, and the call is allowed only as the repository it is.
163 if refused(&decision)
164 && let Some(retargeted) = crate::renamed::retarget(services, &input).await?
165 {
166 let again = decide(op, services, viewer, &retargeted);
167 if !refused(&again) {
168 decision = again;
169 input = std::borrow::Cow::Owned(retargeted);
170 }
171 }
172 let input = input.as_ref();
173 if let Some(decision) = decision.as_ref().filter(|decision| !decision.allowed) {
174 record(op, services, viewer, input, decision, None).await;
175 return Ok(Outcome::fail(
176 FailureCode::Forbidden,
177 decision.reason.as_deref().unwrap_or("Not allowed."),
178 ));
179 }
180 // A runner's credential acts as the person, within the run's scope.
181 let downstream: Viewer = viewer
182 .as_ref()
183 .and_then(as_person)
184 .or_else(|| viewer.clone());
185 let outcome = op.run(services, &downstream, input).await?;
186 let decision = decision.unwrap_or_else(|| match viewer {
187 Some(user) => Decision::allow(principal_rule(user)),
188 None => Decision::allow("anonymous"),
189 });
190 record(op, services, viewer, input, &decision, Some(&outcome)).await;
191 // Every person in the answer with the case they chose (people.rs).
192 Ok(match outcome {
193 Outcome::Ok(value) => Outcome::Ok(crate::people::name_people(services, value).await),
194 failed => failed,
195 })
196}
197
198/// Appends the entry, if this is something the log keeps. A failure to
199/// record is logged, never passed on to the caller.
200async fn record(
201 op: Op,
202 services: &Services,
203 viewer: &Viewer,
204 input: &Value,
205 decision: &Decision,
206 outcome: Option<&Outcome<Value>>,
207) {
208 let Some(user) = viewer.as_ref() else {
209 return;
210 };
211 let actor = AuditActor::of(user);
212 if !actor.records_reads() && is_read(op.name()) {
213 return;
214 }
215 // A person's own inbox is nobody else's business.
216 if op.personal() {
217 return;
218 }
219 let mut entry = NewAuditEntry::new(
220 actor,
221 op.name(),
222 services.audit.surface,
223 target(user, input),
224 decision,
225 services.audit.request_id.clone(),
226 );
227 match outcome {
228 Some(Outcome::Ok(_)) => entry.result = Some("ok".to_owned()),
229 Some(Outcome::Fail(failure)) => {
230 let code = serde_json::to_value(failure.code)
231 .ok()
232 .and_then(|code| code.as_str().map(str::to_owned))
233 .unwrap_or_else(|| "failed".to_owned());
234 // Refused by the service that owns it: still a denial.
235 if matches!(
236 failure.code,
237 FailureCode::Forbidden | FailureCode::Unauthenticated
238 ) {
239 entry.outcome = g1t_contracts::audit::AuditOutcome::Denied;
240 entry.rule = "service".to_owned();
241 }
242 entry.message = Some(failure.message.clone());
243 entry.result = Some(code);
244 }
245 None => entry.result = Some("forbidden".to_owned()),
246 }
247 let recorded: Result<u32> = g1t_kit::call(
248 &services.events,
249 "audit_record",
250 &RecordAuditArgs {
251 entries: vec![entry],
252 },
253 )
254 .await;
255 if let Err(error) = recorded {
256 console_error!("audit entry not recorded for {}: {error}", op.name());
257 }
258}
259
260#[cfg(test)]
261mod tests {
262 use super::*;
263 use g1t_contracts::credentials::{Acting, Principal};
264 use g1t_contracts::identity::AgentScope;
265 use g1t_contracts::scopes::{Scope, TokenAccess};
266 use serde_json::json;
267
268 #[test]
269 fn the_target_comes_from_the_input() {
270 let person = User {
271 id: "usr_1".to_owned(),
272 username: "syntaqx".to_owned(),
273 ..User::default()
274 };
275 let target = target(
276 &person,
277 &json!({ "repo": "Acme/rocket", "number": "12", "path": "src/a.rs" }),
278 );
279 assert_eq!(target.workspace, "acme");
280 assert_eq!(target.repo.as_deref(), Some("Acme/rocket"));
281 assert_eq!(target.number, Some(12));
282 assert_eq!(target.path.as_deref(), Some("src/a.rs"));
283 assert_eq!(
284 super::target(&person, &json!({ "workspace": "Ops" })).workspace,
285 "ops"
286 );
287 }
288
289 fn with_token(scopes: Option<&[Scope]>, legacy: bool) -> User {
290 User {
291 id: "usr_1".to_owned(),
292 username: "syntaqx".to_owned(),
293 token: Some(Box::new(TokenAccess {
294 token_id: "tok_1".to_owned(),
295 scopes: scopes.map(|scopes| scopes.iter().map(|scope| scope.as_str().to_owned()).collect()),
296 legacy,
297 name: None,
298 ..TokenAccess::default()
299 })),
300 ..User::default()
301 }
302 }
303
304 /// A workflow job's token reaches its own repository, by its
305 /// workspace's slug; named under the workspace's alias, the call is
306 /// refused as written and decided again under the slug.
307 #[test]
308 fn a_repositorys_token_reaches_it_under_its_workspaces_alias() {
309 let job = User {
310 id: "usr_1".to_owned(),
311 username: "syntaqx".to_owned(),
312 token: Some(Box::new(TokenAccess {
313 token_id: "tok_job".to_owned(),
314 scopes: Some(vec![Scope::RepoRead.as_str().to_owned()]),
315 repo: Some("flagon-io/deploy".to_owned()),
316 ..TokenAccess::default()
317 })),
318 ..User::default()
319 };
320 let aliased = json!({ "repo": "g1t/deploy" });
321 let first = token_decision(Op::GetRepo, &job, &aliased);
322 assert!(refused(&first));
323 let retargeted = crate::renamed::rewrite(&aliased, "g1t", "flagon-io");
324 assert!(!refused(&token_decision(Op::GetRepo, &job, &retargeted)));
325 // Another repository of the same workspace stays out of reach.
326 let other = crate::renamed::rewrite(&json!({ "repo": "g1t/internal" }), "g1t", "flagon-io");
327 assert!(refused(&token_decision(Op::GetRepo, &job, &other)));
328 assert!(!refused(&None));
329 }
330
331 #[test]
332 fn a_session_is_limited_only_by_the_person_s_role() {
333 let person = User { id: "usr_1".to_owned(), ..User::default() };
334 assert!(token_decision(Op::DeleteRepo, &person, &json!({})).is_none());
335 }
336
337 #[test]
338 fn a_legacy_token_still_does_everything() {
339 let legacy = with_token(None, true);
340 for op in Op::ALL {
341 let decision = token_decision(op, &legacy, &json!({ "repo": "acme/rocket" })).unwrap();
342 assert!(decision.allowed, "{}", op.name());
343 assert_eq!(decision.rule, "token:legacy");
344 }
345 }
346
347 #[test]
348 fn a_token_without_the_scope_is_refused_and_told_which() {
349 let reader = with_token(Some(&[Scope::IssuesRead]), false);
350 let input = json!({ "repo": "acme/rocket", "title": "x" });
351 assert!(token_decision(Op::GetIssue, &reader, &input).unwrap().allowed);
352 let refused = token_decision(Op::CreateIssue, &reader, &input).unwrap();
353 assert!(!refused.allowed);
354 assert!(refused.reason.unwrap().contains("issues:write"));
355 assert_eq!(missing_scope(Op::CreateIssue, &Some(reader.clone()), &input), Some(Scope::IssuesWrite));
356 assert_eq!(missing_scope(Op::GetIssue, &Some(reader), &input), None);
357 }
358
359 #[test]
360 fn a_token_reaches_what_its_owner_can() {
361 // Scopes are the only limit a token adds: which workspaces and
362 // repositories it reaches is the owner's, decided downstream.
363 let admin = with_token(Some(&[Scope::RepoAdmin]), false);
364 let moved = token_decision(Op::TransferRepo, &admin, &json!({ "repo": "acme/rocket", "to": "elsewhere" })).unwrap();
365 assert!(moved.allowed);
366 assert_eq!(moved.rule, "token:scope");
367 let full = with_token(None, false);
368 assert!(token_decision(Op::ListWebhooks, &full, &json!({ "workspace": "other" })).unwrap().allowed);
369 }
370
371 #[test]
372 fn an_agent_with_no_repository_is_logged_in_its_run_s_workspace() {
373 let agent = User {
374 kind: PrincipalKind::Agent,
375 acting: Some(Box::new(Acting {
376 credential_id: "tok_1".to_owned(),
377 agent: "g1t".to_owned(),
378 on_behalf_of: Principal::default(),
379 scope: AgentScope {
380 repo: RepoPath {
381 namespace: "acme".to_owned(),
382 name: "rocket".to_owned(),
383 },
384 operations: vec![],
385 run: None,
386 },
387 })),
388 ..User::default()
389 };
390 assert_eq!(target(&agent, &json!({})).workspace, "acme");
391 }
392}