g1t/crates/contracts/src/audit.rs

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