Files
latchd/crates/latchd-core/src/audit.rs
Zane Walker f2a885d621 refactor: split the TUI and refresh project docs
Move login state, authentication, discovery, input, rendering, and tests into focused TUI modules. Add detailed CLI error reports with recovery commands, align the build and host-check documentation, and keep the legacy Go tree outside normal builds.

Checks: make check; make integration-test; go test ./... (legacy/go).
2026-10-05 21:37:15 +05:30

156 lines
4.8 KiB
Rust

//! Logs audit events for login operations structurally, without including free-form messages from PAM modules that may contain sensitive information.
use std::fmt;
use crate::error::Error;
/// Login operation that produced an audit event.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuditAction {
Authentication,
PasswordChange,
Session,
}
impl fmt::Display for AuditAction {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(match self {
Self::Authentication => "authentication",
Self::PasswordChange => "password_change",
Self::Session => "session",
})
}
}
/// Result recorded for a login operation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuditOutcome {
Started,
Succeeded,
Failed,
PasswordChangeRequired,
}
impl fmt::Display for AuditOutcome {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(match self {
Self::Started => "started",
Self::Succeeded => "succeeded",
Self::Failed => "failed",
Self::PasswordChangeRequired => "password_change_required",
})
}
}
/// Stable category for a PAM status without its free-form message.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PamReason {
AuthenticationRejected,
AuthenticationUnavailable,
AccountExpired,
PasswordChangeRequired,
TooManyAttempts,
Conversation,
System,
Unknown,
}
impl fmt::Display for PamReason {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(match self {
Self::AuthenticationRejected => "authentication_rejected",
Self::AuthenticationUnavailable => "authentication_unavailable",
Self::AccountExpired => "account_expired",
Self::PasswordChangeRequired => "password_change_required",
Self::TooManyAttempts => "too_many_attempts",
Self::Conversation => "conversation_error",
Self::System => "system_error",
Self::Unknown => "unknown_pam_error",
})
}
}
/// Event fields intended for the audit logger.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AuditEvent<'a> {
pub action: AuditAction,
pub outcome: AuditOutcome,
/// Account name supplied by the user, never a password or token.
pub account: &'a str,
pub pam_reason: Option<PamReason>,
}
impl<'a> AuditEvent<'a> {
pub const fn started(action: AuditAction, account: &'a str) -> Self {
Self {
action,
outcome: AuditOutcome::Started,
account,
pam_reason: None,
}
}
pub const fn succeeded(action: AuditAction, account: &'a str) -> Self {
Self {
action,
outcome: AuditOutcome::Succeeded,
account,
pam_reason: None,
}
}
pub fn failed(action: AuditAction, account: &'a str, error: &Error) -> Self {
Self {
action,
outcome: if error.requires_password_change() {
AuditOutcome::PasswordChangeRequired
} else {
AuditOutcome::Failed
},
account,
pam_reason: error.pam_status().map(pam_reason),
}
}
}
/// Map Linux-PAM status codes to stable categories.
pub const fn pam_reason(status: i32) -> PamReason {
match status {
6 | 7 => PamReason::AuthenticationRejected,
9 | 10 => PamReason::AuthenticationUnavailable,
11 => PamReason::TooManyAttempts,
12 | 27 => PamReason::PasswordChangeRequired,
13 => PamReason::AccountExpired,
19 | 20 | 21 | 22 | 23 | 24 | 30 | 31 => PamReason::Conversation,
1 | 2 | 3 | 4 | 5 | 14 | 15 | 16 | 17 | 18 | 25 | 26 | 28 | 29 => PamReason::System,
_ => PamReason::Unknown,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn password_change_status_is_classified_without_raw_message() {
let error = Error::Pam {
operation: "pam_acct_mgmt",
status: 12,
message: "secret module detail".into(),
};
let event = AuditEvent::failed(AuditAction::Authentication, "alice", &error);
assert_eq!(event.outcome, AuditOutcome::PasswordChangeRequired);
assert_eq!(event.pam_reason, Some(PamReason::PasswordChangeRequired));
assert!(!format!("{event:?}").contains("secret module detail"));
}
#[test]
fn every_known_pam_family_has_a_stable_reason() {
assert_eq!(pam_reason(7), PamReason::AuthenticationRejected);
assert_eq!(pam_reason(9), PamReason::AuthenticationUnavailable);
assert_eq!(pam_reason(13), PamReason::AccountExpired);
assert_eq!(pam_reason(11), PamReason::TooManyAttempts);
assert_eq!(pam_reason(999), PamReason::Unknown);
}
}