flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/crates/contracts/src/audit.rs

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