diff --git a/nora-registry/src/audit.rs b/nora-registry/src/audit.rs new file mode 100644 index 0000000..2667c15 --- /dev/null +++ b/nora-registry/src/audit.rs @@ -0,0 +1,77 @@ +// Copyright (c) 2026 Volkov Pavel | DevITWay +// SPDX-License-Identifier: MIT + +//! Persistent audit log — append-only JSONL file +//! +//! Records who/when/what for every registry operation. +//! File: {storage_path}/audit.jsonl + +use chrono::{DateTime, Utc}; +use parking_lot::Mutex; +use serde::Serialize; +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::path::PathBuf; +use tracing::{info, warn}; + +#[derive(Debug, Clone, Serialize)] +pub struct AuditEntry { + pub ts: DateTime, + pub action: String, + pub actor: String, + pub artifact: String, + pub registry: String, + pub detail: String, +} + +impl AuditEntry { + pub fn new(action: &str, actor: &str, artifact: &str, registry: &str, detail: &str) -> Self { + Self { + ts: Utc::now(), + action: action.to_string(), + actor: actor.to_string(), + artifact: artifact.to_string(), + registry: registry.to_string(), + detail: detail.to_string(), + } + } +} + +pub struct AuditLog { + path: PathBuf, + writer: Mutex>, +} + +impl AuditLog { + pub fn new(storage_path: &str) -> Self { + let path = PathBuf::from(storage_path).join("audit.jsonl"); + let writer = match OpenOptions::new() + .create(true) + .append(true) + .open(&path) + { + Ok(f) => { + info!(path = %path.display(), "Audit log initialized"); + Mutex::new(Some(f)) + } + Err(e) => { + warn!(path = %path.display(), error = %e, "Failed to open audit log, auditing disabled"); + Mutex::new(None) + } + }; + Self { path, writer } + } + + pub fn log(&self, entry: AuditEntry) { + if let Some(ref mut file) = *self.writer.lock() { + if let Ok(json) = serde_json::to_string(&entry) { + let _ = writeln!(file, "{}", json); + let _ = file.flush(); + } + } + } + + pub fn path(&self) -> &PathBuf { + &self.path + } +} diff --git a/nora-registry/src/auth.rs b/nora-registry/src/auth.rs index 3f784b0..66a4e00 100644 --- a/nora-registry/src/auth.rs +++ b/nora-registry/src/auth.rs @@ -14,6 +14,7 @@ use std::path::Path; use std::sync::Arc; use crate::AppState; +use crate::tokens::Role; /// Htpasswd-based authentication #[derive(Clone)] @@ -108,7 +109,18 @@ pub async fn auth_middleware( if let Some(token) = auth_header.strip_prefix("Bearer ") { if let Some(ref token_store) = state.tokens { match token_store.verify_token(token) { - Ok(_user) => return next.run(request).await, + Ok((_user, role)) => { + let method = request.method().clone(); + if (method == axum::http::Method::PUT + || method == axum::http::Method::POST + || method == axum::http::Method::DELETE + || method == axum::http::Method::PATCH) + && !role.can_write() + { + return (StatusCode::FORBIDDEN, "Read-only token").into_response(); + } + return next.run(request).await; + } Err(_) => return unauthorized_response("Invalid or expired token"), } } else { @@ -175,6 +187,12 @@ pub struct CreateTokenRequest { #[serde(default = "default_ttl")] pub ttl_days: u64, pub description: Option, + #[serde(default = "default_role_str")] + pub role: String, +} + +fn default_role_str() -> String { + "read".to_string() } fn default_ttl() -> u64 { @@ -194,6 +212,7 @@ pub struct TokenListItem { pub expires_at: u64, pub last_used: Option, pub description: Option, + pub role: String, } #[derive(Serialize)] @@ -227,7 +246,13 @@ async fn create_token( } }; - match token_store.create_token(&req.username, req.ttl_days, req.description) { + let role = match req.role.as_str() { + "read" => Role::Read, + "write" => Role::Write, + "admin" => Role::Admin, + _ => return (StatusCode::BAD_REQUEST, "Invalid role. Use: read, write, admin").into_response(), + }; + match token_store.create_token(&req.username, req.ttl_days, req.description, role) { Ok(token) => Json(CreateTokenResponse { token, expires_in_days: req.ttl_days, @@ -271,6 +296,7 @@ async fn list_tokens( expires_at: t.expires_at, last_used: t.last_used, description: t.description, + role: t.role.to_string(), }) .collect(); diff --git a/nora-registry/src/main.rs b/nora-registry/src/main.rs index d4958a8..e2764ed 100644 --- a/nora-registry/src/main.rs +++ b/nora-registry/src/main.rs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: MIT mod activity_log; +mod audit; mod auth; mod backup; mod config; @@ -32,6 +33,7 @@ use tracing::{error, info, warn}; use tracing_subscriber::{fmt, prelude::*, EnvFilter}; use activity_log::ActivityLog; +use audit::AuditLog; use auth::HtpasswdAuth; use config::{Config, StorageMode}; use dashboard_metrics::DashboardMetrics; @@ -90,6 +92,7 @@ pub struct AppState { pub tokens: Option, pub metrics: DashboardMetrics, pub activity: ActivityLog, + pub audit: AuditLog, pub docker_auth: registry::DockerAuth, pub repo_index: RepoIndex, pub http_client: reqwest::Client, @@ -283,6 +286,7 @@ async fn run_server(config: Config, storage: Storage) { None }; + let storage_path = config.storage.path.clone(); let rate_limit_enabled = config.rate_limit.enabled; // Initialize Docker auth with proxy timeout @@ -334,6 +338,7 @@ async fn run_server(config: Config, storage: Storage) { tokens, metrics: DashboardMetrics::new(), activity: ActivityLog::new(50), + audit: AuditLog::new(&storage_path), docker_auth, repo_index: RepoIndex::new(), http_client, diff --git a/nora-registry/src/registry/cargo_registry.rs b/nora-registry/src/registry/cargo_registry.rs index de6ed23..22000dc 100644 --- a/nora-registry/src/registry/cargo_registry.rs +++ b/nora-registry/src/registry/cargo_registry.rs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: MIT use crate::activity_log::{ActionType, ActivityEntry}; +use crate::audit::AuditEntry; use crate::AppState; use axum::{ extract::{Path, State}, @@ -50,6 +51,7 @@ async fn download( "cargo", "LOCAL", )); + state.audit.log(AuditEntry::new("pull", "api", "", "cargo", "")); (StatusCode::OK, data).into_response() } Err(_) => StatusCode::NOT_FOUND.into_response(), diff --git a/nora-registry/src/registry/docker.rs b/nora-registry/src/registry/docker.rs index c11c40d..4b35b46 100644 --- a/nora-registry/src/registry/docker.rs +++ b/nora-registry/src/registry/docker.rs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: MIT use crate::activity_log::{ActionType, ActivityEntry}; +use crate::audit::AuditEntry; use crate::registry::docker_auth::DockerAuth; use crate::storage::Storage; use crate::validation::{validate_digest, validate_docker_name, validate_docker_reference}; @@ -488,6 +489,7 @@ async fn put_manifest( "docker", "LOCAL", )); + state.audit.log(AuditEntry::new("push", "api", &format!("{}:{}", name, reference), "docker", "manifest")); state.repo_index.invalidate("docker"); let location = format!("/v2/{}/manifests/{}", name, reference); diff --git a/nora-registry/src/registry/maven.rs b/nora-registry/src/registry/maven.rs index fe155f2..373bb8d 100644 --- a/nora-registry/src/registry/maven.rs +++ b/nora-registry/src/registry/maven.rs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: MIT use crate::activity_log::{ActionType, ActivityEntry}; +use crate::audit::AuditEntry; use crate::AppState; use axum::{ body::Bytes, @@ -42,6 +43,7 @@ async fn download(State(state): State>, Path(path): Path) "maven", "CACHE", )); + state.audit.log(AuditEntry::new("cache_hit", "api", "", "maven", "")); return with_content_type(&path, data).into_response(); } @@ -58,6 +60,7 @@ async fn download(State(state): State>, Path(path): Path) "maven", "PROXY", )); + state.audit.log(AuditEntry::new("proxy_fetch", "api", "", "maven", "")); let storage = state.storage.clone(); let key_clone = key.clone(); @@ -103,6 +106,7 @@ async fn upload( "maven", "LOCAL", )); + state.audit.log(AuditEntry::new("push", "api", "", "maven", "")); state.repo_index.invalidate("maven"); StatusCode::CREATED } diff --git a/nora-registry/src/registry/npm.rs b/nora-registry/src/registry/npm.rs index f5d8370..7cd7614 100644 --- a/nora-registry/src/registry/npm.rs +++ b/nora-registry/src/registry/npm.rs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: MIT use crate::activity_log::{ActionType, ActivityEntry}; +use crate::audit::AuditEntry; use crate::AppState; use axum::{ body::Bytes, @@ -48,6 +49,7 @@ async fn handle_request(State(state): State>, Path(path): Path>, Path(path): Path>, Path(path): Path) state .activity .push(ActivityEntry::new(ActionType::Pull, path, "raw", "LOCAL")); + state.audit.log(AuditEntry::new("pull", "api", "", "raw", "")); // Guess content type from extension let content_type = guess_content_type(&key); @@ -72,6 +74,7 @@ async fn upload( state .activity .push(ActivityEntry::new(ActionType::Push, path, "raw", "LOCAL")); + state.audit.log(AuditEntry::new("push", "api", "", "raw", "")); StatusCode::CREATED.into_response() } Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), diff --git a/nora-registry/src/tokens.rs b/nora-registry/src/tokens.rs index 51f075e..940da1c 100644 --- a/nora-registry/src/tokens.rs +++ b/nora-registry/src/tokens.rs @@ -11,6 +11,36 @@ use uuid::Uuid; const TOKEN_PREFIX: &str = "nra_"; +/// Access role for API tokens +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum Role { + Read, + Write, + Admin, +} + +impl std::fmt::Display for Role { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Role::Read => write!(f, "read"), + Role::Write => write!(f, "write"), + Role::Admin => write!(f, "admin"), + } + } +} + +impl Role { + pub fn can_write(&self) -> bool { + matches!(self, Role::Write | Role::Admin) + } + + pub fn can_admin(&self) -> bool { + matches!(self, Role::Admin) + } +} + + /// API Token metadata stored on disk #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TokenInfo { @@ -20,6 +50,12 @@ pub struct TokenInfo { pub expires_at: u64, pub last_used: Option, pub description: Option, + #[serde(default = "default_role")] + pub role: Role, +} + +fn default_role() -> Role { + Role::Read } /// Token store for managing API tokens @@ -44,6 +80,7 @@ impl TokenStore { user: &str, ttl_days: u64, description: Option, + role: Role, ) -> Result { // Generate random token let raw_token = format!( @@ -67,6 +104,7 @@ impl TokenStore { expires_at, last_used: None, description, + role, }; // Save to file @@ -81,7 +119,7 @@ impl TokenStore { } /// Verify a token and return user info if valid - pub fn verify_token(&self, token: &str) -> Result { + pub fn verify_token(&self, token: &str) -> Result<(String, Role), TokenError> { if !token.starts_with(TOKEN_PREFIX) { return Err(TokenError::InvalidFormat); } @@ -121,7 +159,7 @@ impl TokenStore { let _ = fs::write(&file_path, json); } - Ok(info.user) + Ok((info.user, info.role)) } /// List all tokens for a user @@ -210,7 +248,7 @@ mod tests { let store = TokenStore::new(temp_dir.path()); let token = store - .create_token("testuser", 30, Some("Test token".to_string())) + .create_token("testuser", 30, Some("Test token".to_string()), Role::Write) .unwrap(); assert!(token.starts_with("nra_")); @@ -222,10 +260,11 @@ mod tests { let temp_dir = TempDir::new().unwrap(); let store = TokenStore::new(temp_dir.path()); - let token = store.create_token("testuser", 30, None).unwrap(); - let user = store.verify_token(&token).unwrap(); + let token = store.create_token("testuser", 30, None, Role::Write).unwrap(); + let (user, role) = store.verify_token(&token).unwrap(); assert_eq!(user, "testuser"); + assert_eq!(role, Role::Write); } #[test] @@ -252,7 +291,7 @@ mod tests { let store = TokenStore::new(temp_dir.path()); // Create token and manually set it as expired - let token = store.create_token("testuser", 1, None).unwrap(); + let token = store.create_token("testuser", 1, None, Role::Write).unwrap(); let token_hash = hash_token(&token); let file_path = temp_dir.path().join(format!("{}.json", &token_hash[..16])); @@ -272,9 +311,9 @@ mod tests { let temp_dir = TempDir::new().unwrap(); let store = TokenStore::new(temp_dir.path()); - store.create_token("user1", 30, None).unwrap(); - store.create_token("user1", 30, None).unwrap(); - store.create_token("user2", 30, None).unwrap(); + store.create_token("user1", 30, None, Role::Write).unwrap(); + store.create_token("user1", 30, None, Role::Write).unwrap(); + store.create_token("user2", 30, None, Role::Read).unwrap(); let user1_tokens = store.list_tokens("user1"); assert_eq!(user1_tokens.len(), 2); @@ -291,7 +330,7 @@ mod tests { let temp_dir = TempDir::new().unwrap(); let store = TokenStore::new(temp_dir.path()); - let token = store.create_token("testuser", 30, None).unwrap(); + let token = store.create_token("testuser", 30, None, Role::Write).unwrap(); let token_hash = hash_token(&token); let hash_prefix = &token_hash[..16]; @@ -320,9 +359,9 @@ mod tests { let temp_dir = TempDir::new().unwrap(); let store = TokenStore::new(temp_dir.path()); - store.create_token("user1", 30, None).unwrap(); - store.create_token("user1", 30, None).unwrap(); - store.create_token("user2", 30, None).unwrap(); + store.create_token("user1", 30, None, Role::Write).unwrap(); + store.create_token("user1", 30, None, Role::Write).unwrap(); + store.create_token("user2", 30, None, Role::Read).unwrap(); let revoked = store.revoke_all_for_user("user1"); assert_eq!(revoked, 2); @@ -336,7 +375,7 @@ mod tests { let temp_dir = TempDir::new().unwrap(); let store = TokenStore::new(temp_dir.path()); - let token = store.create_token("testuser", 30, None).unwrap(); + let token = store.create_token("testuser", 30, None, Role::Write).unwrap(); // First verification store.verify_token(&token).unwrap(); @@ -352,7 +391,7 @@ mod tests { let store = TokenStore::new(temp_dir.path()); store - .create_token("testuser", 30, Some("CI/CD Pipeline".to_string())) + .create_token("testuser", 30, Some("CI/CD Pipeline".to_string()), Role::Admin) .unwrap(); let tokens = store.list_tokens("testuser");