forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
15.5 KiB
1//! Security audit log: the action names, the entry shape and the list filter. The stored shape is
2//! in migrations/0010_audit_log.sql; the store functions live in store/audit.rs. Rows are never
3//! edited; only the retention purge removes them.
4
5use ulid::Ulid;
6
7use crate::time::now_stamp;
8
9/// Every security-relevant action that is recorded. The `as_str` names are stored and shown.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum AuditAction {
12 LoginSuccess,
13 LoginFailed,
14 Login2faFailed,
15 Logout,
16 PasswordChanged,
17 EmailChanged,
18 PasswordResetByAdmin,
19 TotpEnabled,
20 TotpDisabled,
21 RecoveryCodesRegenerated,
22 SshKeyAdded,
23 SshKeyRemoved,
24 GpgKeyAdded,
25 GpgKeyDeleted,
26 TokenCreated,
27 TokenRevoked,
28 SessionRevoked,
29 UserCreated,
30 UsernameChanged,
31 UserDeleted,
32 RepoCreated,
33 RepoDeleted,
34 RepoRenamed,
35 RepoTransferred,
36 RepoTransferInitiated,
37 RepoTransferAccepted,
38 RepoTransferDeclined,
39 RepoTransferCancelled,
40 RepoVisibilityChanged,
41 CollaboratorAdded,
42 CollaboratorRemoved,
43 CollaboratorRoleChanged,
44 BranchRuleCreated,
45 BranchRuleUpdated,
46 BranchRuleDeleted,
47 RepoArchived,
48 RepoUnarchived,
49 WebhookCreated,
50 WebhookUpdated,
51 WebhookDeleted,
52 MirrorCreated,
53 MirrorUpdated,
54 MirrorDeleted,
55 MirrorSyncFailed,
56 BackupStarted,
57 FsckStarted,
58 ReplicationReconcileStarted,
59 CiSecretSet,
60 CiSecretDeleted,
61 CiRunnerRegistered,
62 CiRunnerDeleted,
63 CiRegistrationTokenCreated,
64 AdvisoryAlertDismissed,
65 AdvisoryAlertReopened,
66 UiPrefsChanged,
67 DiscussionsEnabled,
68 DiscussionsDisabled,
69 DiscussionCreated,
70 DiscussionEdited,
71 DiscussionDeleted,
72 DiscussionLocked,
73 DiscussionUnlocked,
74 DiscussionAnswered,
75 DiscussionCommentCreated,
76 DiscussionCommentEdited,
77 DiscussionCommentDeleted,
78 MailSettingsUpdated,
79 MailTestQueued,
80 SnippetCreated,
81 SnippetUpdated,
82 SnippetDeleted,
83 WikiPageSaved,
84 WikiEnabled,
85 WikiDisabled,
86 WikiPushed,
87 OrgCreated,
88 OrgDeleted,
89 OrgSettingsUpdated,
90 OrgMemberAdded,
91 OrgMemberRemoved,
92 OrgMemberRoleChanged,
93 TeamCreated,
94 TeamUpdated,
95 TeamDeleted,
96 TeamMemberAdded,
97 TeamMemberRemoved,
98 TeamRepoAdded,
99 TeamRepoRemoved,
100 PasswordResetCompleted,
101 EmailVerified,
102 InviteSent,
103 InviteAccepted,
104}
105
106impl AuditAction {
107 /// Every action, in declaration order. Used to parse filter values.
108 pub const ALL: &'static [AuditAction] = &[
109 AuditAction::LoginSuccess,
110 AuditAction::LoginFailed,
111 AuditAction::Login2faFailed,
112 AuditAction::Logout,
113 AuditAction::PasswordChanged,
114 AuditAction::EmailChanged,
115 AuditAction::PasswordResetByAdmin,
116 AuditAction::TotpEnabled,
117 AuditAction::TotpDisabled,
118 AuditAction::RecoveryCodesRegenerated,
119 AuditAction::SshKeyAdded,
120 AuditAction::SshKeyRemoved,
121 AuditAction::GpgKeyAdded,
122 AuditAction::GpgKeyDeleted,
123 AuditAction::TokenCreated,
124 AuditAction::TokenRevoked,
125 AuditAction::SessionRevoked,
126 AuditAction::UserCreated,
127 AuditAction::UsernameChanged,
128 AuditAction::UserDeleted,
129 AuditAction::RepoCreated,
130 AuditAction::RepoDeleted,
131 AuditAction::RepoRenamed,
132 AuditAction::RepoTransferred,
133 AuditAction::RepoTransferInitiated,
134 AuditAction::RepoTransferAccepted,
135 AuditAction::RepoTransferDeclined,
136 AuditAction::RepoTransferCancelled,
137 AuditAction::RepoVisibilityChanged,
138 AuditAction::CollaboratorAdded,
139 AuditAction::CollaboratorRemoved,
140 AuditAction::CollaboratorRoleChanged,
141 AuditAction::BranchRuleCreated,
142 AuditAction::BranchRuleUpdated,
143 AuditAction::BranchRuleDeleted,
144 AuditAction::RepoArchived,
145 AuditAction::RepoUnarchived,
146 AuditAction::WebhookCreated,
147 AuditAction::WebhookUpdated,
148 AuditAction::WebhookDeleted,
149 AuditAction::MirrorCreated,
150 AuditAction::MirrorUpdated,
151 AuditAction::MirrorDeleted,
152 AuditAction::MirrorSyncFailed,
153 AuditAction::BackupStarted,
154 AuditAction::FsckStarted,
155 AuditAction::ReplicationReconcileStarted,
156 AuditAction::CiSecretSet,
157 AuditAction::CiSecretDeleted,
158 AuditAction::CiRunnerRegistered,
159 AuditAction::CiRunnerDeleted,
160 AuditAction::CiRegistrationTokenCreated,
161 AuditAction::AdvisoryAlertDismissed,
162 AuditAction::AdvisoryAlertReopened,
163 AuditAction::UiPrefsChanged,
164 AuditAction::DiscussionsEnabled,
165 AuditAction::DiscussionsDisabled,
166 AuditAction::DiscussionCreated,
167 AuditAction::DiscussionEdited,
168 AuditAction::DiscussionDeleted,
169 AuditAction::DiscussionLocked,
170 AuditAction::DiscussionUnlocked,
171 AuditAction::DiscussionAnswered,
172 AuditAction::DiscussionCommentCreated,
173 AuditAction::DiscussionCommentEdited,
174 AuditAction::DiscussionCommentDeleted,
175 AuditAction::MailSettingsUpdated,
176 AuditAction::MailTestQueued,
177 AuditAction::SnippetCreated,
178 AuditAction::SnippetUpdated,
179 AuditAction::SnippetDeleted,
180 AuditAction::WikiPageSaved,
181 AuditAction::WikiEnabled,
182 AuditAction::WikiDisabled,
183 AuditAction::WikiPushed,
184 AuditAction::OrgCreated,
185 AuditAction::OrgDeleted,
186 AuditAction::OrgSettingsUpdated,
187 AuditAction::OrgMemberAdded,
188 AuditAction::OrgMemberRemoved,
189 AuditAction::OrgMemberRoleChanged,
190 AuditAction::TeamCreated,
191 AuditAction::TeamUpdated,
192 AuditAction::TeamDeleted,
193 AuditAction::TeamMemberAdded,
194 AuditAction::TeamMemberRemoved,
195 AuditAction::TeamRepoAdded,
196 AuditAction::TeamRepoRemoved,
197 AuditAction::PasswordResetCompleted,
198 AuditAction::EmailVerified,
199 AuditAction::InviteSent,
200 AuditAction::InviteAccepted,
201 ];
202
203 pub fn as_str(self) -> &'static str {
204 match self {
205 AuditAction::LoginSuccess => "login_success",
206 AuditAction::LoginFailed => "login_failed",
207 AuditAction::Login2faFailed => "login_2fa_failed",
208 AuditAction::Logout => "logout",
209 AuditAction::PasswordChanged => "password_changed",
210 AuditAction::EmailChanged => "email_changed",
211 AuditAction::PasswordResetByAdmin => "password_reset_by_admin",
212 AuditAction::TotpEnabled => "totp_enabled",
213 AuditAction::TotpDisabled => "totp_disabled",
214 AuditAction::RecoveryCodesRegenerated => "recovery_codes_regenerated",
215 AuditAction::SshKeyAdded => "ssh_key_added",
216 AuditAction::SshKeyRemoved => "ssh_key_removed",
217 AuditAction::GpgKeyAdded => "gpg_key_added",
218 AuditAction::GpgKeyDeleted => "gpg_key_deleted",
219 AuditAction::TokenCreated => "token_created",
220 AuditAction::TokenRevoked => "token_revoked",
221 AuditAction::SessionRevoked => "session_revoked",
222 AuditAction::UserCreated => "user_created",
223 AuditAction::UsernameChanged => "username_changed",
224 AuditAction::UserDeleted => "user_deleted",
225 AuditAction::RepoCreated => "repo_created",
226 AuditAction::RepoDeleted => "repo_deleted",
227 AuditAction::RepoRenamed => "repo_renamed",
228 AuditAction::RepoTransferred => "repo_transferred",
229 AuditAction::RepoTransferInitiated => "repo_transfer_initiated",
230 AuditAction::RepoTransferAccepted => "repo_transfer_accepted",
231 AuditAction::RepoTransferDeclined => "repo_transfer_declined",
232 AuditAction::RepoTransferCancelled => "repo_transfer_cancelled",
233 AuditAction::RepoVisibilityChanged => "repo_visibility_changed",
234 AuditAction::CollaboratorAdded => "collaborator_added",
235 AuditAction::CollaboratorRemoved => "collaborator_removed",
236 AuditAction::CollaboratorRoleChanged => "collaborator_role_changed",
237 AuditAction::BranchRuleCreated => "branch_rule_created",
238 AuditAction::BranchRuleUpdated => "branch_rule_updated",
239 AuditAction::BranchRuleDeleted => "branch_rule_deleted",
240 AuditAction::RepoArchived => "repo_archived",
241 AuditAction::RepoUnarchived => "repo_unarchived",
242 AuditAction::WebhookCreated => "webhook_created",
243 AuditAction::WebhookUpdated => "webhook_updated",
244 AuditAction::WebhookDeleted => "webhook_deleted",
245 AuditAction::MirrorCreated => "mirror_created",
246 AuditAction::MirrorUpdated => "mirror_updated",
247 AuditAction::MirrorDeleted => "mirror_deleted",
248 AuditAction::MirrorSyncFailed => "mirror_sync_failed",
249 AuditAction::BackupStarted => "backup_started",
250 AuditAction::FsckStarted => "fsck_started",
251 AuditAction::ReplicationReconcileStarted => "replication_reconcile_started",
252 AuditAction::CiSecretSet => "ci_secret_set",
253 AuditAction::CiSecretDeleted => "ci_secret_deleted",
254 AuditAction::CiRunnerRegistered => "ci_runner_registered",
255 AuditAction::CiRunnerDeleted => "ci_runner_deleted",
256 AuditAction::CiRegistrationTokenCreated => "ci_registration_token_created",
257 AuditAction::AdvisoryAlertDismissed => "advisory_alert_dismissed",
258 AuditAction::AdvisoryAlertReopened => "advisory_alert_reopened",
259 AuditAction::UiPrefsChanged => "ui_prefs_changed",
260 AuditAction::DiscussionsEnabled => "discussions_enabled",
261 AuditAction::DiscussionsDisabled => "discussions_disabled",
262 AuditAction::DiscussionCreated => "discussion_created",
263 AuditAction::DiscussionEdited => "discussion_edited",
264 AuditAction::DiscussionDeleted => "discussion_deleted",
265 AuditAction::DiscussionLocked => "discussion_locked",
266 AuditAction::DiscussionUnlocked => "discussion_unlocked",
267 AuditAction::DiscussionAnswered => "discussion_answered",
268 AuditAction::DiscussionCommentCreated => "discussion_comment_created",
269 AuditAction::DiscussionCommentEdited => "discussion_comment_edited",
270 AuditAction::DiscussionCommentDeleted => "discussion_comment_deleted",
271 AuditAction::MailSettingsUpdated => "mail_settings_updated",
272 AuditAction::MailTestQueued => "mail_test_queued",
273 AuditAction::SnippetCreated => "snippet_created",
274 AuditAction::SnippetUpdated => "snippet_updated",
275 AuditAction::SnippetDeleted => "snippet_deleted",
276 AuditAction::WikiPageSaved => "wiki_page_saved",
277 AuditAction::WikiEnabled => "wiki_enabled",
278 AuditAction::WikiDisabled => "wiki_disabled",
279 AuditAction::WikiPushed => "wiki_pushed",
280 AuditAction::OrgCreated => "org_created",
281 AuditAction::OrgDeleted => "org_deleted",
282 AuditAction::OrgSettingsUpdated => "org_settings_updated",
283 AuditAction::OrgMemberAdded => "org_member_added",
284 AuditAction::OrgMemberRemoved => "org_member_removed",
285 AuditAction::OrgMemberRoleChanged => "org_member_role_changed",
286 AuditAction::TeamCreated => "team_created",
287 AuditAction::TeamUpdated => "team_updated",
288 AuditAction::TeamDeleted => "team_deleted",
289 AuditAction::TeamMemberAdded => "team_member_added",
290 AuditAction::TeamMemberRemoved => "team_member_removed",
291 AuditAction::TeamRepoAdded => "team_repo_added",
292 AuditAction::TeamRepoRemoved => "team_repo_removed",
293 AuditAction::PasswordResetCompleted => "password_reset_completed",
294 AuditAction::EmailVerified => "email_verified",
295 AuditAction::InviteSent => "invite_sent",
296 AuditAction::InviteAccepted => "invite_accepted",
297 }
298 }
299
300 /// The action with this stored name, if any.
301 pub fn parse(name: &str) -> Option<AuditAction> {
302 AuditAction::ALL
303 .iter()
304 .copied()
305 .find(|action| action.as_str() == name)
306 }
307}
308
309/// One row of the audit log. `at` is an RFC 3339 UTC stamp. `data` is a JSON object as text.
310#[derive(Debug, Clone, PartialEq, Eq)]
311pub struct AuditEntry {
312 pub id: String,
313 pub at: String,
314 pub actor_id: Option<String>,
315 pub actor_name: String,
316 pub action: AuditAction,
317 pub target_kind: String,
318 pub target_id: String,
319 pub target_name: String,
320 pub ip: String,
321 pub data: String,
322}
323
324impl AuditEntry {
325 /// A new entry stamped with the current time and a fresh id. Everything else is empty.
326 pub fn new(action: AuditAction) -> AuditEntry {
327 AuditEntry {
328 id: Ulid::generate().to_string(),
329 at: now_stamp(),
330 actor_id: None,
331 actor_name: String::new(),
332 action,
333 target_kind: String::new(),
334 target_id: String::new(),
335 target_name: String::new(),
336 ip: String::new(),
337 data: "{}".to_string(),
338 }
339 }
340}
341
342/// Optional filters for [`crate::store::Store::list_audit`]. Unset fields do not restrict.
343#[derive(Debug, Clone, Default, PartialEq, Eq)]
344pub struct AuditFilter {
345 pub actor_id: Option<String>,
346 pub action: Option<AuditAction>,
347 pub target_kind: Option<String>,
348 pub target_id: Option<String>,
349 /// Matches entries where this user is the actor, or the `user` target.
350 pub involving_user: Option<String>,
351}
352
353/// Position in the newest-first listing: the last entry of the previous page.
354#[derive(Debug, Clone, PartialEq, Eq)]
355pub struct AuditCursor {
356 pub at: String,
357 pub id: String,
358}
359
360impl AuditCursor {
361 /// The cursor for the entry a page ended on.
362 pub fn after(entry: &AuditEntry) -> AuditCursor {
363 AuditCursor {
364 at: entry.at.clone(),
365 id: entry.id.clone(),
366 }
367 }
368
369 /// Text form for a URL: `<at>_<id>`. Neither part contains `_`.
370 pub fn encode(&self) -> String {
371 format!("{}_{}", self.at, self.id)
372 }
373
374 /// Parses [`AuditCursor::encode`] output. Anything malformed is `None`.
375 pub fn decode(text: &str) -> Option<AuditCursor> {
376 let (at, id) = text.split_once('_')?;
377 let stamp_ok = at.len() == 30 && at.ends_with('Z') && at.contains('T');
378 let id_ok = !id.is_empty() && id.bytes().all(|b| b.is_ascii_alphanumeric());
379 (stamp_ok && id_ok).then(|| AuditCursor {
380 at: at.to_string(),
381 id: id.to_string(),
382 })
383 }
384}
385
386#[cfg(test)]
387mod tests {
388 use super::*;
389
390 #[test]
391 fn names_are_unique_and_round_trip() {
392 let mut names: Vec<&str> = AuditAction::ALL.iter().map(|a| a.as_str()).collect();
393 assert_eq!(names.len(), 92);
394 for action in AuditAction::ALL {
395 assert_eq!(AuditAction::parse(action.as_str()), Some(*action));
396 }
397 names.sort();
398 names.dedup();
399 assert_eq!(names.len(), 92);
400 assert_eq!(AuditAction::parse("nope"), None);
401 }
402
403 #[test]
404 fn cursor_round_trips_and_rejects_junk() {
405 let entry = AuditEntry::new(AuditAction::Logout);
406 let cursor = AuditCursor::after(&entry);
407 assert_eq!(AuditCursor::decode(&cursor.encode()), Some(cursor));
408 assert_eq!(AuditCursor::decode(""), None);
409 assert_eq!(AuditCursor::decode("nounderscore"), None);
410 assert_eq!(AuditCursor::decode("2026-10-15T00:00:00Z_"), None);
411 assert_eq!(AuditCursor::decode("bogus_01ABC"), None);
412 }
413}