fc-common: better support declarative users with password file
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I1eac6decd68a4e59a52fecaecdd476b26a6a6964
This commit is contained in:
parent
b791ed75f3
commit
865b2f5f66
2 changed files with 121 additions and 6 deletions
|
|
@ -1,4 +1,5 @@
|
||||||
//! Declarative bootstrap: upsert projects, jobsets, and API keys from config.
|
//! Declarative bootstrap: upsert projects, jobsets, API keys and users from
|
||||||
|
//! config.
|
||||||
//!
|
//!
|
||||||
//! Called once on server startup to reconcile declarative configuration
|
//! Called once on server startup to reconcile declarative configuration
|
||||||
//! with database state. Uses upsert semantics so repeated runs are idempotent.
|
//! with database state. Uses upsert semantics so repeated runs are idempotent.
|
||||||
|
|
@ -17,20 +18,25 @@ use crate::{
|
||||||
///
|
///
|
||||||
/// This function is idempotent: running it multiple times with the same config
|
/// This function is idempotent: running it multiple times with the same config
|
||||||
/// produces the same database state. It upserts (insert or update) all
|
/// produces the same database state. It upserts (insert or update) all
|
||||||
/// configured projects, jobsets, and API keys.
|
/// configured projects, jobsets, API keys, and users.
|
||||||
pub async fn run(pool: &PgPool, config: &DeclarativeConfig) -> Result<()> {
|
pub async fn run(pool: &PgPool, config: &DeclarativeConfig) -> Result<()> {
|
||||||
if config.projects.is_empty() && config.api_keys.is_empty() {
|
if config.projects.is_empty()
|
||||||
|
&& config.api_keys.is_empty()
|
||||||
|
&& config.users.is_empty()
|
||||||
|
{
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let n_projects = config.projects.len();
|
let n_projects = config.projects.len();
|
||||||
let n_jobsets: usize = config.projects.iter().map(|p| p.jobsets.len()).sum();
|
let n_jobsets: usize = config.projects.iter().map(|p| p.jobsets.len()).sum();
|
||||||
let n_keys = config.api_keys.len();
|
let n_keys = config.api_keys.len();
|
||||||
|
let n_users = config.users.len();
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
projects = n_projects,
|
projects = n_projects,
|
||||||
jobsets = n_jobsets,
|
jobsets = n_jobsets,
|
||||||
api_keys = n_keys,
|
api_keys = n_keys,
|
||||||
|
users = n_users,
|
||||||
"Bootstrapping declarative configuration"
|
"Bootstrapping declarative configuration"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -59,6 +65,7 @@ pub async fn run(pool: &PgPool, config: &DeclarativeConfig) -> Result<()> {
|
||||||
check_interval: Some(decl_jobset.check_interval),
|
check_interval: Some(decl_jobset.check_interval),
|
||||||
branch: None,
|
branch: None,
|
||||||
scheduling_shares: None,
|
scheduling_shares: None,
|
||||||
|
state: None,
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|
@ -87,6 +94,92 @@ pub async fn run(pool: &PgPool, config: &DeclarativeConfig) -> Result<()> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Upsert users
|
||||||
|
for decl_user in &config.users {
|
||||||
|
// Resolve password from inline or file
|
||||||
|
let password = if let Some(ref p) = decl_user.password {
|
||||||
|
Some(p.clone())
|
||||||
|
} else if let Some(ref file) = decl_user.password_file {
|
||||||
|
match std::fs::read_to_string(file) {
|
||||||
|
Ok(p) => Some(p.trim().to_string()),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
username = %decl_user.username,
|
||||||
|
file = %file,
|
||||||
|
"Failed to read password file: {e}"
|
||||||
|
);
|
||||||
|
None
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if user exists
|
||||||
|
let existing =
|
||||||
|
repo::users::get_by_username(pool, &decl_user.username).await?;
|
||||||
|
|
||||||
|
if let Some(user) = existing {
|
||||||
|
// Update existing user
|
||||||
|
let update = crate::models::UpdateUser {
|
||||||
|
email: Some(decl_user.email.clone()),
|
||||||
|
full_name: decl_user.full_name.clone(),
|
||||||
|
password,
|
||||||
|
role: Some(decl_user.role.clone()),
|
||||||
|
enabled: Some(decl_user.enabled),
|
||||||
|
public_dashboard: None,
|
||||||
|
};
|
||||||
|
if let Err(e) = repo::users::update(pool, user.id, &update).await {
|
||||||
|
tracing::warn!(
|
||||||
|
username = %decl_user.username,
|
||||||
|
"Failed to update declarative user: {e}"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
tracing::info!(
|
||||||
|
username = %decl_user.username,
|
||||||
|
"Updated declarative user"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if let Some(pwd) = password {
|
||||||
|
// Create new user
|
||||||
|
let create = crate::models::CreateUser {
|
||||||
|
username: decl_user.username.clone(),
|
||||||
|
email: decl_user.email.clone(),
|
||||||
|
full_name: decl_user.full_name.clone(),
|
||||||
|
password: pwd,
|
||||||
|
role: Some(decl_user.role.clone()),
|
||||||
|
};
|
||||||
|
match repo::users::create(pool, &create).await {
|
||||||
|
Ok(user) => {
|
||||||
|
tracing::info!(
|
||||||
|
username = %user.username,
|
||||||
|
"Created declarative user"
|
||||||
|
);
|
||||||
|
// Set enabled status if false (users are enabled by default)
|
||||||
|
if !decl_user.enabled
|
||||||
|
&& let Err(e) = repo::users::set_enabled(pool, user.id, false).await
|
||||||
|
{
|
||||||
|
tracing::warn!(
|
||||||
|
username = %user.username,
|
||||||
|
"Failed to disable declarative user: {e}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
username = %decl_user.username,
|
||||||
|
"Failed to create declarative user: {e}"
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tracing::warn!(
|
||||||
|
username = %decl_user.username,
|
||||||
|
"Declarative user has no password set, skipping creation"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
tracing::info!("Declarative bootstrap complete");
|
tracing::info!("Declarative bootstrap complete");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -154,13 +154,14 @@ pub struct CacheUploadConfig {
|
||||||
pub store_uri: Option<String>,
|
pub store_uri: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Declarative project/jobset/api-key definitions.
|
/// Declarative project/jobset/api-key/user definitions.
|
||||||
/// These are upserted on server startup, enabling fully declarative operation.
|
/// These are upserted on server startup, enabling fully declarative operation.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct DeclarativeConfig {
|
pub struct DeclarativeConfig {
|
||||||
pub projects: Vec<DeclarativeProject>,
|
pub projects: Vec<DeclarativeProject>,
|
||||||
pub api_keys: Vec<DeclarativeApiKey>,
|
pub api_keys: Vec<DeclarativeApiKey>,
|
||||||
|
pub users: Vec<DeclarativeUser>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|
@ -192,11 +193,31 @@ pub struct DeclarativeApiKey {
|
||||||
pub role: String,
|
pub role: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_true() -> bool {
|
/// Declarative user definition for configuration-driven user management.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DeclarativeUser {
|
||||||
|
pub username: String,
|
||||||
|
pub email: String,
|
||||||
|
pub full_name: Option<String>,
|
||||||
|
/// Password provided inline (for dev/testing only).
|
||||||
|
pub password: Option<String>,
|
||||||
|
/// Path to a file containing the password (for production use with secrets).
|
||||||
|
pub password_file: Option<String>,
|
||||||
|
#[serde(default = "default_user_role")]
|
||||||
|
pub role: String,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_user_role() -> String {
|
||||||
|
"read-only".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn default_true() -> bool {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_check_interval() -> i32 {
|
const fn default_check_interval() -> i32 {
|
||||||
60
|
60
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -531,6 +552,7 @@ mod tests {
|
||||||
key: "fc_test".to_string(),
|
key: "fc_test".to_string(),
|
||||||
role: "admin".to_string(),
|
role: "admin".to_string(),
|
||||||
}],
|
}],
|
||||||
|
users: vec![],
|
||||||
};
|
};
|
||||||
|
|
||||||
let json = serde_json::to_string(&config).unwrap();
|
let json = serde_json::to_string(&config).unwrap();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue