use std::collections::HashMap;
use std::path::Path;
use std::sync::RwLock;
use tracing::info;
use crate::types::TenantId;
use super::super::catalog::SystemCatalog;
use super::super::identity::{AuthMethod, AuthenticatedIdentity, Role};
use super::super::time::now_secs;
use super::hash::{
compute_md5_hash, compute_scram_salted_password, generate_scram_salt, hash_password_argon2,
verify_argon2,
};
use super::lockout::LoginAttemptTracker;
use super::record::UserRecord;
pub struct CredentialStore {
pub(super) users: RwLock<HashMap<String, UserRecord>>,
pub(super) next_user_id: RwLock<u64>,
pub(super) catalog: Option<SystemCatalog>,
pub(super) login_attempts: RwLock<HashMap<String, LoginAttemptTracker>>,
pub(super) max_failed_logins: u32,
pub(super) lockout_duration: std::time::Duration,
pub(super) password_expiry_secs: u64,
}
impl Default for CredentialStore {
fn default() -> Self {
Self::new()
}
}
pub(super) fn read_lock<T>(lock: &RwLock<T>) -> crate::Result<std::sync::RwLockReadGuard<'_, T>> {
lock.read().map_err(|e| {
tracing::error!("credential store read lock poisoned: {e}");
crate::Error::Internal {
detail: "credential store lock poisoned".into(),
}
})
}
pub(super) fn write_lock<T>(lock: &RwLock<T>) -> crate::Result<std::sync::RwLockWriteGuard<'_, T>> {
lock.write().map_err(|e| {
tracing::error!("credential store write lock poisoned: {e}");
crate::Error::Internal {
detail: "credential store lock poisoned".into(),
}
})
}
impl CredentialStore {
pub fn new() -> Self {
Self {
users: RwLock::new(HashMap::new()),
next_user_id: RwLock::new(1),
catalog: None,
login_attempts: RwLock::new(HashMap::new()),
max_failed_logins: 0, lockout_duration: std::time::Duration::from_secs(300),
password_expiry_secs: 0,
}
}
pub fn open(path: &Path) -> crate::Result<Self> {
let catalog = SystemCatalog::open(path)?;
let stored_users = catalog.load_all_users()?;
let next_id = catalog.load_next_user_id()?;
let mut users = HashMap::with_capacity(stored_users.len());
for stored in stored_users {
let record = UserRecord::from_stored(stored);
users.insert(record.username.clone(), record);
}
let count = users.len();
if count > 0 {
info!(count, "loaded users from system catalog");
}
Ok(Self {
users: RwLock::new(users),
next_user_id: RwLock::new(next_id),
catalog: Some(catalog),
login_attempts: RwLock::new(HashMap::new()),
max_failed_logins: 0,
lockout_duration: std::time::Duration::from_secs(300),
password_expiry_secs: 0,
})
}
fn persist_user(&self, record: &mut UserRecord) -> crate::Result<()> {
record.updated_at = now_secs();
if let Some(ref catalog) = self.catalog {
catalog.put_user(&record.to_stored())?;
}
Ok(())
}
fn persist_next_id(&self, id: u64) -> crate::Result<()> {
if let Some(ref catalog) = self.catalog {
catalog.save_next_user_id(id)?;
}
Ok(())
}
fn compute_expiry(&self) -> u64 {
if self.password_expiry_secs > 0 {
now_secs() + self.password_expiry_secs
} else {
0
}
}
fn alloc_user_id(&self) -> crate::Result<u64> {
let mut next = write_lock(&self.next_user_id)?;
let id = *next;
*next += 1;
self.persist_next_id(*next)?;
Ok(id)
}
pub fn bootstrap_superuser(&self, username: &str, password: &str) -> crate::Result<()> {
let salt = generate_scram_salt();
let scram_salted_password = compute_scram_salted_password(password, &salt);
let password_hash = hash_password_argon2(password)?;
let mut users = write_lock(&self.users)?;
if let Some(existing) = users.get_mut(username) {
existing.password_hash = password_hash;
existing.scram_salt = salt;
existing.scram_salted_password = scram_salted_password;
existing.is_superuser = true;
existing.is_active = true;
if !existing.roles.contains(&Role::Superuser) {
existing.roles.push(Role::Superuser);
}
self.persist_user(existing)?;
} else {
let user_id = self.alloc_user_id()?;
let mut record = UserRecord {
user_id,
username: username.to_string(),
tenant_id: TenantId::new(0),
password_hash,
scram_salt: salt,
scram_salted_password,
roles: vec![Role::Superuser],
is_superuser: true,
is_active: true,
is_service_account: false,
created_at: now_secs(),
updated_at: now_secs(),
password_expires_at: self.compute_expiry(),
md5_hash: compute_md5_hash(username, password),
};
self.persist_user(&mut record)?;
users.insert(username.to_string(), record);
}
Ok(())
}
pub fn create_user(
&self,
username: &str,
password: &str,
tenant_id: TenantId,
roles: Vec<Role>,
) -> crate::Result<u64> {
let mut users = write_lock(&self.users)?;
if users.contains_key(username) {
return Err(crate::Error::BadRequest {
detail: format!("user '{username}' already exists"),
});
}
let salt = generate_scram_salt();
let scram_salted_password = compute_scram_salted_password(password, &salt);
let password_hash = hash_password_argon2(password)?;
let user_id = self.alloc_user_id()?;
let is_superuser = roles.contains(&Role::Superuser);
let mut record = UserRecord {
user_id,
username: username.to_string(),
tenant_id,
password_hash,
scram_salt: salt,
scram_salted_password,
roles,
is_superuser,
is_active: true,
is_service_account: false,
created_at: now_secs(),
updated_at: now_secs(),
password_expires_at: self.compute_expiry(),
md5_hash: compute_md5_hash(username, password),
};
self.persist_user(&mut record)?;
users.insert(username.to_string(), record);
Ok(user_id)
}
pub fn create_service_account(
&self,
name: &str,
tenant_id: TenantId,
roles: Vec<Role>,
) -> crate::Result<u64> {
let mut users = write_lock(&self.users)?;
if users.contains_key(name) {
return Err(crate::Error::BadRequest {
detail: format!("user or service account '{name}' already exists"),
});
}
let user_id = self.alloc_user_id()?;
let is_superuser = roles.contains(&Role::Superuser);
let mut record = UserRecord {
user_id,
username: name.to_string(),
tenant_id,
password_hash: String::new(), scram_salt: Vec::new(),
scram_salted_password: Vec::new(),
roles,
is_superuser,
is_active: true,
is_service_account: true,
created_at: now_secs(),
updated_at: now_secs(),
password_expires_at: 0,
md5_hash: String::new(), };
self.persist_user(&mut record)?;
users.insert(name.to_string(), record);
Ok(user_id)
}
pub fn get_user(&self, username: &str) -> Option<UserRecord> {
let users = read_lock(&self.users).ok()?;
users.get(username).filter(|u| u.is_active).cloned()
}
pub fn get_scram_credentials(&self, username: &str) -> Option<(Vec<u8>, Vec<u8>)> {
let users = read_lock(&self.users).ok()?;
users
.get(username)
.filter(|u| u.is_active && !u.is_service_account)
.filter(|u| {
if u.password_expires_at > 0 && now_secs() >= u.password_expires_at {
tracing::warn!(username = u.username, "password expired, login denied");
return false;
}
true
})
.map(|u| (u.scram_salt.clone(), u.scram_salted_password.clone()))
}
pub fn get_md5_hash(&self, username: &str) -> Option<String> {
let users = read_lock(&self.users).ok()?;
users
.get(username)
.filter(|u| u.is_active && !u.is_service_account)
.filter(|u| {
if u.password_expires_at > 0 && now_secs() >= u.password_expires_at {
tracing::warn!(username = u.username, "password expired, MD5 login denied");
return false;
}
true
})
.filter(|u| !u.md5_hash.is_empty())
.map(|u| u.md5_hash.clone())
}
pub fn verify_password(&self, username: &str, password: &str) -> bool {
let users = match read_lock(&self.users) {
Ok(u) => u,
Err(_) => {
let _ = hash_password_argon2(password);
return false;
}
};
match users.get(username).filter(|u| u.is_active) {
Some(record) => verify_argon2(&record.password_hash, password),
None => {
let _ = hash_password_argon2(password);
false
}
}
}
pub fn to_identity(&self, username: &str, method: AuthMethod) -> Option<AuthenticatedIdentity> {
self.get_user(username).map(|record| AuthenticatedIdentity {
user_id: record.user_id,
username: record.username,
tenant_id: record.tenant_id,
auth_method: method,
roles: record.roles,
is_superuser: record.is_superuser,
})
}
pub fn deactivate_user(&self, username: &str) -> crate::Result<bool> {
let mut users = write_lock(&self.users)?;
if let Some(record) = users.get_mut(username) {
record.is_active = false;
self.persist_user(record)?;
Ok(true)
} else {
Ok(false)
}
}
pub fn update_password(&self, username: &str, password: &str) -> crate::Result<()> {
let mut users = write_lock(&self.users)?;
let record = users
.get_mut(username)
.ok_or_else(|| crate::Error::BadRequest {
detail: format!("user '{username}' not found"),
})?;
if !record.is_active {
return Err(crate::Error::BadRequest {
detail: format!("user '{username}' is inactive"),
});
}
let salt = generate_scram_salt();
record.scram_salted_password = compute_scram_salted_password(password, &salt);
record.scram_salt = salt;
record.password_hash = hash_password_argon2(password)?;
record.password_expires_at = self.compute_expiry();
record.md5_hash = compute_md5_hash(username, password);
self.persist_user(record)?;
Ok(())
}
pub fn update_roles(&self, username: &str, roles: Vec<Role>) -> crate::Result<()> {
let mut users = write_lock(&self.users)?;
let record = users
.get_mut(username)
.ok_or_else(|| crate::Error::BadRequest {
detail: format!("user '{username}' not found"),
})?;
record.is_superuser = roles.contains(&Role::Superuser);
record.roles = roles;
self.persist_user(record)?;
Ok(())
}
pub fn add_role(&self, username: &str, role: Role) -> crate::Result<()> {
let mut users = write_lock(&self.users)?;
let record = users
.get_mut(username)
.ok_or_else(|| crate::Error::BadRequest {
detail: format!("user '{username}' not found"),
})?;
if !record.roles.contains(&role) {
record.roles.push(role.clone());
if matches!(role, Role::Superuser) {
record.is_superuser = true;
}
}
self.persist_user(record)?;
Ok(())
}
pub fn remove_role(&self, username: &str, role: &Role) -> crate::Result<()> {
let mut users = write_lock(&self.users)?;
let record = users
.get_mut(username)
.ok_or_else(|| crate::Error::BadRequest {
detail: format!("user '{username}' not found"),
})?;
record.roles.retain(|r| r != role);
if matches!(role, Role::Superuser) {
record.is_superuser = false;
}
self.persist_user(record)?;
Ok(())
}
pub fn list_user_details(&self) -> Vec<UserRecord> {
let users = match read_lock(&self.users) {
Ok(u) => u,
Err(_) => return Vec::new(),
};
users.values().filter(|u| u.is_active).cloned().collect()
}
pub fn list_users(&self) -> Vec<String> {
let users = match read_lock(&self.users) {
Ok(u) => u,
Err(_) => return Vec::new(),
};
users
.values()
.filter(|u| u.is_active)
.map(|u| u.username.clone())
.collect()
}
pub fn is_empty(&self) -> bool {
read_lock(&self.users).map(|u| u.is_empty()).unwrap_or(true)
}
pub fn catalog(&self) -> &Option<SystemCatalog> {
&self.catalog
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn in_memory_create_and_verify() {
let store = CredentialStore::new();
store.bootstrap_superuser("admin", "secret").unwrap();
assert!(store.verify_password("admin", "secret"));
assert!(!store.verify_password("admin", "wrong"));
}
#[test]
fn persistent_create_and_reload() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("system.redb");
{
let store = CredentialStore::open(&path).unwrap();
store
.create_user("alice", "pass123", TenantId::new(1), vec![Role::ReadWrite])
.unwrap();
store.bootstrap_superuser("admin", "secret").unwrap();
}
{
let store = CredentialStore::open(&path).unwrap();
let alice = store.get_user("alice").unwrap();
assert_eq!(alice.tenant_id, TenantId::new(1));
assert!(alice.roles.contains(&Role::ReadWrite));
assert!(store.verify_password("alice", "pass123"));
let admin = store.get_user("admin").unwrap();
assert!(admin.is_superuser);
}
}
#[test]
fn persistent_deactivate_survives_restart() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("system.redb");
{
let store = CredentialStore::open(&path).unwrap();
store
.create_user("bob", "pass", TenantId::new(1), vec![Role::ReadOnly])
.unwrap();
store.deactivate_user("bob").unwrap();
}
{
let store = CredentialStore::open(&path).unwrap();
assert!(store.get_user("bob").is_none());
}
}
#[test]
fn persistent_role_changes_survive_restart() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("system.redb");
{
let store = CredentialStore::open(&path).unwrap();
store
.create_user("carol", "pass", TenantId::new(1), vec![Role::ReadOnly])
.unwrap();
store.add_role("carol", Role::ReadWrite).unwrap();
store.remove_role("carol", &Role::ReadOnly).unwrap();
}
{
let store = CredentialStore::open(&path).unwrap();
let carol = store.get_user("carol").unwrap();
assert!(carol.roles.contains(&Role::ReadWrite));
assert!(!carol.roles.contains(&Role::ReadOnly));
}
}
#[test]
fn persistent_password_change_survives_restart() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("system.redb");
{
let store = CredentialStore::open(&path).unwrap();
store
.create_user("dave", "old_pass", TenantId::new(1), vec![Role::ReadWrite])
.unwrap();
store.update_password("dave", "new_pass").unwrap();
}
{
let store = CredentialStore::open(&path).unwrap();
assert!(store.verify_password("dave", "new_pass"));
assert!(!store.verify_password("dave", "old_pass"));
}
}
#[test]
fn user_id_counter_persists() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("system.redb");
let first_id;
{
let store = CredentialStore::open(&path).unwrap();
first_id = store
.create_user("u1", "p", TenantId::new(1), vec![])
.unwrap();
store
.create_user("u2", "p", TenantId::new(1), vec![])
.unwrap();
}
{
let store = CredentialStore::open(&path).unwrap();
let next_id = store
.create_user("u3", "p", TenantId::new(1), vec![])
.unwrap();
assert!(next_id > first_id + 1);
}
}
}