//! Security audit log: the action names, the entry shape and the list filter. The stored shape is //! in migrations/0010_audit_log.sql; the store functions live in store/audit.rs. Rows are never //! edited; only the retention purge removes them. use ulid::Ulid; use crate::time::now_stamp; /// Every security-relevant action that is recorded. The `as_str` names are stored and shown. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AuditAction { LoginSuccess, LoginFailed, Login2faFailed, Logout, PasswordChanged, EmailChanged, PasswordResetByAdmin, TotpEnabled, TotpDisabled, RecoveryCodesRegenerated, SshKeyAdded, SshKeyRemoved, GpgKeyAdded, GpgKeyDeleted, TokenCreated, TokenRevoked, SessionRevoked, UserCreated, UsernameChanged, UserDeleted, RepoCreated, RepoDeleted, RepoRenamed, RepoTransferred, RepoTransferInitiated, RepoTransferAccepted, RepoTransferDeclined, RepoTransferCancelled, RepoVisibilityChanged, CollaboratorAdded, CollaboratorRemoved, CollaboratorRoleChanged, BranchRuleCreated, BranchRuleUpdated, BranchRuleDeleted, RepoArchived, RepoUnarchived, WebhookCreated, WebhookUpdated, WebhookDeleted, MirrorCreated, MirrorUpdated, MirrorDeleted, MirrorSyncFailed, BackupStarted, FsckStarted, ReplicationReconcileStarted, CiSecretSet, CiSecretDeleted, CiRunnerRegistered, CiRunnerDeleted, CiRegistrationTokenCreated, AdvisoryAlertDismissed, AdvisoryAlertReopened, UiPrefsChanged, DiscussionsEnabled, DiscussionsDisabled, DiscussionCreated, DiscussionEdited, DiscussionDeleted, DiscussionLocked, DiscussionUnlocked, DiscussionAnswered, DiscussionCommentCreated, DiscussionCommentEdited, DiscussionCommentDeleted, MailSettingsUpdated, MailTestQueued, SnippetCreated, SnippetUpdated, SnippetDeleted, WikiPageSaved, WikiEnabled, WikiDisabled, WikiPushed, OrgCreated, OrgDeleted, OrgSettingsUpdated, OrgMemberAdded, OrgMemberRemoved, OrgMemberRoleChanged, TeamCreated, TeamUpdated, TeamDeleted, TeamMemberAdded, TeamMemberRemoved, TeamRepoAdded, TeamRepoRemoved, PasswordResetCompleted, EmailVerified, InviteSent, InviteAccepted, } impl AuditAction { /// Every action, in declaration order. Used to parse filter values. pub const ALL: &'static [AuditAction] = &[ AuditAction::LoginSuccess, AuditAction::LoginFailed, AuditAction::Login2faFailed, AuditAction::Logout, AuditAction::PasswordChanged, AuditAction::EmailChanged, AuditAction::PasswordResetByAdmin, AuditAction::TotpEnabled, AuditAction::TotpDisabled, AuditAction::RecoveryCodesRegenerated, AuditAction::SshKeyAdded, AuditAction::SshKeyRemoved, AuditAction::GpgKeyAdded, AuditAction::GpgKeyDeleted, AuditAction::TokenCreated, AuditAction::TokenRevoked, AuditAction::SessionRevoked, AuditAction::UserCreated, AuditAction::UsernameChanged, AuditAction::UserDeleted, AuditAction::RepoCreated, AuditAction::RepoDeleted, AuditAction::RepoRenamed, AuditAction::RepoTransferred, AuditAction::RepoTransferInitiated, AuditAction::RepoTransferAccepted, AuditAction::RepoTransferDeclined, AuditAction::RepoTransferCancelled, AuditAction::RepoVisibilityChanged, AuditAction::CollaboratorAdded, AuditAction::CollaboratorRemoved, AuditAction::CollaboratorRoleChanged, AuditAction::BranchRuleCreated, AuditAction::BranchRuleUpdated, AuditAction::BranchRuleDeleted, AuditAction::RepoArchived, AuditAction::RepoUnarchived, AuditAction::WebhookCreated, AuditAction::WebhookUpdated, AuditAction::WebhookDeleted, AuditAction::MirrorCreated, AuditAction::MirrorUpdated, AuditAction::MirrorDeleted, AuditAction::MirrorSyncFailed, AuditAction::BackupStarted, AuditAction::FsckStarted, AuditAction::ReplicationReconcileStarted, AuditAction::CiSecretSet, AuditAction::CiSecretDeleted, AuditAction::CiRunnerRegistered, AuditAction::CiRunnerDeleted, AuditAction::CiRegistrationTokenCreated, AuditAction::AdvisoryAlertDismissed, AuditAction::AdvisoryAlertReopened, AuditAction::UiPrefsChanged, AuditAction::DiscussionsEnabled, AuditAction::DiscussionsDisabled, AuditAction::DiscussionCreated, AuditAction::DiscussionEdited, AuditAction::DiscussionDeleted, AuditAction::DiscussionLocked, AuditAction::DiscussionUnlocked, AuditAction::DiscussionAnswered, AuditAction::DiscussionCommentCreated, AuditAction::DiscussionCommentEdited, AuditAction::DiscussionCommentDeleted, AuditAction::MailSettingsUpdated, AuditAction::MailTestQueued, AuditAction::SnippetCreated, AuditAction::SnippetUpdated, AuditAction::SnippetDeleted, AuditAction::WikiPageSaved, AuditAction::WikiEnabled, AuditAction::WikiDisabled, AuditAction::WikiPushed, AuditAction::OrgCreated, AuditAction::OrgDeleted, AuditAction::OrgSettingsUpdated, AuditAction::OrgMemberAdded, AuditAction::OrgMemberRemoved, AuditAction::OrgMemberRoleChanged, AuditAction::TeamCreated, AuditAction::TeamUpdated, AuditAction::TeamDeleted, AuditAction::TeamMemberAdded, AuditAction::TeamMemberRemoved, AuditAction::TeamRepoAdded, AuditAction::TeamRepoRemoved, AuditAction::PasswordResetCompleted, AuditAction::EmailVerified, AuditAction::InviteSent, AuditAction::InviteAccepted, ]; pub fn as_str(self) -> &'static str { match self { AuditAction::LoginSuccess => "login_success", AuditAction::LoginFailed => "login_failed", AuditAction::Login2faFailed => "login_2fa_failed", AuditAction::Logout => "logout", AuditAction::PasswordChanged => "password_changed", AuditAction::EmailChanged => "email_changed", AuditAction::PasswordResetByAdmin => "password_reset_by_admin", AuditAction::TotpEnabled => "totp_enabled", AuditAction::TotpDisabled => "totp_disabled", AuditAction::RecoveryCodesRegenerated => "recovery_codes_regenerated", AuditAction::SshKeyAdded => "ssh_key_added", AuditAction::SshKeyRemoved => "ssh_key_removed", AuditAction::GpgKeyAdded => "gpg_key_added", AuditAction::GpgKeyDeleted => "gpg_key_deleted", AuditAction::TokenCreated => "token_created", AuditAction::TokenRevoked => "token_revoked", AuditAction::SessionRevoked => "session_revoked", AuditAction::UserCreated => "user_created", AuditAction::UsernameChanged => "username_changed", AuditAction::UserDeleted => "user_deleted", AuditAction::RepoCreated => "repo_created", AuditAction::RepoDeleted => "repo_deleted", AuditAction::RepoRenamed => "repo_renamed", AuditAction::RepoTransferred => "repo_transferred", AuditAction::RepoTransferInitiated => "repo_transfer_initiated", AuditAction::RepoTransferAccepted => "repo_transfer_accepted", AuditAction::RepoTransferDeclined => "repo_transfer_declined", AuditAction::RepoTransferCancelled => "repo_transfer_cancelled", AuditAction::RepoVisibilityChanged => "repo_visibility_changed", AuditAction::CollaboratorAdded => "collaborator_added", AuditAction::CollaboratorRemoved => "collaborator_removed", AuditAction::CollaboratorRoleChanged => "collaborator_role_changed", AuditAction::BranchRuleCreated => "branch_rule_created", AuditAction::BranchRuleUpdated => "branch_rule_updated", AuditAction::BranchRuleDeleted => "branch_rule_deleted", AuditAction::RepoArchived => "repo_archived", AuditAction::RepoUnarchived => "repo_unarchived", AuditAction::WebhookCreated => "webhook_created", AuditAction::WebhookUpdated => "webhook_updated", AuditAction::WebhookDeleted => "webhook_deleted", AuditAction::MirrorCreated => "mirror_created", AuditAction::MirrorUpdated => "mirror_updated", AuditAction::MirrorDeleted => "mirror_deleted", AuditAction::MirrorSyncFailed => "mirror_sync_failed", AuditAction::BackupStarted => "backup_started", AuditAction::FsckStarted => "fsck_started", AuditAction::ReplicationReconcileStarted => "replication_reconcile_started", AuditAction::CiSecretSet => "ci_secret_set", AuditAction::CiSecretDeleted => "ci_secret_deleted", AuditAction::CiRunnerRegistered => "ci_runner_registered", AuditAction::CiRunnerDeleted => "ci_runner_deleted", AuditAction::CiRegistrationTokenCreated => "ci_registration_token_created", AuditAction::AdvisoryAlertDismissed => "advisory_alert_dismissed", AuditAction::AdvisoryAlertReopened => "advisory_alert_reopened", AuditAction::UiPrefsChanged => "ui_prefs_changed", AuditAction::DiscussionsEnabled => "discussions_enabled", AuditAction::DiscussionsDisabled => "discussions_disabled", AuditAction::DiscussionCreated => "discussion_created", AuditAction::DiscussionEdited => "discussion_edited", AuditAction::DiscussionDeleted => "discussion_deleted", AuditAction::DiscussionLocked => "discussion_locked", AuditAction::DiscussionUnlocked => "discussion_unlocked", AuditAction::DiscussionAnswered => "discussion_answered", AuditAction::DiscussionCommentCreated => "discussion_comment_created", AuditAction::DiscussionCommentEdited => "discussion_comment_edited", AuditAction::DiscussionCommentDeleted => "discussion_comment_deleted", AuditAction::MailSettingsUpdated => "mail_settings_updated", AuditAction::MailTestQueued => "mail_test_queued", AuditAction::SnippetCreated => "snippet_created", AuditAction::SnippetUpdated => "snippet_updated", AuditAction::SnippetDeleted => "snippet_deleted", AuditAction::WikiPageSaved => "wiki_page_saved", AuditAction::WikiEnabled => "wiki_enabled", AuditAction::WikiDisabled => "wiki_disabled", AuditAction::WikiPushed => "wiki_pushed", AuditAction::OrgCreated => "org_created", AuditAction::OrgDeleted => "org_deleted", AuditAction::OrgSettingsUpdated => "org_settings_updated", AuditAction::OrgMemberAdded => "org_member_added", AuditAction::OrgMemberRemoved => "org_member_removed", AuditAction::OrgMemberRoleChanged => "org_member_role_changed", AuditAction::TeamCreated => "team_created", AuditAction::TeamUpdated => "team_updated", AuditAction::TeamDeleted => "team_deleted", AuditAction::TeamMemberAdded => "team_member_added", AuditAction::TeamMemberRemoved => "team_member_removed", AuditAction::TeamRepoAdded => "team_repo_added", AuditAction::TeamRepoRemoved => "team_repo_removed", AuditAction::PasswordResetCompleted => "password_reset_completed", AuditAction::EmailVerified => "email_verified", AuditAction::InviteSent => "invite_sent", AuditAction::InviteAccepted => "invite_accepted", } } /// The action with this stored name, if any. pub fn parse(name: &str) -> Option { AuditAction::ALL .iter() .copied() .find(|action| action.as_str() == name) } } /// One row of the audit log. `at` is an RFC 3339 UTC stamp. `data` is a JSON object as text. #[derive(Debug, Clone, PartialEq, Eq)] pub struct AuditEntry { pub id: String, pub at: String, pub actor_id: Option, pub actor_name: String, pub action: AuditAction, pub target_kind: String, pub target_id: String, pub target_name: String, pub ip: String, pub data: String, } impl AuditEntry { /// A new entry stamped with the current time and a fresh id. Everything else is empty. pub fn new(action: AuditAction) -> AuditEntry { AuditEntry { id: Ulid::generate().to_string(), at: now_stamp(), actor_id: None, actor_name: String::new(), action, target_kind: String::new(), target_id: String::new(), target_name: String::new(), ip: String::new(), data: "{}".to_string(), } } } /// Optional filters for [`crate::store::Store::list_audit`]. Unset fields do not restrict. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct AuditFilter { pub actor_id: Option, pub action: Option, pub target_kind: Option, pub target_id: Option, /// Matches entries where this user is the actor, or the `user` target. pub involving_user: Option, } /// Position in the newest-first listing: the last entry of the previous page. #[derive(Debug, Clone, PartialEq, Eq)] pub struct AuditCursor { pub at: String, pub id: String, } impl AuditCursor { /// The cursor for the entry a page ended on. pub fn after(entry: &AuditEntry) -> AuditCursor { AuditCursor { at: entry.at.clone(), id: entry.id.clone(), } } /// Text form for a URL: `_`. Neither part contains `_`. pub fn encode(&self) -> String { format!("{}_{}", self.at, self.id) } /// Parses [`AuditCursor::encode`] output. Anything malformed is `None`. pub fn decode(text: &str) -> Option { let (at, id) = text.split_once('_')?; let stamp_ok = at.len() == 30 && at.ends_with('Z') && at.contains('T'); let id_ok = !id.is_empty() && id.bytes().all(|b| b.is_ascii_alphanumeric()); (stamp_ok && id_ok).then(|| AuditCursor { at: at.to_string(), id: id.to_string(), }) } } #[cfg(test)] mod tests { use super::*; #[test] fn names_are_unique_and_round_trip() { let mut names: Vec<&str> = AuditAction::ALL.iter().map(|a| a.as_str()).collect(); assert_eq!(names.len(), 92); for action in AuditAction::ALL { assert_eq!(AuditAction::parse(action.as_str()), Some(*action)); } names.sort(); names.dedup(); assert_eq!(names.len(), 92); assert_eq!(AuditAction::parse("nope"), None); } #[test] fn cursor_round_trips_and_rejects_junk() { let entry = AuditEntry::new(AuditAction::Logout); let cursor = AuditCursor::after(&entry); assert_eq!(AuditCursor::decode(&cursor.encode()), Some(cursor)); assert_eq!(AuditCursor::decode(""), None); assert_eq!(AuditCursor::decode("nounderscore"), None); assert_eq!(AuditCursor::decode("2026-10-15T00:00:00Z_"), None); assert_eq!(AuditCursor::decode("bogus_01ABC"), None); } }