pub mod policy;
use anyhow::Result;
use chrono::Utc;
use std::{
collections::{HashMap, HashSet},
sync::Arc,
time::Instant,
};
use tokio::sync::RwLock;
use uuid::Uuid;
use self::policy::PasswordLoginPolicy;
#[cfg(feature = "auth-jwt")]
use crate::auth::credential::basic::JwtManager;
#[cfg(feature = "auth-password")]
use crate::auth::passport::static_password::{hash_password, verify_password};
#[cfg(feature = "rbac-dynamic")]
use crate::rbac::dynamic::arbiter::AuthorizationArbiter;
#[cfg(all(feature = "auth-password", feature = "auth-jwt"))]
use crate::rbac::traits::AssignmentStore;
use crate::{
error::KirinoError,
models::identity::Identity,
rbac::{
engine::RbacEngine,
shared::Shared,
store::{
memory::InMemoryAssignmentStore,
registry::{SimpleRole, StaticPermissionRegistry, StaticRoleRegistry},
},
subject::StringSubject,
traits::Permission,
},
};
#[derive(Debug, Clone)]
struct RateLimitEntry {
attempts: u32,
window_start: Instant,
}
pub struct LoginRateLimiter {
max_attempts: u32,
window_secs: u64,
lockout_secs: u64,
policy: PasswordLoginPolicy,
entries: Arc<RwLock<HashMap<String, RateLimitEntry>>>,
}
const MIN_PASSWORD_LEN: usize = 8;
const MAX_PASSWORD_LEN: usize = 128;
const MIN_USERNAME_LEN: usize = 2;
const MAX_USERNAME_LEN: usize = 64;
pub fn validate_username(username: &str) -> Result<String> {
let trimmed = username.trim();
if trimmed.is_empty() || trimmed.len() < MIN_USERNAME_LEN {
return Err(KirinoError::Validation(format!(
"username must be at least {MIN_USERNAME_LEN} characters"
))
.into());
}
if trimmed.len() > MAX_USERNAME_LEN {
return Err(KirinoError::Validation(format!(
"username must be at most {MAX_USERNAME_LEN} characters"
))
.into());
}
if !trimmed
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
{
return Err(KirinoError::Validation(
"username may only contain alphanumeric characters, underscores, hyphens, and dots"
.to_string(),
)
.into());
}
Ok(trimmed.to_string())
}
pub fn validate_password(password: &str) -> Result<()> {
if password.len() < MIN_PASSWORD_LEN {
return Err(KirinoError::Validation(format!(
"password must be at least {MIN_PASSWORD_LEN} characters"
))
.into());
}
if password.len() > MAX_PASSWORD_LEN {
return Err(KirinoError::Validation(format!(
"password must be at most {MAX_PASSWORD_LEN} characters"
))
.into());
}
let has_uppercase = password.chars().any(|c| c.is_uppercase());
let has_lowercase = password.chars().any(|c| c.is_lowercase());
let has_digit = password.chars().any(|c| c.is_ascii_digit());
let has_special = password.chars().any(|c| !c.is_alphanumeric());
let categories = [has_uppercase, has_lowercase, has_digit, has_special]
.iter()
.filter(|&&x| x)
.count();
if categories < 3 {
return Err(KirinoError::Validation(
"password must contain at least 3 of: uppercase, lowercase, digit, special character"
.to_string(),
)
.into());
}
Ok(())
}
const RATE_LIMITER_MAX_ENTRIES: usize = 10_000;
impl LoginRateLimiter {
#[must_use]
pub fn new(max_attempts: u32, window_secs: u64, lockout_secs: u64) -> Self {
Self {
max_attempts,
window_secs,
lockout_secs,
policy: PasswordLoginPolicy::Enabled,
entries: Arc::new(RwLock::new(HashMap::new())),
}
}
#[must_use]
pub fn with_policy(mut self, policy: PasswordLoginPolicy) -> Self {
self.policy = policy;
self
}
#[must_use]
pub fn policy(&self) -> PasswordLoginPolicy {
self.policy
}
fn set_policy(&mut self, policy: PasswordLoginPolicy) {
self.policy = policy;
}
pub async fn check_and_record_failure(&self, key: &str) -> Result<()> {
if !self.policy.is_enabled() {
return Ok(());
}
let key = key.trim().to_lowercase();
let mut entries = self.entries.write().await;
let now = Instant::now();
if entries.len() >= RATE_LIMITER_MAX_ENTRIES {
let total_window = std::time::Duration::from_secs(self.window_secs + self.lockout_secs);
entries.retain(|_, entry| now.duration_since(entry.window_start) <= total_window);
}
let entry = entries.entry(key.to_string()).or_insert(RateLimitEntry {
attempts: 0,
window_start: now,
});
let elapsed = now.duration_since(entry.window_start);
let total_window = std::time::Duration::from_secs(self.window_secs + self.lockout_secs);
let window_duration = std::time::Duration::from_secs(self.window_secs);
let should_reset = elapsed > total_window
|| (elapsed > window_duration && entry.attempts < self.max_attempts);
if should_reset {
entry.attempts = 0;
entry.window_start = now;
}
if entry.attempts >= self.max_attempts {
let remaining = total_window.saturating_sub(elapsed);
if remaining > std::time::Duration::ZERO {
return Err(KirinoError::Validation(format!(
"too many login attempts, try again in {} seconds",
remaining.as_secs()
))
.into());
}
entry.attempts = 0;
entry.window_start = now;
}
entry.attempts += 1;
Ok(())
}
pub async fn reset(&self, key: &str) {
if !self.policy.is_enabled() {
return;
}
let key = key.trim().to_lowercase();
let mut entries = self.entries.write().await;
entries.remove(&key);
}
}
#[derive(Clone)]
pub struct UserRecord {
pub id: Uuid,
pub username: String,
pub(crate) password_hash: String,
pub display_name: Option<String>,
pub is_active: bool,
pub identity: Identity,
pub created_at: chrono::DateTime<Utc>,
pub updated_at: chrono::DateTime<Utc>,
}
impl std::fmt::Debug for UserRecord {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("UserRecord")
.field("id", &self.id)
.field("username", &self.username)
.field("password_hash", &"[redacted]")
.field("display_name", &self.display_name)
.field("is_active", &self.is_active)
.field("identity", &self.identity)
.field("created_at", &self.created_at)
.field("updated_at", &self.updated_at)
.finish()
}
}
impl UserRecord {
#[must_use]
pub fn to_public(&self) -> UserInfo {
UserInfo {
id: self.id,
username: self.username.clone(),
display_name: self.display_name.clone(),
is_active: self.is_active,
identity: self.identity.clone(),
created_at: self.created_at,
updated_at: self.updated_at,
}
}
}
#[derive(Debug, Clone)]
pub struct UserInfo {
pub id: Uuid,
pub username: String,
pub display_name: Option<String>,
pub is_active: bool,
pub identity: Identity,
pub created_at: chrono::DateTime<Utc>,
pub updated_at: chrono::DateTime<Utc>,
}
#[derive(Debug, Clone)]
pub struct LoginResult {
pub token: String,
pub user_id: String,
pub username: String,
pub display_name: Option<String>,
pub roles: Vec<String>,
#[cfg(feature = "auth-jwt")]
pub session_id: Option<uuid::Uuid>,
}
#[cfg(all(feature = "auth-password", feature = "auth-jwt"))]
pub struct AuthService<DB, P, A>
where
P: Permission,
A: AssignmentStore<StringSubject, P>,
{
db: DB,
jwt: JwtManager,
engine: Shared<RbacEngine<StringSubject, P, A>>,
#[cfg(feature = "rbac-dynamic")]
arbiter: Option<Shared<AuthorizationArbiter>>,
rate_limiter: LoginRateLimiter,
register_rate_limiter: LoginRateLimiter,
password_login_policy: PasswordLoginPolicy,
first_user_role: String,
default_role: String,
auto_admin_first_user: bool,
has_first_user: std::sync::atomic::AtomicBool,
}
#[cfg(all(feature = "auth-password", feature = "auth-jwt"))]
impl<DB, P, A> AuthService<DB, P, A>
where
DB: UserDatabase,
P: Permission,
A: AssignmentStore<StringSubject, P>,
{
pub fn new(
db: DB,
jwt_secret: &str,
jwt_expiration_hours: i64,
engine: Shared<RbacEngine<StringSubject, P, A>>,
first_user_role: &str,
default_role: &str,
) -> Result<Self> {
Ok(Self {
db,
jwt: JwtManager::new(jwt_secret, jwt_expiration_hours)?,
engine,
#[cfg(feature = "rbac-dynamic")]
arbiter: None,
rate_limiter: LoginRateLimiter::new(5, 300, 900),
register_rate_limiter: LoginRateLimiter::new(3, 300, 1800),
password_login_policy: PasswordLoginPolicy::Enabled,
first_user_role: first_user_role.to_string(),
default_role: default_role.to_string(),
auto_admin_first_user: false,
has_first_user: std::sync::atomic::AtomicBool::new(false),
})
}
#[must_use]
pub fn with_rate_limiter(mut self, limiter: LoginRateLimiter) -> Self {
let policy = self.password_login_policy;
self.rate_limiter = limiter;
self.rate_limiter.set_policy(policy);
self
}
#[must_use]
pub fn with_password_login_policy(mut self, policy: PasswordLoginPolicy) -> Self {
self.password_login_policy = policy;
self.rate_limiter.set_policy(policy);
self
}
#[must_use]
pub fn password_login_policy(&self) -> PasswordLoginPolicy {
self.password_login_policy
}
fn ensure_password_login_allowed(&self) -> Result<()> {
if !self.password_login_policy.is_enabled() {
return Err(KirinoError::Validation(
"password login is disabled by policy; sign in with a third-party identity provider"
.to_string(),
)
.into());
}
Ok(())
}
#[must_use]
pub fn with_auto_admin_first_user(mut self, enabled: bool) -> Self {
self.auto_admin_first_user = enabled;
self
}
#[must_use]
#[cfg(feature = "rbac-dynamic")]
pub fn with_arbiter(mut self, arbiter: AuthorizationArbiter) -> Self {
self.arbiter = Some(Shared::new(arbiter));
self
}
#[cfg(feature = "rbac-dynamic")]
#[must_use]
pub fn arbiter(&self) -> Option<Shared<AuthorizationArbiter>> {
self.arbiter.clone()
}
#[must_use]
pub fn jwt_manager(&self) -> &JwtManager {
&self.jwt
}
#[must_use]
pub fn engine(&self) -> Shared<RbacEngine<StringSubject, P, A>> {
self.engine.clone()
}
pub async fn register(
&self,
username: &str,
password: &str,
display_name: Option<&str>,
) -> Result<UserInfo> {
let username = validate_username(username)?;
validate_password(password)?;
self.register_rate_limiter
.check_and_record_failure(&username)
.await?;
let result = self.register_impl(&username, password, display_name).await;
if result.is_ok() {
self.register_rate_limiter.reset(&username).await;
}
result
}
async fn register_impl(
&self,
username: &str,
password: &str,
display_name: Option<&str>,
) -> Result<UserInfo> {
if self.db.find_by_username(username).await?.is_some() {
return Err(KirinoError::Validation("username already exists".to_string()).into());
}
let password_hash = hash_password(password)?;
let user_id = Uuid::now_v7();
let now = Utc::now();
let identity = Identity::Basic {
id: user_id,
created_at: now,
};
let user = UserRecord {
id: user_id,
username: username.to_string(),
password_hash,
display_name: display_name.map(ToString::to_string),
is_active: true,
identity,
created_at: now,
updated_at: now,
};
let is_first = self.auto_admin_first_user
&& !self
.has_first_user
.swap(true, std::sync::atomic::Ordering::SeqCst)
&& self.db.count_users().await? == 0;
self.db.create_user(&user).await?;
let role_name = if is_first {
&self.first_user_role
} else {
&self.default_role
};
let subject = StringSubject::new(user_id.to_string());
self.engine
.assignment_store()
.assign_role(&subject, role_name)
.await?;
Ok(user.to_public())
}
const DUMMY_HASH: &str =
"$argon2id$v=19$m=19456,t=2,p=1$AAAAAAAAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
async fn authenticate_user(&self, username: &str, password: &str) -> Result<UserRecord> {
let Some(user) = self.db.find_by_username(username).await? else {
if let Err(e) = verify_password(password, Self::DUMMY_HASH) {
tracing::error!(target: "kirino::service::login",
error = %e,
"dummy hash verification failed"
);
}
return Err(KirinoError::AuthenticationFailed.into());
};
if !user.is_active {
let _ = verify_password(password, &user.password_hash);
return Err(KirinoError::AuthenticationFailed.into());
}
if !verify_password(password, &user.password_hash)? {
return Err(KirinoError::AuthenticationFailed.into());
}
self.rate_limiter.reset(username).await;
Ok(user)
}
async fn fetch_user_roles_and_perms(
&self,
user_id: &str,
) -> Result<(Vec<String>, Vec<String>)> {
let subject = StringSubject::new(user_id);
let roles = self
.engine
.assignment_store()
.roles_of(&subject)
.await
.map_err(|e| {
tracing::warn!("failed to fetch roles for user {user_id}: {e}");
KirinoError::AuthorizationDenied("store unavailable".into())
})?;
let perm_names: Vec<String> = self
.engine
.effective_permissions(&subject)
.await
.map_err(|e| {
tracing::warn!("failed to fetch effective permissions: {e}");
KirinoError::AuthorizationDenied("store unavailable".into())
})?
.into_iter()
.map(|p| p.name().to_string())
.collect();
Ok((roles, perm_names))
}
pub async fn login(&self, username: &str, password: &str) -> Result<LoginResult> {
self.ensure_password_login_allowed()?;
let username = username.trim();
self.rate_limiter.check_and_record_failure(username).await?;
let user = self.authenticate_user(username, password).await?;
let user_id = user.id.to_string();
let (roles, perm_names) = self.fetch_user_roles_and_perms(&user_id).await?;
let token = self.jwt.issue_with_options(
&user_id,
&user.username,
roles.clone(),
perm_names,
None,
)?;
Ok(LoginResult {
token,
user_id,
username: user.username.clone(),
display_name: user.display_name.clone(),
roles,
session_id: None,
})
}
pub async fn verify_token(
&self,
token: &str,
) -> Result<crate::auth::credential::basic::Claims> {
self.jwt.verify_with_revocation(token).await
}
#[must_use]
pub async fn check_permission(&self, user_id: &str, permission: &P) -> bool {
let subject = StringSubject::new(user_id);
self.engine.check(&subject, permission).await
}
#[cfg(feature = "rbac-dynamic")]
#[must_use]
pub async fn check_static_and_dynamic(
&self,
user_id: &str,
permission: &P,
action_request: &crate::rbac::dynamic::metrics::ActionRequest,
) -> bool {
let subject = StringSubject::new(user_id);
if !self.engine.check(&subject, permission).await {
return false;
}
if let Some(ref arbiter) = self.arbiter {
let verdict = arbiter.authorize(action_request).await;
return verdict.allowed;
}
true
}
pub async fn change_password(
&self,
user_id: &str,
old_password: &str,
new_password: &str,
) -> Result<()> {
let uid = Uuid::parse_str(user_id)
.map_err(|_| KirinoError::Validation("invalid user_id".to_string()))?;
let user = self
.db
.find_by_id(&uid)
.await?
.ok_or_else(|| KirinoError::NotFound("user not found".to_string()))?;
if !verify_password(old_password, &user.password_hash)? {
return Err(KirinoError::AuthenticationFailed.into());
}
validate_password(new_password)?;
let new_hash = hash_password(new_password)?;
self.jwt.revoke_all_for_user(user_id).await;
self.db.update_password(&uid, &new_hash).await
}
pub async fn list_users(&self) -> Result<Vec<UserInfo>> {
let users = self.db.list_users().await?;
Ok(users.iter().map(UserRecord::to_public).collect())
}
pub async fn delete_user(&self, user_id: &str) -> Result<bool> {
let uid = Uuid::parse_str(user_id)
.map_err(|_| KirinoError::Validation("invalid user_id".to_string()))?;
self.jwt.revoke_all_for_user(user_id).await;
self.db.delete_user(&uid).await
}
pub async fn login_with_session<SM>(
&self,
username: &str,
password: &str,
session_mgr: &SM,
session_ttl: chrono::Duration,
) -> Result<(LoginResult, crate::rbac::session::Session<StringSubject>)>
where
SM: crate::rbac::session::SessionManager<StringSubject>,
{
self.ensure_password_login_allowed()?;
let username_trimmed = username.trim();
self.rate_limiter
.check_and_record_failure(username_trimmed)
.await?;
let user = self.authenticate_user(username_trimmed, password).await?;
let user_id = user.id.to_string();
let (roles, perm_names) = self.fetch_user_roles_and_perms(&user_id).await?;
let active_roles: HashSet<String> = roles.iter().cloned().collect();
let subject = StringSubject::new(&user_id);
let session = session_mgr
.create_session(&subject, active_roles, session_ttl)
.await?;
let token = self.jwt.issue_with_options(
&user_id,
&user.username,
roles.clone(),
perm_names,
Some(session.id.to_string()),
)?;
Ok((
LoginResult {
token,
user_id,
username: user.username.clone(),
display_name: user.display_name.clone(),
roles,
session_id: Some(session.id),
},
session,
))
}
pub async fn logout<SM>(&self, _user_id: &str, session_id: Uuid, session_mgr: &SM) -> Result<()>
where
SM: crate::rbac::session::SessionManager<StringSubject>,
{
self.jwt.revoke_session(&session_id.to_string()).await;
session_mgr.destroy_session(session_id).await
}
}
#[must_use]
pub fn build_default_engine() -> Shared<
RbacEngine<
StringSubject,
crate::rbac::permission::Permission,
InMemoryAssignmentStore<StringSubject, crate::rbac::permission::Permission>,
>,
> {
use crate::rbac::permission::Permission;
let mut role_reg = StaticRoleRegistry::new();
role_reg.register(SimpleRole::new(
"admin",
Permission::all().into_iter().collect(),
));
role_reg.register(SimpleRole::new(
"operator",
Permission::all()
.into_iter()
.filter(|p: &Permission| p.domain() != "system" && p.domain() != "rbac")
.collect(),
));
role_reg.register(SimpleRole::new(
"member",
Permission::all()
.into_iter()
.filter(|p: &Permission| {
matches!(
p.name(),
"provider.list"
| "provider.use"
| "mcp.list"
| "mcp.use"
| "agent.list"
| "agent.use"
| "channel.list"
| "channel.use"
| "workspace.list"
| "workspace.create"
| "device.list"
| "device.connect"
)
})
.collect(),
));
role_reg.register(SimpleRole::new(
"viewer",
Permission::all()
.into_iter()
.filter(|p| p.name().ends_with(".list") || p.name() == "deploy.read")
.collect(),
));
let perm_reg = StaticPermissionRegistry::new(Permission::all().into_iter().collect());
let store = InMemoryAssignmentStore::new();
Shared::new(RbacEngine::new(role_reg, perm_reg, store))
}
#[async_trait::async_trait]
pub trait UserDatabase: Send + Sync + Clone + 'static {
async fn create_user(&self, user: &UserRecord) -> Result<()>;
async fn find_by_username(&self, username: &str) -> Result<Option<UserRecord>>;
async fn find_by_id(&self, id: &Uuid) -> Result<Option<UserRecord>>;
async fn update_password(&self, id: &Uuid, new_hash: &str) -> Result<()>;
async fn delete_user(&self, id: &Uuid) -> Result<bool>;
async fn list_users(&self) -> Result<Vec<UserRecord>>;
async fn count_users(&self) -> Result<u64>;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_username_valid() {
assert_eq!(validate_username("alice").unwrap(), "alice");
assert_eq!(validate_username("bob_123").unwrap(), "bob_123");
assert_eq!(validate_username("user.name").unwrap(), "user.name");
assert_eq!(validate_username("a-b").unwrap(), "a-b");
}
#[test]
fn test_validate_username_too_short() {
assert!(validate_username("a").is_err());
assert!(validate_username("").is_err());
}
#[test]
fn test_validate_username_too_long() {
let long = "a".repeat(65);
assert!(validate_username(&long).is_err());
}
#[test]
fn test_validate_username_exactly_max_length() {
let max = "a".repeat(64);
assert!(validate_username(&max).is_ok());
}
#[test]
fn test_validate_username_invalid_chars() {
assert!(validate_username("alice bob").is_err());
assert!(validate_username("alice@domain").is_err());
assert!(validate_username("用户").is_err());
assert!(validate_username("user!name").is_err());
}
#[test]
fn test_validate_username_whitespace_only() {
assert!(validate_username(" ").is_err());
}
#[test]
fn test_validate_username_trimmed() {
assert_eq!(validate_username(" ab ").unwrap(), "ab");
}
#[test]
fn test_validate_password_valid() {
assert!(validate_password("Password1!").is_ok());
assert!(validate_password("Abcdefg1").is_ok());
assert!(validate_password("HELLOworld123!").is_ok());
}
#[test]
fn test_validate_password_too_short() {
assert!(validate_password("Ab1!").is_err());
}
#[test]
fn test_validate_password_exactly_8() {
assert!(validate_password("Abcd123!").is_ok());
}
#[test]
fn test_validate_password_too_long() {
let long = "Aa1!".repeat(33);
assert!(validate_password(&long).is_err());
}
#[test]
fn test_validate_password_exactly_max() {
let max = "A".repeat(96) + "a1!";
assert!(validate_password(&max).is_ok());
}
#[test]
fn test_validate_password_only_two_categories() {
assert!(validate_password("abcdefgh").is_err());
assert!(validate_password("ABCDEFGH").is_err());
assert!(validate_password("12345678").is_err());
assert!(validate_password("abcdABCD").is_err());
assert!(validate_password("abcd1234").is_err());
assert!(validate_password("ABCD1234").is_err());
}
#[test]
fn test_validate_password_exactly_three_categories() {
assert!(validate_password("ABCDefgh1").is_ok());
assert!(validate_password("Abcd1234").is_ok());
assert!(validate_password("abcd123!").is_ok());
}
#[test]
fn test_validate_password_unicode_uppercase_lowercase() {
assert!(
validate_password("Élève1!").is_ok(),
"French accented chars should work"
);
assert!(
validate_password("ÜberCafé1!").is_ok(),
"German umlauts should work"
);
assert!(
validate_password("Niño123!").is_ok(),
"Spanish tilde should work"
);
assert!(
validate_password("中文密码A1!").is_ok(),
"CJK + ascii uppercase + digit + special"
);
}
#[test]
fn test_validate_password_unicode_all_one_category() {
assert!(
validate_password("abcdefgh").is_err(),
"only lowercase fails"
);
assert!(
validate_password("ÉÉÉÉÉÉÉÉ").is_err(),
"only unicode uppercase fails"
);
assert!(
validate_password("汉字汉字汉字汉字").is_err(),
"only CJK fails"
);
}
#[test]
fn test_validate_password_unicode_with_special() {
assert!(
validate_password("Passエンド1!").is_ok(),
"mixed ascii + CJK + special"
);
}
#[tokio::test]
async fn test_rate_limiter_allows_under_max() {
let limiter = LoginRateLimiter::new(3, 300, 900);
assert!(limiter.check_and_record_failure("user").await.is_ok());
assert!(limiter.check_and_record_failure("user").await.is_ok());
assert!(limiter.check_and_record_failure("user").await.is_ok());
}
#[tokio::test]
async fn test_rate_limiter_keys_share_one_bucket_across_case() {
let limiter = LoginRateLimiter::new(3, 300, 900);
limiter.check_and_record_failure("Admin").await.unwrap();
limiter.check_and_record_failure("ADMIN").await.unwrap();
limiter.check_and_record_failure("admin").await.unwrap();
assert!(
limiter.check_and_record_failure("aDmin").await.is_err(),
"case variants must share one lockout bucket"
);
assert!(
limiter.check_and_record_failure(" admin ").await.is_err(),
"whitespace-trimmed case variants share the bucket too"
);
limiter.reset("ADMIN").await;
assert!(
limiter.check_and_record_failure("admin").await.is_ok(),
"reset must clear the bucket regardless of key case"
);
}
#[tokio::test]
async fn test_rate_limiter_blocks_at_max() {
let limiter = LoginRateLimiter::new(3, 300, 900);
limiter.check_and_record_failure("user").await.unwrap();
limiter.check_and_record_failure("user").await.unwrap();
limiter.check_and_record_failure("user").await.unwrap();
assert!(limiter.check_and_record_failure("user").await.is_err());
}
#[tokio::test]
async fn test_rate_limiter_independent_keys() {
let limiter = LoginRateLimiter::new(2, 300, 900);
limiter.check_and_record_failure("user-a").await.unwrap();
limiter.check_and_record_failure("user-a").await.unwrap();
assert!(limiter.check_and_record_failure("user-a").await.is_err());
assert!(limiter.check_and_record_failure("user-b").await.is_ok());
}
#[tokio::test]
async fn test_rate_limiter_reset() {
let limiter = LoginRateLimiter::new(2, 300, 900);
limiter.check_and_record_failure("user").await.unwrap();
limiter.check_and_record_failure("user").await.unwrap();
limiter.reset("user").await;
assert!(limiter.check_and_record_failure("user").await.is_ok());
assert!(limiter.check_and_record_failure("user").await.is_ok());
assert!(limiter.check_and_record_failure("user").await.is_err());
}
#[tokio::test]
async fn test_rate_limiter_reset_nonexistent_key() {
let limiter = LoginRateLimiter::new(2, 300, 900);
limiter.reset("nonexistent").await;
assert!(limiter
.check_and_record_failure("nonexistent")
.await
.is_ok());
}
#[tokio::test]
async fn test_rate_limiter_window_reset_below_max() {
let limiter = LoginRateLimiter::new(5, 1, 60);
limiter.check_and_record_failure("user").await.unwrap();
limiter.check_and_record_failure("user").await.unwrap();
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
assert!(limiter.check_and_record_failure("user").await.is_ok());
}
#[tokio::test]
async fn test_rate_limiter_lockout_persists_during_window() {
let limiter = LoginRateLimiter::new(2, 60, 60);
limiter.check_and_record_failure("user").await.unwrap();
limiter.check_and_record_failure("user").await.unwrap();
assert!(limiter.check_and_record_failure("user").await.is_err());
}
#[test]
fn test_rate_limiter_default_policy_is_enabled() {
let limiter = LoginRateLimiter::new(3, 300, 900);
assert_eq!(limiter.policy(), PasswordLoginPolicy::Enabled);
}
#[tokio::test]
async fn test_rate_limiter_with_policy_disabled_records_nothing() {
let limiter = LoginRateLimiter::new(2, 300, 900).with_policy(PasswordLoginPolicy::Disabled);
assert_eq!(limiter.policy(), PasswordLoginPolicy::Disabled);
for _ in 0..10 {
assert!(limiter.check_and_record_failure("user").await.is_ok());
}
limiter.reset("user").await;
assert!(limiter.entries.read().await.is_empty());
}
#[cfg(all(feature = "auth-password", feature = "auth-jwt"))]
mod password_login_policy {
use super::*;
use crate::{
database::memory::InMemoryUserDatabase,
rbac::{
permission::Permission, session::InMemorySessionManager,
store::memory::InMemoryAssignmentStore, subject::StringSubject,
},
};
type Service = AuthService<
InMemoryUserDatabase,
Permission,
InMemoryAssignmentStore<StringSubject, Permission>,
>;
fn make_auth() -> Service {
AuthService::new(
InMemoryUserDatabase::new(),
"test-secret-that-is-at-least-32-bytes-long",
24,
build_default_engine(),
"admin",
"viewer",
)
.unwrap()
}
fn make_session_mgr() -> InMemorySessionManager<StringSubject, Permission> {
InMemorySessionManager::new(InMemoryAssignmentStore::new())
}
#[tokio::test]
async fn test_default_policy_keeps_password_login_enabled() {
let auth = make_auth();
assert_eq!(auth.password_login_policy(), PasswordLoginPolicy::Enabled);
auth.register("alice", "Password123!", None).await.unwrap();
let result = auth.login("alice", "Password123!").await.unwrap();
assert_eq!(result.username, "alice");
}
#[test]
fn test_builder_order_limiter_inherits_service_policy() {
let svc = make_auth()
.with_password_login_policy(PasswordLoginPolicy::Disabled)
.with_rate_limiter(LoginRateLimiter::new(5, 300, 900));
assert_eq!(svc.rate_limiter.policy(), PasswordLoginPolicy::Disabled);
let svc2 = make_auth()
.with_rate_limiter(LoginRateLimiter::new(5, 300, 900))
.with_password_login_policy(PasswordLoginPolicy::Disabled);
assert_eq!(svc2.rate_limiter.policy(), PasswordLoginPolicy::Disabled);
}
#[tokio::test]
async fn test_disabled_policy_rejects_login_before_credentials() {
let auth = make_auth().with_password_login_policy(PasswordLoginPolicy::Disabled);
assert_eq!(auth.password_login_policy(), PasswordLoginPolicy::Disabled);
auth.register("alice", "Password123!", None).await.unwrap();
let err = auth.login("alice", "Password123!").await.unwrap_err();
assert!(
err.to_string().contains("password login is disabled"),
"expected the policy refusal, got: {err}"
);
}
#[tokio::test]
async fn test_disabled_policy_performs_no_rate_limit_bookkeeping() {
let auth = make_auth()
.with_rate_limiter(LoginRateLimiter::new(2, 300, 900))
.with_password_login_policy(PasswordLoginPolicy::Disabled);
auth.register("alice", "Password123!", None).await.unwrap();
for _ in 0..6 {
let err = auth.login("alice", "wrong").await.unwrap_err();
assert!(
err.to_string().contains("password login is disabled"),
"expected the policy refusal, got: {err}"
);
}
}
#[tokio::test]
async fn test_disabled_policy_rejects_login_with_session() {
let auth = make_auth().with_password_login_policy(PasswordLoginPolicy::Disabled);
auth.register("alice", "Password123!", None).await.unwrap();
let mgr = make_session_mgr();
let err = auth
.login_with_session("alice", "Password123!", &mgr, chrono::Duration::hours(1))
.await
.unwrap_err();
assert!(
err.to_string().contains("password login is disabled"),
"expected the policy refusal, got: {err}"
);
}
#[tokio::test]
async fn test_disabled_policy_keeps_registration_and_change_password() {
let auth = make_auth().with_password_login_policy(PasswordLoginPolicy::Disabled);
let user = auth.register("alice", "Password123!", None).await.unwrap();
auth.change_password(&user.id.to_string(), "Password123!", "NewPassword456!")
.await
.unwrap();
}
}
#[test]
fn test_build_default_engine_roles() {
let engine = build_default_engine();
let reg = engine.role_registry();
assert!(reg.get_role_permissions("admin").is_some());
assert!(reg.get_role_permissions("operator").is_some());
assert!(reg.get_role_permissions("viewer").is_some());
assert!(reg.get_role_permissions("member").is_some());
assert!(reg.get_role_permissions("nonexistent").is_none());
}
#[test]
fn test_build_default_engine_admin_has_all_perms() {
let engine = build_default_engine();
let admin = engine
.role_registry()
.get_role_permissions("admin")
.unwrap();
assert_eq!(
admin.len(),
crate::rbac::permission::Permission::all().len()
);
}
#[test]
fn test_build_default_engine_viewer_is_list_read_only() {
let engine = build_default_engine();
let viewer = engine
.role_registry()
.get_role_permissions("viewer")
.unwrap();
for perm in &viewer {
let n = perm.name();
assert!(
n.ends_with(".list") || n == "deploy.read",
"viewer should only have .list or deploy.read, got {}",
n
);
}
}
#[test]
fn test_permission_all_count() {
assert_eq!(crate::rbac::permission::Permission::all().len(), 41);
}
#[test]
fn test_permission_domains() {
use crate::rbac::permission::Permission;
let p = Permission::from_path("agent.read").unwrap();
assert_eq!(p.domain(), "agent");
let p = Permission::from_path("config.write").unwrap();
assert_eq!(p.domain(), "config");
let p = Permission::from_path("system.read").unwrap();
assert_eq!(p.domain(), "system");
let p = Permission::from_path("deploy.execute").unwrap();
assert_eq!(p.domain(), "deploy");
}
#[test]
fn test_user_record_to_public() {
let id = Uuid::now_v7();
let now = Utc::now();
let record = UserRecord {
id,
username: "alice".to_string(),
password_hash: "hash".to_string(),
display_name: Some("Alice".to_string()),
is_active: true,
identity: Identity::Basic {
id,
created_at: now,
},
created_at: now,
updated_at: now,
};
let info = record.to_public();
assert_eq!(info.id, id);
assert_eq!(info.username, "alice");
assert_eq!(info.display_name, Some("Alice".to_string()));
assert!(info.is_active);
}
}