use crate::time::SharedClock;
use sui_id_shared::ids::UserId;
use sui_id_store::repos::audit;
use sui_id_store::Database;
#[derive(Debug, Clone, Copy)]
pub enum Outcome {
Ok,
Failure,
Skipped,
Active,
Inactive,
}
impl Outcome {
pub fn as_str(self) -> &'static str {
match self {
Self::Ok => "ok",
Self::Failure => "failure",
Self::Skipped => "skipped",
Self::Active => "active",
Self::Inactive => "inactive",
}
}
}
#[derive(Debug, Clone)]
pub enum SecurityEvent {
LoginPasswordSuccess {
user_id: UserId,
username: String,
},
LoginPasswordFailure {
username: String,
reason: &'static str,
},
LoginPasswordOkMfaRequired {
user_id: UserId,
},
MfaSuccess {
user_id: UserId,
method: &'static str, },
MfaFailure {
user_id: UserId,
reason: &'static str,
},
SessionRevoked {
user_id: UserId,
reason: &'static str,
},
AdminMfaReset {
actor: UserId,
target_user: UserId,
totp_removed: bool,
passkeys_removed: usize,
},
AuthorizeIssued {
user_id: UserId,
client_id: String,
scope: String,
},
AuthorizeRejected {
client_id: Option<String>,
reason: &'static str,
},
TokenIssued {
user_id: UserId,
client_id: String,
grant_type: &'static str,
},
TokenRefreshed {
user_id: UserId,
client_id: String,
},
TokenIntrospected {
client_id: String,
outcome: Outcome,
kind: Option<&'static str>,
},
TokenRevoked {
client_id: String,
kind: Option<&'static str>,
},
Logout {
user_id: UserId,
},
PasswordResetRequested {
user_id: Option<UserId>,
},
PasswordResetThrottled {
user_id: UserId,
outstanding: i64,
},
PasswordResetEmailSent {
user_id: UserId,
},
PasswordResetEmailFailed {
user_id: UserId,
reason: String,
},
PasswordResetCompleted {
user_id: UserId,
},
}
impl SecurityEvent {
pub fn name(&self) -> &'static str {
match self {
Self::LoginPasswordSuccess { .. } => "auth.login.success",
Self::LoginPasswordFailure { .. } => "auth.login.failure",
Self::LoginPasswordOkMfaRequired { .. } => "auth.login.password_ok_mfa_required",
Self::MfaSuccess { .. } => "auth.mfa.success",
Self::MfaFailure { .. } => "auth.mfa.failure",
Self::SessionRevoked { .. } => "auth.session.revoked",
Self::AdminMfaReset { .. } => "mfa.admin_reset",
Self::AuthorizeIssued { .. } => "oauth.authorize.issued",
Self::AuthorizeRejected { .. } => "oauth.authorize.rejected",
Self::TokenIssued { .. } => "oauth.token.issued",
Self::TokenRefreshed { .. } => "oauth.token.refreshed",
Self::TokenIntrospected { .. } => "oauth.token.introspected",
Self::TokenRevoked { .. } => "oauth.token.revoked",
Self::Logout { .. } => "auth.logout",
Self::PasswordResetRequested { .. } => "auth.password.reset_requested",
Self::PasswordResetThrottled { .. } => "auth.password.reset_throttled",
Self::PasswordResetEmailSent { .. } => "auth.password.reset_email_sent",
Self::PasswordResetEmailFailed { .. } => "auth.password.reset_email_failed",
Self::PasswordResetCompleted { .. } => "auth.password.reset_completed",
}
}
fn target(&self) -> Option<String> {
match self {
Self::LoginPasswordSuccess { user_id, .. }
| Self::LoginPasswordOkMfaRequired { user_id }
| Self::MfaSuccess { user_id, .. }
| Self::MfaFailure { user_id, .. }
| Self::SessionRevoked { user_id, .. }
| Self::Logout { user_id } => Some(user_id.to_string()),
Self::LoginPasswordFailure { username, .. } => Some(username.clone()),
Self::AdminMfaReset { target_user, .. } => Some(target_user.to_string()),
Self::AuthorizeIssued {
user_id, client_id, ..
}
| Self::TokenIssued {
user_id, client_id, ..
}
| Self::TokenRefreshed { user_id, client_id } => {
Some(format!("{user_id}:{client_id}"))
}
Self::AuthorizeRejected { client_id, .. } => client_id.clone(),
Self::TokenIntrospected { client_id, .. } | Self::TokenRevoked { client_id, .. } => {
Some(client_id.clone())
}
Self::PasswordResetRequested { user_id } => user_id.map(|u| u.to_string()),
Self::PasswordResetThrottled { user_id, .. }
| Self::PasswordResetEmailSent { user_id }
| Self::PasswordResetEmailFailed { user_id, .. }
| Self::PasswordResetCompleted { user_id } => Some(user_id.to_string()),
}
}
fn outcome(&self) -> Outcome {
match self {
Self::LoginPasswordSuccess { .. }
| Self::LoginPasswordOkMfaRequired { .. }
| Self::MfaSuccess { .. }
| Self::SessionRevoked { .. }
| Self::AdminMfaReset { .. }
| Self::AuthorizeIssued { .. }
| Self::TokenIssued { .. }
| Self::TokenRefreshed { .. }
| Self::TokenRevoked { .. }
| Self::Logout { .. }
| Self::PasswordResetRequested { .. }
| Self::PasswordResetEmailSent { .. }
| Self::PasswordResetCompleted { .. } => Outcome::Ok,
Self::LoginPasswordFailure { .. }
| Self::MfaFailure { .. }
| Self::AuthorizeRejected { .. }
| Self::PasswordResetThrottled { .. }
| Self::PasswordResetEmailFailed { .. } => Outcome::Failure,
Self::TokenIntrospected { outcome, .. } => *outcome,
}
}
fn note(&self) -> Option<String> {
match self {
Self::LoginPasswordFailure { reason, .. } | Self::MfaFailure { reason, .. } => {
Some((*reason).into())
}
Self::SessionRevoked { reason, .. } => Some((*reason).into()),
Self::MfaSuccess { method, .. } => Some((*method).into()),
Self::AuthorizeIssued { scope, .. } => Some(scope.clone()),
Self::AuthorizeRejected { reason, .. } => Some((*reason).into()),
Self::TokenIssued { grant_type, .. } => Some((*grant_type).into()),
Self::TokenIntrospected { kind, .. } | Self::TokenRevoked { kind, .. } => {
kind.map(|k| k.into())
}
Self::AdminMfaReset {
totp_removed,
passkeys_removed,
..
} => Some(format!(
"totp={} passkeys={}",
if *totp_removed { "removed" } else { "absent" },
passkeys_removed
)),
Self::PasswordResetRequested { user_id } => Some(format!(
"matched={}",
user_id.is_some()
)),
Self::PasswordResetThrottled { outstanding, .. } => {
Some(format!("outstanding={outstanding}"))
}
Self::PasswordResetEmailFailed { reason, .. } => {
Some(format!("reason={reason}"))
}
_ => None,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct Context {
pub actor: Option<UserId>,
pub client_ip: Option<String>,
pub request_id: Option<String>,
}
impl Context {
pub fn anonymous() -> Self {
Self::default()
}
pub fn with_actor(mut self, actor: UserId) -> Self {
self.actor = Some(actor);
self
}
pub fn with_client_ip(mut self, ip: impl Into<String>) -> Self {
self.client_ip = Some(ip.into());
self
}
}
pub async fn emit(db: &Database, clock: &SharedClock, ctx: &Context, event: SecurityEvent) {
let name = event.name();
let outcome = event.outcome();
let target = event.target();
let note = event.note();
let actor = ctx.actor;
let actor_str = actor.map(|a| a.to_string()).unwrap_or_else(|| "-".into());
let target_str = target.clone().unwrap_or_else(|| "-".into());
let note_str = note.clone().unwrap_or_default();
let request_id = ctx.request_id.clone().unwrap_or_default();
let client_ip = ctx.client_ip.clone().unwrap_or_default();
match outcome {
Outcome::Failure => {
tracing::warn!(
event = name,
actor = %actor_str,
target = %target_str,
result = outcome.as_str(),
note = %note_str,
request_id = %request_id,
client_ip = %client_ip,
"security event"
);
}
_ => {
tracing::info!(
event = name,
actor = %actor_str,
target = %target_str,
result = outcome.as_str(),
note = %note_str,
request_id = %request_id,
client_ip = %client_ip,
"security event"
);
}
}
if let Err(e) = audit::append(
db,
&sui_id_store::models::AuditLogRow {
at: clock.now(),
actor,
action: name.into(),
target,
result: outcome.as_str().into(),
note,
},
).await {
tracing::warn!(
error = %e,
event = name,
"failed to append audit-log row for security event"
);
}
}