use anyhow::Result;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::{collections::HashMap, sync::Arc};
use tokio::sync::RwLock;
use uuid::Uuid;
use async_trait::async_trait;
use crate::error::KirinoError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PasswordChangeReason {
InitialPassword,
Expired {
age_days: u32,
max_age_days: u32,
},
}
impl PasswordChangeReason {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::InitialPassword => "initial_password",
Self::Expired { .. } => "expired",
}
}
}
impl std::fmt::Display for PasswordChangeReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InitialPassword => f.write_str(self.as_str()),
Self::Expired {
age_days,
max_age_days,
} => write!(f, "{}({age_days}/{max_age_days}d)", self.as_str()),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct PasswordPolicy {
enabled: bool,
force_change_on_first_login: bool,
max_age_days: Option<u32>,
}
impl PasswordPolicy {
#[must_use]
pub const fn new(force_change_on_first_login: bool, max_age_days: Option<u32>) -> Self {
Self {
enabled: true,
force_change_on_first_login,
max_age_days,
}
}
#[must_use]
pub const fn disabled() -> Self {
Self {
enabled: false,
force_change_on_first_login: false,
max_age_days: None,
}
}
pub fn from_config(
enabled: bool,
force_change_on_first_login: bool,
max_age_days: Option<u32>,
) -> Result<Self> {
if max_age_days == Some(0) {
return Err(KirinoError::Validation(
"password policy max_age_days must be at least 1 day; omit it to disable expiration"
.to_string(),
)
.into());
}
Ok(Self {
enabled,
force_change_on_first_login,
max_age_days,
})
}
#[must_use]
pub const fn with_enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
#[must_use]
pub const fn with_force_change_on_first_login(mut self, force: bool) -> Self {
self.force_change_on_first_login = force;
self
}
#[must_use]
pub const fn with_max_age_days(mut self, days: u32) -> Self {
self.max_age_days = Some(days);
self
}
#[must_use]
pub const fn is_enabled(self) -> bool {
self.enabled
}
#[must_use]
pub const fn force_change_on_first_login(self) -> bool {
self.force_change_on_first_login
}
#[must_use]
pub const fn max_age_days(self) -> Option<u32> {
self.max_age_days
}
#[must_use]
pub fn change_requirement(
&self,
state: &PasswordState,
now: DateTime<Utc>,
) -> Option<PasswordChangeReason> {
if !self.enabled {
return None;
}
if self.force_change_on_first_login && state.is_initial() {
return Some(PasswordChangeReason::InitialPassword);
}
if let Some(max_age_days) = self.max_age_days {
let age_days = state.age_days(now);
if age_days >= max_age_days {
return Some(PasswordChangeReason::Expired {
age_days,
max_age_days,
});
}
}
None
}
}
impl Default for PasswordPolicy {
fn default() -> Self {
Self::new(true, None)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct PasswordState {
is_initial: bool,
changed_at: DateTime<Utc>,
}
impl PasswordState {
#[must_use]
pub const fn initial(changed_at: DateTime<Utc>) -> Self {
Self {
is_initial: true,
changed_at,
}
}
#[must_use]
pub const fn rotated(changed_at: DateTime<Utc>) -> Self {
Self {
is_initial: false,
changed_at,
}
}
#[must_use]
pub const fn from_parts(changed_at: DateTime<Utc>, is_initial: bool) -> Self {
Self {
is_initial,
changed_at,
}
}
#[must_use]
pub const fn is_initial(self) -> bool {
self.is_initial
}
#[must_use]
pub const fn changed_at(self) -> DateTime<Utc> {
self.changed_at
}
#[must_use]
pub fn age_days(self, now: DateTime<Utc>) -> u32 {
let elapsed = now.signed_duration_since(self.changed_at);
u32::try_from(elapsed.num_days().max(0)).unwrap_or(u32::MAX)
}
}
#[async_trait]
pub trait PasswordStateStore: Send + Sync {
async fn get(&self, account: &Uuid) -> Result<Option<PasswordState>>;
async fn put(&self, account: &Uuid, state: PasswordState) -> Result<()>;
}
#[derive(Clone, Default)]
pub struct InMemoryPasswordStateStore {
states: Arc<RwLock<HashMap<Uuid, PasswordState>>>,
}
impl InMemoryPasswordStateStore {
#[must_use]
pub fn new() -> Self {
Self::default()
}
}
#[async_trait]
impl PasswordStateStore for InMemoryPasswordStateStore {
async fn get(&self, account: &Uuid) -> Result<Option<PasswordState>> {
Ok(self.states.read().await.get(account).copied())
}
async fn put(&self, account: &Uuid, state: PasswordState) -> Result<()> {
self.states.write().await.insert(*account, state);
Ok(())
}
}
#[derive(Clone)]
pub struct BootstrapCredential {
username: String,
password: String,
state: PasswordState,
}
impl BootstrapCredential {
#[must_use]
pub fn username(&self) -> &str {
&self.username
}
#[must_use]
pub fn password(&self) -> &str {
&self.password
}
#[must_use]
pub const fn state(&self) -> PasswordState {
self.state
}
#[must_use]
pub fn log_line(&self) -> String {
format!(
"temporary password for '{}': {} — change it on first login",
self.username, self.password
)
}
pub fn emit_log(&self) {
tracing::warn!(
target: "kirino::rbac::policy",
username = %self.username,
temporary_password = %self.password,
"bootstrap temporary password issued; the account must change it on first login"
);
}
}
impl std::fmt::Debug for BootstrapCredential {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("BootstrapCredential")
.field("username", &self.username)
.field("password", &"[redacted]")
.field("state", &self.state)
.finish()
}
}
#[cfg(feature = "auth-password")]
#[must_use]
pub fn bootstrap_credential(username: &str, now: DateTime<Utc>) -> BootstrapCredential {
BootstrapCredential {
username: username.to_string(),
password: crate::auth::passport::static_password::generate_temporary_password(),
state: PasswordState::initial(now),
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Duration;
fn at() -> PasswordPolicy {
PasswordPolicy::new(true, Some(90))
}
#[test]
fn test_initial_password_requires_change() {
let now = Utc::now();
let policy = at();
assert_eq!(
policy.change_requirement(&PasswordState::initial(now), now),
Some(PasswordChangeReason::InitialPassword)
);
}
#[test]
fn test_initial_password_with_switch_off_is_accepted() {
let now = Utc::now();
let policy = PasswordPolicy::new(false, None);
assert_eq!(
policy.change_requirement(&PasswordState::initial(now), now),
None
);
}
#[test]
fn test_disabled_policy_never_requires_change() {
let now = Utc::now();
let policy = PasswordPolicy::new(true, Some(30)).with_enabled(false);
assert!(!policy.is_enabled());
assert_eq!(
policy.change_requirement(&PasswordState::initial(now), now),
None
);
assert_eq!(
policy.change_requirement(&PasswordState::rotated(now - Duration::days(3650)), now),
None
);
assert_eq!(
PasswordPolicy::disabled().change_requirement(&PasswordState::initial(now), now),
None
);
}
#[test]
fn test_rotated_password_not_required_when_fresh() {
let now = Utc::now();
let policy = at();
assert_eq!(
policy.change_requirement(&PasswordState::rotated(now), now),
None
);
assert_eq!(
policy.change_requirement(&PasswordState::rotated(now - Duration::days(89)), now),
None
);
}
#[test]
fn test_expiry_boundary_at_exact_limit_is_stale() {
let now = Utc::now();
let policy = PasswordPolicy::new(false, Some(90));
let state = PasswordState::rotated(now - Duration::days(90));
assert_eq!(
policy.change_requirement(&state, now),
Some(PasswordChangeReason::Expired {
age_days: 90,
max_age_days: 90
})
);
}
#[test]
fn test_expiry_boundary_one_second_before_limit_is_fresh() {
let now = Utc::now();
let policy = PasswordPolicy::new(false, Some(90));
let state = PasswordState::rotated(now - Duration::days(90) + Duration::seconds(1));
assert_eq!(policy.change_requirement(&state, now), None);
assert_eq!(state.age_days(now), 89);
}
#[test]
fn test_expiry_truncates_partial_days() {
let now = Utc::now();
let policy = PasswordPolicy::new(false, Some(7));
let state = PasswordState::rotated(now - Duration::days(6) - Duration::hours(23));
assert_eq!(state.age_days(now), 6);
assert_eq!(policy.change_requirement(&state, now), None);
let state = PasswordState::rotated(now - Duration::days(7));
assert!(policy.change_requirement(&state, now).is_some());
}
#[test]
fn test_future_timestamp_is_age_zero_never_expired() {
let now = Utc::now();
let policy = PasswordPolicy::new(false, Some(1));
let state = PasswordState::rotated(now + Duration::days(30));
assert_eq!(state.age_days(now), 0);
assert_eq!(policy.change_requirement(&state, now), None);
}
#[test]
fn test_initial_reason_wins_over_expiry() {
let now = Utc::now();
let policy = at();
let state = PasswordState::initial(now - Duration::days(400));
assert_eq!(
policy.change_requirement(&state, now),
Some(PasswordChangeReason::InitialPassword)
);
}
#[test]
fn test_default_policy_forces_bootstrap_change_only() {
let now = Utc::now();
let policy = PasswordPolicy::default();
assert!(policy.is_enabled());
assert!(policy.force_change_on_first_login());
assert_eq!(policy.max_age_days(), None);
assert_eq!(
policy.change_requirement(&PasswordState::initial(now), now),
Some(PasswordChangeReason::InitialPassword)
);
assert_eq!(
policy.change_requirement(&PasswordState::rotated(now - Duration::days(3650)), now),
None
);
}
#[test]
fn test_from_config_round_trip() {
let policy = PasswordPolicy::from_config(true, true, Some(45)).unwrap();
assert!(policy.is_enabled());
assert!(policy.force_change_on_first_login());
assert_eq!(policy.max_age_days(), Some(45));
let off = PasswordPolicy::from_config(false, true, Some(45)).unwrap();
assert!(!off.is_enabled());
assert_eq!(off.max_age_days(), Some(45));
let no_expiry = PasswordPolicy::from_config(true, false, None).unwrap();
assert_eq!(no_expiry.max_age_days(), None);
}
#[test]
fn test_from_config_rejects_zero_max_age() {
let err = PasswordPolicy::from_config(true, true, Some(0)).unwrap_err();
assert!(err
.downcast_ref::<KirinoError>()
.is_some_and(|e| matches!(e, KirinoError::Validation(_))));
}
#[test]
fn test_serde_defaults_fill_missing_config_keys() {
let bare: PasswordPolicy = serde_json::from_str("{}").unwrap();
assert_eq!(bare, PasswordPolicy::default());
let configured: PasswordPolicy =
serde_json::from_str(r#"{"enabled":false,"max_age_days":30}"#).unwrap();
assert!(!configured.is_enabled());
assert!(configured.force_change_on_first_login());
assert_eq!(configured.max_age_days(), Some(30));
let round_trip: PasswordPolicy =
serde_json::from_str(&serde_json::to_string(&configured).unwrap()).unwrap();
assert_eq!(round_trip, configured);
}
#[test]
fn test_state_serde_round_trip() {
let now = Utc::now();
let state = PasswordState::from_parts(now, true);
let json = serde_json::to_string(&state).unwrap();
assert_eq!(serde_json::from_str::<PasswordState>(&json).unwrap(), state);
assert!(state.is_initial());
assert_eq!(state.changed_at(), now);
}
#[test]
fn test_reason_codes() {
assert_eq!(
PasswordChangeReason::InitialPassword.as_str(),
"initial_password"
);
assert_eq!(
PasswordChangeReason::Expired {
age_days: 91,
max_age_days: 90
}
.as_str(),
"expired"
);
assert_eq!(
PasswordChangeReason::InitialPassword.to_string(),
"initial_password"
);
assert_eq!(
PasswordChangeReason::Expired {
age_days: 91,
max_age_days: 90
}
.to_string(),
"expired(91/90d)"
);
}
#[tokio::test]
async fn test_store_round_trip() {
let store = InMemoryPasswordStateStore::new();
let account = Uuid::now_v7();
let now = Utc::now();
assert_eq!(store.get(&account).await.unwrap(), None);
let state = PasswordState::initial(now);
store.put(&account, state).await.unwrap();
assert_eq!(store.get(&account).await.unwrap(), Some(state));
let rotated = PasswordState::rotated(now);
store.put(&account, rotated).await.unwrap();
assert_eq!(store.get(&account).await.unwrap(), Some(rotated));
assert_eq!(store.get(&Uuid::now_v7()).await.unwrap(), None);
}
#[cfg(feature = "auth-password")]
mod bootstrap {
use super::*;
use crate::auth::passport::static_password::TEMPORARY_PASSWORD_LEN;
use crate::service::login::validate_password;
#[test]
fn test_bootstrap_credential_is_initial_and_valid() {
let now = Utc::now();
let credential = bootstrap_credential("admin", now);
assert_eq!(credential.username(), "admin");
assert_eq!(credential.state(), PasswordState::initial(now));
assert_eq!(
credential.password().chars().count(),
TEMPORARY_PASSWORD_LEN
);
validate_password(credential.password()).unwrap();
}
#[test]
fn test_bootstrap_credential_is_random() {
let a = bootstrap_credential("admin", Utc::now());
let b = bootstrap_credential("admin", Utc::now());
assert_ne!(
a.password(),
b.password(),
"a generated temporary password must never be a fixed literal"
);
}
#[test]
fn test_bootstrap_credential_forces_change_under_default_policy() {
let now = Utc::now();
let credential = bootstrap_credential("admin", now);
assert_eq!(
PasswordPolicy::default().change_requirement(&credential.state(), now),
Some(PasswordChangeReason::InitialPassword)
);
}
#[test]
fn test_bootstrap_debug_redacts_password_and_log_line_carries_it() {
let credential = bootstrap_credential("admin", Utc::now());
let debug = format!("{credential:?}");
assert!(debug.contains("[redacted]"));
assert!(!debug.contains(credential.password()));
assert!(credential.log_line().contains(credential.password()));
assert!(credential.log_line().contains("admin"));
}
}
}