Tahinli/forge
public0 watching
★ 0
| 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 | |
| 5 | use ulid::Ulid; |
| 6 | |
| 7 | use 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)] |
| 11 | pub 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 | |
| 106 | impl 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)] |
| 311 | pub 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 | |
| 324 | impl 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)] |
| 344 | pub 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)] |
| 355 | pub struct AuditCursor { |
| 356 | pub at: String, |
| 357 | pub id: String, |
| 358 | } |
| 359 | |
| 360 | impl 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)] |
| 387 | mod 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 | } |