fc-common: validation and roles modules

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Idc4d0743153c77b4dd915a95a603680f6a6a6964
This commit is contained in:
raf 2026-02-02 22:37:00 +03:00
commit a3155f54e8
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
7 changed files with 324 additions and 0 deletions

33
Cargo.lock generated
View file

@ -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"

View file

@ -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"

View file

@ -8,6 +8,7 @@ repository.workspace = true
[dependencies]
anyhow.workspace = true
argon2.workspace = true
chrono.workspace = true
clap.workspace = true
config.workspace = true

View file

@ -42,6 +42,9 @@ pub enum CiError {
#[error("Forbidden: {0}")]
Forbidden(String),
#[error("Internal error: {0}")]
Internal(String),
}
pub type Result<T> = std::result::Result<T, CiError>;

View file

@ -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::*;

View file

@ -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
}

View file

@ -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<Regex> = 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<Regex> = 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<char> =
"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(())
}