g1t/crates/contracts/src/audit.rs

363 lines11,944 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API1//! 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,
Fast pages, required checks on the branch, self-hosted runners, honest incidents30 /// A self-hosted runner, with its own credential.
31 Runner,
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily32 /// g1t itself: what the platform does on its own, such as a security
33 /// update or a merge from the queue.
34 System,
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API35}
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",
Fast pages, required checks on the branch, self-hosted runners, honest incidents43 ActorKind::Runner => "runner",
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily44 ActorKind::System => "system",
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API45 }
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,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look73 /// g1t.sh itself: settings changed on its pages.
74 Web,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member75 /// The package registries: `docker push`, `npm publish` and the like.
76 Registry,
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API77}
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>,
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent84 /// The username: a person's, the agent's (`g1t`), or the
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API85 /// 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
97impl AuditActor {
98 pub fn of(user: &User) -> Self {
99 let kind = match user.kind {
100 PrincipalKind::User => ActorKind::Person,
101 PrincipalKind::Agent => ActorKind::Agent,
102 PrincipalKind::Workspace => ActorKind::Workspace,
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily103 PrincipalKind::System => ActorKind::System,
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API104 };
105 let acting: Option<&Acting> = user.acting.as_deref();
106 AuditActor {
107 actor_kind: Some(kind),
108 actor: user.username.clone(),
109 actor_id: user.id.clone(),
110 agent: acting.map(|acting| acting.agent.clone()),
111 on_behalf_of: acting.map(|acting| acting.on_behalf_of.username.clone()),
112 run_id: acting
113 .and_then(|acting| acting.run())
114 .and_then(|run| run.run_id.clone()),
115 run_kind: acting
116 .and_then(|acting| acting.run())
117 .map(|run| run.kind.as_str().to_owned()),
118 credential_id: acting.map(|acting| acting.credential_id.clone()),
119 }
120 }
121
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily122 /// g1t itself, as the actor of what it does on its own.
123 pub fn system() -> Self {
124 AuditActor {
125 actor_kind: Some(ActorKind::System),
126 actor: crate::system::USERNAME.to_owned(),
127 actor_id: crate::system::ID.to_owned(),
128 ..AuditActor::default()
129 }
130 }
131
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API132 /// Whether everything this actor does is recorded, reads too.
133 pub fn records_reads(&self) -> bool {
134 self.actor_kind == Some(ActorKind::Agent)
135 }
136}
137
138/// What an action was done to.
139#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
140#[serde(rename_all = "camelCase")]
141pub struct AuditTarget {
142 /// The workspace's slug: whose log it goes in.
143 pub workspace: String,
144 /// `owner/name`, when the action was about one repository.
145 pub repo: Option<String>,
146 /// The issue or pull request.
147 pub number: Option<u32>,
148 /// A full git ref, such as `refs/heads/main`.
149 pub git_ref: Option<String>,
150 /// A file, or another path the action named.
151 pub path: Option<String>,
152}
153
154/// An entry to record. The log assigns the id and time.
155#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
156#[serde(rename_all = "camelCase")]
157pub struct NewAuditEntry {
158 #[serde(flatten)]
159 pub actor: AuditActor,
160 /// An API or MCP operation, such as `create_issue`, or `git.push` and
161 /// `git.fetch`.
162 pub action: String,
163 pub surface: Surface,
164 #[serde(flatten)]
165 pub target: AuditTarget,
166 pub outcome: AuditOutcome,
167 /// The rule that allowed or refused it, such as `run:implement/tools`,
168 /// `scope:repository` or `member`.
169 pub rule: String,
170 /// `ok`, or the failure's code when the service refused or failed it.
171 pub result: Option<String>,
172 /// Why it was refused, when it was.
173 pub message: Option<String>,
174 pub request_id: String,
175}
176
177impl NewAuditEntry {
178 pub fn new(
179 actor: AuditActor,
180 action: impl Into<String>,
181 surface: Surface,
182 target: AuditTarget,
183 decision: &Decision,
184 request_id: impl Into<String>,
185 ) -> Self {
186 NewAuditEntry {
187 actor,
188 action: action.into(),
189 surface,
190 target,
191 outcome: if decision.allowed {
192 AuditOutcome::Allowed
193 } else {
194 AuditOutcome::Denied
195 },
196 rule: decision.rule.clone(),
197 result: None,
198 message: decision.reason.clone(),
199 request_id: request_id.into(),
200 }
201 }
202}
203
204/// A recorded entry.
205#[derive(Clone, Debug, Serialize, Deserialize)]
206#[serde(rename_all = "camelCase")]
207pub struct AuditEntry {
208 pub id: String,
209 /// RFC 3339.
210 pub time: String,
211 #[serde(flatten)]
212 pub entry: NewAuditEntry,
213}
214
215#[derive(Clone, Debug, Default, Serialize, Deserialize)]
216pub struct RecordAuditArgs {
217 pub entries: Vec<NewAuditEntry>,
218}
219
220/// Which of a workspace's entries the viewer may see.
221#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
222#[serde(tag = "kind", rename_all = "snake_case")]
223pub enum AuditVisibility {
224 /// An owner: everything.
225 All,
226 /// A member: what was done to the workspace's projects, and what they
227 /// did themselves or had done on their behalf; not what owners did to
228 /// the workspace itself.
229 Projects { username: String },
230}
231
232/// The most entries one `audit_list` returns.
233pub const MAX_AUDIT_PAGE: u32 = 500;
234
235#[derive(Clone, Debug, Serialize, Deserialize)]
236#[serde(rename_all = "camelCase")]
237pub struct ListAuditArgs {
238 pub workspace: String,
239 pub visibility: AuditVisibility,
240 /// Matches the actor or whoever an agent acted for.
241 #[serde(default)]
242 pub actor: Option<String>,
243 #[serde(default)]
244 pub agent: Option<String>,
245 #[serde(default)]
246 pub action: Option<String>,
247 /// `owner/name`.
248 #[serde(default)]
249 pub repo: Option<String>,
250 #[serde(default)]
251 pub number: Option<u32>,
252 #[serde(default)]
253 pub outcome: Option<AuditOutcome>,
254 #[serde(default)]
255 pub actor_kind: Option<ActorKind>,
256 /// Entries of these runs only.
257 #[serde(default)]
258 pub run_ids: Vec<String>,
259 /// RFC 3339; inclusive.
260 #[serde(default)]
261 pub since: Option<String>,
262 /// RFC 3339; exclusive.
263 #[serde(default)]
264 pub until: Option<String>,
265 /// Entries older than this entry id, for the next page.
266 #[serde(default)]
267 pub before: Option<String>,
268 #[serde(default)]
269 pub limit: Option<u32>,
270}
271
272/// Newest first.
273#[derive(Clone, Debug, Default, Serialize, Deserialize)]
274#[serde(rename_all = "camelCase")]
275pub struct AuditPage {
276 pub entries: Vec<AuditEntry>,
277 /// Pass as `before` for the next page; null on the last.
278 pub next: Option<String>,
279}
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284 use crate::credentials::{CredentialUse, Principal, RunBinding, RunCredentialKind};
285 use crate::identity::AgentScope;
286 use crate::repos::RepoPath;
287
288 #[test]
289 fn an_agent_is_recorded_with_who_it_worked_for() {
290 let user = User {
291 id: "usr_g1t_agent".to_owned(),
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent292 username: "g1t".to_owned(),
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API293 kind: PrincipalKind::Agent,
294 acting: Some(Box::new(Acting {
295 credential_id: "tok_9".to_owned(),
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent296 agent: "g1t".to_owned(),
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API297 on_behalf_of: Principal {
298 id: "usr_1".to_owned(),
299 username: "syntaqx".to_owned(),
300 },
301 scope: AgentScope {
302 repo: RepoPath {
303 namespace: "acme".to_owned(),
304 name: "rocket".to_owned(),
305 },
306 operations: vec![],
307 run: Some(RunBinding {
308 kind: RunCredentialKind::Implement,
309 usage: CredentialUse::Tools,
310 run_id: Some("run_3".to_owned()),
311 number: Some(4),
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent312 agent: "g1t".to_owned(),
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily313 system: false,
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API314 read: vec![],
315 push: vec![],
316 }),
317 },
318 })),
319 ..User::default()
320 };
321 let actor = AuditActor::of(&user);
322 assert_eq!(actor.actor_kind, Some(ActorKind::Agent));
323 assert_eq!(actor.on_behalf_of.as_deref(), Some("syntaqx"));
324 assert_eq!(actor.run_id.as_deref(), Some("run_3"));
325 assert_eq!(actor.run_kind.as_deref(), Some("implement"));
326 assert_eq!(actor.credential_id.as_deref(), Some("tok_9"));
327 assert!(actor.records_reads());
328
329 let person = AuditActor::of(&User {
330 id: "usr_1".to_owned(),
331 username: "syntaqx".to_owned(),
332 ..User::default()
333 });
334 assert_eq!(person.actor_kind, Some(ActorKind::Person));
335 assert!(!person.records_reads());
336 }
337
338 #[test]
339 fn an_entry_carries_the_rule_that_refused_it() {
340 let entry = NewAuditEntry::new(
341 AuditActor::default(),
342 "merge_pull_request",
343 Surface::Mcp,
344 AuditTarget::default(),
345 &Decision::deny("never", "No."),
346 "req_1",
347 );
348 assert_eq!(entry.outcome, AuditOutcome::Denied);
349 assert_eq!(entry.rule, "never");
350 assert_eq!(entry.message.as_deref(), Some("No."));
351 let json = serde_json::to_value(&entry).unwrap();
352 // Flattened, in the names the site reads.
353 assert_eq!(json["outcome"], "denied");
354 assert_eq!(json["surface"], "mcp");
355 assert!(json.get("actorKind").is_some());
356 assert!(json.get("gitRef").is_some());
357 let visibility = serde_json::to_value(AuditVisibility::Projects {
358 username: "ana".to_owned(),
359 })
360 .unwrap();
361 assert_eq!(visibility["kind"], "projects");
362 }
363}