fc-common: validation and roles modules
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: Idc4d0743153c77b4dd915a95a603680f6a6a6964
This commit is contained in:
parent
8d07063d3f
commit
a3155f54e8
7 changed files with 324 additions and 0 deletions
33
Cargo.lock
generated
33
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ repository.workspace = true
|
|||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
argon2.workspace = true
|
||||
chrono.workspace = true
|
||||
clap.workspace = true
|
||||
config.workspace = true
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
|
|
|||
77
crates/common/src/roles.rs
Normal file
77
crates/common/src/roles.rs
Normal 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
|
||||
}
|
||||
206
crates/common/src/validation.rs
Normal file
206
crates/common/src/validation.rs
Normal 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(())
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue