pr_01m47d15m3e54sn21z27rpy5n9/crates/contracts/src/audit.rs

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