use crate::types::TenantId;
use super::super::super::catalog::StoredUser;
use super::super::super::identity::Role;
use super::super::super::time::now_secs;
use super::super::hash::{
compute_scram_salted_password, generate_scram_salt, hash_password_argon2,
};
use super::super::record::UserRecord;
use super::core::{CredentialStore, PasswordPrincipal, read_lock, validate_password_assignment};
impl CredentialStore {
pub fn prepare_user(
&self,
username: &str,
password: &str,
tenant_id: TenantId,
roles: Vec<Role>,
) -> crate::Result<StoredUser> {
{
let users = read_lock(&self.users)?;
if users.contains_key(username) {
return Err(crate::Error::BadRequest {
detail: format!("user '{username}' already exists"),
});
}
}
validate_password_assignment(password, PasswordPrincipal::New)?;
let salt = generate_scram_salt();
let scram_salted_password = compute_scram_salted_password(password, &salt);
let password_hash = hash_password_argon2(password, &self.argon2_config)?;
let user_id = self.alloc_user_id()?;
let is_superuser = roles.contains(&Role::Superuser);
let now = now_secs();
Ok(StoredUser {
user_id,
username: username.to_string(),
tenant_id: tenant_id.as_u64(),
password_hash,
scram_salt: salt,
scram_salted_password,
roles: roles.iter().map(|r| r.to_string()).collect(),
is_superuser,
is_active: true,
is_service_account: false,
created_at: now,
updated_at: now,
password_expires_at: self.compute_expiry(),
must_change_password: false,
password_changed_at: now,
default_database_id: 0,
accessible_databases: vec![],
})
}
pub fn prepare_user_update(
&self,
username: &str,
new_password: Option<&str>,
new_roles: Option<Vec<Role>>,
) -> crate::Result<StoredUser> {
let users = read_lock(&self.users)?;
let existing = users
.get(username)
.ok_or_else(|| crate::Error::BadRequest {
detail: format!("user '{username}' not found"),
})?;
if !existing.is_active {
return Err(crate::Error::BadRequest {
detail: format!("user '{username}' is inactive"),
});
}
if let Some(password) = new_password {
validate_password_assignment(
password,
PasswordPrincipal::Existing {
is_service_account: existing.is_service_account,
},
)?;
}
let mut stored = existing.to_stored();
drop(users);
if let Some(pw) = new_password {
let salt = generate_scram_salt();
stored.scram_salted_password = compute_scram_salted_password(pw, &salt);
stored.scram_salt = salt;
stored.password_hash = hash_password_argon2(pw, &self.argon2_config)?;
stored.password_expires_at = self.compute_expiry();
stored.must_change_password = false;
stored.password_changed_at = now_secs();
}
if let Some(roles) = new_roles {
stored.is_superuser = roles.contains(&Role::Superuser);
stored.roles = roles.iter().map(|r| r.to_string()).collect();
}
stored.updated_at = now_secs();
Ok(stored)
}
pub fn prepare_set_must_change_password(
&self,
username: &str,
required: bool,
) -> crate::Result<StoredUser> {
let users = read_lock(&self.users)?;
let existing = users
.get(username)
.ok_or_else(|| crate::Error::BadRequest {
detail: format!("user '{username}' not found"),
})?;
if !existing.is_active {
return Err(crate::Error::BadRequest {
detail: format!("user '{username}' is inactive"),
});
}
let mut stored = existing.to_stored();
drop(users);
stored.must_change_password = required;
stored.updated_at = now_secs();
Ok(stored)
}
pub fn prepare_set_password_expires_at(
&self,
username: &str,
expires_at: u64,
) -> crate::Result<StoredUser> {
let users = read_lock(&self.users)?;
let existing = users
.get(username)
.ok_or_else(|| crate::Error::BadRequest {
detail: format!("user '{username}' not found"),
})?;
if !existing.is_active {
return Err(crate::Error::BadRequest {
detail: format!("user '{username}' is inactive"),
});
}
let mut stored = existing.to_stored();
drop(users);
stored.password_expires_at = expires_at;
stored.updated_at = now_secs();
Ok(stored)
}
pub fn prepare_set_default_database(
&self,
username: &str,
database_id: u64,
) -> crate::Result<StoredUser> {
let users = read_lock(&self.users)?;
let existing = users
.get(username)
.ok_or_else(|| crate::Error::BadRequest {
detail: format!("user '{username}' not found"),
})?;
if !existing.is_active {
return Err(crate::Error::BadRequest {
detail: format!("user '{username}' is inactive"),
});
}
let mut stored = existing.to_stored();
drop(users);
stored.default_database_id = database_id;
stored.updated_at = now_secs();
Ok(stored)
}
pub fn install_replicated_user(
&self,
stored: &StoredUser,
invalidation: Option<super::super::super::buses::SessionInvalidationReason>,
) {
let record = UserRecord::from_stored(stored.clone());
{
let mut next = self.next_user_id.write().unwrap_or_else(|p| p.into_inner());
if stored.user_id + 1 > *next {
*next = stored.user_id + 1;
}
}
let user_id = record.user_id;
let _ = self.bump_version(user_id);
if let Some(bus) = self.uc_bus.get() {
bus.publish(super::super::super::buses::UserChanged { user_id });
}
if let Some(reason) = invalidation
&& let Some(bus) = self.si_bus.get()
{
bus.publish(super::super::super::buses::SessionInvalidated { user_id, reason });
}
let mut users = self.users.write().unwrap_or_else(|p| p.into_inner());
users.insert(stored.username.clone(), record);
}
pub fn install_replicated_drop(&self, username: &str) {
let record = {
let mut users = self.users.write().unwrap_or_else(|p| p.into_inner());
users.remove(username)
};
if let Some(record) = record {
let _ = self.purge_user(&record);
}
}
}
#[cfg(test)]
mod tests {
use super::{
super::core::{assert_bad_request, assert_user_unchanged},
CredentialStore,
};
use crate::control::security::identity::Role;
use crate::types::TenantId;
#[test]
fn prepare_user_rejects_empty_password_without_id_allocation_or_proposal() {
let store = CredentialStore::new().expect("in-memory credential store");
let next_user_id = *store.next_user_id.read().expect("next user ID lock");
let error = store
.prepare_user(
"replicated-empty",
"",
TenantId::new(9),
vec![Role::ReadWrite],
)
.expect_err("empty password must not produce a replicated user proposal");
assert_bad_request(error);
assert!(store.get_user("replicated-empty").is_none());
assert_eq!(
*store.next_user_id.read().expect("next user ID lock"),
next_user_id,
"rejected proposal preparation must not allocate a user ID"
);
assert!(
store
.catalog()
.get_user("replicated-empty")
.expect("read catalog")
.is_none(),
"proposal preparation must not write the catalog"
);
}
#[test]
fn prepare_user_update_rejects_empty_password_without_source_state_or_version_mutation() {
let store = CredentialStore::new().expect("in-memory credential store");
let user_id = store
.create_user(
"replicated-update",
"old-password",
TenantId::new(9),
vec![Role::ReadWrite],
)
.expect("create user");
store
.set_must_change_password("replicated-update", true)
.expect("set password policy");
let before = store
.get_user("replicated-update")
.expect("created user must exist");
let version = store.current_version(user_id);
let error = store
.prepare_user_update("replicated-update", Some(""), None)
.expect_err("empty password must not produce an update proposal");
assert_bad_request(error);
let after = store
.get_user("replicated-update")
.expect("source user must remain present");
assert_user_unchanged(&before, &after);
assert_eq!(store.current_version(user_id), version);
}
#[test]
fn prepare_user_update_rejects_service_account_password_without_proposal_or_source_mutation() {
let store = CredentialStore::new().expect("in-memory credential store");
let user_id = store
.create_service_account(
"replicated-api",
TenantId::new(9),
vec![Role::ReadWrite],
vec![],
)
.expect("create service account");
let before = store
.get_user("replicated-api")
.expect("created service account must exist");
let version = store.current_version(user_id);
let error = store
.prepare_user_update("replicated-api", Some("non-empty-password"), None)
.expect_err("service account password update must not produce a proposal");
assert_bad_request(error);
let after = store
.get_user("replicated-api")
.expect("source service account must remain present");
assert!(after.is_service_account);
assert!(after.password_hash.is_empty());
assert!(after.scram_salt.is_empty());
assert!(after.scram_salted_password.is_empty());
assert_user_unchanged(&before, &after);
assert_eq!(store.current_version(user_id), version);
}
}