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",
|
"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]]
|
[[package]]
|
||||||
name = "arraydeque"
|
name = "arraydeque"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
|
|
@ -343,6 +355,15 @@ dependencies = [
|
||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "blake2"
|
||||||
|
version = "0.10.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||||
|
dependencies = [
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
version = "0.10.4"
|
version = "0.10.4"
|
||||||
|
|
@ -746,6 +767,7 @@ name = "fc-common"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"argon2",
|
||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"config",
|
"config",
|
||||||
|
|
@ -1816,6 +1838,17 @@ dependencies = [
|
||||||
"windows-link",
|
"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]]
|
[[package]]
|
||||||
name = "pathdiff"
|
name = "pathdiff"
|
||||||
version = "0.2.3"
|
version = "0.2.3"
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ fc-server = { path = "./crates/server" }
|
||||||
|
|
||||||
|
|
||||||
anyhow = "1.0.100"
|
anyhow = "1.0.100"
|
||||||
|
argon2 = "0.5"
|
||||||
askama = "0.12"
|
askama = "0.12"
|
||||||
askama_axum = "0.4"
|
askama_axum = "0.4"
|
||||||
async-stream = "0.3"
|
async-stream = "0.3"
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ repository.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
|
argon2.workspace = true
|
||||||
chrono.workspace = true
|
chrono.workspace = true
|
||||||
clap.workspace = true
|
clap.workspace = true
|
||||||
config.workspace = true
|
config.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,9 @@ pub enum CiError {
|
||||||
|
|
||||||
#[error("Forbidden: {0}")]
|
#[error("Forbidden: {0}")]
|
||||||
Forbidden(String),
|
Forbidden(String),
|
||||||
|
|
||||||
|
#[error("Internal error: {0}")]
|
||||||
|
Internal(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type Result<T> = std::result::Result<T, CiError>;
|
pub type Result<T> = std::result::Result<T, CiError>;
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,10 @@ pub mod repo;
|
||||||
|
|
||||||
pub mod bootstrap;
|
pub mod bootstrap;
|
||||||
pub mod nix_probe;
|
pub mod nix_probe;
|
||||||
|
pub mod roles;
|
||||||
pub mod tracing_init;
|
pub mod tracing_init;
|
||||||
pub mod validate;
|
pub mod validate;
|
||||||
|
pub mod validation;
|
||||||
|
|
||||||
pub use config::*;
|
pub use config::*;
|
||||||
pub use database::*;
|
pub use database::*;
|
||||||
|
|
@ -23,3 +25,4 @@ pub use migrate::*;
|
||||||
pub use models::*;
|
pub use models::*;
|
||||||
pub use tracing_init::init_tracing;
|
pub use tracing_init::init_tracing;
|
||||||
pub use validate::Validate;
|
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