diff --git a/src/auth.rs b/src/auth.rs index 7d213c1..8f674f0 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,6 +1,7 @@ use base64::{prelude::BASE64_STANDARD, Engine}; use std::sync::Arc; +use crate::permissions::{has_permission, Action}; use crate::response::{ok, unauthorized}; use crate::state::{self, User}; use axum::{ @@ -8,7 +9,7 @@ use axum::{ http::{HeaderMap, Response}, }; -fn parse_auth_header(headers: HeaderMap) -> Option { +fn parse_auth_header(headers: &HeaderMap) -> Option { let auth_header = headers.get("authorization")?; let auth_str = auth_header.to_str().ok()?; let auth_decoded_vec = BASE64_STANDARD @@ -21,37 +22,67 @@ fn parse_auth_header(headers: HeaderMap) -> Option { Some(User { username: parts[0].to_string(), password: parts[1].to_string(), + permissions: vec![], }) } else { None } } +/// Authenticate user from headers and return User object +pub async fn authenticate_user(state: &Arc, headers: &HeaderMap) -> Result { + let user = parse_auth_header(headers).ok_or(())?; + + let users = state.users.lock().await; + for u in users.iter() { + if u.username == user.username && u.password == user.password { + return Ok(u.clone()); + } + } + + Err(()) +} + +/// Check if authenticated user has permission for the action +pub async fn check_permission( + state: &Arc, + headers: &HeaderMap, + repository: &str, + tag: Option<&str>, + action: Action, +) -> Result { + // First authenticate + let user = authenticate_user(state, headers).await?; + + // Then check permission + if has_permission(&user, repository, tag, action) { + Ok(user) + } else { + log::warn!( + "User {} denied {} access to {}/{}", + user.username, + action.as_str(), + repository, + tag.unwrap_or("*") + ); + Err(()) + } +} + pub(crate) async fn get( State(data): State>, headers: HeaderMap, ) -> Response { log::info!("Incoming request headers: {:?}", headers); - let user = match parse_auth_header(headers) { - Some(user) => { - log::info!("Parsed user from headers: {:?}", user); - user + match authenticate_user(&data, &headers).await { + Ok(user) => { + log::info!("User {} authenticated successfully", user.username); + ok() } - None => { - log::warn!("Failed to parse user from headers"); - return unauthorized(&data.args.host); - } - }; - - let users = data.users.lock().await; - - for u in users.iter() { - if u.username == user.username && u.password == user.password { - log::info!("User authenticated successfully"); - return ok(); + Err(_) => { + log::warn!("Authentication failed"); + unauthorized(&data.args.host) } } - - unauthorized(&data.args.host) } diff --git a/src/blobs.rs b/src/blobs.rs index 0b8ddcd..41f3232 100644 --- a/src/blobs.rs +++ b/src/blobs.rs @@ -13,7 +13,7 @@ use serde::Deserialize; use std::sync::Arc; use crate::{ - auth, response, state, + auth, permissions, response, state, storage::{self, write_blob}, }; use axum::{ @@ -38,17 +38,33 @@ pub(crate) async fn get_blob_by_digest( ); let host = &state.args.host; - - // Authenticate - if auth::get(State(state.clone()), headers).await.status() != StatusCode::OK { - return Response::builder() - .status(StatusCode::UNAUTHORIZED) - .header( - "WWW-Authenticate", - format!("Basic realm=\"{}\", charset=\"UTF-8\"", host), - ) - .body(Body::from("401 Unauthorized")) - .unwrap(); + let repository = format!("{}/{}", org, repo); + + // Check permission (Pull for blob retrieval) + match auth::check_permission( + &state, + &headers, + &repository, + None, + permissions::Action::Pull, + ) + .await + { + Ok(_) => {} + Err(_) => { + return if auth::authenticate_user(&state, &headers).await.is_ok() { + response::forbidden() + } else { + Response::builder() + .status(StatusCode::UNAUTHORIZED) + .header( + "WWW-Authenticate", + format!("Basic realm=\"{}\", charset=\"UTF-8\"", host), + ) + .body(Body::from("401 Unauthorized")) + .unwrap() + }; + } } // Strip sha256: prefix if present @@ -95,17 +111,36 @@ pub(crate) async fn head_blob_by_digest( ); let host = &state.args.host; - - // Authenticate - if auth::get(State(state.clone()), headers).await.status() != StatusCode::OK { - return Response::builder() - .status(StatusCode::UNAUTHORIZED) - .header( - "WWW-Authenticate", - format!("Basic realm=\"{}\", charset=\"UTF-8\"", host), - ) - .body(Body::empty()) - .unwrap(); + let repository = format!("{}/{}", org, repo); + + // Check permission (Pull for blob retrieval) + match auth::check_permission( + &state, + &headers, + &repository, + None, + permissions::Action::Pull, + ) + .await + { + Ok(_) => {} + Err(_) => { + return if auth::authenticate_user(&state, &headers).await.is_ok() { + Response::builder() + .status(StatusCode::FORBIDDEN) + .body(Body::empty()) + .unwrap() + } else { + Response::builder() + .status(StatusCode::UNAUTHORIZED) + .header( + "WWW-Authenticate", + format!("Basic realm=\"{}\", charset=\"UTF-8\"", host), + ) + .body(Body::empty()) + .unwrap() + }; + } } // Strip sha256: prefix if present @@ -160,20 +195,33 @@ pub(crate) async fn post_blob_upload( log::info!("blobs/post_blob_upload: org: {}, repo: {}", org, repo); let host = &state.args.host; - - if auth::get(State(state.clone()), headers.clone()) - .await - .status() - != StatusCode::OK + let repository = format!("{}/{}", org, repo); + + // Check permission (Push for blob upload) + match auth::check_permission( + &state, + &headers, + &repository, + None, + permissions::Action::Push, + ) + .await { - return Response::builder() - .status(StatusCode::UNAUTHORIZED) - .header( - "WWW-Authenticate", - format!("Basic realm=\"{}\", charset=\"UTF-8\"", host), - ) - .body(Body::from("401 Unauthorized")) - .unwrap(); + Ok(_) => {} + Err(_) => { + return if auth::authenticate_user(&state, &headers).await.is_ok() { + response::forbidden() + } else { + Response::builder() + .status(StatusCode::UNAUTHORIZED) + .header( + "WWW-Authenticate", + format!("Basic realm=\"{}\", charset=\"UTF-8\"", host), + ) + .body(Body::from("401 Unauthorized")) + .unwrap() + }; + } } // If digest is provided, handle monolithic upload (end-4b) @@ -239,16 +287,33 @@ pub(crate) async fn patch_blob_upload( ); let host = &state.args.host; - - if auth::get(State(state.clone()), headers).await.status() != StatusCode::OK { - return Response::builder() - .status(StatusCode::UNAUTHORIZED) - .header( - "WWW-Authenticate", - format!("Basic realm=\"{}\", charset=\"UTF-8\"", host), - ) - .body(Body::from("401 Unauthorized")) - .unwrap(); + let repository = format!("{}/{}", org, repo); + + // Check permission (Push for blob upload) + match auth::check_permission( + &state, + &headers, + &repository, + None, + permissions::Action::Push, + ) + .await + { + Ok(_) => {} + Err(_) => { + return if auth::authenticate_user(&state, &headers).await.is_ok() { + response::forbidden() + } else { + Response::builder() + .status(StatusCode::UNAUTHORIZED) + .header( + "WWW-Authenticate", + format!("Basic realm=\"{}\", charset=\"UTF-8\"", host), + ) + .body(Body::from("401 Unauthorized")) + .unwrap() + }; + } } match storage::append_upload_chunk(&org, &repo, &uuid, &body) { @@ -295,16 +360,33 @@ pub(crate) async fn put_blob_upload_by_reference( ); let host = &state.args.host; - - if auth::get(State(state.clone()), headers).await.status() != StatusCode::OK { - return Response::builder() - .status(StatusCode::UNAUTHORIZED) - .header( - "WWW-Authenticate", - format!("Basic realm=\"{}\", charset=\"UTF-8\"", host), - ) - .body(Body::from("401 Unauthorized")) - .unwrap(); + let repository = format!("{}/{}", org, repo); + + // Check permission (Push for blob upload) + match auth::check_permission( + &state, + &headers, + &repository, + None, + permissions::Action::Push, + ) + .await + { + Ok(_) => {} + Err(_) => { + return if auth::authenticate_user(&state, &headers).await.is_ok() { + response::forbidden() + } else { + Response::builder() + .status(StatusCode::UNAUTHORIZED) + .header( + "WWW-Authenticate", + format!("Basic realm=\"{}\", charset=\"UTF-8\"", host), + ) + .body(Body::from("401 Unauthorized")) + .unwrap() + }; + } } // Append final chunk if body is not empty @@ -352,17 +434,33 @@ pub(crate) async fn delete_blob_by_digest( headers: HeaderMap, ) -> Response { let host = &state.args.host; - - // Authenticate - if auth::get(State(state.clone()), headers).await.status() != StatusCode::OK { - return Response::builder() - .status(StatusCode::UNAUTHORIZED) - .header( - "WWW-Authenticate", - format!("Basic realm=\"{}\", charset=\"UTF-8\"", host), - ) - .body(Body::from("401 Unauthorized")) - .unwrap(); + let repository = format!("{}/{}", org, repo); + + // Check permission (Delete for blob deletion) + match auth::check_permission( + &state, + &headers, + &repository, + None, + permissions::Action::Delete, + ) + .await + { + Ok(_) => {} + Err(_) => { + return if auth::authenticate_user(&state, &headers).await.is_ok() { + response::forbidden() + } else { + Response::builder() + .status(StatusCode::UNAUTHORIZED) + .header( + "WWW-Authenticate", + format!("Basic realm=\"{}\", charset=\"UTF-8\"", host), + ) + .body(Body::from("401 Unauthorized")) + .unwrap() + }; + } } // Clean digest (strip sha256: prefix if present) diff --git a/src/main.rs b/src/main.rs index 21b3d75..34a2dec 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,6 +12,7 @@ mod auth; mod blobs; mod manifests; mod meta; +mod permissions; mod response; mod state; mod storage; diff --git a/src/manifests.rs b/src/manifests.rs index 0aeb36d..16bd702 100644 --- a/src/manifests.rs +++ b/src/manifests.rs @@ -7,7 +7,7 @@ use serde_json::Value; use std::sync::Arc; -use crate::{auth, state, storage}; +use crate::{auth, permissions, state, storage}; use axum::{ body::Body, extract::{Path, State}, @@ -94,24 +94,39 @@ pub(crate) async fn head_manifest_by_reference( headers: HeaderMap, ) -> Response { let host = &state.args.host; + let repository = format!("{}/{}", org, repo); + let clean_reference = reference.strip_prefix("sha256:").unwrap_or(&reference); - if auth::get(State(state.clone()), headers.clone()) - .await - .status() - != StatusCode::OK + // Check permission (Pull for manifest retrieval, tag-specific) + match auth::check_permission( + &state, + &headers, + &repository, + Some(clean_reference), + permissions::Action::Pull, + ) + .await { - return Response::builder() - .status(StatusCode::UNAUTHORIZED) - .header( - "WWW-Authenticate", - format!("Basic realm=\"{}\", charset=\"UTF-8\"", host), - ) - .body(Body::from("401 Unauthorized")) - .unwrap(); + Ok(_) => {} + Err(_) => { + return if auth::authenticate_user(&state, &headers).await.is_ok() { + Response::builder() + .status(StatusCode::FORBIDDEN) + .body(Body::from("403 Forbidden: Insufficient permissions")) + .unwrap() + } else { + Response::builder() + .status(StatusCode::UNAUTHORIZED) + .header( + "WWW-Authenticate", + format!("Basic realm=\"{}\", charset=\"UTF-8\"", host), + ) + .body(Body::from("401 Unauthorized")) + .unwrap() + }; + } } - let clean_reference = reference.strip_prefix("sha256:").unwrap_or(&reference); - log::info!( "manifests/head_manifest_by_reference: org: {}, repo: {}, reference: {}", org, @@ -158,7 +173,9 @@ pub(crate) async fn head_manifest_by_reference( // end-7 PUT /v2/:name/manifests/:reference #[axum::debug_handler] pub(crate) async fn put_manifest_by_reference( + State(state): State>, Path((org, repo, reference)): Path<(String, String, String)>, + headers: HeaderMap, body: Request, ) -> Response { log::info!( @@ -168,6 +185,40 @@ pub(crate) async fn put_manifest_by_reference( reference ); + let host = &state.args.host; + let repository = format!("{}/{}", org, repo); + let clean_reference = reference.strip_prefix("sha256:").unwrap_or(&reference); + + // Check permission (Push for manifest upload, tag-specific) + match auth::check_permission( + &state, + &headers, + &repository, + Some(clean_reference), + permissions::Action::Push, + ) + .await + { + Ok(_) => {} + Err(_) => { + return if auth::authenticate_user(&state, &headers).await.is_ok() { + Response::builder() + .status(StatusCode::FORBIDDEN) + .body(Body::from("403 Forbidden: Insufficient permissions")) + .unwrap() + } else { + Response::builder() + .status(StatusCode::UNAUTHORIZED) + .header( + "WWW-Authenticate", + format!("Basic realm=\"{}\", charset=\"UTF-8\"", host), + ) + .body(Body::from("401 Unauthorized")) + .unwrap() + }; + } + } + let success = storage::write_manifest(&org, &repo, &reference, body.into_body()).await; if !success { return Response::builder() @@ -193,22 +244,39 @@ pub(crate) async fn delete_manifest_by_reference( headers: HeaderMap, ) -> Response { let host = &state.args.host; + let repository = format!("{}/{}", org, repo); + let clean_reference = reference.strip_prefix("sha256:").unwrap_or(&reference); - // Authenticate - if auth::get(State(state.clone()), headers).await.status() != StatusCode::OK { - return Response::builder() - .status(StatusCode::UNAUTHORIZED) - .header( - "WWW-Authenticate", - format!("Basic realm=\"{}\", charset=\"UTF-8\"", host), - ) - .body(Body::from("401 Unauthorized")) - .unwrap(); + // Check permission (Delete for manifest deletion, tag-specific) + match auth::check_permission( + &state, + &headers, + &repository, + Some(clean_reference), + permissions::Action::Delete, + ) + .await + { + Ok(_) => {} + Err(_) => { + return if auth::authenticate_user(&state, &headers).await.is_ok() { + Response::builder() + .status(StatusCode::FORBIDDEN) + .body(Body::from("403 Forbidden: Insufficient permissions")) + .unwrap() + } else { + Response::builder() + .status(StatusCode::UNAUTHORIZED) + .header( + "WWW-Authenticate", + format!("Basic realm=\"{}\", charset=\"UTF-8\"", host), + ) + .body(Body::from("401 Unauthorized")) + .unwrap() + }; + } } - // Clean reference (strip sha256: prefix if present) - let clean_reference = reference.strip_prefix("sha256:").unwrap_or(&reference); - log::info!( "manifests/delete_manifest_by_reference: org: {}, repo: {}, reference: {}", org, diff --git a/src/permissions.rs b/src/permissions.rs new file mode 100644 index 0000000..e0cbcc4 --- /dev/null +++ b/src/permissions.rs @@ -0,0 +1,267 @@ +use crate::state::User; + +#[derive(Debug, PartialEq, Clone, Copy)] +pub enum Action { + Pull, + Push, + Delete, +} + +impl Action { + pub fn as_str(&self) -> &str { + match self { + Action::Pull => "pull", + Action::Push => "push", + Action::Delete => "delete", + } + } +} + +/// Check if a user has permission to perform an action on a specific repository/tag +pub fn has_permission(user: &User, repository: &str, tag: Option<&str>, action: Action) -> bool { + // If user has no permissions defined, deny by default + if user.permissions.is_empty() { + return false; + } + + let action_str = action.as_str(); + + for perm in &user.permissions { + // Check if repository matches + if !matches_pattern(&perm.repository, repository) { + continue; + } + + // Check if tag matches (if tag is required for the operation) + if let Some(tag_name) = tag { + if !matches_pattern(&perm.tag, tag_name) { + continue; + } + } + + // Check if action is allowed + if perm.actions.contains(&action_str.to_string()) { + return true; + } + } + + false +} + +/// Match a pattern with wildcards (* and ?) +fn matches_pattern(pattern: &str, value: &str) -> bool { + if pattern == "*" { + return true; + } + + if pattern == value { + return true; + } + + // Simple wildcard matching + if pattern.contains('*') { + let parts: Vec<&str> = pattern.split('*').collect(); + + if parts.len() == 2 { + let prefix = parts[0]; + let suffix = parts[1]; + + if prefix.is_empty() && suffix.is_empty() { + return true; // "*" + } + + if prefix.is_empty() { + return value.ends_with(suffix); + } + + if suffix.is_empty() { + return value.starts_with(prefix); + } + + return value.starts_with(prefix) && value.ends_with(suffix); + } + } + + false +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::state::Permission; + + #[test] + fn test_pattern_matching() { + assert!(matches_pattern("*", "anything")); + assert!(matches_pattern("myorg/*", "myorg/myrepo")); + assert!(matches_pattern("myorg/*", "myorg/another")); + assert!(!matches_pattern("myorg/*", "other/repo")); + assert!(matches_pattern("v*", "v1.0.0")); + assert!(matches_pattern("*-prod", "app-prod")); + assert!(matches_pattern("exact", "exact")); + assert!(!matches_pattern("exact", "notexact")); + } + + #[test] + fn test_has_permission() { + let user = User { + username: "alice".to_string(), + password: "pass".to_string(), + permissions: vec![ + Permission { + repository: "myorg/myrepo".to_string(), + tag: "latest".to_string(), + actions: vec!["pull".to_string()], + }, + Permission { + repository: "myorg/myrepo".to_string(), + tag: "dev".to_string(), + actions: vec!["pull".to_string(), "push".to_string()], + }, + ], + }; + + assert!(has_permission( + &user, + "myorg/myrepo", + Some("latest"), + Action::Pull + )); + assert!(!has_permission( + &user, + "myorg/myrepo", + Some("latest"), + Action::Push + )); + assert!(has_permission( + &user, + "myorg/myrepo", + Some("dev"), + Action::Push + )); + assert!(!has_permission( + &user, + "other/repo", + Some("latest"), + Action::Pull + )); + } + + #[test] + fn test_wildcard_permissions() { + let admin = User { + username: "admin".to_string(), + password: "admin".to_string(), + permissions: vec![Permission { + repository: "*".to_string(), + tag: "*".to_string(), + actions: vec!["pull".to_string(), "push".to_string(), "delete".to_string()], + }], + }; + + assert!(has_permission( + &admin, + "any/repo", + Some("any-tag"), + Action::Pull + )); + assert!(has_permission( + &admin, + "any/repo", + Some("any-tag"), + Action::Push + )); + assert!(has_permission( + &admin, + "any/repo", + Some("any-tag"), + Action::Delete + )); + } + + #[test] + fn test_no_permissions_deny() { + let user = User { + username: "noperms".to_string(), + password: "pass".to_string(), + permissions: vec![], + }; + + assert!(!has_permission( + &user, + "any/repo", + Some("tag"), + Action::Pull + )); + assert!(!has_permission( + &user, + "any/repo", + Some("tag"), + Action::Push + )); + } + + #[test] + fn test_repository_wildcard() { + let user = User { + username: "dev".to_string(), + password: "pass".to_string(), + permissions: vec![Permission { + repository: "myorg/*".to_string(), + tag: "*".to_string(), + actions: vec!["pull".to_string()], + }], + }; + + assert!(has_permission( + &user, + "myorg/repo1", + Some("latest"), + Action::Pull + )); + assert!(has_permission( + &user, + "myorg/repo2", + Some("v1.0"), + Action::Pull + )); + assert!(!has_permission( + &user, + "other/repo", + Some("latest"), + Action::Pull + )); + } + + #[test] + fn test_tag_wildcard() { + let user = User { + username: "dev".to_string(), + password: "pass".to_string(), + permissions: vec![Permission { + repository: "myorg/myrepo".to_string(), + tag: "v*".to_string(), + actions: vec!["pull".to_string()], + }], + }; + + assert!(has_permission( + &user, + "myorg/myrepo", + Some("v1.0.0"), + Action::Pull + )); + assert!(has_permission( + &user, + "myorg/myrepo", + Some("v2.0.0"), + Action::Pull + )); + assert!(!has_permission( + &user, + "myorg/myrepo", + Some("latest"), + Action::Pull + )); + } +} diff --git a/src/response.rs b/src/response.rs index 8c58842..0fa279e 100644 --- a/src/response.rs +++ b/src/response.rs @@ -39,3 +39,10 @@ pub(crate) fn digest_mismatch() -> Response { .body(Body::from("Digest mismatch")) .unwrap() } + +pub(crate) fn forbidden() -> Response { + Response::builder() + .status(403) + .body(Body::from("403 Forbidden: Insufficient permissions")) + .unwrap() +} diff --git a/src/state.rs b/src/state.rs index a964f32..cc236e3 100644 --- a/src/state.rs +++ b/src/state.rs @@ -11,10 +11,24 @@ pub(crate) enum ServerStatus { Ready, } -#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Hash)] -pub(crate) struct User { - pub(crate) username: String, - pub(crate) password: String, +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub struct Permission { + pub repository: String, + pub tag: String, + pub actions: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub struct User { + pub username: String, + pub password: String, + #[serde(default)] + pub permissions: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct UsersFile { + pub users: Vec, } impl fmt::Display for ServerStatus { @@ -41,8 +55,8 @@ fn load_users_from_file(file_path: &str) -> HashSet { } }; - let users: Vec = match serde_json::from_str(&file_content) { - Ok(users) => users, + let users_file: UsersFile = match serde_json::from_str(&file_content) { + Ok(users_file) => users_file, Err(err) => { log::error!( "Failed to parse JSON from users file {}: {}", @@ -53,8 +67,8 @@ fn load_users_from_file(file_path: &str) -> HashSet { } }; - log::info!("Loaded {} users", users.len()); - HashSet::from_iter(users) + log::info!("Loaded {} users", users_file.users.len()); + HashSet::from_iter(users_file.users) } pub(crate) fn new_app(args: &Args) -> App { diff --git a/src/tags.rs b/src/tags.rs index 48e1700..e38309c 100644 --- a/src/tags.rs +++ b/src/tags.rs @@ -9,7 +9,7 @@ use axum::response::Response; use serde::Deserialize; use std::sync::Arc; -use crate::{auth, state, storage}; +use crate::{auth, permissions, state, storage}; use axum::extract::{Path, Query, State}; // end-8a GET /v2/:name/tags/list @@ -46,21 +46,36 @@ pub(crate) async fn get_tags_list( headers: HeaderMap, ) -> Response { let host = &state.args.host; + let repository = format!("{}/{}", org, repo); - // Authenticate - if auth::get(State(state.clone()), headers.clone()) - .await - .status() - != StatusCode::OK + // Check permission (Pull for tag listing) + match auth::check_permission( + &state, + &headers, + &repository, + None, + permissions::Action::Pull, + ) + .await { - return Response::builder() - .status(StatusCode::UNAUTHORIZED) - .header( - "WWW-Authenticate", - format!("Basic realm=\"{}\", charset=\"UTF-8\"", host), - ) - .body(Body::from("401 Unauthorized")) - .unwrap(); + Ok(_) => {} + Err(_) => { + return if auth::authenticate_user(&state, &headers).await.is_ok() { + Response::builder() + .status(StatusCode::FORBIDDEN) + .body(Body::from("403 Forbidden: Insufficient permissions")) + .unwrap() + } else { + Response::builder() + .status(StatusCode::UNAUTHORIZED) + .header( + "WWW-Authenticate", + format!("Basic realm=\"{}\", charset=\"UTF-8\"", host), + ) + .body(Body::from("401 Unauthorized")) + .unwrap() + }; + } } // Get all tags from storage