From f5c54b1e05e70005f30c14af806fc561f09aacb4 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Mon, 2 Feb 2026 22:37:47 +0300 Subject: [PATCH] fc-common: implement user management repository layer Signed-off-by: NotAShelf Change-Id: I020c2fd3b061b5a671fe75d50048519f6a6a6964 --- crates/common/src/repo/mod.rs | 3 + crates/common/src/repo/project_members.rs | 188 ++++++++++++ crates/common/src/repo/starred_jobs.rs | 146 +++++++++ crates/common/src/repo/users.rs | 357 ++++++++++++++++++++++ 4 files changed, 694 insertions(+) create mode 100644 crates/common/src/repo/project_members.rs create mode 100644 crates/common/src/repo/starred_jobs.rs create mode 100644 crates/common/src/repo/users.rs diff --git a/crates/common/src/repo/mod.rs b/crates/common/src/repo/mod.rs index 86b383f..63ed508 100644 --- a/crates/common/src/repo/mod.rs +++ b/crates/common/src/repo/mod.rs @@ -8,6 +8,9 @@ pub mod evaluations; pub mod jobset_inputs; pub mod jobsets; pub mod notification_configs; +pub mod project_members; pub mod projects; pub mod remote_builders; +pub mod starred_jobs; +pub mod users; pub mod webhook_configs; diff --git a/crates/common/src/repo/project_members.rs b/crates/common/src/repo/project_members.rs new file mode 100644 index 0000000..d77c9f4 --- /dev/null +++ b/crates/common/src/repo/project_members.rs @@ -0,0 +1,188 @@ +//! Project members repository - for per-project permissions + +use sqlx::PgPool; +use uuid::Uuid; + +use crate::{ + error::{CiError, Result}, + models::{CreateProjectMember, ProjectMember, UpdateProjectMember}, + roles::{VALID_PROJECT_ROLES, has_project_permission}, + validation::validate_role, +}; + +/// Add a member to a project with role validation +pub async fn create( + pool: &PgPool, + project_id: Uuid, + data: &CreateProjectMember, +) -> Result { + // Validate role + validate_role(&data.role, VALID_PROJECT_ROLES) + .map_err(|e| CiError::Validation(e.to_string()))?; + + sqlx::query_as::<_, ProjectMember>( + "INSERT INTO project_members (project_id, user_id, role) VALUES ($1, $2, \ + $3) RETURNING *", + ) + .bind(project_id) + .bind(data.user_id) + .bind(&data.role) + .fetch_one(pool) + .await + .map_err(|e| { + match &e { + sqlx::Error::Database(db_err) if db_err.is_unique_violation() => { + CiError::Conflict( + "User is already a member of this project".to_string(), + ) + }, + _ => CiError::Database(e), + } + }) +} + +/// Get a project member by ID +pub async fn get(pool: &PgPool, id: Uuid) -> Result { + sqlx::query_as::<_, ProjectMember>( + "SELECT * FROM project_members WHERE id = $1", + ) + .bind(id) + .fetch_one(pool) + .await + .map_err(|e| { + match e { + sqlx::Error::RowNotFound => { + CiError::NotFound(format!("Project member {} not found", id)) + }, + _ => CiError::Database(e), + } + }) +} + +/// Get a project member by project and user +pub async fn get_by_project_and_user( + pool: &PgPool, + project_id: Uuid, + user_id: Uuid, +) -> Result> { + sqlx::query_as::<_, ProjectMember>( + "SELECT * FROM project_members WHERE project_id = $1 AND user_id = $2", + ) + .bind(project_id) + .bind(user_id) + .fetch_optional(pool) + .await + .map_err(CiError::Database) +} + +/// List all members of a project +pub async fn list_for_project( + pool: &PgPool, + project_id: Uuid, +) -> Result> { + sqlx::query_as::<_, ProjectMember>( + "SELECT * FROM project_members WHERE project_id = $1 ORDER BY created_at", + ) + .bind(project_id) + .fetch_all(pool) + .await + .map_err(CiError::Database) +} + +/// List all projects a user is a member of +pub async fn list_for_user( + pool: &PgPool, + user_id: Uuid, +) -> Result> { + sqlx::query_as::<_, ProjectMember>( + "SELECT * FROM project_members WHERE user_id = $1 ORDER BY created_at", + ) + .bind(user_id) + .fetch_all(pool) + .await + .map_err(CiError::Database) +} + +/// Update a project member's role with validation +pub async fn update( + pool: &PgPool, + id: Uuid, + data: &UpdateProjectMember, +) -> Result { + if let Some(ref role) = data.role { + validate_role(role, VALID_PROJECT_ROLES) + .map_err(|e| CiError::Validation(e.to_string()))?; + + sqlx::query_as::<_, ProjectMember>( + "UPDATE project_members SET role = $1 WHERE id = $2 RETURNING *", + ) + .bind(role) + .bind(id) + .fetch_one(pool) + .await + .map_err(|e| { + match e { + sqlx::Error::RowNotFound => { + CiError::NotFound(format!("Project member {} not found", id)) + }, + _ => CiError::Database(e), + } + }) + } else { + get(pool, id).await + } +} + +/// Remove a member from a project +pub async fn delete(pool: &PgPool, id: Uuid) -> Result<()> { + let result = sqlx::query("DELETE FROM project_members WHERE id = $1") + .bind(id) + .execute(pool) + .await?; + if result.rows_affected() == 0 { + return Err(CiError::NotFound(format!( + "Project member {} not found", + id + ))); + } + Ok(()) +} + +/// Remove a specific user from a project +pub async fn delete_by_project_and_user( + pool: &PgPool, + project_id: Uuid, + user_id: Uuid, +) -> Result<()> { + let result = sqlx::query( + "DELETE FROM project_members WHERE project_id = $1 AND user_id = $2", + ) + .bind(project_id) + .bind(user_id) + .execute(pool) + .await?; + if result.rows_affected() == 0 { + return Err(CiError::NotFound( + "User is not a member of this project".to_string(), + )); + } + Ok(()) +} + +/// Check if a user has a specific role or higher in a project +pub async fn check_permission( + pool: &PgPool, + project_id: Uuid, + user_id: Uuid, + required_role: &str, +) -> Result { + use crate::roles::has_project_permission; + + let member = get_by_project_and_user(pool, project_id, user_id).await?; + + if let Some(m) = member { + Ok(has_project_permission(&m.role, required_role)) + } else { + Ok(false) + } +} diff --git a/crates/common/src/repo/starred_jobs.rs b/crates/common/src/repo/starred_jobs.rs new file mode 100644 index 0000000..3186b29 --- /dev/null +++ b/crates/common/src/repo/starred_jobs.rs @@ -0,0 +1,146 @@ +//! Starred jobs repository - for personalized dashboard + +use sqlx::PgPool; +use uuid::Uuid; + +use crate::{ + error::{CiError, Result}, + models::{CreateStarredJob, StarredJob}, +}; + +/// Create a new starred job +pub async fn create( + pool: &PgPool, + user_id: Uuid, + data: &CreateStarredJob, +) -> Result { + sqlx::query_as::<_, StarredJob>( + "INSERT INTO starred_jobs (user_id, project_id, jobset_id, job_name) \ + VALUES ($1, $2, $3, $4) RETURNING *", + ) + .bind(user_id) + .bind(data.project_id) + .bind(data.jobset_id) + .bind(&data.job_name) + .fetch_one(pool) + .await + .map_err(|e| { + match &e { + sqlx::Error::Database(db_err) if db_err.is_unique_violation() => { + CiError::Conflict("Job already starred".to_string()) + }, + _ => CiError::Database(e), + } + }) +} + +/// Get a starred job by ID +pub async fn get(pool: &PgPool, id: Uuid) -> Result { + sqlx::query_as::<_, StarredJob>("SELECT * FROM starred_jobs WHERE id = $1") + .bind(id) + .fetch_one(pool) + .await + .map_err(|e| { + match e { + sqlx::Error::RowNotFound => { + CiError::NotFound(format!("Starred job {} not found", id)) + }, + _ => CiError::Database(e), + } + }) +} + +/// List starred jobs for a user with pagination +pub async fn list_for_user( + pool: &PgPool, + user_id: Uuid, + limit: i64, + offset: i64, +) -> Result> { + sqlx::query_as::<_, StarredJob>( + "SELECT * FROM starred_jobs WHERE user_id = $1 ORDER BY created_at DESC \ + LIMIT $2 OFFSET $3", + ) + .bind(user_id) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await + .map_err(CiError::Database) +} + +/// Count starred jobs for a user +pub async fn count_for_user(pool: &PgPool, user_id: Uuid) -> Result { + let (count,): (i64,) = + sqlx::query_as("SELECT COUNT(*) FROM starred_jobs WHERE user_id = $1") + .bind(user_id) + .fetch_one(pool) + .await?; + Ok(count) +} + +/// Check if a user has starred a specific job +pub async fn is_starred( + pool: &PgPool, + user_id: Uuid, + project_id: Uuid, + jobset_id: Option, + job_name: &str, +) -> Result { + let (count,): (i64,) = sqlx::query_as( + "SELECT COUNT(*) FROM starred_jobs WHERE user_id = $1 AND project_id = $2 \ + AND jobset_id IS NOT DISTINCT FROM $3 AND job_name = $4", + ) + .bind(user_id) + .bind(project_id) + .bind(jobset_id) + .bind(job_name) + .fetch_one(pool) + .await?; + Ok(count > 0) +} + +/// Delete a starred job +pub async fn delete(pool: &PgPool, id: Uuid) -> Result<()> { + let result = sqlx::query("DELETE FROM starred_jobs WHERE id = $1") + .bind(id) + .execute(pool) + .await?; + if result.rows_affected() == 0 { + return Err(CiError::NotFound(format!("Starred job {} not found", id))); + } + Ok(()) +} + +/// Delete a starred job by user and job details +pub async fn delete_by_job( + pool: &PgPool, + user_id: Uuid, + project_id: Uuid, + jobset_id: Option, + job_name: &str, +) -> Result<()> { + let result = sqlx::query( + "DELETE FROM starred_jobs WHERE user_id = $1 AND project_id = $2 AND \ + jobset_id IS NOT DISTINCT FROM $3 AND job_name = $4", + ) + .bind(user_id) + .bind(project_id) + .bind(jobset_id) + .bind(job_name) + .execute(pool) + .await?; + if result.rows_affected() == 0 { + return Err(CiError::NotFound("Starred job not found".to_string())); + } + Ok(()) +} + +/// Delete all starred jobs for a user (when user is deleted) +pub async fn delete_all_for_user(pool: &PgPool, user_id: Uuid) -> Result<()> { + sqlx::query("DELETE FROM starred_jobs WHERE user_id = $1") + .bind(user_id) + .execute(pool) + .await?; + Ok(()) +} diff --git a/crates/common/src/repo/users.rs b/crates/common/src/repo/users.rs new file mode 100644 index 0000000..ca91cd6 --- /dev/null +++ b/crates/common/src/repo/users.rs @@ -0,0 +1,357 @@ +//! User repository - CRUD operations and authentication + +use sqlx::PgPool; +use uuid::Uuid; + +use crate::{ + error::{CiError, Result}, + models::{CreateUser, LoginCredentials, UpdateUser, User}, + roles::{ROLE_READ_ONLY, VALID_ROLES, is_valid_role}, + validation::{ + validate_email, + validate_full_name, + validate_password, + validate_role, + validate_username, + }, +}; + +/// Hash a password using argon2id +pub fn hash_password(password: &str) -> Result { + use argon2::{ + Argon2, + PasswordHasher, + password_hash::{SaltString, rand_core::OsRng}, + }; + + let salt = SaltString::generate(&mut OsRng); + let argon2 = Argon2::default(); + argon2 + .hash_password(password.as_bytes(), &salt) + .map(|h| h.to_string()) + .map_err(|e| CiError::Internal(format!("Password hashing failed: {}", e))) +} + +/// Verify a password against a hash +pub fn verify_password(password: &str, hash: &str) -> Result { + use argon2::{Argon2, PasswordHash, PasswordVerifier}; + + let parsed_hash = PasswordHash::new(hash) + .map_err(|e| CiError::Internal(format!("Invalid password hash: {}", e)))?; + let argon2 = Argon2::default(); + Ok( + argon2 + .verify_password(password.as_bytes(), &parsed_hash) + .is_ok(), + ) +} + +/// Create a new user with validation +pub async fn create(pool: &PgPool, data: &CreateUser) -> Result { + // Validate username + validate_username(&data.username) + .map_err(|e| CiError::Validation(e.to_string()))?; + + // Validate email + validate_email(&data.email) + .map_err(|e| CiError::Validation(e.to_string()))?; + + // Validate password + validate_password(&data.password) + .map_err(|e| CiError::Validation(e.to_string()))?; + + // Validate full name if provided + if let Some(ref name) = data.full_name { + validate_full_name(name).map_err(|e| CiError::Validation(e.to_string()))?; + } + + // Validate role + let role = data.role.as_deref().unwrap_or(ROLE_READ_ONLY); + validate_role(role, VALID_ROLES) + .map_err(|e| CiError::Validation(e.to_string()))?; + + let password_hash = hash_password(&data.password)?; + + sqlx::query_as::<_, User>( + "INSERT INTO users (username, email, full_name, password_hash, role) \ + VALUES ($1, $2, $3, $4, $5) RETURNING *", + ) + .bind(&data.username) + .bind(&data.email) + .bind(&data.full_name) + .bind(&password_hash) + .bind(role) + .fetch_one(pool) + .await + .map_err(|e| { + match &e { + sqlx::Error::Database(db_err) if db_err.is_unique_violation() => { + CiError::Conflict("Username or email already exists".to_string()) + }, + _ => CiError::Database(e), + } + }) +} + +/// Authenticate a user with username and password +pub async fn authenticate( + pool: &PgPool, + creds: &LoginCredentials, +) -> Result { + let user = sqlx::query_as::<_, User>( + "SELECT * FROM users WHERE username = $1 AND enabled = true", + ) + .bind(&creds.username) + .fetch_one(pool) + .await + .map_err(|_| CiError::Unauthorized("Invalid credentials".to_string()))?; + + if let Some(ref hash) = user.password_hash { + if verify_password(&creds.password, hash)? { + // Update last login time + let _ = + sqlx::query("UPDATE users SET last_login_at = NOW() WHERE id = $1") + .bind(user.id) + .execute(pool) + .await; + Ok(user) + } else { + Err(CiError::Unauthorized("Invalid credentials".to_string())) + } + } else { + Err(CiError::Unauthorized( + "OAuth user - use OAuth login".to_string(), + )) + } +} + +/// Get a user by ID +pub async fn get(pool: &PgPool, id: Uuid) -> Result { + sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1") + .bind(id) + .fetch_one(pool) + .await + .map_err(|e| { + match e { + sqlx::Error::RowNotFound => { + CiError::NotFound(format!("User {} not found", id)) + }, + _ => CiError::Database(e), + } + }) +} + +/// Get a user by username +pub async fn get_by_username( + pool: &PgPool, + username: &str, +) -> Result> { + sqlx::query_as::<_, User>("SELECT * FROM users WHERE username = $1") + .bind(username) + .fetch_optional(pool) + .await + .map_err(CiError::Database) +} + +/// Get a user by email +pub async fn get_by_email(pool: &PgPool, email: &str) -> Result> { + sqlx::query_as::<_, User>("SELECT * FROM users WHERE email = $1") + .bind(email) + .fetch_optional(pool) + .await + .map_err(CiError::Database) +} + +/// List all users with pagination +pub async fn list(pool: &PgPool, limit: i64, offset: i64) -> Result> { + sqlx::query_as::<_, User>( + "SELECT * FROM users ORDER BY created_at DESC LIMIT $1 OFFSET $2", + ) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await + .map_err(CiError::Database) +} + +/// Count total users +pub async fn count(pool: &PgPool) -> Result { + let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users") + .fetch_one(pool) + .await?; + Ok(count) +} + +/// Update a user with the provided data +pub async fn update( + pool: &PgPool, + id: Uuid, + data: &UpdateUser, +) -> Result { + // Apply all updates sequentially + if let Some(ref email) = data.email { + update_email(pool, id, email).await?; + } + + if let Some(ref full_name) = data.full_name { + update_full_name(pool, id, Some(full_name.as_str())).await?; + } + + if let Some(ref password) = data.password { + update_password(pool, id, password).await?; + } + + if let Some(ref role) = data.role { + update_role(pool, id, role).await?; + } + + if let Some(enabled) = data.enabled { + set_enabled(pool, id, enabled).await?; + } + + if let Some(public) = data.public_dashboard { + set_public_dashboard(pool, id, public).await?; + } + + get(pool, id).await +} + +/// Update user email with validation +pub async fn update_email( + pool: &PgPool, + id: Uuid, + email: &str, +) -> Result { + validate_email(email).map_err(|e| CiError::Validation(e.to_string()))?; + + sqlx::query_as::<_, User>( + "UPDATE users SET email = $1 WHERE id = $2 RETURNING *", + ) + .bind(email) + .bind(id) + .fetch_one(pool) + .await + .map_err(|e| { + match &e { + sqlx::Error::Database(db_err) if db_err.is_unique_violation() => { + CiError::Conflict("Email already in use".to_string()) + }, + _ => CiError::Database(e), + } + }) +} + +/// Update user full name with validation +pub async fn update_full_name( + pool: &PgPool, + id: Uuid, + full_name: Option<&str>, +) -> Result<()> { + if let Some(name) = full_name { + validate_full_name(name).map_err(|e| CiError::Validation(e.to_string()))?; + } + + sqlx::query("UPDATE users SET full_name = $1 WHERE id = $2") + .bind(full_name) + .bind(id) + .execute(pool) + .await?; + Ok(()) +} + +/// Update user password with validation +pub async fn update_password( + pool: &PgPool, + id: Uuid, + password: &str, +) -> Result<()> { + validate_password(password) + .map_err(|e| CiError::Validation(e.to_string()))?; + + let hash = hash_password(password)?; + sqlx::query("UPDATE users SET password_hash = $1 WHERE id = $2") + .bind(&hash) + .bind(id) + .execute(pool) + .await?; + Ok(()) +} + +/// Update user role with validation +pub async fn update_role(pool: &PgPool, id: Uuid, role: &str) -> Result<()> { + validate_role(role, VALID_ROLES) + .map_err(|e| CiError::Validation(e.to_string()))?; + + sqlx::query("UPDATE users SET role = $1 WHERE id = $2") + .bind(role) + .bind(id) + .execute(pool) + .await?; + Ok(()) +} + +/// Enable/disable user +pub async fn set_enabled(pool: &PgPool, id: Uuid, enabled: bool) -> Result<()> { + sqlx::query("UPDATE users SET enabled = $1 WHERE id = $2") + .bind(enabled) + .bind(id) + .execute(pool) + .await?; + Ok(()) +} + +/// Set public dashboard preference +pub async fn set_public_dashboard( + pool: &PgPool, + id: Uuid, + public: bool, +) -> Result<()> { + sqlx::query("UPDATE users SET public_dashboard = $1 WHERE id = $2") + .bind(public) + .bind(id) + .execute(pool) + .await?; + Ok(()) +} + +/// Delete a user +pub async fn delete(pool: &PgPool, id: Uuid) -> Result<()> { + let result = sqlx::query("DELETE FROM users WHERE id = $1") + .bind(id) + .execute(pool) + .await?; + if result.rows_affected() == 0 { + return Err(CiError::NotFound(format!("User {} not found", id))); + } + Ok(()) +} + +/// Create or update OAuth user +pub async fn upsert_oauth_user( + pool: &PgPool, + username: &str, + email: &str, + full_name: Option<&str>, + user_type: &str, +) -> Result { + sqlx::query_as::<_, User>( + "INSERT INTO users (username, email, full_name, user_type, password_hash) \ + VALUES ($1, $2, $3, $4, NULL) ON CONFLICT (username) DO UPDATE SET email \ + = EXCLUDED.email, full_name = EXCLUDED.full_name, updated_at = NOW() \ + RETURNING *", + ) + .bind(username) + .bind(email) + .bind(full_name) + .bind(user_type) + .fetch_one(pool) + .await + .map_err(|e| { + match &e { + sqlx::Error::Database(db_err) if db_err.is_unique_violation() => { + CiError::Conflict("Email already in use".to_string()) + }, + _ => CiError::Database(e), + } + }) +}