Skip to content

g1t/crates/contracts/src/audit.rs

372 lines12,451 bytesCodeBlame
1//! The audit log: who did what, with which credential, to what, and
2//! whether it was allowed.
3//!
4//! Every action taken with a run credential is recorded, reads included,
5//! and so is every change people and workspace tokens make through the API
6//! and git. Refusals are recorded with the rule that refused them. Entries
7//! are only ever appended.
8//!
9//! The events service keeps the log, beside the event log; the API and the
10//! repos service, which see the requests, write to it. Methods, served at
11//! `POST /rpc/<method>` on the events service:
12//!
13//! - `audit_record` takes `RecordAuditArgs` and returns how many were kept.
14//! - `audit_list` takes `ListAuditArgs` and returns `AuditPage`. Callers
15//! check who may see a workspace's log and say so in `visibility`.
16
17use serde::{Deserialize, Serialize};
18
19use crate::credentials::{Acting, Decision};
20use crate::{PrincipalKind, User};
21
22/// What kind of actor did it.
23#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub enum ActorKind {
26 Person,
27 Agent,
28 /// A workspace's own access token.
29 Workspace,
30 /// A self-hosted runner, with its own credential.
31 Runner,
32 /// g1t itself: what the platform does on its own, such as a security
33 /// update or a merge from the queue.
34 System,
35}
36
37impl ActorKind {
38 pub fn as_str(self) -> &'static str {
39 match self {
40 ActorKind::Person => "person",
41 ActorKind::Agent => "agent",
42 ActorKind::Workspace => "workspace",
43 ActorKind::Runner => "runner",
44 ActorKind::System => "system",
45 }
46 }
47}
48
49/// Whether the action was let through.
50#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(rename_all = "snake_case")]
52pub enum AuditOutcome {
53 Allowed,
54 Denied,
55}
56
57impl AuditOutcome {
58 pub fn as_str(self) -> &'static str {
59 match self {
60 AuditOutcome::Allowed => "allowed",
61 AuditOutcome::Denied => "denied",
62 }
63 }
64}
65
66/// Where the request came in.
67#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(rename_all = "snake_case")]
69pub enum Surface {
70 Rest,
71 Mcp,
72 Git,
73 /// g1t.sh itself: settings changed on its pages.
74 Web,
75 /// The package registries: `docker push`, `npm publish` and the like.
76 Registry,
77}
78
79/// The actor of an entry, from whoever made the request.
80#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
81#[serde(rename_all = "camelCase")]
82pub struct AuditActor {
83 pub actor_kind: Option<ActorKind>,
84 /// The username: a person's, the agent's (`g1t`), or the
85 /// workspace's slug.
86 pub actor: String,
87 pub actor_id: String,
88 pub agent: Option<String>,
89 /// The person an agent acted for.
90 pub on_behalf_of: Option<String>,
91 pub run_id: Option<String>,
92 pub run_kind: Option<String>,
93 /// The token used, when one was.
94 pub credential_id: Option<String>,
95}
96
97/// The `run_kind` of what a workflow job's token did.
98pub const WORKFLOW_JOB: &str = "workflow_job";
99
100impl AuditActor {
101 pub fn of(user: &User) -> Self {
102 let kind = match user.kind {
103 PrincipalKind::User => ActorKind::Person,
104 PrincipalKind::Agent => ActorKind::Agent,
105 PrincipalKind::Workspace => ActorKind::Workspace,
106 PrincipalKind::System => ActorKind::System,
107 };
108 let acting: Option<&Acting> = user.acting.as_deref();
109 // A workflow job's token: what it does is the job's, under its run.
110 let job = user.token.as_deref().and_then(|token| token.job.as_ref().map(|job| (token, job)));
111 AuditActor {
112 actor_kind: Some(kind),
113 actor: user.username.clone(),
114 actor_id: user.id.clone(),
115 agent: acting.map(|acting| acting.agent.clone()),
116 on_behalf_of: acting.map(|acting| acting.on_behalf_of.username.clone()),
117 run_id: acting
118 .and_then(|acting| acting.run())
119 .and_then(|run| run.run_id.clone())
120 .or_else(|| job.map(|(_, job)| job.run_id.clone())),
121 run_kind: acting
122 .and_then(|acting| acting.run())
123 .map(|run| run.kind.as_str().to_owned())
124 .or_else(|| job.map(|_| WORKFLOW_JOB.to_owned())),
125 credential_id: acting
126 .map(|acting| acting.credential_id.clone())
127 .or_else(|| job.map(|(token, _)| token.token_id.clone())),
128 }
129 }
130
131 /// g1t itself, as the actor of what it does on its own.
132 pub fn system() -> Self {
133 AuditActor {
134 actor_kind: Some(ActorKind::System),
135 actor: crate::system::USERNAME.to_owned(),
136 actor_id: crate::system::ID.to_owned(),
137 ..AuditActor::default()
138 }
139 }
140
141 /// Whether everything this actor does is recorded, reads too.
142 pub fn records_reads(&self) -> bool {
143 self.actor_kind == Some(ActorKind::Agent)
144 }
145}
146
147/// What an action was done to.
148#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
149#[serde(rename_all = "camelCase")]
150pub struct AuditTarget {
151 /// The workspace's slug: whose log it goes in.
152 pub workspace: String,
153 /// `owner/name`, when the action was about one repository.
154 pub repo: Option<String>,
155 /// The issue or pull request.
156 pub number: Option<u32>,
157 /// A full git ref, such as `refs/heads/main`.
158 pub git_ref: Option<String>,
159 /// A file, or another path the action named.
160 pub path: Option<String>,
161}
162
163/// An entry to record. The log assigns the id and time.
164#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
165#[serde(rename_all = "camelCase")]
166pub struct NewAuditEntry {
167 #[serde(flatten)]
168 pub actor: AuditActor,
169 /// An API or MCP operation, such as `create_issue`, or `git.push` and
170 /// `git.fetch`.
171 pub action: String,
172 pub surface: Surface,
173 #[serde(flatten)]
174 pub target: AuditTarget,
175 pub outcome: AuditOutcome,
176 /// The rule that allowed or refused it, such as `run:implement/tools`,
177 /// `scope:repository` or `member`.
178 pub rule: String,
179 /// `ok`, or the failure's code when the service refused or failed it.
180 pub result: Option<String>,
181 /// Why it was refused, when it was.
182 pub message: Option<String>,
183 pub request_id: String,
184}
185
186impl NewAuditEntry {
187 pub fn new(
188 actor: AuditActor,
189 action: impl Into<String>,
190 surface: Surface,
191 target: AuditTarget,
192 decision: &Decision,
193 request_id: impl Into<String>,
194 ) -> Self {
195 NewAuditEntry {
196 actor,
197 action: action.into(),
198 surface,
199 target,
200 outcome: if decision.allowed {
201 AuditOutcome::Allowed
202 } else {
203 AuditOutcome::Denied
204 },
205 rule: decision.rule.clone(),
206 result: None,
207 message: decision.reason.clone(),
208 request_id: request_id.into(),
209 }
210 }
211}
212
213/// A recorded entry.
214#[derive(Clone, Debug, Serialize, Deserialize)]
215#[serde(rename_all = "camelCase")]
216pub struct AuditEntry {
217 pub id: String,
218 /// RFC 3339.
219 pub time: String,
220 #[serde(flatten)]
221 pub entry: NewAuditEntry,
222}
223
224#[derive(Clone, Debug, Default, Serialize, Deserialize)]
225pub struct RecordAuditArgs {
226 pub entries: Vec<NewAuditEntry>,
227}
228
229/// Which of a workspace's entries the viewer may see.
230#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
231#[serde(tag = "kind", rename_all = "snake_case")]
232pub enum AuditVisibility {
233 /// An owner: everything.
234 All,
235 /// A member: what was done to the workspace's projects, and what they
236 /// did themselves or had done on their behalf; not what owners did to
237 /// the workspace itself.
238 Projects { username: String },
239}
240
241/// The most entries one `audit_list` returns.
242pub const MAX_AUDIT_PAGE: u32 = 500;
243
244#[derive(Clone, Debug, Serialize, Deserialize)]
245#[serde(rename_all = "camelCase")]
246pub struct ListAuditArgs {
247 pub workspace: String,
248 pub visibility: AuditVisibility,
249 /// Matches the actor or whoever an agent acted for.
250 #[serde(default)]
251 pub actor: Option<String>,
252 #[serde(default)]
253 pub agent: Option<String>,
254 #[serde(default)]
255 pub action: Option<String>,
256 /// `owner/name`.
257 #[serde(default)]
258 pub repo: Option<String>,
259 #[serde(default)]
260 pub number: Option<u32>,
261 #[serde(default)]
262 pub outcome: Option<AuditOutcome>,
263 #[serde(default)]
264 pub actor_kind: Option<ActorKind>,
265 /// Entries of these runs only.
266 #[serde(default)]
267 pub run_ids: Vec<String>,
268 /// RFC 3339; inclusive.
269 #[serde(default)]
270 pub since: Option<String>,
271 /// RFC 3339; exclusive.
272 #[serde(default)]
273 pub until: Option<String>,
274 /// Entries older than this entry id, for the next page.
275 #[serde(default)]
276 pub before: Option<String>,
277 #[serde(default)]
278 pub limit: Option<u32>,
279}
280
281/// Newest first.
282#[derive(Clone, Debug, Default, Serialize, Deserialize)]
283#[serde(rename_all = "camelCase")]
284pub struct AuditPage {
285 pub entries: Vec<AuditEntry>,
286 /// Pass as `before` for the next page; null on the last.
287 pub next: Option<String>,
288}
289
290#[cfg(test)]
291mod tests {
292 use super::*;
293 use crate::credentials::{CredentialUse, Principal, RunBinding, RunCredentialKind};
294 use crate::identity::AgentScope;
295 use crate::repos::RepoPath;
296
297 #[test]
298 fn an_agent_is_recorded_with_who_it_worked_for() {
299 let user = User {
300 id: "usr_g1t_agent".to_owned(),
301 username: "g1t".to_owned(),
302 kind: PrincipalKind::Agent,
303 acting: Some(Box::new(Acting {
304 credential_id: "tok_9".to_owned(),
305 agent: "g1t".to_owned(),
306 on_behalf_of: Principal {
307 id: "usr_1".to_owned(),
308 username: "syntaqx".to_owned(),
309 },
310 scope: AgentScope {
311 repo: RepoPath {
312 namespace: "acme".to_owned(),
313 name: "rocket".to_owned(),
314 },
315 operations: vec![],
316 run: Some(RunBinding {
317 kind: RunCredentialKind::Implement,
318 usage: CredentialUse::Tools,
319 run_id: Some("run_3".to_owned()),
320 number: Some(4),
321 agent: "g1t".to_owned(),
322 system: false,
323 read: vec![],
324 push: vec![],
325 }),
326 },
327 })),
328 ..User::default()
329 };
330 let actor = AuditActor::of(&user);
331 assert_eq!(actor.actor_kind, Some(ActorKind::Agent));
332 assert_eq!(actor.on_behalf_of.as_deref(), Some("syntaqx"));
333 assert_eq!(actor.run_id.as_deref(), Some("run_3"));
334 assert_eq!(actor.run_kind.as_deref(), Some("implement"));
335 assert_eq!(actor.credential_id.as_deref(), Some("tok_9"));
336 assert!(actor.records_reads());
337
338 let person = AuditActor::of(&User {
339 id: "usr_1".to_owned(),
340 username: "syntaqx".to_owned(),
341 ..User::default()
342 });
343 assert_eq!(person.actor_kind, Some(ActorKind::Person));
344 assert!(!person.records_reads());
345 }
346
347 #[test]
348 fn an_entry_carries_the_rule_that_refused_it() {
349 let entry = NewAuditEntry::new(
350 AuditActor::default(),
351 "merge_pull_request",
352 Surface::Mcp,
353 AuditTarget::default(),
354 &Decision::deny("never", "No."),
355 "req_1",
356 );
357 assert_eq!(entry.outcome, AuditOutcome::Denied);
358 assert_eq!(entry.rule, "never");
359 assert_eq!(entry.message.as_deref(), Some("No."));
360 let json = serde_json::to_value(&entry).unwrap();
361 // Flattened, in the names the site reads.
362 assert_eq!(json["outcome"], "denied");
363 assert_eq!(json["surface"], "mcp");
364 assert!(json.get("actorKind").is_some());
365 assert!(json.get("gitRef").is_some());
366 let visibility = serde_json::to_value(AuditVisibility::Projects {
367 username: "ana".to_owned(),
368 })
369 .unwrap();
370 assert_eq!(visibility["kind"], "projects");
371 }
372}