feat: add RBAC (read/write/admin) and persistent audit log

- Add Role enum to tokens: Read, Write, Admin (default: Read)
- Enforce role-based access in auth middleware (read-only tokens blocked from PUT/POST/DELETE)
- Add role field to token create/list/verify API
- Add persistent audit log (append-only JSONL) for all registry operations
- Audit logging across all registries: docker, npm, maven, pypi, cargo, raw

DevITWay
This commit is contained in:
2026-03-03 10:40:59 +00:00
parent f560e5f76b
commit 402d2321ef
10 changed files with 181 additions and 17 deletions

View File

@@ -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<Utc>,
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<Option<fs::File>>,
}
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
}
}

View File

@@ -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<String>,
#[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<u64>,
pub description: Option<String>,
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();

View File

@@ -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<TokenStore>,
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,

View File

@@ -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(),

View File

@@ -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);

View File

@@ -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<Arc<AppState>>, Path(path): Path<String>)
"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<Arc<AppState>>, Path(path): Path<String>)
"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
}

View File

@@ -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<Arc<AppState>>, Path(path): Path<Str
"npm",
"CACHE",
));
state.audit.log(AuditEntry::new("cache_hit", "api", "", "npm", ""));
}
return with_content_type(is_tarball, data).into_response();
}
@@ -67,6 +69,7 @@ async fn handle_request(State(state): State<Arc<AppState>>, Path(path): Path<Str
"npm",
"PROXY",
));
state.audit.log(AuditEntry::new("proxy_fetch", "api", "", "npm", ""));
}
let storage = state.storage.clone();

View File

@@ -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},
@@ -115,6 +116,7 @@ async fn download_file(
"pypi",
"CACHE",
));
state.audit.log(AuditEntry::new("cache_hit", "api", "", "pypi", ""));
let content_type = if filename.ends_with(".whl") {
"application/zip"
@@ -156,6 +158,7 @@ async fn download_file(
"pypi",
"PROXY",
));
state.audit.log(AuditEntry::new("proxy_fetch", "api", "", "pypi", ""));
// Cache in local storage
let storage = state.storage.clone();

View File

@@ -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,
@@ -35,6 +36,7 @@ async fn download(State(state): State<Arc<AppState>>, Path(path): Path<String>)
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(),

View File

@@ -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<u64>,
pub description: Option<String>,
#[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<String>,
role: Role,
) -> Result<String, TokenError> {
// 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<String, TokenError> {
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");