Initial public release v0.1.0
This commit is contained in:
commit
6c39e0bfbf
87 files changed
+10867
No files matched your search
+233
@@ -0,0 +1,233 @@
|
||||
use axum::{Json, extract::State};
|
||||
use axum_extra::extract::{CookieJar, cookie::Cookie};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{
|
||||
audit::{self, AuditEvent},
|
||||
db::models::AuditSeverity,
|
||||
db::repository::users as user_repo,
|
||||
error::{AppError, Result},
|
||||
identity::{permissions, roles},
|
||||
middleware::{audit::AuditContext, auth::AuthUser},
|
||||
security::{passwords, sessions},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
// ── Login ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LoginRequest {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
/// POST /api/v1/auth/login
|
||||
pub async fn login(
|
||||
State(state): State<AppState>,
|
||||
ctx: AuditContext,
|
||||
jar: CookieJar,
|
||||
Json(body): Json<LoginRequest>,
|
||||
) -> Result<(CookieJar, Json<Value>)> {
|
||||
let ip = ctx.ip_address.as_deref();
|
||||
|
||||
// Rate limit check
|
||||
if let Some(ip_str) = &ctx.ip_address {
|
||||
if let Ok(ip_addr) = ip_str.parse::<std::net::IpAddr>() {
|
||||
state.rate_limiter.check(ip_addr)?;
|
||||
}
|
||||
}
|
||||
|
||||
// Look up user
|
||||
let user_opt = user_repo::find_by_username(&state.pool, &body.username)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let mut is_authed = false;
|
||||
let mut final_user = None;
|
||||
|
||||
if let Some(user) = user_opt {
|
||||
let password_ok = passwords::verify_password(&body.password, &user.password_hash)?;
|
||||
if password_ok && user.is_active() {
|
||||
is_authed = true;
|
||||
final_user = Some(user);
|
||||
}
|
||||
} else {
|
||||
// Run dummy verify to take same execution time
|
||||
passwords::verify_dummy(&state.config.security)?;
|
||||
}
|
||||
|
||||
if !is_authed {
|
||||
record_login_failure(&state, &body.username, ip, ctx.user_agent.as_deref()).await;
|
||||
if let Some(ip_str) = &ctx.ip_address {
|
||||
if let Ok(ip_addr) = ip_str.parse::<std::net::IpAddr>() {
|
||||
state.rate_limiter.record_failure(ip_addr);
|
||||
}
|
||||
}
|
||||
return Err(AppError::Unauthorized);
|
||||
}
|
||||
|
||||
let user = final_user.unwrap();
|
||||
|
||||
// Clear rate limit on success
|
||||
if let Some(ip_str) = &ctx.ip_address {
|
||||
if let Ok(ip_addr) = ip_str.parse::<std::net::IpAddr>() {
|
||||
state.rate_limiter.record_success(ip_addr);
|
||||
}
|
||||
}
|
||||
|
||||
// Create session
|
||||
let (session, raw_token) = sessions::create_session(
|
||||
&state.pool,
|
||||
&user.id,
|
||||
ip,
|
||||
ctx.user_agent.as_deref(),
|
||||
&state.config.security,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Update last_login_at and audit in the same transaction
|
||||
if let Ok(mut tx) = state.pool.begin().await {
|
||||
let _ = user_repo::set_last_login(&mut tx, &user.id).await;
|
||||
let _ = audit::log(
|
||||
&mut tx,
|
||||
AuditEvent {
|
||||
actor_id: Some(&user.id),
|
||||
target_id: Some(&user.id),
|
||||
action: "login_success",
|
||||
resource_type: "session",
|
||||
resource_id: Some(&session.id),
|
||||
severity: AuditSeverity::Info,
|
||||
ip,
|
||||
ua: ctx.user_agent.as_deref(),
|
||||
metadata: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let _ = tx.commit().await;
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
event = "login_success",
|
||||
user_id = %user.id,
|
||||
username = %user.username,
|
||||
ip = ip.unwrap_or("unknown"),
|
||||
);
|
||||
|
||||
// Build secure session cookie using time::Duration for max_age
|
||||
let max_age_secs = state.config.security.session_absolute_ttl_days as i64 * 86400;
|
||||
let mut cookie = Cookie::new(sessions::SESSION_COOKIE, raw_token);
|
||||
cookie.set_http_only(true);
|
||||
cookie.set_secure(true);
|
||||
cookie.set_same_site(axum_extra::extract::cookie::SameSite::Lax);
|
||||
cookie.set_path("/");
|
||||
cookie.set_max_age(time::Duration::seconds(max_age_secs));
|
||||
|
||||
Ok((jar.add(cookie), Json(json!({ "success": true }))))
|
||||
}
|
||||
|
||||
async fn record_login_failure(
|
||||
state: &AppState,
|
||||
username: &str,
|
||||
ip: Option<&str>,
|
||||
ua: Option<&str>,
|
||||
) {
|
||||
if let Ok(mut tx) = state.pool.begin().await {
|
||||
let _ = audit::log(
|
||||
&mut tx,
|
||||
AuditEvent {
|
||||
actor_id: None,
|
||||
target_id: None,
|
||||
action: "login_failed",
|
||||
resource_type: "session",
|
||||
resource_id: None,
|
||||
severity: AuditSeverity::Warning,
|
||||
ip,
|
||||
ua,
|
||||
metadata: Some(&format!(r#"{{"username":"{}"}}"#, username)),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let _ = tx.commit().await;
|
||||
}
|
||||
|
||||
tracing::warn!(
|
||||
event = "login_failed",
|
||||
username = %username,
|
||||
ip = ip.unwrap_or("unknown"),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Logout ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// POST /api/v1/auth/logout
|
||||
pub async fn logout(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
jar: CookieJar,
|
||||
) -> Result<(CookieJar, Json<Value>)> {
|
||||
if let Some(session_id) = &auth.session_id {
|
||||
sessions::revoke_session(&state.pool, session_id).await?;
|
||||
|
||||
// Audit log for logout
|
||||
if let Ok(mut tx) = state.pool.begin().await {
|
||||
let _ = audit::log(
|
||||
&mut tx,
|
||||
AuditEvent {
|
||||
actor_id: Some(&auth.user.id),
|
||||
target_id: Some(&auth.user.id),
|
||||
action: "logout",
|
||||
resource_type: "session",
|
||||
resource_id: Some(session_id),
|
||||
severity: AuditSeverity::Info,
|
||||
ip: None,
|
||||
ua: None,
|
||||
metadata: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let _ = tx.commit().await;
|
||||
}
|
||||
}
|
||||
|
||||
let mut removal = Cookie::from(sessions::SESSION_COOKIE);
|
||||
removal.set_path("/");
|
||||
let removed = jar.remove(removal);
|
||||
Ok((removed, Json(json!({ "success": true }))))
|
||||
}
|
||||
|
||||
// ── Me ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct MeResponse {
|
||||
pub user: UserView,
|
||||
pub roles: Vec<String>,
|
||||
pub permissions: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct UserView {
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
pub status: String,
|
||||
pub last_login_at: Option<String>,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
/// GET /api/v1/auth/me
|
||||
pub async fn me(State(state): State<AppState>, auth: AuthUser) -> Result<Json<MeResponse>> {
|
||||
let user_roles = roles::list_user_roles(&state.pool, &auth.user.id).await?;
|
||||
let user_perms = permissions::list_user_permissions(&state.pool, &auth.user.id).await?;
|
||||
|
||||
Ok(Json(MeResponse {
|
||||
user: UserView {
|
||||
id: auth.user.id.clone(),
|
||||
username: auth.user.username.clone(),
|
||||
status: auth.user.status().to_string(),
|
||||
last_login_at: auth.user.last_login_at.clone(),
|
||||
created_at: auth.user.created_at.clone(),
|
||||
},
|
||||
roles: user_roles.into_iter().map(|r| r.name).collect(),
|
||||
permissions: user_perms,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
use axum::Json;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// GET /health
|
||||
pub async fn health() -> Json<Value> {
|
||||
Json(json!({ "status": "ok" }))
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
pub mod auth;
|
||||
pub mod health;
|
||||
pub mod router;
|
||||
pub mod tokens;
|
||||
pub mod users;
|
||||
pub mod version;
|
||||
@@ -0,0 +1,51 @@
|
||||
use axum::{
|
||||
Router,
|
||||
routing::{delete, get, post},
|
||||
};
|
||||
use tower_http::{
|
||||
compression::CompressionLayer,
|
||||
cors::{Any, CorsLayer},
|
||||
trace::TraceLayer,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
api::{auth, health, tokens, users, version},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
/// Build the full Axum application router.
|
||||
pub fn build(state: AppState) -> Router {
|
||||
let api_v1 = Router::new()
|
||||
// Auth
|
||||
.route("/auth/login", post(auth::login))
|
||||
.route("/auth/logout", post(auth::logout))
|
||||
.route("/auth/me", get(auth::me))
|
||||
// Users
|
||||
.route("/users", get(users::list_users).post(users::create_user))
|
||||
.route(
|
||||
"/users/{id}",
|
||||
get(users::get_user)
|
||||
.patch(users::update_user)
|
||||
.delete(users::delete_user),
|
||||
)
|
||||
// Tokens
|
||||
.route(
|
||||
"/tokens",
|
||||
get(tokens::list_tokens).post(tokens::create_token),
|
||||
)
|
||||
.route("/tokens/{id}", delete(tokens::revoke_token));
|
||||
|
||||
Router::new()
|
||||
.route("/health", get(health::health))
|
||||
.route("/version", get(version::version))
|
||||
.nest("/api/v1", api_v1)
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.layer(CompressionLayer::new())
|
||||
.layer(
|
||||
CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any),
|
||||
)
|
||||
.with_state(state)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{
|
||||
db::models::ApiToken,
|
||||
db::repository::tokens as token_repo,
|
||||
error::{AppError, Result},
|
||||
middleware::{audit::AuditContext, auth::AuthUser, permissions::require},
|
||||
security::tokens as token_security,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
// ── Response type ─────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct TokenResponse {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub last_used_at: Option<String>,
|
||||
pub expires_at: Option<String>,
|
||||
pub created_at: String,
|
||||
pub revoked: bool,
|
||||
}
|
||||
|
||||
impl From<ApiToken> for TokenResponse {
|
||||
fn from(t: ApiToken) -> Self {
|
||||
Self {
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
last_used_at: t.last_used_at,
|
||||
expires_at: t.expires_at,
|
||||
created_at: t.created_at,
|
||||
revoked: t.revoked,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── POST /api/v1/tokens ───────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateTokenRequest {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Create a personal access token for the authenticated user.
|
||||
///
|
||||
/// The raw token is returned **once** in this response and never stored.
|
||||
pub async fn create_token(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
ctx: AuditContext,
|
||||
Json(body): Json<CreateTokenRequest>,
|
||||
) -> Result<Json<Value>> {
|
||||
if body.name.trim().is_empty() {
|
||||
return Err(AppError::InvalidInput("token name cannot be empty".into()));
|
||||
}
|
||||
|
||||
let (token, raw) = token_security::create_token(
|
||||
&state.pool,
|
||||
&auth.user.id,
|
||||
&body.name,
|
||||
&state.config.security,
|
||||
Some(&auth.user.id),
|
||||
ctx.ip_address.as_deref(),
|
||||
ctx.user_agent.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
tracing::info!(
|
||||
event = "token_created",
|
||||
user_id = %auth.user.id,
|
||||
token_id = %token.id,
|
||||
name = %token.name,
|
||||
);
|
||||
|
||||
Ok(Json(json!({
|
||||
"token": TokenResponse::from(token),
|
||||
"raw_token": raw,
|
||||
"warning": "Store this token securely — it will not be shown again.",
|
||||
})))
|
||||
}
|
||||
|
||||
// ── GET /api/v1/tokens ────────────────────────────────────────────────────────
|
||||
|
||||
/// List the authenticated user's own tokens.
|
||||
pub async fn list_tokens(State(state): State<AppState>, auth: AuthUser) -> Result<Json<Value>> {
|
||||
let tokens = token_repo::list_for_user(&state.pool, &auth.user.id)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let views: Vec<TokenResponse> = tokens.into_iter().map(TokenResponse::from).collect();
|
||||
Ok(Json(json!({ "tokens": views })))
|
||||
}
|
||||
|
||||
// ── DELETE /api/v1/tokens/:id ────────────────────────────────────────────────
|
||||
|
||||
/// Revoke a token. The caller must own the token or hold `tokens:revoke`.
|
||||
pub async fn revoke_token(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
ctx: AuditContext,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<Value>> {
|
||||
let token = token_repo::find_by_id(&state.pool, &id)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
|
||||
// Must be owner or have tokens:revoke permission
|
||||
if token.user_id != auth.user.id {
|
||||
require(&state.pool, &auth.user.id, "tokens:revoke").await?;
|
||||
}
|
||||
|
||||
token_security::revoke_token(
|
||||
&state.pool,
|
||||
&id,
|
||||
Some(&auth.user.id),
|
||||
ctx.ip_address.as_deref(),
|
||||
ctx.user_agent.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({ "success": true })))
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{
|
||||
db::models::Tenant,
|
||||
db::models::{User, UserStatus},
|
||||
error::{AppError, Result},
|
||||
identity::users as identity,
|
||||
middleware::{audit::AuditContext, auth::AuthUser, permissions::require},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
// ── Response type ─────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct UserResponse {
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
pub status: String,
|
||||
pub last_login_at: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
impl From<User> for UserResponse {
|
||||
fn from(u: User) -> Self {
|
||||
Self {
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
status: UserStatus::from_i32(u.status).to_string(),
|
||||
last_login_at: u.last_login_at,
|
||||
created_at: u.created_at,
|
||||
updated_at: u.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── GET /api/v1/users ─────────────────────────────────────────────────────────
|
||||
|
||||
pub async fn list_users(State(state): State<AppState>, auth: AuthUser) -> Result<Json<Value>> {
|
||||
require(&state.pool, &auth.user.id, "users:create").await?;
|
||||
|
||||
let users = identity::list_users(&state.pool, Tenant::DEFAULT_ID).await?;
|
||||
let views: Vec<UserResponse> = users.into_iter().map(UserResponse::from).collect();
|
||||
Ok(Json(json!({ "users": views })))
|
||||
}
|
||||
|
||||
// ── POST /api/v1/users ────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateUserRequest {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
pub async fn create_user(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
ctx: AuditContext,
|
||||
Json(body): Json<CreateUserRequest>,
|
||||
) -> Result<Json<Value>> {
|
||||
require(&state.pool, &auth.user.id, "users:create").await?;
|
||||
|
||||
let user = identity::create_user(
|
||||
&state.pool,
|
||||
&state.config.security,
|
||||
Tenant::DEFAULT_ID,
|
||||
&body.username,
|
||||
&body.password,
|
||||
Some(&auth.user.id),
|
||||
ctx.ip_address.as_deref(),
|
||||
ctx.user_agent.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({ "user": UserResponse::from(user) })))
|
||||
}
|
||||
|
||||
// ── GET /api/v1/users/:id ─────────────────────────────────────────────────────
|
||||
|
||||
pub async fn get_user(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<Value>> {
|
||||
// Users may view themselves; admins may view anyone
|
||||
if id != auth.user.id {
|
||||
require(&state.pool, &auth.user.id, "users:create").await?;
|
||||
}
|
||||
|
||||
let user = identity::get_user(&state.pool, &id).await?;
|
||||
Ok(Json(json!({ "user": UserResponse::from(user) })))
|
||||
}
|
||||
|
||||
// ── PATCH /api/v1/users/:id ───────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateUserRequest {
|
||||
pub status: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn update_user(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
ctx: AuditContext,
|
||||
Path(id): Path<String>,
|
||||
Json(body): Json<UpdateUserRequest>,
|
||||
) -> Result<Json<Value>> {
|
||||
require(&state.pool, &auth.user.id, "users:update").await?;
|
||||
|
||||
if let Some(status_str) = &body.status {
|
||||
let status = match status_str.as_str() {
|
||||
"active" => UserStatus::Active as i32,
|
||||
"disabled" => UserStatus::Disabled as i32,
|
||||
"locked" => UserStatus::Locked as i32,
|
||||
other => return Err(AppError::InvalidInput(format!("unknown status: {other}"))),
|
||||
};
|
||||
identity::update_status(
|
||||
&state.pool,
|
||||
&id,
|
||||
status,
|
||||
Some(&auth.user.id),
|
||||
ctx.ip_address.as_deref(),
|
||||
ctx.user_agent.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let user = identity::get_user(&state.pool, &id).await?;
|
||||
Ok(Json(json!({ "user": UserResponse::from(user) })))
|
||||
}
|
||||
|
||||
// ── DELETE /api/v1/users/:id ──────────────────────────────────────────────────
|
||||
|
||||
/// Soft-deletes a user by setting status = Disabled. Never hard-deletes.
|
||||
pub async fn delete_user(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
ctx: AuditContext,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<Value>> {
|
||||
require(&state.pool, &auth.user.id, "users:delete").await?;
|
||||
|
||||
// Prevent self-deletion
|
||||
if id == auth.user.id {
|
||||
return Err(AppError::InvalidInput(
|
||||
"cannot disable your own account".into(),
|
||||
));
|
||||
}
|
||||
|
||||
identity::update_status(
|
||||
&state.pool,
|
||||
&id,
|
||||
UserStatus::Disabled as i32,
|
||||
Some(&auth.user.id),
|
||||
ctx.ip_address.as_deref(),
|
||||
ctx.user_agent.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({ "success": true })))
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
use axum::Json;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// GET /version
|
||||
///
|
||||
/// Returns build metadata baked in at compile time via `build.rs`.
|
||||
pub async fn version() -> Json<Value> {
|
||||
Json(json!({
|
||||
"name": env!("CARGO_PKG_NAME"),
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"git_commit": env!("GIT_COMMIT"),
|
||||
"build_date": env!("BUILD_DATE"),
|
||||
"rust_version": env!("RUST_VERSION"),
|
||||
}))
|
||||
}
|
||||
Reference in new issue
Block a user