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/apps/web/app/lib/audit.ts

195 lines6,885 bytesCodeBlame
1/**
2 * The audit log, as the site shows and exports it: who may see what, the
3 * filters a page's address carries, and the CSV and JSON it downloads.
4 * Pure, so it can be tested; the server side is in audit.server.ts.
5 */
6
7import type {
8 ActorKind,
9 AuditEntry,
10 AuditOutcome,
11 AuditQuery,
12 AuditVisibility,
13 Role,
14} from "@g1t/contracts";
15
16/**
17 * How much of a workspace's log a viewer sees: an owner everything; a
18 * member what was done to the workspace's projects, and what they did or
19 * had done for them; anyone else nothing.
20 */
21export function visibilityFor(role: Role | null, username: string): AuditVisibility | null {
22 if (role === "owner") return { kind: "all" };
23 if (role === "member") return { kind: "projects", username };
24 return null;
25}
26
27/** The filters a page's address can carry. */
28export type AuditFilters = {
29 actor: string;
30 agent: string;
31 action: string;
32 project: string;
33 outcome: "" | AuditOutcome;
34 kind: "" | ActorKind;
35 run: string;
36 /** `YYYY-MM-DD`, inclusive. */
37 from: string;
38 /** `YYYY-MM-DD`, inclusive. */
39 to: string;
40 before: string;
41};
42
43const OUTCOMES: AuditOutcome[] = ["allowed", "denied"];
44const KINDS: ActorKind[] = ["person", "agent", "workspace"];
45const DAY = /^\d{4}-\d{2}-\d{2}$/;
46
47function clean(value: string | null, max = 120): string {
48 return (value ?? "").trim().slice(0, max);
49}
50
51export function parseFilters(params: URLSearchParams): AuditFilters {
52 const outcome = clean(params.get("outcome"));
53 const kind = clean(params.get("kind"));
54 const day = (name: string) => {
55 const value = clean(params.get(name));
56 return DAY.test(value) ? value : "";
57 };
58 return {
59 actor: clean(params.get("actor")),
60 agent: clean(params.get("agent")),
61 action: clean(params.get("action")),
62 project: clean(params.get("project")),
63 outcome: OUTCOMES.includes(outcome as AuditOutcome) ? (outcome as AuditOutcome) : "",
64 kind: KINDS.includes(kind as ActorKind) ? (kind as ActorKind) : "",
65 run: clean(params.get("run")),
66 from: day("from"),
67 to: day("to"),
68 before: clean(params.get("before")),
69 };
70}
71
72/** The day after `day`, for an inclusive end. */
73function nextDay(day: string): string {
74 const date = new Date(`${day}T00:00:00Z`);
75 date.setUTCDate(date.getUTCDate() + 1);
76 return date.toISOString().slice(0, 10);
77}
78
79/** What to ask the log for. `project` is a project's name in `workspace`, or `owner/name`. */
80export function toQuery(
81 workspace: string,
82 visibility: AuditVisibility,
83 filters: AuditFilters,
84 limit: number,
85): AuditQuery {
86 const project = filters.project.includes("/") ? filters.project : filters.project ? `${workspace}/${filters.project}` : null;
87 return {
88 workspace,
89 visibility,
90 actor: filters.actor || null,
91 agent: filters.agent || null,
92 action: filters.action || null,
93 repo: project,
94 outcome: filters.outcome || null,
95 actorKind: filters.kind || null,
96 runIds: filters.run ? [filters.run] : [],
97 since: filters.from ? `${filters.from}T00:00:00.000Z` : null,
98 until: filters.to ? `${nextDay(filters.to)}T00:00:00.000Z` : null,
99 before: filters.before || null,
100 limit,
101 };
102}
103
104/** The address of the same view with `changes` applied, for links. */
105export function filterHref(base: string, filters: AuditFilters, changes: Partial<AuditFilters> = {}): string {
106 const merged = { ...filters, ...changes };
107 const params = new URLSearchParams();
108 for (const [key, value] of Object.entries(merged)) {
109 if (value) params.set(key, value);
110 }
111 const search = params.toString();
112 return search ? `${base}?${search}` : base;
113}
114
115/** Who acted, as people read it: "g1t-agent on behalf of syntaqx". */
116export function actorLabel(entry: Pick<AuditEntry, "actor" | "agent" | "onBehalfOf">): string {
117 return entry.onBehalfOf ? `${entry.agent ?? entry.actor} on behalf of ${entry.onBehalfOf}` : entry.actor;
118}
119
120/** What it was done to: `acme/rocket#12`, with a ref or path when there is one. */
121export function targetLabel(entry: Pick<AuditEntry, "workspace" | "repo" | "number" | "gitRef" | "path">): string {
122 const where = entry.repo ? `${entry.repo}${entry.number != null ? `#${entry.number}` : ""}` : entry.workspace;
123 const what = [entry.gitRef, entry.path].filter(Boolean).join(" ");
124 return what ? `${where} ${what}` : where;
125}
126
127/** An operation's name as a phrase: `create_issue` is "create issue". */
128export function actionLabel(action: string): string {
129 if (action === "git.push") return "git push";
130 if (action === "git.fetch") return "git fetch";
131 return action.replaceAll("_", " ");
132}
133
134export const CSV_COLUMNS = [
135 "id",
136 "time",
137 "workspace",
138 "actorKind",
139 "actor",
140 "agent",
141 "onBehalfOf",
142 "runId",
143 "runKind",
144 "credentialId",
145 "action",
146 "surface",
147 "repo",
148 "number",
149 "gitRef",
150 "path",
151 "outcome",
152 "rule",
153 "result",
154 "message",
155 "requestId",
156] as const satisfies readonly (keyof AuditEntry)[];
157
158function csvCell(value: unknown): string {
159 if (value == null) return "";
160 let text = String(value);
161 // A spreadsheet runs a cell that starts like a formula; keep it text.
162 if (/^[=+\-@\t\r]/.test(text)) text = `'${text}`;
163 return /[",\n\r]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
164}
165
166/** RFC 4180 CSV, a header row and one row per entry. */
167export function toCsv(entries: AuditEntry[]): string {
168 const rows = [CSV_COLUMNS.join(",")];
169 for (const entry of entries) rows.push(CSV_COLUMNS.map((column) => csvCell(entry[column])).join(","));
170 return `${rows.join("\r\n")}\r\n`;
171}
172
173/** The download's file name: `acme-audit-2026-10-04.csv`. */
174export function exportName(workspace: string, format: "csv" | "json", now: Date): string {
175 return `${workspace}-audit-${now.toISOString().slice(0, 10)}.${format}`;
176}
177
178/** Plain words for the rule that decided an entry. */
179export function ruleLabel(rule: string): string {
180 if (rule === "never") return "never allowed for agents";
181 if (rule === "scope:operation") return "not in the run's scope";
182 if (rule === "scope:repository") return "outside the run's repository";
183 if (rule === "scope:pull") return "outside the run's pull request";
184 if (rule === "on-behalf-of:membership") return "the person it works for is not a member";
185 if (rule === "git:push") return "no push grant";
186 if (rule === "git:read") return "no read grant";
187 if (rule === "git:ref") return "branch not granted";
188 if (rule === "git:not-a-run") return "tools token used with git";
189 if (rule === "service" || rule === "repository") return "refused by the repository's rules";
190 if (rule === "person") return "the person's own access";
191 if (rule === "workspace-token") return "the workspace token's access";
192 const run = /^run:([a-z]+)\/(runner|tools)(?::(push|read))?$/.exec(rule);
193 if (run) return `${run[1]} run ${run[2] === "tools" ? "tools" : "runner"}${run[3] ? ` (${run[3]})` : ""}`;
194 return rule;
195}