mirror of
https://github.com/getnora-io/nora.git
synced 2026-04-12 17:20:33 +00:00
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:
77
nora-registry/src/audit.rs
Normal file
77
nora-registry/src/audit.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ use std::path::Path;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
use crate::tokens::Role;
|
||||||
|
|
||||||
/// Htpasswd-based authentication
|
/// Htpasswd-based authentication
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -108,7 +109,18 @@ pub async fn auth_middleware(
|
|||||||
if let Some(token) = auth_header.strip_prefix("Bearer ") {
|
if let Some(token) = auth_header.strip_prefix("Bearer ") {
|
||||||
if let Some(ref token_store) = state.tokens {
|
if let Some(ref token_store) = state.tokens {
|
||||||
match token_store.verify_token(token) {
|
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"),
|
Err(_) => return unauthorized_response("Invalid or expired token"),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -175,6 +187,12 @@ pub struct CreateTokenRequest {
|
|||||||
#[serde(default = "default_ttl")]
|
#[serde(default = "default_ttl")]
|
||||||
pub ttl_days: u64,
|
pub ttl_days: u64,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
|
#[serde(default = "default_role_str")]
|
||||||
|
pub role: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_role_str() -> String {
|
||||||
|
"read".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_ttl() -> u64 {
|
fn default_ttl() -> u64 {
|
||||||
@@ -194,6 +212,7 @@ pub struct TokenListItem {
|
|||||||
pub expires_at: u64,
|
pub expires_at: u64,
|
||||||
pub last_used: Option<u64>,
|
pub last_used: Option<u64>,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
|
pub role: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[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 {
|
Ok(token) => Json(CreateTokenResponse {
|
||||||
token,
|
token,
|
||||||
expires_in_days: req.ttl_days,
|
expires_in_days: req.ttl_days,
|
||||||
@@ -271,6 +296,7 @@ async fn list_tokens(
|
|||||||
expires_at: t.expires_at,
|
expires_at: t.expires_at,
|
||||||
last_used: t.last_used,
|
last_used: t.last_used,
|
||||||
description: t.description,
|
description: t.description,
|
||||||
|
role: t.role.to_string(),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
mod activity_log;
|
mod activity_log;
|
||||||
|
mod audit;
|
||||||
mod auth;
|
mod auth;
|
||||||
mod backup;
|
mod backup;
|
||||||
mod config;
|
mod config;
|
||||||
@@ -32,6 +33,7 @@ use tracing::{error, info, warn};
|
|||||||
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
|
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
|
||||||
|
|
||||||
use activity_log::ActivityLog;
|
use activity_log::ActivityLog;
|
||||||
|
use audit::AuditLog;
|
||||||
use auth::HtpasswdAuth;
|
use auth::HtpasswdAuth;
|
||||||
use config::{Config, StorageMode};
|
use config::{Config, StorageMode};
|
||||||
use dashboard_metrics::DashboardMetrics;
|
use dashboard_metrics::DashboardMetrics;
|
||||||
@@ -90,6 +92,7 @@ pub struct AppState {
|
|||||||
pub tokens: Option<TokenStore>,
|
pub tokens: Option<TokenStore>,
|
||||||
pub metrics: DashboardMetrics,
|
pub metrics: DashboardMetrics,
|
||||||
pub activity: ActivityLog,
|
pub activity: ActivityLog,
|
||||||
|
pub audit: AuditLog,
|
||||||
pub docker_auth: registry::DockerAuth,
|
pub docker_auth: registry::DockerAuth,
|
||||||
pub repo_index: RepoIndex,
|
pub repo_index: RepoIndex,
|
||||||
pub http_client: reqwest::Client,
|
pub http_client: reqwest::Client,
|
||||||
@@ -283,6 +286,7 @@ async fn run_server(config: Config, storage: Storage) {
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let storage_path = config.storage.path.clone();
|
||||||
let rate_limit_enabled = config.rate_limit.enabled;
|
let rate_limit_enabled = config.rate_limit.enabled;
|
||||||
|
|
||||||
// Initialize Docker auth with proxy timeout
|
// Initialize Docker auth with proxy timeout
|
||||||
@@ -334,6 +338,7 @@ async fn run_server(config: Config, storage: Storage) {
|
|||||||
tokens,
|
tokens,
|
||||||
metrics: DashboardMetrics::new(),
|
metrics: DashboardMetrics::new(),
|
||||||
activity: ActivityLog::new(50),
|
activity: ActivityLog::new(50),
|
||||||
|
audit: AuditLog::new(&storage_path),
|
||||||
docker_auth,
|
docker_auth,
|
||||||
repo_index: RepoIndex::new(),
|
repo_index: RepoIndex::new(),
|
||||||
http_client,
|
http_client,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
use crate::activity_log::{ActionType, ActivityEntry};
|
use crate::activity_log::{ActionType, ActivityEntry};
|
||||||
|
use crate::audit::AuditEntry;
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
@@ -50,6 +51,7 @@ async fn download(
|
|||||||
"cargo",
|
"cargo",
|
||||||
"LOCAL",
|
"LOCAL",
|
||||||
));
|
));
|
||||||
|
state.audit.log(AuditEntry::new("pull", "api", "", "cargo", ""));
|
||||||
(StatusCode::OK, data).into_response()
|
(StatusCode::OK, data).into_response()
|
||||||
}
|
}
|
||||||
Err(_) => StatusCode::NOT_FOUND.into_response(),
|
Err(_) => StatusCode::NOT_FOUND.into_response(),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
use crate::activity_log::{ActionType, ActivityEntry};
|
use crate::activity_log::{ActionType, ActivityEntry};
|
||||||
|
use crate::audit::AuditEntry;
|
||||||
use crate::registry::docker_auth::DockerAuth;
|
use crate::registry::docker_auth::DockerAuth;
|
||||||
use crate::storage::Storage;
|
use crate::storage::Storage;
|
||||||
use crate::validation::{validate_digest, validate_docker_name, validate_docker_reference};
|
use crate::validation::{validate_digest, validate_docker_name, validate_docker_reference};
|
||||||
@@ -488,6 +489,7 @@ async fn put_manifest(
|
|||||||
"docker",
|
"docker",
|
||||||
"LOCAL",
|
"LOCAL",
|
||||||
));
|
));
|
||||||
|
state.audit.log(AuditEntry::new("push", "api", &format!("{}:{}", name, reference), "docker", "manifest"));
|
||||||
state.repo_index.invalidate("docker");
|
state.repo_index.invalidate("docker");
|
||||||
|
|
||||||
let location = format!("/v2/{}/manifests/{}", name, reference);
|
let location = format!("/v2/{}/manifests/{}", name, reference);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
use crate::activity_log::{ActionType, ActivityEntry};
|
use crate::activity_log::{ActionType, ActivityEntry};
|
||||||
|
use crate::audit::AuditEntry;
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use axum::{
|
use axum::{
|
||||||
body::Bytes,
|
body::Bytes,
|
||||||
@@ -42,6 +43,7 @@ async fn download(State(state): State<Arc<AppState>>, Path(path): Path<String>)
|
|||||||
"maven",
|
"maven",
|
||||||
"CACHE",
|
"CACHE",
|
||||||
));
|
));
|
||||||
|
state.audit.log(AuditEntry::new("cache_hit", "api", "", "maven", ""));
|
||||||
return with_content_type(&path, data).into_response();
|
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",
|
"maven",
|
||||||
"PROXY",
|
"PROXY",
|
||||||
));
|
));
|
||||||
|
state.audit.log(AuditEntry::new("proxy_fetch", "api", "", "maven", ""));
|
||||||
|
|
||||||
let storage = state.storage.clone();
|
let storage = state.storage.clone();
|
||||||
let key_clone = key.clone();
|
let key_clone = key.clone();
|
||||||
@@ -103,6 +106,7 @@ async fn upload(
|
|||||||
"maven",
|
"maven",
|
||||||
"LOCAL",
|
"LOCAL",
|
||||||
));
|
));
|
||||||
|
state.audit.log(AuditEntry::new("push", "api", "", "maven", ""));
|
||||||
state.repo_index.invalidate("maven");
|
state.repo_index.invalidate("maven");
|
||||||
StatusCode::CREATED
|
StatusCode::CREATED
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
use crate::activity_log::{ActionType, ActivityEntry};
|
use crate::activity_log::{ActionType, ActivityEntry};
|
||||||
|
use crate::audit::AuditEntry;
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use axum::{
|
use axum::{
|
||||||
body::Bytes,
|
body::Bytes,
|
||||||
@@ -48,6 +49,7 @@ async fn handle_request(State(state): State<Arc<AppState>>, Path(path): Path<Str
|
|||||||
"npm",
|
"npm",
|
||||||
"CACHE",
|
"CACHE",
|
||||||
));
|
));
|
||||||
|
state.audit.log(AuditEntry::new("cache_hit", "api", "", "npm", ""));
|
||||||
}
|
}
|
||||||
return with_content_type(is_tarball, data).into_response();
|
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",
|
"npm",
|
||||||
"PROXY",
|
"PROXY",
|
||||||
));
|
));
|
||||||
|
state.audit.log(AuditEntry::new("proxy_fetch", "api", "", "npm", ""));
|
||||||
}
|
}
|
||||||
|
|
||||||
let storage = state.storage.clone();
|
let storage = state.storage.clone();
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
use crate::activity_log::{ActionType, ActivityEntry};
|
use crate::activity_log::{ActionType, ActivityEntry};
|
||||||
|
use crate::audit::AuditEntry;
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
@@ -115,6 +116,7 @@ async fn download_file(
|
|||||||
"pypi",
|
"pypi",
|
||||||
"CACHE",
|
"CACHE",
|
||||||
));
|
));
|
||||||
|
state.audit.log(AuditEntry::new("cache_hit", "api", "", "pypi", ""));
|
||||||
|
|
||||||
let content_type = if filename.ends_with(".whl") {
|
let content_type = if filename.ends_with(".whl") {
|
||||||
"application/zip"
|
"application/zip"
|
||||||
@@ -156,6 +158,7 @@ async fn download_file(
|
|||||||
"pypi",
|
"pypi",
|
||||||
"PROXY",
|
"PROXY",
|
||||||
));
|
));
|
||||||
|
state.audit.log(AuditEntry::new("proxy_fetch", "api", "", "pypi", ""));
|
||||||
|
|
||||||
// Cache in local storage
|
// Cache in local storage
|
||||||
let storage = state.storage.clone();
|
let storage = state.storage.clone();
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
use crate::activity_log::{ActionType, ActivityEntry};
|
use crate::activity_log::{ActionType, ActivityEntry};
|
||||||
|
use crate::audit::AuditEntry;
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use axum::{
|
use axum::{
|
||||||
body::Bytes,
|
body::Bytes,
|
||||||
@@ -35,6 +36,7 @@ async fn download(State(state): State<Arc<AppState>>, Path(path): Path<String>)
|
|||||||
state
|
state
|
||||||
.activity
|
.activity
|
||||||
.push(ActivityEntry::new(ActionType::Pull, path, "raw", "LOCAL"));
|
.push(ActivityEntry::new(ActionType::Pull, path, "raw", "LOCAL"));
|
||||||
|
state.audit.log(AuditEntry::new("pull", "api", "", "raw", ""));
|
||||||
|
|
||||||
// Guess content type from extension
|
// Guess content type from extension
|
||||||
let content_type = guess_content_type(&key);
|
let content_type = guess_content_type(&key);
|
||||||
@@ -72,6 +74,7 @@ async fn upload(
|
|||||||
state
|
state
|
||||||
.activity
|
.activity
|
||||||
.push(ActivityEntry::new(ActionType::Push, path, "raw", "LOCAL"));
|
.push(ActivityEntry::new(ActionType::Push, path, "raw", "LOCAL"));
|
||||||
|
state.audit.log(AuditEntry::new("push", "api", "", "raw", ""));
|
||||||
StatusCode::CREATED.into_response()
|
StatusCode::CREATED.into_response()
|
||||||
}
|
}
|
||||||
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||||
|
|||||||
@@ -11,6 +11,36 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
const TOKEN_PREFIX: &str = "nra_";
|
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
|
/// API Token metadata stored on disk
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct TokenInfo {
|
pub struct TokenInfo {
|
||||||
@@ -20,6 +50,12 @@ pub struct TokenInfo {
|
|||||||
pub expires_at: u64,
|
pub expires_at: u64,
|
||||||
pub last_used: Option<u64>,
|
pub last_used: Option<u64>,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
|
#[serde(default = "default_role")]
|
||||||
|
pub role: Role,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_role() -> Role {
|
||||||
|
Role::Read
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Token store for managing API tokens
|
/// Token store for managing API tokens
|
||||||
@@ -44,6 +80,7 @@ impl TokenStore {
|
|||||||
user: &str,
|
user: &str,
|
||||||
ttl_days: u64,
|
ttl_days: u64,
|
||||||
description: Option<String>,
|
description: Option<String>,
|
||||||
|
role: Role,
|
||||||
) -> Result<String, TokenError> {
|
) -> Result<String, TokenError> {
|
||||||
// Generate random token
|
// Generate random token
|
||||||
let raw_token = format!(
|
let raw_token = format!(
|
||||||
@@ -67,6 +104,7 @@ impl TokenStore {
|
|||||||
expires_at,
|
expires_at,
|
||||||
last_used: None,
|
last_used: None,
|
||||||
description,
|
description,
|
||||||
|
role,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Save to file
|
// Save to file
|
||||||
@@ -81,7 +119,7 @@ impl TokenStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Verify a token and return user info if valid
|
/// 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) {
|
if !token.starts_with(TOKEN_PREFIX) {
|
||||||
return Err(TokenError::InvalidFormat);
|
return Err(TokenError::InvalidFormat);
|
||||||
}
|
}
|
||||||
@@ -121,7 +159,7 @@ impl TokenStore {
|
|||||||
let _ = fs::write(&file_path, json);
|
let _ = fs::write(&file_path, json);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(info.user)
|
Ok((info.user, info.role))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List all tokens for a user
|
/// List all tokens for a user
|
||||||
@@ -210,7 +248,7 @@ mod tests {
|
|||||||
let store = TokenStore::new(temp_dir.path());
|
let store = TokenStore::new(temp_dir.path());
|
||||||
|
|
||||||
let token = store
|
let token = store
|
||||||
.create_token("testuser", 30, Some("Test token".to_string()))
|
.create_token("testuser", 30, Some("Test token".to_string()), Role::Write)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert!(token.starts_with("nra_"));
|
assert!(token.starts_with("nra_"));
|
||||||
@@ -222,10 +260,11 @@ mod tests {
|
|||||||
let temp_dir = TempDir::new().unwrap();
|
let temp_dir = TempDir::new().unwrap();
|
||||||
let store = TokenStore::new(temp_dir.path());
|
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 user = store.verify_token(&token).unwrap();
|
let (user, role) = store.verify_token(&token).unwrap();
|
||||||
|
|
||||||
assert_eq!(user, "testuser");
|
assert_eq!(user, "testuser");
|
||||||
|
assert_eq!(role, Role::Write);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -252,7 +291,7 @@ mod tests {
|
|||||||
let store = TokenStore::new(temp_dir.path());
|
let store = TokenStore::new(temp_dir.path());
|
||||||
|
|
||||||
// Create token and manually set it as expired
|
// 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 token_hash = hash_token(&token);
|
||||||
let file_path = temp_dir.path().join(format!("{}.json", &token_hash[..16]));
|
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 temp_dir = TempDir::new().unwrap();
|
||||||
let store = TokenStore::new(temp_dir.path());
|
let store = TokenStore::new(temp_dir.path());
|
||||||
|
|
||||||
store.create_token("user1", 30, None).unwrap();
|
store.create_token("user1", 30, None, Role::Write).unwrap();
|
||||||
store.create_token("user1", 30, None).unwrap();
|
store.create_token("user1", 30, None, Role::Write).unwrap();
|
||||||
store.create_token("user2", 30, None).unwrap();
|
store.create_token("user2", 30, None, Role::Read).unwrap();
|
||||||
|
|
||||||
let user1_tokens = store.list_tokens("user1");
|
let user1_tokens = store.list_tokens("user1");
|
||||||
assert_eq!(user1_tokens.len(), 2);
|
assert_eq!(user1_tokens.len(), 2);
|
||||||
@@ -291,7 +330,7 @@ mod tests {
|
|||||||
let temp_dir = TempDir::new().unwrap();
|
let temp_dir = TempDir::new().unwrap();
|
||||||
let store = TokenStore::new(temp_dir.path());
|
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 token_hash = hash_token(&token);
|
||||||
let hash_prefix = &token_hash[..16];
|
let hash_prefix = &token_hash[..16];
|
||||||
|
|
||||||
@@ -320,9 +359,9 @@ mod tests {
|
|||||||
let temp_dir = TempDir::new().unwrap();
|
let temp_dir = TempDir::new().unwrap();
|
||||||
let store = TokenStore::new(temp_dir.path());
|
let store = TokenStore::new(temp_dir.path());
|
||||||
|
|
||||||
store.create_token("user1", 30, None).unwrap();
|
store.create_token("user1", 30, None, Role::Write).unwrap();
|
||||||
store.create_token("user1", 30, None).unwrap();
|
store.create_token("user1", 30, None, Role::Write).unwrap();
|
||||||
store.create_token("user2", 30, None).unwrap();
|
store.create_token("user2", 30, None, Role::Read).unwrap();
|
||||||
|
|
||||||
let revoked = store.revoke_all_for_user("user1");
|
let revoked = store.revoke_all_for_user("user1");
|
||||||
assert_eq!(revoked, 2);
|
assert_eq!(revoked, 2);
|
||||||
@@ -336,7 +375,7 @@ mod tests {
|
|||||||
let temp_dir = TempDir::new().unwrap();
|
let temp_dir = TempDir::new().unwrap();
|
||||||
let store = TokenStore::new(temp_dir.path());
|
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
|
// First verification
|
||||||
store.verify_token(&token).unwrap();
|
store.verify_token(&token).unwrap();
|
||||||
@@ -352,7 +391,7 @@ mod tests {
|
|||||||
let store = TokenStore::new(temp_dir.path());
|
let store = TokenStore::new(temp_dir.path());
|
||||||
|
|
||||||
store
|
store
|
||||||
.create_token("testuser", 30, Some("CI/CD Pipeline".to_string()))
|
.create_token("testuser", 30, Some("CI/CD Pipeline".to_string()), Role::Admin)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let tokens = store.list_tokens("testuser");
|
let tokens = store.list_tokens("testuser");
|
||||||
|
|||||||
Reference in New Issue
Block a user