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

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