From a3155f54e8e74853b5248b777d198447cbc75baf Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Mon, 2 Feb 2026 22:37:00 +0300 Subject: [PATCH] fc-common: validation and roles modules Signed-off-by: NotAShelf Change-Id: Idc4d0743153c77b4dd915a95a603680f6a6a6964 --- Cargo.lock | 33 +++++ Cargo.toml | 1 + crates/common/Cargo.toml | 1 + crates/common/src/error.rs | 3 + crates/common/src/lib.rs | 3 + crates/common/src/roles.rs | 77 ++++++++++++ crates/common/src/validation.rs | 206 ++++++++++++++++++++++++++++++++ 7 files changed, 324 insertions(+) create mode 100644 crates/common/src/roles.rs create mode 100644 crates/common/src/validation.rs diff --git a/Cargo.lock b/Cargo.lock index 2a17f23..5927f77 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -103,6 +103,18 @@ dependencies = [ "object", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + [[package]] name = "arraydeque" version = "0.5.1" @@ -343,6 +355,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -746,6 +767,7 @@ name = "fc-common" version = "0.1.0" dependencies = [ "anyhow", + "argon2", "chrono", "clap", "config", @@ -1816,6 +1838,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "pathdiff" version = "0.2.3" diff --git a/Cargo.toml b/Cargo.toml index 640215c..80fa071 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ fc-server = { path = "./crates/server" } anyhow = "1.0.100" +argon2 = "0.5" askama = "0.12" askama_axum = "0.4" async-stream = "0.3" diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index c6a6724..fbaacdd 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -8,6 +8,7 @@ repository.workspace = true [dependencies] anyhow.workspace = true +argon2.workspace = true chrono.workspace = true clap.workspace = true config.workspace = true diff --git a/crates/common/src/error.rs b/crates/common/src/error.rs index f459403..73418bf 100644 --- a/crates/common/src/error.rs +++ b/crates/common/src/error.rs @@ -42,6 +42,9 @@ pub enum CiError { #[error("Forbidden: {0}")] Forbidden(String), + + #[error("Internal error: {0}")] + Internal(String), } pub type Result = std::result::Result; diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index e55b66c..0c6b6ea 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -13,8 +13,10 @@ pub mod repo; pub mod bootstrap; pub mod nix_probe; +pub mod roles; pub mod tracing_init; pub mod validate; +pub mod validation; pub use config::*; pub use database::*; @@ -23,3 +25,4 @@ pub use migrate::*; pub use models::*; pub use tracing_init::init_tracing; pub use validate::Validate; +pub use validation::*; diff --git a/crates/common/src/roles.rs b/crates/common/src/roles.rs new file mode 100644 index 0000000..7e42281 --- /dev/null +++ b/crates/common/src/roles.rs @@ -0,0 +1,77 @@ +//! Role constants and validation for FC + +/// Global role - full system access +pub const ROLE_ADMIN: &str = "admin"; + +/// Global role - view only +pub const ROLE_READ_ONLY: &str = "read-only"; + +/// Global role - can create projects +pub const ROLE_CREATE_PROJECTS: &str = "create-projects"; + +/// Global role - can evaluate jobsets +pub const ROLE_EVAL_JOBSET: &str = "eval-jobset"; + +/// Global role - can cancel builds +pub const ROLE_CANCEL_BUILD: &str = "cancel-build"; + +/// Global role - can restart jobs +pub const ROLE_RESTART_JOBS: &str = "restart-jobs"; + +/// Global role - can bump jobs to front of queue +pub const ROLE_BUMP_TO_FRONT: &str = "bump-to-front"; + +/// Project role - full project access +pub const PROJECT_ROLE_ADMIN: &str = "admin"; + +/// Project role - can manage project settings and builds +pub const PROJECT_ROLE_MAINTAINER: &str = "maintainer"; + +/// Project role - basic project access +pub const PROJECT_ROLE_MEMBER: &str = "member"; + +/// All valid global roles +pub const VALID_ROLES: &[&str] = &[ + ROLE_ADMIN, + ROLE_READ_ONLY, + ROLE_CREATE_PROJECTS, + ROLE_EVAL_JOBSET, + ROLE_CANCEL_BUILD, + ROLE_RESTART_JOBS, + ROLE_BUMP_TO_FRONT, +]; + +/// All valid project roles +pub const VALID_PROJECT_ROLES: &[&str] = &[ + PROJECT_ROLE_ADMIN, + PROJECT_ROLE_MAINTAINER, + PROJECT_ROLE_MEMBER, +]; + +/// Check if a global role is valid +pub fn is_valid_role(role: &str) -> bool { + VALID_ROLES.contains(&role) +} + +/// Check if a project role is valid +pub fn is_valid_project_role(role: &str) -> bool { + VALID_PROJECT_ROLES.contains(&role) +} + +/// Get the highest project role (for permission checks) +pub fn project_role_level(role: &str) -> i32 { + match role { + PROJECT_ROLE_ADMIN => 3, + PROJECT_ROLE_MAINTAINER => 2, + PROJECT_ROLE_MEMBER => 1, + _ => 0, + } +} + +/// Check if user has required project permission +/// Higher level roles automatically have lower level permissions +pub fn has_project_permission(user_role: &str, required: &str) -> bool { + let user_level = project_role_level(user_role); + let required_level = project_role_level(required); + user_level >= required_level +} diff --git a/crates/common/src/validation.rs b/crates/common/src/validation.rs new file mode 100644 index 0000000..241983e --- /dev/null +++ b/crates/common/src/validation.rs @@ -0,0 +1,206 @@ +//! Input validation utilities for FC + +use std::sync::LazyLock; + +use regex::Regex; + +/// Username validation: 3-32 chars, alphanumeric + underscore + hyphen +static USERNAME_REGEX: LazyLock = LazyLock::new(|| { + Regex::new(r"^[a-zA-Z0-9_-]{3,32}$").expect("Invalid username regex pattern") +}); + +/// Email validation (basic RFC 5322 compliant) +static EMAIL_REGEX: LazyLock = LazyLock::new(|| { + Regex::new(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$") + .expect("Invalid email regex pattern") +}); + +/// Validation errors +#[derive(Debug, Clone)] +pub struct ValidationError { + pub field: String, + pub message: String, +} + +impl std::fmt::Display for ValidationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}: {}", self.field, self.message) + } +} + +impl std::error::Error for ValidationError {} + +/// Validate username format +/// Requirements: +/// - 3-32 characters +/// - Alphanumeric, underscore, hyphen only +pub fn validate_username(username: &str) -> Result<(), ValidationError> { + if username.is_empty() { + return Err(ValidationError { + field: "username".to_string(), + message: "Username is required".to_string(), + }); + } + + if !USERNAME_REGEX.is_match(username) { + return Err(ValidationError { + field: "username".to_string(), + message: "Username must be 3-32 characters and contain only letters, \ + numbers, underscores, and hyphens" + .to_string(), + }); + } + + Ok(()) +} + +/// Validate email format +pub fn validate_email(email: &str) -> Result<(), ValidationError> { + if email.is_empty() { + return Err(ValidationError { + field: "email".to_string(), + message: "Email is required".to_string(), + }); + } + + if !EMAIL_REGEX.is_match(email) { + return Err(ValidationError { + field: "email".to_string(), + message: "Invalid email format".to_string(), + }); + } + + Ok(()) +} + +/// Validate password strength +/// Requirements: +/// - At least 12 characters +/// - At least one uppercase letter +/// - At least one lowercase letter +/// - At least one number +/// - At least one special character +pub fn validate_password(password: &str) -> Result<(), ValidationError> { + if password.len() < 12 { + return Err(ValidationError { + field: "password".to_string(), + message: "Password must be at least 12 characters".to_string(), + }); + } + + let has_upper = password.chars().any(|c| c.is_ascii_uppercase()); + let has_lower = password.chars().any(|c| c.is_ascii_lowercase()); + let has_digit = password.chars().any(|c| c.is_ascii_digit()); + let has_special = password.chars().any(|c| !c.is_ascii_alphanumeric()); + + if !has_upper { + return Err(ValidationError { + field: "password".to_string(), + message: "Password must contain at least one uppercase letter" + .to_string(), + }); + } + + if !has_lower { + return Err(ValidationError { + field: "password".to_string(), + message: "Password must contain at least one lowercase letter" + .to_string(), + }); + } + + if !has_digit { + return Err(ValidationError { + field: "password".to_string(), + message: "Password must contain at least one number".to_string(), + }); + } + + if !has_special { + return Err(ValidationError { + field: "password".to_string(), + message: "Password must contain at least one special character" + .to_string(), + }); + } + + Ok(()) +} + +/// Validate role against allowed roles +pub fn validate_role( + role: &str, + allowed: &[&str], +) -> Result<(), ValidationError> { + if role.is_empty() { + return Err(ValidationError { + field: "role".to_string(), + message: "Role is required".to_string(), + }); + } + + if !allowed.contains(&role) { + return Err(ValidationError { + field: "role".to_string(), + message: format!("Invalid role. Must be one of: {}", allowed.join(", ")), + }); + } + + Ok(()) +} + +/// Validate full name (optional field) +/// - Max 255 characters +/// - Must not contain control characters +pub fn validate_full_name(name: &str) -> Result<(), ValidationError> { + if name.len() > 255 { + return Err(ValidationError { + field: "full_name".to_string(), + message: "Full name must be 255 characters or less".to_string(), + }); + } + + if name.chars().any(|c| c.is_control()) { + return Err(ValidationError { + field: "full_name".to_string(), + message: "Full name cannot contain control characters".to_string(), + }); + } + + Ok(()) +} + +/// Validate job name +/// Requirements: +/// - 1-255 characters +/// - Alphanumeric + common path characters +pub fn validate_job_name(name: &str) -> Result<(), ValidationError> { + if name.is_empty() { + return Err(ValidationError { + field: "job_name".to_string(), + message: "Job name is required".to_string(), + }); + } + + if name.len() > 255 { + return Err(ValidationError { + field: "job_name".to_string(), + message: "Job name must be 255 characters or less".to_string(), + }); + } + + // Allow alphanumeric, hyphen, underscore, dot, and path separators + let valid_chars: std::collections::HashSet = + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-./" + .chars() + .collect(); + + if !name.chars().all(|c| valid_chars.contains(&c)) { + return Err(ValidationError { + field: "job_name".to_string(), + message: "Job name contains invalid characters".to_string(), + }); + } + + Ok(()) +}