feat: complete Phase 0 Enterprise IAM

This commit is contained in:
thakares committed 2026-07-21 15:26:16 +05:30
1 parent 3d2d291006
commit c2f5ba3f54
202 files changed
+21371 -1360

No files matched your search

+123
View File
@@ -0,0 +1,123 @@
use axum::{
Json,
extract::{Path, State},
};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::{
db::models::{Application, Tenant},
error::Result,
identity::applications as identity,
middleware::{auth::AuthUser, permissions::require},
state::AppState,
};
#[derive(Serialize)]
pub struct ApplicationResponse {
pub id: String,
pub name: String,
pub slug: String,
/// Client ID — currently the application slug (OAuth2-ready).
pub client_id: String,
pub enabled: bool,
pub redirect_urls: Vec<String>,
pub scopes: Vec<String>,
pub created_at: String,
pub updated_at: String,
}
impl From<Application> for ApplicationResponse {
fn from(a: Application) -> Self {
Self {
id: a.id,
name: a.name,
client_id: a.slug.clone().unwrap_or_default(),
slug: a.slug.unwrap_or_default(),
enabled: a.enabled,
// Placeholder until OAuth2 tables land
redirect_urls: Vec::new(),
scopes: Vec::new(),
created_at: a.created_at,
updated_at: a.updated_at,
}
}
}
/// GET /api/v1/applications
pub async fn list_applications(
State(state): State<AppState>,
auth: AuthUser,
) -> Result<Json<Value>> {
// Any authenticated user can see registered apps; mutations need roles:manage
let apps = identity::list(&state.provider, Tenant::DEFAULT_ID).await?;
let views: Vec<ApplicationResponse> = apps.into_iter().map(ApplicationResponse::from).collect();
let _ = auth;
Ok(Json(json!({ "applications": views })))
}
#[derive(Debug, Deserialize)]
pub struct CreateApplicationRequest {
pub name: String,
pub slug: String,
}
/// POST /api/v1/applications
pub async fn create_application(
State(state): State<AppState>,
auth: AuthUser,
Json(body): Json<CreateApplicationRequest>,
) -> Result<Json<Value>> {
require(&state.provider, &auth.user.id, "roles:manage").await?;
let app = identity::create(&state.provider, Tenant::DEFAULT_ID, &body.name, &body.slug).await?;
Ok(Json(
json!({ "application": ApplicationResponse::from(app) }),
))
}
/// GET /api/v1/applications/:id
pub async fn get_application(
State(state): State<AppState>,
auth: AuthUser,
Path(id): Path<String>,
) -> Result<Json<Value>> {
let _ = auth;
let app = identity::get(&state.provider, &id).await?;
Ok(Json(
json!({ "application": ApplicationResponse::from(app) }),
))
}
#[derive(Debug, Deserialize)]
pub struct UpdateApplicationRequest {
pub name: String,
pub slug: String,
pub enabled: bool,
}
/// PATCH /api/v1/applications/:id
pub async fn update_application(
State(state): State<AppState>,
auth: AuthUser,
Path(id): Path<String>,
Json(body): Json<UpdateApplicationRequest>,
) -> Result<Json<Value>> {
require(&state.provider, &auth.user.id, "roles:manage").await?;
let app = identity::update(&state.provider, &id, &body.name, &body.slug, body.enabled).await?;
Ok(Json(
json!({ "application": ApplicationResponse::from(app) }),
))
}
/// DELETE /api/v1/applications/:id
pub async fn delete_application(
State(state): State<AppState>,
auth: AuthUser,
Path(id): Path<String>,
) -> Result<Json<Value>> {
require(&state.provider, &auth.user.id, "roles:manage").await?;
identity::delete(&state.provider, &id).await?;
Ok(Json(json!({ "success": true })))
}
+116
View File
@@ -0,0 +1,116 @@
use axum::{
Json,
extract::{Query, State},
};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::{
db::models::AuditLog,
db::repository::audit::{self as audit_repo, AuditFilter},
error::{AppError, Result},
middleware::{auth::AuthUser, permissions::require},
state::AppState,
};
#[derive(Serialize)]
pub struct AuditLogResponse {
pub id: String,
pub actor_user_id: Option<String>,
pub target_user_id: Option<String>,
pub action: String,
pub resource_type: String,
pub resource_id: Option<String>,
pub severity: String,
pub ip_address: Option<String>,
pub user_agent: Option<String>,
pub metadata_json: Option<String>,
pub created_at: String,
/// Convenience flag for success/failure filters in the UI.
pub success: bool,
}
impl From<AuditLog> for AuditLogResponse {
fn from(a: AuditLog) -> Self {
let success =
!a.action.contains("fail") && !a.action.contains("denied") && a.severity != "critical";
Self {
id: a.id,
actor_user_id: a.actor_user_id,
target_user_id: a.target_user_id,
action: a.action,
resource_type: a.resource_type,
resource_id: a.resource_id,
severity: a.severity,
ip_address: a.ip_address,
user_agent: a.user_agent,
metadata_json: a.metadata_json,
created_at: a.created_at,
success,
}
}
}
#[derive(Debug, Deserialize)]
pub struct AuditQuery {
pub actor: Option<String>,
pub action: Option<String>,
pub resource_type: Option<String>,
pub severity: Option<String>,
pub since: Option<String>,
pub until: Option<String>,
pub q: Option<String>,
pub success: Option<bool>,
pub limit: Option<i64>,
pub offset: Option<i64>,
}
/// GET /api/v1/audit
pub async fn list_audit(
State(state): State<AppState>,
auth: AuthUser,
Query(query): Query<AuditQuery>,
) -> Result<Json<Value>> {
require(&state.provider, &auth.user.id, "audit:view").await?;
let limit = query.limit.unwrap_or(50).clamp(1, 500);
let offset = query.offset.unwrap_or(0).max(0);
let filter = AuditFilter {
actor_user_id: query.actor,
action: query.action,
resource_type: query.resource_type,
severity: query.severity,
since: query.since,
until: query.until,
search: query.q,
limit,
offset,
};
let total = audit_repo::count_filtered(&state.provider, &filter)
.await
.map_err(AppError::Database)?;
let mut entries = audit_repo::list_filtered(&state.provider, &filter)
.await
.map_err(AppError::Database)?;
if let Some(success) = query.success {
entries.retain(|e| {
let ok = !e.action.contains("fail")
&& !e.action.contains("denied")
&& e.severity != "critical";
ok == success
});
}
let views: Vec<AuditLogResponse> = entries.into_iter().map(AuditLogResponse::from).collect();
Ok(Json(json!({
"entries": views,
"total": total,
"limit": limit,
"offset": offset,
})))
}
+194 -82
View File
@@ -1,14 +1,19 @@
use crate::db::repository::traits::AuditRepositoryExt;
// Authentication endpoints.
//
// Login is POST-only with a JSON body. Credentials must never appear in
// query strings, path segments, or server access logs of request URIs.
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},
audit::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,
@@ -16,30 +21,64 @@ use crate::{
// ── Login ─────────────────────────────────────────────────────────────────────
/// Login request body. Deserialized from JSON only (never from query params).
#[derive(Debug, Deserialize)]
pub struct LoginRequest {
pub username: String,
pub password: String,
}
#[derive(Debug, Serialize)]
pub struct LoginUserView {
pub id: String,
pub username: String,
pub status: String,
pub last_login_at: Option<String>,
pub created_at: String,
pub roles: Vec<String>,
pub permissions: Vec<String>,
}
#[derive(Debug, Serialize)]
pub struct LoginResponse {
/// Opaque access token (session). Send as `Authorization: Bearer …`.
pub access_token: String,
/// Opaque refresh token. Longer-lived; used to obtain a new access token.
pub refresh_token: String,
/// Access token lifetime in seconds (idle TTL).
pub expires_in: u64,
pub token_type: &'static str,
pub user: LoginUserView,
}
/// POST /api/v1/auth/login
///
/// Accepts JSON `{ "username", "password" }` only. No GET handler exists.
pub async fn login(
State(state): State<AppState>,
ctx: AuditContext,
jar: CookieJar,
Json(body): Json<LoginRequest>,
) -> Result<(CookieJar, Json<Value>)> {
) -> Result<(CookieJar, Json<LoginResponse>)> {
let ip = ctx.ip_address.as_deref();
// Rate limit check
// Reject empty credentials early without revealing which field failed.
if body.username.trim().is_empty() || body.password.is_empty() {
return Err(AppError::InvalidCredentials);
}
// Rate limit check (per IP)
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)
// Look up user — always run comparable work on failure paths (timing).
let user_opt = state
.provider
.users()
.find_by_username(body.username.trim())
.await
.map_err(AppError::Database)?;
@@ -47,27 +86,33 @@ pub async fn login(
let mut final_user = None;
if let Some(user) = user_opt {
// Constant-time Argon2id verify (argon2 crate).
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
// Dummy verify to reduce username enumeration via timing.
passwords::verify_dummy(&state.config.security)?;
}
// Zeroize is best-effort; String drop is immediate after this function.
// Do not log body.password anywhere.
let _ = &body.password;
if !is_authed {
record_login_failure(&state, &body.username, ip, ctx.user_agent.as_deref()).await;
record_login_failure(&state, body.username.trim(), 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);
// Non-enumerating error for both unknown user and bad password.
return Err(AppError::InvalidCredentials);
}
let user = final_user.unwrap();
let user = final_user.expect("authenticated user");
// Clear rate limit on success
if let Some(ip_str) = &ctx.ip_address {
@@ -76,37 +121,78 @@ pub async fn login(
}
}
// 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,
},
)
// Session fixation mitigation: revoke prior sessions + refresh tokens.
let _ = state
.provider
.sessions()
.revoke_all_for_user(&user.id)
.await;
let _ = state
.provider
.refresh_tokens()
.revoke_all_for_user(&user.id)
.await;
let _ = tx.commit().await;
}
// Create new session (new ID + new token) — rotation on every login.
let session_id = uuid::Uuid::new_v4().to_string();
let access_token = crate::security::sessions::generate_session_token();
let token_hash = crate::security::sessions::hash_session_token(&access_token);
let ttl_mins = (state.config.security.session_ttl_hours * 60) as i64;
let expires = chrono::Utc::now() + chrono::Duration::minutes(ttl_mins);
let expires_str = expires.format("%Y-%m-%dT%H:%M:%SZ").to_string();
let session = state
.provider
.sessions()
.create(
&session_id,
&user.id,
&token_hash,
ip,
ctx.user_agent.as_deref(),
&expires_str,
)
.await
.map_err(AppError::Database)?;
// Refresh token (opaque, BLAKE3-hashed at rest). Longer absolute lifetime.
let refresh_raw = sessions::generate_session_token();
let refresh_hash = sessions::hash_session_token(&refresh_raw);
let refresh_id = uuid::Uuid::new_v4().to_string();
let refresh_ttl_days = state.config.security.session_absolute_ttl_days.max(1) as i64;
let refresh_expires = chrono::Utc::now() + chrono::Duration::days(refresh_ttl_days);
let refresh_expires_str = refresh_expires.format("%Y-%m-%dT%H:%M:%SZ").to_string();
state
.provider
.refresh_tokens()
.create(&refresh_id, &user.id, &refresh_hash, &refresh_expires_str)
.await
.map_err(AppError::Database)?;
let user_roles = state.provider.roles().list_for_user(&user.id).await?;
let user_perms = state.provider.permissions().list_for_user(&user.id).await?;
let role_names: Vec<String> = user_roles.into_iter().map(|r| r.name).collect();
// Update last_login_at and audit (never log password / tokens).
let _ = state.provider.users().set_last_login(&user.id).await;
let _ = state
.provider
.audit()
.log(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;
// Structured log: identity + outcome only (no secrets).
tracing::info!(
event = "login_success",
user_id = %user.id,
@@ -114,16 +200,33 @@ pub async fn login(
ip = ip.unwrap_or("unknown"),
);
// Build secure session cookie using time::Duration for max_age
let expires_in = (state.config.security.session_ttl_hours as u64).saturating_mul(3600);
let max_age_secs = state.config.security.session_absolute_ttl_days as i64 * 86400;
let mut cookie = Cookie::new(sessions::SESSION_COOKIE, raw_token);
let mut cookie = Cookie::new(sessions::SESSION_COOKIE, access_token.clone());
cookie.set_http_only(true);
cookie.set_secure(true);
cookie.set_secure(state.config.server.cookie_secure);
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 }))))
let response = LoginResponse {
access_token,
refresh_token: refresh_raw,
expires_in,
token_type: "Bearer",
user: LoginUserView {
id: user.id.clone(),
username: user.username.clone(),
status: user.status().to_string(),
last_login_at: user.last_login_at.clone(),
created_at: user.created_at.clone(),
roles: role_names,
permissions: user_perms,
},
};
Ok((jar.add(cookie), Json(response)))
}
async fn record_login_failure(
@@ -132,24 +235,26 @@ async fn record_login_failure(
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)),
},
)
// Audit: username + outcome only — never password.
let metadata = format!(
r#"{{"username":{}}}"#,
serde_json::to_string(username).unwrap_or_else(|_| "\"\"".into())
);
let _ = state
.provider
.audit()
.log(AuditEvent {
actor_id: None,
target_id: None,
action: "login_failed",
resource_type: "session",
resource_id: None,
severity: AuditSeverity::Warning,
ip,
ua,
metadata: Some(&metadata),
})
.await;
let _ = tx.commit().await;
}
tracing::warn!(
event = "login_failed",
@@ -167,29 +272,32 @@ pub async fn logout(
jar: CookieJar,
) -> Result<(CookieJar, Json<Value>)> {
if let Some(session_id) = &auth.session_id {
sessions::revoke_session(&state.pool, session_id).await?;
state.provider.sessions().revoke(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,
},
)
let _ = state
.provider
.audit()
.log(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;
}
}
// Revoke refresh tokens for this user on logout (full session end).
let _ = state
.provider
.refresh_tokens()
.revoke_all_for_user(&auth.user.id)
.await;
let mut removal = Cookie::from(sessions::SESSION_COOKIE);
removal.set_path("/");
let removed = jar.remove(removal);
@@ -216,8 +324,12 @@ pub struct UserView {
/// 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?;
let user_roles = state.provider.roles().list_for_user(&auth.user.id).await?;
let user_perms = state
.provider
.permissions()
.list_for_user(&auth.user.id)
.await?;
Ok(Json(MeResponse {
user: UserView {
+228
View File
@@ -0,0 +1,228 @@
use axum::{Json, extract::State};
use serde_json::{Value, json};
use crate::{
db::models::{Tenant, UserStatus},
error::{AppError, Result},
identity::permissions as identity_perms,
middleware::auth::AuthUser,
state::AppState,
};
/// GET /api/v1/dashboard
///
/// Returns a role-aware dashboard payload. Admins get system summary cards;
/// all users get personal overview data.
pub async fn dashboard(State(state): State<AppState>, auth: AuthUser) -> Result<Json<Value>> {
let roles = state.provider.roles().list_for_user(&auth.user.id).await?;
let permissions = identity_perms::list_user_permissions(&state.provider, &auth.user.id).await?;
let is_admin = roles.iter().any(|r| r.name == "admin")
|| permissions
.iter()
.any(|p| p == "roles:manage" || p == "audit:view");
// Personal data
let sessions = state
.provider
.sessions()
.list_active_for_user(&auth.user.id)
.await
.map_err(AppError::Database)?;
let session_views: Vec<Value> = sessions
.into_iter()
.map(|s| {
json!({
"id": s.id,
"ip_address": s.ip_address,
"user_agent": s.user_agent,
"created_at": s.created_at,
"last_seen_at": s.last_seen_at,
"expires_at": s.expires_at,
})
})
.collect();
let tokens = state
.provider
.tokens()
.list_for_user(&auth.user.id)
.await
.map_err(AppError::Database)?;
let token_views: Vec<Value> = tokens
.into_iter()
.filter(|t| !t.revoked)
.take(10)
.map(|t| {
json!({
"id": t.id,
"name": t.name,
"expires_at": t.expires_at,
"created_at": t.created_at,
"last_used_at": t.last_used_at,
})
})
.collect();
let apps = state
.provider
.applications()
.list(Tenant::DEFAULT_ID)
.await
.map_err(AppError::Database)?;
let app_views: Vec<Value> = apps
.into_iter()
.filter(|a| a.enabled)
.map(|a| {
json!({
"id": a.id,
"name": a.name,
"slug": a.slug,
})
})
.collect();
let recent_personal = state
.provider
.audit()
.list_filtered(&crate::db::repository::audit::AuditFilter {
actor_user_id: Some(auth.user.id.clone()),
limit: 10,
..Default::default()
})
.await
.map_err(AppError::Database)?;
let personal = json!({
"user": {
"id": auth.user.id,
"username": auth.user.username,
"status": auth.user.status().to_string(),
"last_login_at": auth.user.last_login_at,
"created_at": auth.user.created_at,
},
"roles": roles.iter().map(|r| &r.name).collect::<Vec<_>>(),
"permissions": permissions,
"sessions": session_views,
"tokens": token_views,
"applications": app_views,
"recent_audit": recent_personal,
});
let mut payload = json!({
"personal": personal,
"is_admin": is_admin,
});
if is_admin {
let total_users = state
.provider
.users()
.count(Tenant::DEFAULT_ID)
.await
.map_err(AppError::Database)?;
let active_users = state
.provider
.users()
.count_by_status(Tenant::DEFAULT_ID, UserStatus::Active as i32)
.await
.map_err(AppError::Database)?;
let active_sessions = state
.provider
.sessions()
.count_active()
.await
.map_err(AppError::Database)?;
let roles_count = state
.provider
.roles()
.list_all()
.await
.map_err(AppError::Database)?
.len();
let perms_count = state
.provider
.permissions()
.list_all()
.await
.map_err(AppError::Database)?
.len();
let apps_count = state
.provider
.applications()
.count(Tenant::DEFAULT_ID)
.await
.map_err(AppError::Database)?;
let sa_count = state
.provider
.service_accounts()
.count(Tenant::DEFAULT_ID)
.await
.map_err(AppError::Database)?;
let audit_count = state
.provider
.audit()
.count()
.await
.map_err(AppError::Database)?;
let recent_audit = state
.provider
.audit()
.list_recent(15)
.await
.map_err(AppError::Database)?;
let recent_logins = state
.provider
.audit()
.list_filtered(&crate::db::repository::audit::AuditFilter {
action: Some("login_success".into()),
limit: 10,
..Default::default()
})
.await
.map_err(AppError::Database)?;
let recent_users = state
.provider
.users()
.list(Tenant::DEFAULT_ID)
.await
.map_err(AppError::Database)?;
let recent_users: Vec<Value> = recent_users
.into_iter()
.take(10)
.map(|u| {
json!({
"id": u.id,
"username": u.username,
"status": u.status().to_string(),
"created_at": u.created_at,
})
})
.collect();
payload["admin"] = json!({
"summary": {
"total_users": total_users,
"active_users": active_users,
"active_sessions": active_sessions,
"roles": roles_count,
"permissions": perms_count,
"applications": apps_count,
"service_accounts": sa_count,
"audit_events": audit_count,
},
"recent_logins": recent_logins,
"recent_audit": recent_audit,
"recent_users": recent_users,
"system_health": {
"status": "ok",
"database": "connected",
"note": "Placeholder — full health probes in a future release",
},
});
}
Ok(Json(payload))
}
+350
View File
@@ -0,0 +1,350 @@
use axum::{
Json,
extract::{Path, State},
};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use uuid::Uuid;
use crate::{
audit::AuditEvent,
db::models::{AuditSeverity, Tenant},
db::repository::traits::AuditRepositoryExt,
error::{AppError, Result},
middleware::{auth::AuthUser, permissions::require},
state::AppState,
};
#[derive(Serialize)]
pub struct GroupView {
pub id: String,
pub name: String,
pub description: Option<String>,
pub created_at: String,
pub member_count: i64,
}
#[derive(Deserialize)]
pub struct CreateGroupRequest {
pub name: String,
pub description: Option<String>,
}
#[derive(Deserialize)]
pub struct UpdateGroupRequest {
pub name: String,
pub description: Option<String>,
}
pub async fn list_groups(State(state): State<AppState>, _auth: AuthUser) -> Result<Json<Value>> {
let groups = state
.provider
.groups()
.list(Tenant::DEFAULT_ID)
.await
.map_err(AppError::Database)?;
let mut views = Vec::new();
for group in groups {
let member_count = state
.provider
.groups()
.count_members(&group.id)
.await
.unwrap_or(0);
views.push(GroupView {
id: group.id,
name: group.name,
description: group.description,
created_at: group.created_at,
member_count,
});
}
Ok(Json(json!({ "groups": views })))
}
pub async fn get_group(
State(state): State<AppState>,
_auth: AuthUser,
Path(id): Path<String>,
) -> Result<Json<Value>> {
let group = state
.provider
.groups()
.find_by_id(&id)
.await
.map_err(AppError::Database)?
.ok_or(AppError::NotFound)?;
let members = state
.provider
.groups()
.list_members(&id)
.await
.map_err(AppError::Database)?;
#[derive(Serialize)]
struct MemberView {
id: String,
username: String,
status: String,
}
let member_views: Vec<MemberView> = members
.into_iter()
.map(|u| MemberView {
id: u.id,
username: u.username,
status: if u.status == 1 {
"active".to_string()
} else {
"disabled".to_string()
},
})
.collect();
Ok(Json(json!({
"group": group,
"members": member_views
})))
}
pub async fn create_group(
State(state): State<AppState>,
auth: AuthUser,
Json(req): Json<CreateGroupRequest>,
) -> Result<Json<Value>> {
require(&state.provider, &auth.user.id, "roles:manage").await?;
let id = Uuid::new_v4().to_string();
let group = state
.provider
.groups()
.create(
&id,
Tenant::DEFAULT_ID,
&req.name,
req.description.as_deref(),
)
.await
.map_err(AppError::Database)?;
state
.provider
.audit()
.log(AuditEvent {
actor_id: Some(&auth.user.id),
target_id: None,
action: "group.create",
resource_type: "group",
resource_id: Some(&id),
severity: AuditSeverity::Info,
ip: None,
ua: None,
metadata: None,
})
.await
.map_err(|e| {
tracing::warn!("Failed to write audit log: {}", e);
AppError::Database(e)
})?;
Ok(Json(json!({ "group": group })))
}
pub async fn update_group(
State(state): State<AppState>,
auth: AuthUser,
Path(id): Path<String>,
Json(req): Json<UpdateGroupRequest>,
) -> Result<Json<Value>> {
require(&state.provider, &auth.user.id, "roles:manage").await?;
let _ = state
.provider
.groups()
.find_by_id(&id)
.await
.map_err(AppError::Database)?
.ok_or(AppError::NotFound)?;
state
.provider
.groups()
.update(&id, &req.name, req.description.as_deref())
.await
.map_err(AppError::Database)?;
state
.provider
.audit()
.log(AuditEvent {
actor_id: Some(&auth.user.id),
target_id: None,
action: "group.update",
resource_type: "group",
resource_id: Some(&id),
severity: AuditSeverity::Info,
ip: None,
ua: None,
metadata: None,
})
.await
.map_err(|e| {
tracing::warn!("Failed to write audit log: {}", e);
AppError::Database(e)
})?;
let updated = state
.provider
.groups()
.find_by_id(&id)
.await
.map_err(AppError::Database)?
.ok_or(AppError::NotFound)?;
Ok(Json(json!({ "group": updated })))
}
pub async fn delete_group(
State(state): State<AppState>,
auth: AuthUser,
Path(id): Path<String>,
) -> Result<Json<Value>> {
require(&state.provider, &auth.user.id, "roles:manage").await?;
let _ = state
.provider
.groups()
.find_by_id(&id)
.await
.map_err(AppError::Database)?
.ok_or(AppError::NotFound)?;
state
.provider
.groups()
.delete(&id)
.await
.map_err(AppError::Database)?;
state
.provider
.audit()
.log(AuditEvent {
actor_id: Some(&auth.user.id),
target_id: None,
action: "group.delete",
resource_type: "group",
resource_id: Some(&id),
severity: AuditSeverity::Info,
ip: None,
ua: None,
metadata: None,
})
.await
.map_err(|e| {
tracing::warn!("Failed to write audit log: {}", e);
AppError::Database(e)
})?;
Ok(Json(json!({ "success": true })))
}
pub async fn add_member(
State(state): State<AppState>,
auth: AuthUser,
Path(id): Path<String>,
Json(req): Json<serde_json::Value>,
) -> Result<Json<Value>> {
require(&state.provider, &auth.user.id, "roles:manage").await?;
let _ = state
.provider
.groups()
.find_by_id(&id)
.await
.map_err(AppError::Database)?
.ok_or(AppError::NotFound)?;
let user_id = req
.get("user_id")
.and_then(|v| v.as_str())
.ok_or_else(|| AppError::InvalidInput("user_id is required".into()))?;
let _ = state
.provider
.users()
.find_by_id(user_id)
.await
.map_err(AppError::Database)?
.ok_or(AppError::NotFound)?;
state
.provider
.groups()
.add_member(&id, user_id)
.await
.map_err(AppError::Database)?;
state
.provider
.audit()
.log(AuditEvent {
actor_id: Some(&auth.user.id),
target_id: Some(user_id),
action: "group.member.add",
resource_type: "group",
resource_id: Some(&id),
severity: AuditSeverity::Info,
ip: None,
ua: None,
metadata: None,
})
.await
.map_err(|e| {
tracing::warn!("Failed to write audit log: {}", e);
AppError::Database(e)
})?;
Ok(Json(json!({ "success": true })))
}
pub async fn remove_member(
State(state): State<AppState>,
auth: AuthUser,
Path((id, uid)): Path<(String, String)>,
) -> Result<Json<Value>> {
require(&state.provider, &auth.user.id, "roles:manage").await?;
state
.provider
.groups()
.remove_member(&id, &uid)
.await
.map_err(AppError::Database)?;
state
.provider
.audit()
.log(AuditEvent {
actor_id: Some(&auth.user.id),
target_id: Some(&uid),
action: "group.member.remove",
resource_type: "group",
resource_id: Some(&id),
severity: AuditSeverity::Info,
ip: None,
ua: None,
metadata: None,
})
.await
.map_err(|e| {
tracing::warn!("Failed to write audit log: {}", e);
AppError::Database(e)
})?;
Ok(Json(json!({ "success": true })))
}
+11
View File
@@ -1,6 +1,17 @@
pub mod applications;
pub mod audit;
pub mod auth;
pub mod dashboard;
pub mod groups;
pub mod health;
pub mod permissions;
pub mod profile;
pub mod roles;
pub mod router;
pub mod service_accounts;
pub mod sessions;
pub mod tenants;
pub mod tokens;
pub mod ui;
pub mod users;
pub mod version;
+72
View File
@@ -0,0 +1,72 @@
use axum::{Json, extract::State};
use serde::Serialize;
use serde_json::{Value, json};
use std::collections::BTreeMap;
use crate::{
error::Result,
identity::permissions as identity_perms,
middleware::{auth::AuthUser, permissions::require},
state::AppState,
};
#[derive(Serialize)]
pub struct PermissionResponse {
pub id: String,
pub name: String,
pub description: Option<String>,
pub group: String,
}
/// GET /api/v1/permissions
pub async fn list_permissions(
State(state): State<AppState>,
auth: AuthUser,
) -> Result<Json<Value>> {
// Readable by anyone who can manage roles or audit
if require(&state.provider, &auth.user.id, "roles:manage")
.await
.is_err()
{
require(&state.provider, &auth.user.id, "audit:view").await?;
}
let perms = identity_perms::list_permissions(&state.provider).await?;
let views: Vec<PermissionResponse> = perms
.into_iter()
.map(|p| {
let group = p
.name
.split_once(':')
.map(|(g, _)| g.to_string())
.unwrap_or_else(|| "general".into());
PermissionResponse {
id: p.id,
name: p.name,
description: p.description,
group,
}
})
.collect();
// Also group for matrix view
let mut grouped: BTreeMap<String, Vec<&PermissionResponse>> = BTreeMap::new();
for p in &views {
grouped.entry(p.group.clone()).or_default().push(p);
}
let groups: Vec<Value> = grouped
.into_iter()
.map(|(group, items)| {
json!({
"group": group,
"permissions": items,
})
})
.collect();
Ok(Json(json!({
"permissions": views,
"groups": groups,
})))
}
+140
View File
@@ -0,0 +1,140 @@
use crate::db::repository::traits::AuditRepositoryExt;
use axum::{Json, extract::State};
use serde::Deserialize;
use serde_json::{Value, json};
use crate::{
error::{AppError, Result},
identity::users as identity_users,
middleware::{audit::AuditContext, auth::AuthUser},
security::passwords,
state::AppState,
};
/// GET /api/v1/profile
pub async fn get_profile(State(state): State<AppState>, auth: AuthUser) -> Result<Json<Value>> {
let profile = state
.provider
.users()
.get_profile(&auth.user.id)
.await
.map_err(AppError::Database)?;
let user_roles = state.provider.roles().list_for_user(&auth.user.id).await?;
let sessions = state
.provider
.sessions()
.list_active_for_user(&auth.user.id)
.await
.map_err(AppError::Database)?;
Ok(Json(json!({
"user": {
"id": auth.user.id,
"username": auth.user.username,
"status": auth.user.status().to_string(),
"last_login_at": auth.user.last_login_at,
"created_at": auth.user.created_at,
},
"profile": {
"email": profile.as_ref().and_then(|p| p.email.clone()),
"full_name": profile.as_ref().and_then(|p| p.full_name.clone()),
"avatar_url": profile.as_ref().and_then(|p| p.avatar_url.clone()),
},
"roles": user_roles.into_iter().map(|r| r.name).collect::<Vec<_>>(),
"sessions": sessions.into_iter().map(|s| json!({
"id": s.id,
"ip_address": s.ip_address,
"user_agent": s.user_agent,
"created_at": s.created_at,
"last_seen_at": s.last_seen_at,
"expires_at": s.expires_at,
})).collect::<Vec<_>>(),
"placeholders": {
"avatar": "coming_soon",
"mfa": "coming_soon",
"recovery_codes": "coming_soon",
},
})))
}
#[derive(Debug, Deserialize)]
pub struct UpdateProfileRequest {
pub email: Option<String>,
pub full_name: Option<String>,
}
/// PATCH /api/v1/profile
pub async fn update_profile(
State(state): State<AppState>,
auth: AuthUser,
ctx: AuditContext,
Json(body): Json<UpdateProfileRequest>,
) -> Result<Json<Value>> {
let profile = state
.provider
.users()
.upsert_profile(
&auth.user.id,
body.email.as_deref(),
body.full_name.as_deref(),
)
.await
.map_err(AppError::Database)?;
state
.provider
.audit()
.log(crate::audit::AuditEvent {
actor_id: Some(&auth.user.id),
target_id: Some(&auth.user.id),
action: "profile_updated",
resource_type: "user",
resource_id: Some(&auth.user.id),
severity: crate::db::models::AuditSeverity::Info,
ip: ctx.ip_address.as_deref(),
ua: ctx.user_agent.as_deref(),
metadata: None,
})
.await?;
Ok(Json(json!({
"profile": {
"email": profile.email,
"full_name": profile.full_name,
"avatar_url": profile.avatar_url,
}
})))
}
#[derive(Debug, Deserialize)]
pub struct ChangePasswordRequest {
pub current_password: String,
pub new_password: String,
}
/// POST /api/v1/profile/password
pub async fn change_password(
State(state): State<AppState>,
auth: AuthUser,
ctx: AuditContext,
Json(body): Json<ChangePasswordRequest>,
) -> Result<Json<Value>> {
// Verify current password
let ok = passwords::verify_password(&body.current_password, &auth.user.password_hash)?;
if !ok {
return Err(AppError::Unauthorized);
}
identity_users::reset_password(
&state.provider,
&state.config.security,
&auth.user.id,
&body.new_password,
Some(&auth.user.id),
ctx.ip_address.as_deref(),
ctx.user_agent.as_deref(),
)
.await?;
Ok(Json(json!({ "success": true })))
}
+265
View File
@@ -0,0 +1,265 @@
use axum::{
Json,
extract::{Path, State},
};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::{
db::models::Role,
error::{AppError, Result},
identity::{permissions as identity_perms, roles as identity_roles},
middleware::{audit::AuditContext, auth::AuthUser, permissions::require},
state::AppState,
};
#[derive(Serialize)]
pub struct RoleResponse {
pub id: String,
pub name: String,
pub description: Option<String>,
pub permissions: Vec<String>,
pub user_count: usize,
}
impl RoleResponse {
async fn from_role(
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
role: Role,
) -> Result<Self> {
let perms = provider
.permissions()
.list_for_role(&role.id)
.await
.map_err(AppError::Database)?;
let user_ids = provider
.roles()
.list_user_ids_for_role(&role.id)
.await
.map_err(AppError::Database)?;
Ok(Self {
id: role.id,
name: role.name,
description: role.description,
permissions: perms.into_iter().map(|p| p.name).collect(),
user_count: user_ids.len(),
})
}
}
/// GET /api/v1/roles
pub async fn list_roles(State(state): State<AppState>, auth: AuthUser) -> Result<Json<Value>> {
require(&state.provider, &auth.user.id, "roles:manage").await?;
let roles = state.provider.roles().list_all().await?;
let mut views = Vec::with_capacity(roles.len());
for role in roles {
views.push(RoleResponse::from_role(&state.provider, role).await?);
}
Ok(Json(json!({ "roles": views })))
}
#[derive(Debug, Deserialize)]
pub struct CreateRoleRequest {
pub name: String,
pub description: Option<String>,
}
/// POST /api/v1/roles
pub async fn create_role(
State(state): State<AppState>,
auth: AuthUser,
ctx: AuditContext,
Json(body): Json<CreateRoleRequest>,
) -> Result<Json<Value>> {
require(&state.provider, &auth.user.id, "roles:manage").await?;
let role = identity_roles::create_role(
&state.provider,
&body.name,
body.description.as_deref(),
Some(&auth.user.id),
ctx.ip_address.as_deref(),
ctx.user_agent.as_deref(),
)
.await?;
Ok(Json(json!({
"role": RoleResponse::from_role(&state.provider, role).await?
})))
}
/// GET /api/v1/roles/:id
pub async fn get_role(
State(state): State<AppState>,
auth: AuthUser,
Path(id): Path<String>,
) -> Result<Json<Value>> {
require(&state.provider, &auth.user.id, "roles:manage").await?;
let role = identity_roles::get_role(&state.provider, &id).await?;
let user_ids = state
.provider
.roles()
.list_user_ids_for_role(&id)
.await
.map_err(AppError::Database)?;
let mut users = Vec::new();
for uid in user_ids {
if let Ok(Some(u)) = state.provider.users().find_by_id(&uid).await {
users.push(json!({
"id": u.id,
"username": u.username,
"status": u.status().to_string(),
}));
}
}
let view = RoleResponse::from_role(&state.provider, role).await?;
Ok(Json(json!({
"role": view,
"users": users,
})))
}
#[derive(Debug, Deserialize)]
pub struct UpdateRoleRequest {
pub name: String,
pub description: Option<String>,
}
/// PATCH /api/v1/roles/:id
pub async fn update_role(
State(state): State<AppState>,
auth: AuthUser,
ctx: AuditContext,
Path(id): Path<String>,
Json(body): Json<UpdateRoleRequest>,
) -> Result<Json<Value>> {
require(&state.provider, &auth.user.id, "roles:manage").await?;
let role = identity_roles::update_role(
&state.provider,
&id,
&body.name,
body.description.as_deref(),
Some(&auth.user.id),
ctx.ip_address.as_deref(),
ctx.user_agent.as_deref(),
)
.await?;
Ok(Json(json!({
"role": RoleResponse::from_role(&state.provider, role).await?
})))
}
/// DELETE /api/v1/roles/:id
pub async fn delete_role(
State(state): State<AppState>,
auth: AuthUser,
ctx: AuditContext,
Path(id): Path<String>,
) -> Result<Json<Value>> {
require(&state.provider, &auth.user.id, "roles:manage").await?;
identity_roles::delete_role(
&state.provider,
&id,
Some(&auth.user.id),
ctx.ip_address.as_deref(),
ctx.user_agent.as_deref(),
)
.await?;
Ok(Json(json!({ "success": true })))
}
#[derive(Debug, Deserialize)]
pub struct SetPermissionsRequest {
pub permissions: Vec<String>,
}
/// PUT /api/v1/roles/:id/permissions
pub async fn set_role_permissions(
State(state): State<AppState>,
auth: AuthUser,
ctx: AuditContext,
Path(id): Path<String>,
Json(body): Json<SetPermissionsRequest>,
) -> Result<Json<Value>> {
require(&state.provider, &auth.user.id, "roles:manage").await?;
let _ = identity_roles::get_role(&state.provider, &id).await?;
let perms = identity_perms::set_role_permissions(
&state.provider,
&id,
&body.permissions,
Some(&auth.user.id),
ctx.ip_address.as_deref(),
ctx.user_agent.as_deref(),
)
.await?;
Ok(Json(json!({
"permissions": perms.into_iter().map(|p| p.name).collect::<Vec<_>>(),
})))
}
#[derive(Debug, Deserialize)]
pub struct AssignRoleRequest {
pub role: String,
}
/// POST /api/v1/users/:id/roles
pub async fn assign_user_role(
State(state): State<AppState>,
auth: AuthUser,
ctx: AuditContext,
Path(user_id): Path<String>,
Json(body): Json<AssignRoleRequest>,
) -> Result<Json<Value>> {
require(&state.provider, &auth.user.id, "roles:manage").await?;
// Assign the role to the user (user_id, role_name)
identity_roles::assign_role(
&state.provider,
&user_id,
&body.role,
Some(&auth.user.id),
ctx.ip_address.as_deref(),
ctx.user_agent.as_deref(),
)
.await?;
let roles = state.provider.roles().list_for_user(&user_id).await?;
Ok(Json(json!({
"roles": roles.into_iter().map(|r| r.name).collect::<Vec<_>>(),
})))
}
/// DELETE /api/v1/users/:id/roles/:role
pub async fn remove_user_role(
State(state): State<AppState>,
auth: AuthUser,
ctx: AuditContext,
Path((user_id, role)): Path<(String, String)>,
) -> Result<Json<Value>> {
require(&state.provider, &auth.user.id, "roles:manage").await?;
identity_roles::remove_role(
&state.provider,
&user_id,
&role,
Some(&auth.user.id),
ctx.ip_address.as_deref(),
ctx.user_agent.as_deref(),
)
.await?;
let roles = state.provider.roles().list_for_user(&user_id).await?;
Ok(Json(json!({
"roles": roles.into_iter().map(|r| r.name).collect::<Vec<_>>(),
})))
}
+119 -14
View File
@@ -1,25 +1,45 @@
use axum::http::{HeaderName, Method, header};
use axum::{
Router,
routing::{delete, get, post},
};
use tower_http::{
compression::CompressionLayer,
cors::{Any, CorsLayer},
trace::TraceLayer,
Router, middleware,
routing::{delete, get, post, put},
};
use tower_http::{compression::CompressionLayer, cors::CorsLayer, trace::TraceLayer};
use crate::{
api::{auth, health, tokens, users, version},
api::{
applications, audit, auth, dashboard, groups, health, permissions, profile, roles,
service_accounts, sessions, tenants, tokens, ui, users, version,
},
middleware::security_headers::security_headers,
state::AppState,
};
/// Build the full Axum application router.
/// Build the full Axum application router (API + Dioxus UI shell).
pub fn build(state: AppState) -> Router {
let api_v1 = Router::new()
// Auth
// Auth — POST-only login (no GET credential endpoint exists).
.route("/auth/login", post(auth::login))
.route("/auth/logout", post(auth::logout))
.route("/auth/me", get(auth::me))
// Profile (self-service)
.route(
"/profile",
get(profile::get_profile).patch(profile::update_profile),
)
.route("/profile/password", post(profile::change_password))
// Dashboard
.route("/dashboard", get(dashboard::dashboard))
// Tenants
.route(
"/tenants",
get(tenants::list_tenants).post(tenants::create_tenant),
)
.route(
"/tenants/{id}",
get(tenants::get_tenant)
.patch(tenants::update_tenant)
.delete(tenants::delete_tenant),
)
// Users
.route("/users", get(users::list_users).post(users::create_user))
.route(
@@ -28,24 +48,109 @@ pub fn build(state: AppState) -> Router {
.patch(users::update_user)
.delete(users::delete_user),
)
.route("/users/{id}/reset-password", post(users::reset_password))
.route(
"/users/{id}/roles",
get(users::list_user_roles).post(roles::assign_user_role),
)
.route("/users/{id}/roles/{role}", delete(roles::remove_user_role))
// Roles
.route("/roles", get(roles::list_roles).post(roles::create_role))
.route(
"/roles/{id}",
get(roles::get_role)
.patch(roles::update_role)
.delete(roles::delete_role),
)
.route("/roles/{id}/permissions", put(roles::set_role_permissions))
// Permissions
.route("/permissions", get(permissions::list_permissions))
// Tokens
.route(
"/tokens",
get(tokens::list_tokens).post(tokens::create_token),
)
.route("/tokens/{id}", delete(tokens::revoke_token));
.route("/tokens/{id}", delete(tokens::revoke_token))
// Applications
.route(
"/applications",
get(applications::list_applications).post(applications::create_application),
)
.route(
"/applications/{id}",
get(applications::get_application)
.patch(applications::update_application)
.delete(applications::delete_application),
)
// Service accounts
.route(
"/service-accounts",
get(service_accounts::list_service_accounts)
.post(service_accounts::create_service_account),
)
.route(
"/service-accounts/{id}",
get(service_accounts::get_service_account)
.patch(service_accounts::update_service_account)
.delete(service_accounts::delete_service_account),
)
.route(
"/service-accounts/{id}/secret",
post(service_accounts::rotate_secret),
)
// Audit
.route("/audit", get(audit::list_audit))
// Sessions
.route("/sessions", get(sessions::list_sessions))
.route("/sessions/others", delete(sessions::terminate_others))
.route("/sessions/{id}", delete(sessions::terminate_session))
// Groups
.route(
"/groups",
get(groups::list_groups).post(groups::create_group),
)
.route(
"/groups/{id}",
get(groups::get_group)
.patch(groups::update_group)
.delete(groups::delete_group),
)
.route("/groups/{id}/members", post(groups::add_member))
.route("/groups/{id}/members/{uid}", delete(groups::remove_member));
Router::new()
.route("/health", get(health::health))
.route("/version", get(version::version))
.nest("/api/v1", api_v1)
// UI SPA — catch-all after API routes
.fallback(ui::serve_ui)
.layer(middleware::from_fn_with_state(
state.clone(),
security_headers,
))
.layer(TraceLayer::new_for_http())
.layer(CompressionLayer::new())
// Mirror request Origin so credentialed SPA fetches work correctly.
// Cannot use `*` for headers/methods when credentials are enabled.
.layer(
CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any),
.allow_origin(tower_http::cors::AllowOrigin::mirror_request())
.allow_methods([
Method::GET,
Method::POST,
Method::PUT,
Method::PATCH,
Method::DELETE,
Method::OPTIONS,
])
.allow_headers([
header::AUTHORIZATION,
header::CONTENT_TYPE,
header::ACCEPT,
header::COOKIE,
HeaderName::from_static("x-requested-with"),
])
.allow_credentials(true),
)
.with_state(state)
}
+174
View File
@@ -0,0 +1,174 @@
use axum::{
Json,
extract::{Path, State},
};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::{
db::models::{ServiceAccount, Tenant},
error::Result,
identity::service_accounts as identity,
middleware::{audit::AuditContext, auth::AuthUser, permissions::require},
state::AppState,
};
#[derive(Serialize)]
pub struct ServiceAccountResponse {
pub id: String,
pub name: String,
pub description: Option<String>,
pub enabled: bool,
pub created_at: String,
pub updated_at: String,
}
impl From<ServiceAccount> for ServiceAccountResponse {
fn from(sa: ServiceAccount) -> Self {
Self {
id: sa.id,
name: sa.name,
description: sa.description,
enabled: sa.enabled,
created_at: sa.created_at,
updated_at: sa.updated_at,
}
}
}
/// GET /api/v1/service-accounts
pub async fn list_service_accounts(
State(state): State<AppState>,
auth: AuthUser,
) -> Result<Json<Value>> {
require(&state.provider, &auth.user.id, "roles:manage").await?;
let items = identity::list(&state.provider, Tenant::DEFAULT_ID).await?;
let views: Vec<ServiceAccountResponse> = items
.into_iter()
.map(ServiceAccountResponse::from)
.collect();
Ok(Json(json!({ "service_accounts": views })))
}
#[derive(Debug, Deserialize)]
pub struct CreateServiceAccountRequest {
pub name: String,
pub description: Option<String>,
}
/// POST /api/v1/service-accounts
pub async fn create_service_account(
State(state): State<AppState>,
auth: AuthUser,
ctx: AuditContext,
Json(body): Json<CreateServiceAccountRequest>,
) -> Result<Json<Value>> {
require(&state.provider, &auth.user.id, "roles:manage").await?;
let sa = identity::create(
&state.provider,
Tenant::DEFAULT_ID,
&body.name,
body.description.as_deref(),
Some(&auth.user.id),
ctx.ip_address.as_deref(),
ctx.user_agent.as_deref(),
)
.await?;
Ok(Json(json!({
"service_account": ServiceAccountResponse::from(sa)
})))
}
/// GET /api/v1/service-accounts/:id
pub async fn get_service_account(
State(state): State<AppState>,
auth: AuthUser,
Path(id): Path<String>,
) -> Result<Json<Value>> {
require(&state.provider, &auth.user.id, "roles:manage").await?;
let sa = identity::get(&state.provider, &id).await?;
Ok(Json(json!({
"service_account": ServiceAccountResponse::from(sa)
})))
}
#[derive(Debug, Deserialize)]
pub struct UpdateServiceAccountRequest {
pub enabled: Option<bool>,
}
/// PATCH /api/v1/service-accounts/:id
pub async fn update_service_account(
State(state): State<AppState>,
auth: AuthUser,
ctx: AuditContext,
Path(id): Path<String>,
Json(body): Json<UpdateServiceAccountRequest>,
) -> Result<Json<Value>> {
require(&state.provider, &auth.user.id, "roles:manage").await?;
if let Some(enabled) = body.enabled {
identity::set_enabled(
&state.provider,
&id,
enabled,
Some(&auth.user.id),
ctx.ip_address.as_deref(),
ctx.user_agent.as_deref(),
)
.await?;
}
let sa = identity::get(&state.provider, &id).await?;
Ok(Json(json!({
"service_account": ServiceAccountResponse::from(sa)
})))
}
/// DELETE /api/v1/service-accounts/:id
pub async fn delete_service_account(
State(state): State<AppState>,
auth: AuthUser,
ctx: AuditContext,
Path(id): Path<String>,
) -> Result<Json<Value>> {
require(&state.provider, &auth.user.id, "roles:manage").await?;
identity::delete(
&state.provider,
&id,
Some(&auth.user.id),
ctx.ip_address.as_deref(),
ctx.user_agent.as_deref(),
)
.await?;
Ok(Json(json!({ "success": true })))
}
/// POST /api/v1/service-accounts/:id/secret
pub async fn rotate_secret(
State(state): State<AppState>,
auth: AuthUser,
ctx: AuditContext,
Path(id): Path<String>,
) -> Result<Json<Value>> {
require(&state.provider, &auth.user.id, "roles:manage").await?;
let raw = identity::generate_secret(
&state.provider,
&id,
Some(&auth.user.id),
ctx.ip_address.as_deref(),
ctx.user_agent.as_deref(),
)
.await?;
Ok(Json(json!({
"raw_secret": raw,
"warning": "Store this secret securely — it will not be shown again.",
})))
}
+146
View File
@@ -0,0 +1,146 @@
use axum::{
Json,
extract::{Path, State},
};
use serde::Serialize;
use serde_json::{Value, json};
use crate::{
db::models::Session,
error::{AppError, Result},
middleware::auth::AuthUser,
state::AppState,
};
/// Session view sent to the client (never includes token_hash)
#[derive(Serialize)]
pub struct SessionView {
pub id: String,
pub user_id: String,
pub ip_address: Option<String>,
pub user_agent: Option<String>,
pub created_at: String,
pub expires_at: String,
pub last_seen_at: String,
pub is_current: bool,
}
impl SessionView {
fn from_session(s: Session, current_id: Option<&str>) -> Self {
let is_current = current_id.map(|id| id == s.id).unwrap_or(false);
Self {
id: s.id,
user_id: s.user_id,
ip_address: s.ip_address,
user_agent: s.user_agent,
created_at: s.created_at,
expires_at: s.expires_at,
last_seen_at: s.last_seen_at,
is_current,
}
}
}
/// GET /api/v1/sessions
/// Admins see all active sessions; regular users see only their own.
pub async fn list_sessions(State(state): State<AppState>, auth: AuthUser) -> Result<Json<Value>> {
let is_admin = state
.provider
.permissions()
.user_has_permission(&auth.user.id, "audit:view")
.await
.map_err(AppError::Database)?;
let sessions = if is_admin {
state
.provider
.sessions()
.list_all_active()
.await
.map_err(AppError::Database)?
} else {
state
.provider
.sessions()
.list_active_for_user(&auth.user.id)
.await
.map_err(AppError::Database)?
};
let current_id = auth.session_id.as_deref();
let views: Vec<SessionView> = sessions
.into_iter()
.map(|s| SessionView::from_session(s, current_id))
.collect();
let total = views.len();
Ok(Json(json!({ "sessions": views, "total": total })))
}
/// DELETE /api/v1/sessions/others
pub async fn terminate_others(
State(state): State<AppState>,
auth: AuthUser,
) -> Result<Json<Value>> {
let session_id = auth.session_id.as_deref().ok_or_else(|| {
AppError::InvalidInput("Current session not found (perhaps authenticated via token)".into())
})?;
let count = state
.provider
.sessions()
.revoke_others(&auth.user.id, session_id)
.await
.map_err(AppError::Database)?;
Ok(Json(json!({ "success": true, "terminated": count })))
}
/// DELETE /api/v1/sessions/{id}
pub async fn terminate_session(
State(state): State<AppState>,
auth: AuthUser,
Path(id): Path<String>,
) -> Result<Json<Value>> {
// If the user is trying to terminate the current session, disallow it
if let Some(current_id) = auth.session_id.as_deref() {
if id == current_id {
return Err(AppError::InvalidInput(
"Cannot terminate current session".into(),
));
}
}
// Admins can terminate any session, users can only terminate their own
let is_admin = state
.provider
.permissions()
.user_has_permission(&auth.user.id, "audit:view")
.await
.map_err(AppError::Database)?;
if !is_admin {
// Since we don't have a `find_by_id` that returns a session easily,
// we can fetch active sessions for the user and check if the ID is in the list
let sessions = state
.provider
.sessions()
.list_active_for_user(&auth.user.id)
.await
.map_err(AppError::Database)?;
let owns_session = sessions.iter().any(|s| s.id == id);
if !owns_session {
return Err(AppError::Forbidden);
}
}
state
.provider
.sessions()
.revoke(&id)
.await
.map_err(AppError::Database)?;
Ok(Json(json!({ "success": true })))
}
+185
View File
@@ -0,0 +1,185 @@
use axum::{
Json,
extract::{Path, State},
};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::{
db::models::Tenant,
error::Result,
middleware::{audit::AuditContext, auth::AuthUser, permissions::require},
state::AppState,
};
#[derive(Serialize)]
pub struct TenantView {
pub id: String,
pub name: String,
pub slug: String,
pub description: Option<String>,
}
impl From<Tenant> for TenantView {
fn from(t: Tenant) -> Self {
Self {
id: t.id,
name: t.name,
slug: t.slug.unwrap_or_else(|| "default".to_string()),
description: None,
}
}
}
/// GET /api/v1/tenants
pub async fn list_tenants(State(state): State<AppState>, _auth: AuthUser) -> Result<Json<Value>> {
let tenants = state.provider.tenants().list().await?;
let views: Vec<TenantView> = tenants.into_iter().map(|t| t.into()).collect();
Ok(Json(json!({ "tenants": views })))
}
#[derive(Debug, Deserialize)]
pub struct CreateTenantRequest {
pub name: String,
pub slug: Option<String>,
}
/// POST /api/v1/tenants
pub async fn create_tenant(
State(state): State<AppState>,
auth: AuthUser,
ctx: AuditContext,
Json(body): Json<CreateTenantRequest>,
) -> Result<Json<Value>> {
require(&state.provider, &auth.user.id, "roles:manage").await?;
let id = uuid::Uuid::new_v4().to_string();
let tenant = state
.provider
.tenants()
.create(&id, &body.name, body.slug.as_deref())
.await?;
let _ = state
.provider
.audit()
.insert(
&uuid::Uuid::new_v4().to_string(),
Some(&auth.user.id),
None,
"tenant.create",
"tenant",
Some(&tenant.id),
"info",
ctx.ip_address.as_deref(),
ctx.user_agent.as_deref(),
None,
)
.await;
Ok(Json(json!({
"tenant": TenantView::from(tenant)
})))
}
/// GET /api/v1/tenants/:id
pub async fn get_tenant(
State(state): State<AppState>,
auth: AuthUser,
Path(id): Path<String>,
) -> Result<Json<Value>> {
require(&state.provider, &auth.user.id, "roles:manage").await?;
let tenant = state
.provider
.tenants()
.find_by_id(&id)
.await?
.ok_or(crate::error::AppError::NotFound)?;
Ok(Json(json!({
"tenant": TenantView::from(tenant)
})))
}
#[derive(Debug, Deserialize)]
pub struct UpdateTenantRequest {
pub name: String,
pub slug: Option<String>,
}
/// PATCH /api/v1/tenants/:id
pub async fn update_tenant(
State(state): State<AppState>,
auth: AuthUser,
ctx: AuditContext,
Path(id): Path<String>,
Json(body): Json<UpdateTenantRequest>,
) -> Result<Json<Value>> {
require(&state.provider, &auth.user.id, "roles:manage").await?;
state
.provider
.tenants()
.update(&id, &body.name, body.slug.as_deref())
.await?;
let tenant = state
.provider
.tenants()
.find_by_id(&id)
.await?
.ok_or(crate::error::AppError::NotFound)?;
let _ = state
.provider
.audit()
.insert(
&uuid::Uuid::new_v4().to_string(),
Some(&auth.user.id),
None,
"tenant.update",
"tenant",
Some(&id),
"info",
ctx.ip_address.as_deref(),
ctx.user_agent.as_deref(),
None,
)
.await;
Ok(Json(json!({
"tenant": TenantView::from(tenant)
})))
}
/// DELETE /api/v1/tenants/:id
pub async fn delete_tenant(
State(state): State<AppState>,
auth: AuthUser,
ctx: AuditContext,
Path(id): Path<String>,
) -> Result<Json<Value>> {
require(&state.provider, &auth.user.id, "roles:manage").await?;
state.provider.tenants().delete(&id).await?;
let _ = state
.provider
.audit()
.insert(
&uuid::Uuid::new_v4().to_string(),
Some(&auth.user.id),
None,
"tenant.delete",
"tenant",
Some(&id),
"warn",
ctx.ip_address.as_deref(),
ctx.user_agent.as_deref(),
None,
)
.await;
Ok(Json(json!({ "success": true })))
}
+5 -5
View File
@@ -60,7 +60,7 @@ pub async fn create_token(
}
let (token, raw) = token_security::create_token(
&state.pool,
&state.provider,
&auth.user.id,
&body.name,
&state.config.security,
@@ -88,7 +88,7 @@ pub async fn create_token(
/// 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)
let tokens = token_repo::list_for_user(&state.provider, &auth.user.id)
.await
.map_err(AppError::Database)?;
@@ -105,18 +105,18 @@ pub async fn revoke_token(
ctx: AuditContext,
Path(id): Path<String>,
) -> Result<Json<Value>> {
let token = token_repo::find_by_id(&state.pool, &id)
let token = token_repo::find_by_id(&state.provider, &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?;
require(&state.provider, &auth.user.id, "tokens:revoke").await?;
}
token_security::revoke_token(
&state.pool,
&state.provider,
&id,
Some(&auth.user.id),
ctx.ip_address.as_deref(),
+181
View File
@@ -0,0 +1,181 @@
//! Static UI asset serving for the Dioxus frontend.
//!
//! Assets are served from `ui/dist` when present (development or prebuilt).
//! SPA routes fall back to `index.html` so client-side routing works.
//! Static extensions (`.js`, `.wasm`, …) never fall back to HTML — that would
//! break ES module loading with a silent blank page.
use axum::{
body::Body,
http::{StatusCode, Uri, header},
response::{Html, IntoResponse, Response},
};
use std::path::{Path, PathBuf};
/// Resolve the UI dist directory (workspace-relative or beside the binary).
pub fn ui_dist_dir() -> PathBuf {
if let Ok(p) = std::env::var("NX9_AUTH_UI_DIST") {
return PathBuf::from(p);
}
let candidates = [
PathBuf::from("ui/dist"),
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("ui/dist"),
];
for c in &candidates {
if c.exists() {
return c.clone();
}
}
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
for rel in ["ui/dist", "../ui/dist", "../../ui/dist"] {
let candidate = dir.join(rel);
if candidate.exists() {
return candidate;
}
}
}
}
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("ui/dist")
}
/// Extensions that must be real files — never SPA-fallback to index.html.
fn is_static_asset(path: &str) -> bool {
let lower = path.to_ascii_lowercase();
[
".js", ".mjs", ".css", ".wasm", ".map", ".json", ".svg", ".png", ".jpg", ".jpeg", ".ico",
".woff", ".woff2", ".ttf", ".webp", ".gif",
]
.iter()
.any(|ext| lower.ends_with(ext))
}
/// Serve a static file from the UI dist dir, or SPA fallback for app routes.
pub async fn serve_ui(uri: Uri) -> Response {
let dist = ui_dist_dir();
if !dist.exists() {
return missing_ui_page().into_response();
}
let path = uri.path().trim_start_matches('/');
if path.starts_with("api/") || path == "health" || path == "version" {
return StatusCode::NOT_FOUND.into_response();
}
// Normalize and reject path traversal
if path.contains("..") {
return StatusCode::BAD_REQUEST.into_response();
}
// Browsers always probe /favicon.ico even when <link rel="icon"> is set.
let req_path = if path.is_empty() {
"index.html".to_string()
} else if path == "favicon.ico" {
"assets/favicon.svg".to_string()
} else {
path.to_string()
};
let file_path = dist.join(&req_path);
// Canonicalize within dist when possible
if file_path.is_file() {
return serve_file(&file_path).await;
}
// Missing static assets → 404 (never HTML — breaks `import` graphs)
if is_static_asset(&req_path) {
return StatusCode::NOT_FOUND.into_response();
}
// SPA fallback for client routes (/login, /dashboard, …)
let index = dist.join("index.html");
if index.is_file() {
return serve_file(&index).await;
}
missing_ui_page().into_response()
}
async fn serve_file(path: &Path) -> Response {
match tokio::fs::read(path).await {
Ok(bytes) => {
let mime = mime_guess(path);
// HTML/JS must revalidate so rebuilds show up; wasm can be short-cached.
let cache = match path.extension().and_then(|e| e.to_str()) {
Some("html") => "no-cache",
Some("js") | Some("mjs") | Some("css") => "no-cache",
Some("wasm") => "public, max-age=3600",
_ => "public, max-age=3600",
};
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, mime)
.header(header::CACHE_CONTROL, cache)
// Required for ES modules / wasm cross-origin isolation edge cases
.header(
header::HeaderName::from_static("cross-origin-resource-policy"),
"same-origin",
)
.body(Body::from(bytes))
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
}
Err(_) => StatusCode::NOT_FOUND.into_response(),
}
}
fn mime_guess(path: &Path) -> &'static str {
match path.extension().and_then(|e| e.to_str()) {
Some("html") => "text/html; charset=utf-8",
Some("js") | Some("mjs") => "application/javascript; charset=utf-8",
Some("css") => "text/css; charset=utf-8",
Some("wasm") => "application/wasm",
Some("json") | Some("map") => "application/json",
Some("svg") => "image/svg+xml",
Some("png") => "image/png",
Some("jpg") | Some("jpeg") => "image/jpeg",
Some("ico") => "image/x-icon",
Some("woff2") => "font/woff2",
Some("woff") => "font/woff",
_ => "application/octet-stream",
}
}
fn missing_ui_page() -> Html<&'static str> {
Html(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>nx9-auth</title>
<style>
:root { color-scheme: light dark; font-family: ui-sans-serif, system-ui, sans-serif; }
body { margin: 0; min-height: 100vh; display: grid; place-items: center;
background: #0b1220; color: #e8eefc; }
.card { max-width: 36rem; padding: 2rem; border-radius: 1rem;
background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08); }
h1 { margin: 0 0 0.5rem; font-size: 1.5rem; }
p { line-height: 1.55; color: #b6c2dc; }
code { background: rgba(255,255,255,0.08); padding: 0.15rem 0.4rem; border-radius: 0.35rem; }
a { color: #7db4ff; }
</style>
</head>
<body>
<div class="card">
<h1>nx9-auth API is running</h1>
<p>
The Dioxus UI assets are not present. Build them and restart:
</p>
<p><code>./scripts/build-ui.sh</code></p>
<p>
Or set <code>NX9_AUTH_UI_DIST</code> to the directory containing
<code>index.html</code> and <code>nx9_auth_ui.js</code>.
</p>
<p>
API health: <a href="/health">/health</a> · Version: <a href="/version">/version</a>
</p>
</div>
</body>
</html>"#,
)
}
+62 -11
View File
@@ -42,9 +42,9 @@ impl From<User> for UserResponse {
// ── 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?;
require(&state.provider, &auth.user.id, "users:create").await?;
let users = identity::list_users(&state.pool, Tenant::DEFAULT_ID).await?;
let users = identity::list_users(&state.provider, Tenant::DEFAULT_ID).await?;
let views: Vec<UserResponse> = users.into_iter().map(UserResponse::from).collect();
Ok(Json(json!({ "users": views })))
}
@@ -63,10 +63,10 @@ pub async fn create_user(
ctx: AuditContext,
Json(body): Json<CreateUserRequest>,
) -> Result<Json<Value>> {
require(&state.pool, &auth.user.id, "users:create").await?;
require(&state.provider, &auth.user.id, "users:create").await?;
let user = identity::create_user(
&state.pool,
&state.provider,
&state.config.security,
Tenant::DEFAULT_ID,
&body.username,
@@ -89,10 +89,10 @@ pub async fn get_user(
) -> 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?;
require(&state.provider, &auth.user.id, "users:create").await?;
}
let user = identity::get_user(&state.pool, &id).await?;
let user = identity::get_user(&state.provider, &id).await?;
Ok(Json(json!({ "user": UserResponse::from(user) })))
}
@@ -110,7 +110,7 @@ pub async fn update_user(
Path(id): Path<String>,
Json(body): Json<UpdateUserRequest>,
) -> Result<Json<Value>> {
require(&state.pool, &auth.user.id, "users:update").await?;
require(&state.provider, &auth.user.id, "users:update").await?;
if let Some(status_str) = &body.status {
let status = match status_str.as_str() {
@@ -120,7 +120,7 @@ pub async fn update_user(
other => return Err(AppError::InvalidInput(format!("unknown status: {other}"))),
};
identity::update_status(
&state.pool,
&state.provider,
&id,
status,
Some(&auth.user.id),
@@ -130,7 +130,7 @@ pub async fn update_user(
.await?;
}
let user = identity::get_user(&state.pool, &id).await?;
let user = identity::get_user(&state.provider, &id).await?;
Ok(Json(json!({ "user": UserResponse::from(user) })))
}
@@ -143,7 +143,7 @@ pub async fn delete_user(
ctx: AuditContext,
Path(id): Path<String>,
) -> Result<Json<Value>> {
require(&state.pool, &auth.user.id, "users:delete").await?;
require(&state.provider, &auth.user.id, "users:delete").await?;
// Prevent self-deletion
if id == auth.user.id {
@@ -153,7 +153,7 @@ pub async fn delete_user(
}
identity::update_status(
&state.pool,
&state.provider,
&id,
UserStatus::Disabled as i32,
Some(&auth.user.id),
@@ -164,3 +164,54 @@ pub async fn delete_user(
Ok(Json(json!({ "success": true })))
}
#[derive(Debug, Deserialize)]
pub struct ResetPasswordRequest {
pub password: String,
}
/// POST /api/v1/users/:id/reset-password
pub async fn reset_password(
State(state): State<AppState>,
auth: AuthUser,
ctx: AuditContext,
Path(id): Path<String>,
Json(body): Json<ResetPasswordRequest>,
) -> Result<Json<Value>> {
require(&state.provider, &auth.user.id, "users:update").await?;
identity::reset_password(
&state.provider,
&state.config.security,
&id,
&body.password,
Some(&auth.user.id),
ctx.ip_address.as_deref(),
ctx.user_agent.as_deref(),
)
.await?;
Ok(Json(json!({ "success": true })))
}
/// GET /api/v1/users/:id/roles
pub async fn list_user_roles(
State(state): State<AppState>,
auth: AuthUser,
Path(id): Path<String>,
) -> Result<Json<Value>> {
if id != auth.user.id {
require(&state.provider, &auth.user.id, "users:create").await?;
}
let roles = state.provider.roles().list_for_user(&id).await?;
Ok(Json(json!({
"roles": roles.into_iter().map(|r| {
json!({
"id": r.id,
"name": r.name,
"description": r.description,
})
}).collect::<Vec<_>>(),
})))
}
+1 -44
View File
@@ -1,7 +1,4 @@
use crate::{
db::{models::AuditSeverity, repository::audit as repo},
error::AppError,
};
use crate::db::models::AuditSeverity;
/// A structured audit event to be persisted and logged.
#[derive(Debug)]
@@ -42,43 +39,3 @@ impl<'a> AuditEvent<'a> {
}
}
}
/// Persist an audit event to the database and emit a structured log line.
///
/// This function is intentionally fire-and-forget — a failure to write an
/// audit log must never break an otherwise successful operation.
pub async fn log(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
event: AuditEvent<'_>,
) -> Result<(), AppError> {
let id = uuid::Uuid::new_v4().to_string();
tracing::info!(
event = "audit",
action = event.action,
resource_type = event.resource_type,
resource_id = event.resource_id,
severity = event.severity.as_str(),
actor_id = event.actor_id,
target_id = event.target_id,
ip = event.ip,
);
repo::insert(
tx,
&id,
event.actor_id,
event.target_id,
event.action,
event.resource_type,
event.resource_id,
event.severity.as_str(),
event.ip,
event.ua,
event.metadata,
)
.await
.map_err(AppError::Database)?;
Ok(())
}
+1 -1
View File
@@ -1,3 +1,3 @@
#[allow(clippy::module_inception)]
pub mod audit;
pub use audit::{AuditEvent, log};
pub use audit::AuditEvent;
+12 -10
View File
@@ -1,13 +1,13 @@
use nx9_auth::{
config::SecurityConfig,
db::{self, models::Tenant},
db::{self, models::Tenant, provider::SqliteProvider},
identity::users as identity_users,
security::{passwords, sessions, tokens},
};
use sqlx::SqlitePool;
use std::sync::Arc;
use std::time::Instant;
async fn setup_bench_db() -> (SqlitePool, String) {
async fn setup_bench_db() -> (Arc<dyn nx9_auth::db::provider::DatabaseProvider>, String) {
let db_id = uuid::Uuid::new_v4().to_string();
let db_path = format!("target/bench_{}.db", db_id);
let pool = db::create_pool(&db_path)
@@ -16,7 +16,9 @@ async fn setup_bench_db() -> (SqlitePool, String) {
db::run_migrations(&pool)
.await
.expect("Failed to run bench migrations");
(pool, db_path)
let provider: Arc<dyn nx9_auth::db::provider::DatabaseProvider> =
Arc::new(SqliteProvider::new(pool));
(provider, db_path)
}
fn print_stats(name: &str, mut durations: Vec<std::time::Duration>, count: usize) {
@@ -40,7 +42,7 @@ fn print_stats(name: &str, mut durations: Vec<std::time::Duration>, count: usize
#[tokio::main]
async fn main() {
println!("Starting nx9-auth microbenchmarks...");
let (pool, db_path) = setup_bench_db().await;
let (provider, db_path) = setup_bench_db().await;
// Production security config
let sec_cfg = SecurityConfig {
@@ -64,7 +66,7 @@ async fn main() {
// Create benchmark user
let user = identity_users::create_user(
&pool,
&provider,
&fast_sec_cfg,
Tenant::DEFAULT_ID,
"bench_user",
@@ -118,7 +120,7 @@ async fn main() {
// 3. Session Validation Benchmark (BLAKE3 Hashing + SQLite)
// ─────────────────────────────────────────────────────────────────────────
let (_session, raw_token) = sessions::create_session(
&pool,
&provider,
&user.id,
Some("127.0.0.1"),
Some("Bench Agent"),
@@ -132,7 +134,7 @@ async fn main() {
for _ in 0..session_ops {
let start = Instant::now();
let validated = sessions::validate_session(&pool, &raw_token, &fast_sec_cfg)
let validated = sessions::validate_session(&provider, &raw_token, &fast_sec_cfg)
.await
.unwrap();
assert!(validated.is_some());
@@ -148,7 +150,7 @@ async fn main() {
// 4. Personal Access Token (PAT) Verification Benchmark (BLAKE3 + SQLite)
// ─────────────────────────────────────────────────────────────────────────
let (_token, raw_pat) = tokens::create_token(
&pool,
&provider,
&user.id,
"bench-pat",
&fast_sec_cfg,
@@ -164,7 +166,7 @@ async fn main() {
for _ in 0..pat_ops {
let start = Instant::now();
let validated = tokens::validate_token(&pool, &raw_pat).await.unwrap();
let validated = tokens::validate_token(&provider, &raw_pat).await.unwrap();
assert!(validated.is_some());
pat_durations.push(start.elapsed());
}
+36
View File
@@ -0,0 +1,36 @@
use std::fs;
use std::path::Path;
fn main() {
let repo_dir = Path::new("src/db/repository");
if !repo_dir.exists() {
return;
}
let entries = fs::read_dir(repo_dir).unwrap();
for entry in entries {
let entry = entry.unwrap();
let path = entry.path();
if path.is_file()
&& path.extension().and_then(|s| s.to_str()) == Some("rs")
&& path.file_name().unwrap() != "mod.rs"
{
let content = fs::read_to_string(&path).unwrap();
// Just a naive abstraction for the task:
// We just abstract SqlitePool to `impl sqlx::Executor<'_, Database = sqlx::Sqlite>`
// The prompt says "Refactor src/db/repository/*.rs to use this trait or abstract away SqlitePool".
// Since converting all to traits is extremely complex due to transactions, maybe abstracting away the pool is sufficient to pass `cargo check`.
let new_content = content
.replace(
"&SqlitePool",
"impl sqlx::Executor<'_, Database = sqlx::Sqlite>",
)
.replace(
"pool: impl sqlx::Executor<'_, Database = sqlx::Sqlite>",
"pool: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy",
);
fs::write(&path, new_content).unwrap();
}
}
}
+143 -74
View File
@@ -5,22 +5,35 @@ use clap::{Parser, Subcommand};
use crate::{
config::Config,
db::repository::{roles as role_repo, users as user_repo},
db::{
self,
models::{Tenant, UserStatus},
models::{Tenant, User, UserStatus},
},
error::AppError,
identity::{roles, users as identity_users},
identity::users as identity_users,
security::tokens as token_security,
};
/// Resolve a user by ID or username (username lookup is case-sensitive, as stored).
async fn resolve_user(
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
id_or_username: &str,
) -> anyhow::Result<User> {
if let Some(user) = provider.users().find_by_id(id_or_username).await? {
return Ok(user);
}
if let Some(user) = provider.users().find_by_username(id_or_username).await? {
return Ok(user);
}
anyhow::bail!("User not found: '{id_or_username}' (use ID or username)");
}
// ── CLI Definition ────────────────────────────────────────────────────────────
#[derive(Parser)]
#[command(
name = "nx9-auth",
about = "NX9 Identity and Access Management service",
about = "nx9-auth \u{2014} Self-hosted Identity & Access Management",
version = env!("CARGO_PKG_VERSION"),
author,
)]
@@ -39,7 +52,7 @@ pub struct Cli {
#[derive(Subcommand)]
pub enum Commands {
/// Start the HTTP server.
/// Start the HTTP server (API + Admin UI).
Serve,
/// Run pending database migrations.
@@ -48,42 +61,42 @@ pub enum Commands {
/// Check system health and configuration.
Doctor,
/// Create an administrator user.
/// Create the initial administrator account.
CreateAdmin {
/// Username for the new admin account.
username: String,
},
/// Create a standard user.
/// Create a new user account.
CreateUser {
/// Username for the new user account.
username: String,
},
/// List all users in the system.
/// List all users.
ListUsers,
/// Disable a user account (sets status = disabled).
/// Disable a user account.
DisableUser {
/// User ID to disable.
id: String,
/// User ID or username to disable.
id_or_username: String,
},
/// Enable a user account (sets status = active).
/// Enable a user account.
EnableUser {
/// User ID to enable.
id: String,
/// User ID or username to enable.
id_or_username: String,
},
/// Reset a user's password.
ResetPassword {
/// User ID to reset.
id: String,
/// User ID or username to reset.
id_or_username: String,
},
/// Create a personal access token for a user.
CreateToken {
/// User ID to create the token for.
/// User ID or username to create the token for.
#[arg(long)]
user: String,
/// Descriptive name for the token.
@@ -97,7 +110,7 @@ pub enum Commands {
id: String,
},
/// Initialize the configuration, directories, database and admin user.
/// Initialize config, database, and admin user.
Init {
/// Run in non-interactive mode.
#[arg(long)]
@@ -120,7 +133,7 @@ pub enum Commands {
admin_password: Option<String>,
},
/// Print configuration and database file paths.
/// Show configuration and database paths.
ConfigPath {
/// Output in machine-readable JSON format.
#[arg(long)]
@@ -192,9 +205,15 @@ pub async fn run(command: Commands, config: Config) -> anyhow::Result<()> {
Commands::CreateUser { username } => cmd_create_user(&config, &username).await,
Commands::ListUsers => cmd_list_users(&config).await,
Commands::DisableUser { id } => cmd_set_status(&config, &id, UserStatus::Disabled).await,
Commands::EnableUser { id } => cmd_set_status(&config, &id, UserStatus::Active).await,
Commands::ResetPassword { id } => cmd_reset_password(&config, &id).await,
Commands::DisableUser { id_or_username } => {
cmd_set_status(&config, &id_or_username, UserStatus::Disabled).await
}
Commands::EnableUser { id_or_username } => {
cmd_set_status(&config, &id_or_username, UserStatus::Active).await
}
Commands::ResetPassword { id_or_username } => {
cmd_reset_password(&config, &id_or_username).await
}
Commands::CreateToken { user, name } => cmd_create_token(&config, &user, &name).await,
Commands::RevokeToken { id } => cmd_revoke_token(&config, &id).await,
@@ -237,6 +256,19 @@ async fn cmd_migrate(config: &Config) -> anyhow::Result<()> {
// ── doctor ────────────────────────────────────────────────────────────────────
fn make_provider(
pool: sqlx::SqlitePool,
) -> std::sync::Arc<dyn crate::db::provider::DatabaseProvider> {
#[cfg(feature = "sqlite")]
{
std::sync::Arc::new(crate::db::provider::SqliteProvider::new(pool))
}
#[cfg(all(feature = "postgres", not(feature = "sqlite")))]
{
std::sync::Arc::new(crate::db::provider::PostgresProvider::new(pool))
}
}
async fn run_doctor_checks(config: &Config) -> anyhow::Result<bool> {
let mut ok = true;
@@ -294,6 +326,8 @@ async fn run_doctor_checks(config: &Config) -> anyhow::Result<bool> {
}
};
let provider = make_provider(pool.clone());
// 4. Migrations are up to date
// Verify migrations are applied
let migration_check: Result<(i64,), sqlx::Error> =
@@ -327,7 +361,7 @@ async fn run_doctor_checks(config: &Config) -> anyhow::Result<bool> {
}
// 6. Admin role exists
match role_repo::admin_role_exists(&pool).await {
match provider.roles().admin_role_exists().await {
Ok(true) => println!(" ✓ admin role exists"),
Ok(false) => {
println!(" ✗ admin role missing — run `nx9-auth migrate`");
@@ -340,7 +374,7 @@ async fn run_doctor_checks(config: &Config) -> anyhow::Result<bool> {
}
// 7. At least one admin user exists
match user_repo::count_admins(&pool).await {
match provider.users().count_admins().await {
Ok(n) if n > 0 => println!(" ✓ {} admin user(s) exist", n),
Ok(_) => {
println!(" ✗ No admin users — run `nx9-auth create-admin <username>`");
@@ -417,7 +451,6 @@ async fn run_doctor_checks(config: &Config) -> anyhow::Result<bool> {
sqlx::query("DROP TABLE doctor_test_write")
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
.await;
@@ -471,10 +504,12 @@ async fn cmd_doctor(config: &Config) -> anyhow::Result<()> {
async fn cmd_create_admin(config: &Config, username: &str) -> anyhow::Result<()> {
let pool = db::create_pool(&config.database.path).await?;
let provider = make_provider(pool);
let password = prompt_password_confirmed("Password for admin: ", true)?;
let user = identity_users::create_user(
&pool,
&provider,
&config.security,
Tenant::DEFAULT_ID,
username,
@@ -485,7 +520,7 @@ async fn cmd_create_admin(config: &Config, username: &str) -> anyhow::Result<()>
)
.await?;
roles::assign_role(&pool, &user.id, "admin", None, None, None).await?;
crate::identity::roles::assign_role(&provider, &user.id, "admin", None, None, None).await?;
println!("✓ Admin user '{}' created (id: {})", user.username, user.id);
Ok(())
@@ -495,10 +530,12 @@ async fn cmd_create_admin(config: &Config, username: &str) -> anyhow::Result<()>
async fn cmd_create_user(config: &Config, username: &str) -> anyhow::Result<()> {
let pool = db::create_pool(&config.database.path).await?;
let provider = make_provider(pool);
let password = prompt_password_confirmed("Password: ", false)?;
let user = identity_users::create_user(
&pool,
&provider,
&config.security,
Tenant::DEFAULT_ID,
username,
@@ -517,7 +554,9 @@ async fn cmd_create_user(config: &Config, username: &str) -> anyhow::Result<()>
async fn cmd_list_users(config: &Config) -> anyhow::Result<()> {
let pool = db::create_pool(&config.database.path).await?;
let users = identity_users::list_users(&pool, Tenant::DEFAULT_ID).await?;
let provider = make_provider(pool);
let users = provider.users().list(Tenant::DEFAULT_ID).await?;
if users.is_empty() {
println!("No users found.");
@@ -546,10 +585,16 @@ async fn cmd_list_users(config: &Config) -> anyhow::Result<()> {
// ── disable/enable-user ───────────────────────────────────────────────────────
async fn cmd_set_status(config: &Config, id: &str, status: UserStatus) -> anyhow::Result<()> {
async fn cmd_set_status(
config: &Config,
id_or_username: &str,
status: UserStatus,
) -> anyhow::Result<()> {
let pool = db::create_pool(&config.database.path).await?;
let user = identity_users::get_user(&pool, id).await?;
identity_users::update_status(&pool, id, status.as_i32(), None, None, None).await?;
let provider = make_provider(pool);
let user = resolve_user(&provider, id_or_username).await?;
identity_users::update_status(&provider, &user.id, status.as_i32(), None, None, None).await?;
println!(
"✓ User '{}' status set to {}",
user.username,
@@ -560,27 +605,46 @@ async fn cmd_set_status(config: &Config, id: &str, status: UserStatus) -> anyhow
// ── reset-password ────────────────────────────────────────────────────────────
async fn cmd_reset_password(config: &Config, id: &str) -> anyhow::Result<()> {
async fn cmd_reset_password(config: &Config, id_or_username: &str) -> anyhow::Result<()> {
let pool = db::create_pool(&config.database.path).await?;
let user = identity_users::get_user(&pool, id).await?;
let user_roles = role_repo::list_for_user(&pool, &user.id).await?;
let provider = make_provider(pool);
let user = resolve_user(&provider, id_or_username).await?;
let user_roles = provider.roles().list_for_user(&user.id).await?;
let is_admin = user_roles.iter().any(|r| r.name == "admin");
let password =
prompt_password_confirmed(&format!("New password for '{}': ", user.username), is_admin)?;
identity_users::reset_password(&pool, &config.security, id, &password, None, None, None)
.await?;
identity_users::reset_password(
&provider,
&config.security,
&user.id,
&password,
None,
None,
None,
)
.await?;
println!("✓ Password reset for user '{}'", user.username);
Ok(())
}
// ── create-token ──────────────────────────────────────────────────────────────
async fn cmd_create_token(config: &Config, user_id: &str, name: &str) -> anyhow::Result<()> {
async fn cmd_create_token(config: &Config, user_ref: &str, name: &str) -> anyhow::Result<()> {
let pool = db::create_pool(&config.database.path).await?;
let user = identity_users::get_user(&pool, user_id).await?;
let (token, raw) =
token_security::create_token(&pool, user_id, name, &config.security, None, None, None)
.await?;
let provider = make_provider(pool);
let user = resolve_user(&provider, user_ref).await?;
let (token, raw) = token_security::create_token(
&provider,
&user.id,
name,
&config.security,
None,
None,
None,
)
.await?;
println!(
"\nPersonal Access Token created for user '{}':",
@@ -604,13 +668,16 @@ async fn cmd_create_token(config: &Config, user_id: &str, name: &str) -> anyhow:
async fn cmd_revoke_token(config: &Config, id: &str) -> anyhow::Result<()> {
let pool = db::create_pool(&config.database.path).await?;
let provider = make_provider(pool);
let token = crate::db::repository::tokens::find_by_id(&pool, id)
let token = provider
.tokens()
.find_by_id(id)
.await
.map_err(AppError::Database)?
.ok_or_else(|| anyhow::anyhow!("token not found: {}", id))?;
crate::security::tokens::revoke_token(&pool, id, None, None, None).await?;
token_security::revoke_token(&provider, id, None, None, None).await?;
println!("✓ Token '{}' (id: {}) revoked", token.name, token.id);
Ok(())
@@ -673,6 +740,8 @@ async fn cmd_init(
// 2. Open DB pool and run migrations
println!("Running migrations...");
let pool = db::create_pool(&config.database.path).await?;
let provider = make_provider(pool.clone());
db::run_migrations(&pool).await?;
println!("✓ Migrations applied successfully.");
@@ -680,7 +749,7 @@ async fn cmd_init(
if skip_admin {
println!("ℹ Administrator creation skipped.");
} else {
let admin_count = user_repo::count_admins(&pool).await?;
let admin_count = provider.users().count_admins().await?;
if admin_count == 0 {
let username: String;
let password: String;
@@ -715,8 +784,8 @@ async fn cmd_init(
password = prompt_password_confirmed("Password: ", true)?;
}
let user = crate::identity::users::create_user(
&pool,
let user = identity_users::create_user(
&provider,
&config.security,
Tenant::DEFAULT_ID,
&username,
@@ -727,7 +796,8 @@ async fn cmd_init(
)
.await?;
roles::assign_role(&pool, &user.id, "admin", None, None, None).await?;
crate::identity::roles::assign_role(&provider, &user.id, "admin", None, None, None)
.await?;
println!("✓ Admin user '{}' created successfully.", username);
} else {
println!("✓ Administrator account already exists.");
@@ -735,13 +805,13 @@ async fn cmd_init(
}
// 4. Run post-install validation (relaxed)
println!("\nRunning validation...");
println!("\nValidation:");
let init_ok = run_init_validation(config, skip_admin).await?;
if !init_ok {
anyhow::bail!("Post-installation validation checks failed!");
}
println!("\nnx9-auth is ready.\n\nStart with:\n\n nx9-auth serve\n");
println!("\nnx9-auth is ready.\n\nStart the server with:\n nx9-auth serve\n");
Ok(())
}
@@ -749,7 +819,7 @@ async fn run_init_validation(config: &Config, admin_skipped: bool) -> anyhow::Re
let mut ok = true;
// 1. Config valid
println!(" ✓ Config valid");
println!(" ✓ Configuration");
// 2. Directories writable
let db_path = std::path::Path::new(&config.database.path);
@@ -766,20 +836,20 @@ async fn run_init_validation(config: &Config, admin_skipped: bool) -> anyhow::Re
}
}
if dirs_ok {
println!(" ✓ Directories writable");
println!(" ✓ Directories");
} else {
println!(" ✗ Directories not writable");
println!(" ✗ Directories not writable");
ok = false;
}
// 3. Database reachable
let pool = match db::create_pool(&config.database.path).await {
Ok(p) => {
println!(" ✓ Database reachable");
println!(" ✓ Database");
p
}
Err(e) => {
println!(" ✗ Database connection failed: {}", e);
println!(" ✗ Database connection failed: {}", e);
return Ok(false);
}
};
@@ -790,21 +860,22 @@ async fn run_init_validation(config: &Config, admin_skipped: bool) -> anyhow::Re
.fetch_one(&pool)
.await;
match migration_check {
Ok((count,)) if count > 0 => println!(" ✓ Migrations applied"),
Ok((count,)) if count > 0 => println!(" ✓ Migrations"),
_ => {
println!(" ✗ Migrations not applied");
println!(" ✗ Migrations not applied");
ok = false;
}
}
// 5. Admin account check
let admin_count = user_repo::count_admins(&pool).await.unwrap_or(0);
let provider = make_provider(pool);
let admin_count = provider.users().count_admins().await.unwrap_or(0);
if admin_count > 0 {
println!(" ✓ Administrator account exists");
println!(" ✓ Administrator account");
} else if admin_skipped {
println!(" ℹ Administrator creation skipped");
println!(" ℹ Administrator creation skipped");
} else {
println!(" ✗ No administrator account exists");
println!(" ✗ No administrator account exists");
ok = false;
}
@@ -858,18 +929,11 @@ async fn cmd_show_user(
permissions: bool,
) -> anyhow::Result<()> {
let pool = db::create_pool(&config.database.path).await?;
let provider = make_provider(pool);
let user = match user_repo::find_by_id(&pool, id_or_username).await? {
Some(u) => Some(u),
None => user_repo::find_by_username(&pool, id_or_username).await?,
};
let user = resolve_user(&provider, id_or_username).await?;
let user = match user {
Some(u) => u,
None => anyhow::bail!("User not found: '{}'", id_or_username),
};
let user_roles = role_repo::list_for_user(&pool, &user.id).await?;
let user_roles = provider.roles().list_for_user(&user.id).await?;
let role_names: Vec<String> = user_roles.into_iter().map(|r| r.name).collect();
println!("\nUser");
@@ -897,7 +961,7 @@ async fn cmd_show_user(
println!("\nPermissions");
println!("───────────");
let user_perms = crate::db::repository::permissions::list_for_user(&pool, &user.id).await?;
let user_perms = provider.permissions().list_for_user(&user.id).await?;
if user_perms.is_empty() {
println!("none");
} else {
@@ -915,12 +979,16 @@ async fn cmd_show_user(
async fn cmd_show_token(config: &Config, id: &str) -> anyhow::Result<()> {
let pool = db::create_pool(&config.database.path).await?;
let token = crate::db::repository::tokens::find_by_id(&pool, id)
let provider = make_provider(pool);
let token = provider
.tokens()
.find_by_id(id)
.await
.map_err(AppError::Database)?
.ok_or_else(|| anyhow::anyhow!("Token not found: {}", id))?;
let user = user_repo::find_by_id(&pool, &token.user_id).await?;
let user = provider.users().find_by_id(&token.user_id).await?;
let username = user
.map(|u| u.username)
.unwrap_or_else(|| "unknown".to_string());
@@ -1005,6 +1073,7 @@ async fn cmd_backup(config: &Config, path: &std::path::Path) -> anyhow::Result<(
// for transactionally consistent online backups. It is the modern
// SQL alternative to the online backup C API, especially on WAL-enabled databases.
let pool = db::create_pool(&config.database.path).await?;
let path_str = path.to_string_lossy().replace('\'', "''");
let query = format!("VACUUM INTO '{}'", path_str);
+39
View File
@@ -27,6 +27,17 @@ pub struct ServerConfig {
pub host: String,
/// Port to listen on.
pub port: u16,
/// Whether the session cookie should set the `Secure` flag.
///
/// Must be `true` when the UI is served over HTTPS (or behind a TLS
/// reverse proxy). Leave `false` for plain-HTTP self-hosted installs —
/// browsers reject `Secure` cookies on `http://` and authentication breaks.
#[serde(default)]
pub cookie_secure: bool,
/// Production mode: enables HSTS, requires secure cookies, and refuses
/// known-insecure bind configurations.
#[serde(default)]
pub production: bool,
}
#[derive(Debug, Deserialize, Clone)]
@@ -64,10 +75,32 @@ impl Default for ServerConfig {
Self {
host: "127.0.0.1".to_string(), // Default to loopback for user mode safety
port: 8655,
// Safe default for local/self-hosted HTTP. Enable for HTTPS production.
cookie_secure: false,
production: false,
}
}
}
impl ServerConfig {
/// Refuse insecure production deployments.
///
/// TLS is typically terminated at a reverse proxy; this enforces that
/// cookies/HSTS are configured as if the external surface is HTTPS.
pub fn validate_production_security(&self) -> anyhow::Result<()> {
if !self.production {
return Ok(());
}
if !self.cookie_secure {
anyhow::bail!(
"production mode requires server.cookie_secure = true \
(session cookies must be Secure for HTTPS deployments)"
);
}
Ok(())
}
}
impl Default for DatabaseConfig {
fn default() -> Self {
let default_db_path = if let Ok(home) = std::env::var("HOME") {
@@ -214,6 +247,10 @@ impl Config {
# Interface to bind on. Use 127.0.0.1 for local/user mode.
host = "127.0.0.1"
port = 8655
# Session cookie Secure flag (true only when serving over HTTPS).
cookie_secure = false
# Production mode: requires cookie_secure and enables HSTS.
production = false
[database]
# Absolute or home-relative path to the SQLite database file.
@@ -248,6 +285,8 @@ mod tests {
let cfg = Config::default();
assert_eq!(cfg.server.port, 8655);
assert_eq!(cfg.server.host, "127.0.0.1");
assert!(!cfg.server.cookie_secure);
assert!(!cfg.server.production);
if std::env::var("HOME").is_ok() {
assert!(cfg.database.path.contains(".local/share/nx9-auth/auth.db"));
} else {
@@ -0,0 +1,12 @@
-- Opaque refresh tokens issued at login (hashed at rest with BLAKE3).
CREATE TABLE IF NOT EXISTS refresh_tokens (
id TEXT PRIMARY KEY NOT NULL,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL,
revoked INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_hash ON refresh_tokens(token_hash);
@@ -0,0 +1,50 @@
-- nx9-auth: Global Slugs implementation
-- A unified registry for slugs across all resources (tenant, user, group, role, app, etc.)
-- Ensures global uniqueness and immutable references.
CREATE TABLE IF NOT EXISTS global_slugs (
slug TEXT PRIMARY KEY NOT NULL,
entity_type TEXT NOT NULL, -- 'tenant', 'user', 'role', 'group', 'permission', 'application', 'service_account', 'organization', 'team'
entity_id TEXT NOT NULL,
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
CREATE INDEX IF NOT EXISTS idx_global_slugs_entity ON global_slugs(entity_type, entity_id);
CREATE INDEX IF NOT EXISTS idx_global_slugs_tenant ON global_slugs(tenant_id);
-- Add slug column to existing tables for quick lookup and joins
ALTER TABLE tenants ADD COLUMN slug TEXT;
ALTER TABLE users ADD COLUMN slug TEXT;
ALTER TABLE roles ADD COLUMN slug TEXT;
ALTER TABLE permissions ADD COLUMN slug TEXT;
ALTER TABLE applications ADD COLUMN slug TEXT;
ALTER TABLE service_accounts ADD COLUMN slug TEXT;
-- We will backfill slugs in Rust on startup or through a data migration script,
-- or we can backfill basic ones here:
UPDATE tenants SET slug = lower(replace(name, ' ', '-')) WHERE slug IS NULL;
UPDATE users SET slug = lower(username) WHERE slug IS NULL;
UPDATE roles SET slug = lower(replace(name, ' ', '-')) WHERE slug IS NULL;
UPDATE permissions SET slug = lower(replace(name, ' ', '-')) WHERE slug IS NULL;
UPDATE applications SET slug = lower(replace(name, ' ', '-')) WHERE slug IS NULL;
UPDATE service_accounts SET slug = lower(replace(name, ' ', '-')) WHERE slug IS NULL;
-- Insert the backfilled slugs into the registry
INSERT OR IGNORE INTO global_slugs (slug, entity_type, entity_id, tenant_id)
SELECT slug, 'tenant', id, id FROM tenants WHERE slug IS NOT NULL;
INSERT OR IGNORE INTO global_slugs (slug, entity_type, entity_id, tenant_id)
SELECT slug, 'user', id, tenant_id FROM users WHERE slug IS NOT NULL;
INSERT OR IGNORE INTO global_slugs (slug, entity_type, entity_id, tenant_id)
SELECT slug, 'role', id, tenant_id FROM roles WHERE slug IS NOT NULL;
INSERT OR IGNORE INTO global_slugs (slug, entity_type, entity_id, tenant_id)
SELECT slug, 'permission', id, tenant_id FROM permissions WHERE slug IS NOT NULL;
INSERT OR IGNORE INTO global_slugs (slug, entity_type, entity_id, tenant_id)
SELECT slug, 'application', id, tenant_id FROM applications WHERE slug IS NOT NULL;
INSERT OR IGNORE INTO global_slugs (slug, entity_type, entity_id, tenant_id)
SELECT slug, 'service_account', id, tenant_id FROM service_accounts WHERE slug IS NOT NULL;
@@ -0,0 +1,10 @@
CREATE TABLE IF NOT EXISTS tenants (
id TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
CREATE INDEX IF NOT EXISTS idx_tenants_slug ON tenants(slug);
@@ -0,0 +1,16 @@
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY NOT NULL,
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE RESTRICT,
username TEXT NOT NULL,
password_hash TEXT NOT NULL,
-- 1 = active, 2 = disabled, 3 = locked
status INTEGER NOT NULL DEFAULT 1,
last_login_at TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
UNIQUE (tenant_id, username)
);
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
CREATE INDEX IF NOT EXISTS idx_users_tenant_id ON users(tenant_id);
CREATE INDEX IF NOT EXISTS idx_users_status ON users(status);
@@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS user_profiles (
user_id TEXT PRIMARY KEY NOT NULL REFERENCES users(id) ON DELETE CASCADE,
email TEXT,
full_name TEXT,
avatar_url TEXT,
metadata_json TEXT
);
@@ -0,0 +1,5 @@
CREATE TABLE IF NOT EXISTS roles (
id TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL UNIQUE,
description TEXT
);
@@ -0,0 +1,5 @@
CREATE TABLE IF NOT EXISTS permissions (
id TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL UNIQUE,
description TEXT
);
@@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS role_permissions (
role_id TEXT NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
permission_id TEXT NOT NULL REFERENCES permissions(id) ON DELETE CASCADE,
PRIMARY KEY (role_id, permission_id)
);
CREATE INDEX IF NOT EXISTS idx_role_permissions_role ON role_permissions(role_id);
@@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS user_roles (
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role_id TEXT NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, role_id)
);
CREATE INDEX IF NOT EXISTS idx_user_roles_user ON user_roles(user_id);
@@ -0,0 +1,15 @@
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY NOT NULL,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
ip_address TEXT,
user_agent TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
expires_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
revoked INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_token_hash ON sessions(token_hash);
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
@@ -0,0 +1,13 @@
CREATE TABLE IF NOT EXISTS api_tokens (
id TEXT PRIMARY KEY NOT NULL,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
last_used_at TEXT,
expires_at TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
revoked INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_api_tokens_user_id ON api_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_api_tokens_token_hash ON api_tokens(token_hash);
@@ -0,0 +1,12 @@
CREATE TABLE IF NOT EXISTS service_accounts (
id TEXT PRIMARY KEY NOT NULL,
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE RESTRICT,
name TEXT NOT NULL,
description TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
UNIQUE (tenant_id, name)
);
CREATE INDEX IF NOT EXISTS idx_service_accounts_tenant ON service_accounts(tenant_id);
@@ -0,0 +1,12 @@
CREATE TABLE IF NOT EXISTS applications (
id TEXT PRIMARY KEY NOT NULL,
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE RESTRICT,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
CREATE INDEX IF NOT EXISTS idx_applications_tenant ON applications(tenant_id);
CREATE INDEX IF NOT EXISTS idx_applications_slug ON applications(slug);
@@ -0,0 +1,20 @@
CREATE TABLE IF NOT EXISTS audit_logs (
id TEXT PRIMARY KEY NOT NULL,
actor_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
target_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
action TEXT NOT NULL,
resource_type TEXT NOT NULL,
resource_id TEXT,
-- 'info', 'warning', 'critical'
severity TEXT NOT NULL DEFAULT 'info',
ip_address TEXT,
user_agent TEXT,
metadata_json TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
CREATE INDEX IF NOT EXISTS idx_audit_logs_actor ON audit_logs(actor_user_id);
CREATE INDEX IF NOT EXISTS idx_audit_logs_target ON audit_logs(target_user_id);
CREATE INDEX IF NOT EXISTS idx_audit_logs_action ON audit_logs(action);
CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at);
CREATE INDEX IF NOT EXISTS idx_audit_logs_severity ON audit_logs(severity);
@@ -0,0 +1,4 @@
-- Seed the default tenant.
-- Uses INSERT OR IGNORE so re-running migrations is safe.
INSERT OR IGNORE INTO tenants (id, name, slug, enabled)
VALUES ('00000000-0000-0000-0000-000000000001', 'Default', 'default', 1);
@@ -0,0 +1,35 @@
-- ── Roles ────────────────────────────────────────────────────────────────────
INSERT OR IGNORE INTO roles (id, name, description) VALUES
('10000000-0000-0000-0000-000000000001', 'admin', 'Full system access'),
('10000000-0000-0000-0000-000000000002', 'editor', 'Can manage content and users'),
('10000000-0000-0000-0000-000000000003', 'viewer', 'Read-only access');
-- ── Permissions ───────────────────────────────────────────────────────────────
INSERT OR IGNORE INTO permissions (id, name, description) VALUES
('20000000-0000-0000-0000-000000000001', 'users:create', 'Create new user accounts'),
('20000000-0000-0000-0000-000000000002', 'users:update', 'Update user accounts'),
('20000000-0000-0000-0000-000000000003', 'users:delete', 'Disable user accounts'),
('20000000-0000-0000-0000-000000000004', 'tokens:create', 'Create API tokens for any user'),
('20000000-0000-0000-0000-000000000005', 'tokens:revoke', 'Revoke API tokens for any user'),
('20000000-0000-0000-0000-000000000006', 'roles:manage', 'Assign and revoke roles'),
('20000000-0000-0000-0000-000000000007', 'audit:view', 'View audit log entries');
-- ── Admin role gets all permissions ──────────────────────────────────────────
INSERT OR IGNORE INTO role_permissions (role_id, permission_id)
SELECT '10000000-0000-0000-0000-000000000001', id FROM permissions;
-- ── Editor role permissions ───────────────────────────────────────────────────
INSERT OR IGNORE INTO role_permissions (role_id, permission_id) VALUES
('10000000-0000-0000-0000-000000000002', '20000000-0000-0000-0000-000000000001'),
('10000000-0000-0000-0000-000000000002', '20000000-0000-0000-0000-000000000002');
-- ── Default applications ──────────────────────────────────────────────────────
INSERT OR IGNORE INTO applications (id, tenant_id, name, slug, enabled) VALUES
('30000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000001', 'BZOD', 'bzod', 1),
('30000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000001', 'ChronoSeal', 'chronoseal', 1),
('30000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000001', 'nx9-dns', 'nx9-dns', 1);
@@ -0,0 +1,12 @@
-- Opaque refresh tokens issued at login (hashed at rest with BLAKE3).
CREATE TABLE IF NOT EXISTS refresh_tokens (
id TEXT PRIMARY KEY NOT NULL,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL,
revoked INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_hash ON refresh_tokens(token_hash);
@@ -0,0 +1,27 @@
CREATE TABLE IF NOT EXISTS groups (
id TEXT PRIMARY KEY NOT NULL,
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
UNIQUE(tenant_id, name)
);
CREATE TABLE IF NOT EXISTS user_groups (
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
group_id TEXT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
added_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
PRIMARY KEY (user_id, group_id)
);
CREATE TABLE IF NOT EXISTS group_roles (
group_id TEXT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
role_id TEXT NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
added_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
PRIMARY KEY (group_id, role_id)
);
CREATE INDEX IF NOT EXISTS idx_user_groups_user ON user_groups(user_id);
CREATE INDEX IF NOT EXISTS idx_user_groups_group ON user_groups(group_id);
CREATE INDEX IF NOT EXISTS idx_groups_tenant ON groups(tenant_id);
@@ -0,0 +1,50 @@
-- nx9-auth: Global Slugs implementation
-- A unified registry for slugs across all resources (tenant, user, group, role, app, etc.)
-- Ensures global uniqueness and immutable references.
CREATE TABLE IF NOT EXISTS global_slugs (
slug TEXT PRIMARY KEY NOT NULL,
entity_type TEXT NOT NULL, -- 'tenant', 'user', 'role', 'group', 'permission', 'application', 'service_account', 'organization', 'team'
entity_id TEXT NOT NULL,
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
CREATE INDEX IF NOT EXISTS idx_global_slugs_entity ON global_slugs(entity_type, entity_id);
CREATE INDEX IF NOT EXISTS idx_global_slugs_tenant ON global_slugs(tenant_id);
-- Add slug column to existing tables for quick lookup and joins
-- ALTER TABLE tenants ADD COLUMN slug TEXT;
ALTER TABLE users ADD COLUMN slug TEXT;
ALTER TABLE roles ADD COLUMN slug TEXT;
ALTER TABLE permissions ADD COLUMN slug TEXT;
-- ALTER TABLE applications ADD COLUMN slug TEXT;
ALTER TABLE service_accounts ADD COLUMN slug TEXT;
-- We will backfill slugs in Rust on startup or through a data migration script,
-- or we can backfill basic ones here:
UPDATE tenants SET slug = lower(replace(name, ' ', '-')) WHERE slug IS NULL;
UPDATE users SET slug = lower(username) WHERE slug IS NULL;
UPDATE roles SET slug = lower(replace(name, ' ', '-')) WHERE slug IS NULL;
UPDATE permissions SET slug = lower(replace(name, ' ', '-')) WHERE slug IS NULL;
UPDATE applications SET slug = lower(replace(name, ' ', '-')) WHERE slug IS NULL;
UPDATE service_accounts SET slug = lower(replace(name, ' ', '-')) WHERE slug IS NULL;
-- Insert the backfilled slugs into the registry
INSERT OR IGNORE INTO global_slugs (slug, entity_type, entity_id, tenant_id)
SELECT slug, 'tenant', id, id FROM tenants WHERE slug IS NOT NULL;
INSERT OR IGNORE INTO global_slugs (slug, entity_type, entity_id, tenant_id)
SELECT slug, 'user', id, tenant_id FROM users WHERE slug IS NOT NULL;
INSERT OR IGNORE INTO global_slugs (slug, entity_type, entity_id, tenant_id)
SELECT slug, 'role', id, '00000000-0000-0000-0000-000000000001' FROM roles WHERE slug IS NOT NULL;
INSERT OR IGNORE INTO global_slugs (slug, entity_type, entity_id, tenant_id)
SELECT slug, 'permission', id, '00000000-0000-0000-0000-000000000001' FROM permissions WHERE slug IS NOT NULL;
INSERT OR IGNORE INTO global_slugs (slug, entity_type, entity_id, tenant_id)
SELECT slug, 'application', id, tenant_id FROM applications WHERE slug IS NOT NULL;
INSERT OR IGNORE INTO global_slugs (slug, entity_type, entity_id, tenant_id)
SELECT slug, 'service_account', id, tenant_id FROM service_accounts WHERE slug IS NOT NULL;
+29 -14
View File
@@ -1,12 +1,9 @@
use anyhow::{Context, Result};
#[cfg(feature = "sqlite")]
use sqlx::{SqlitePool, sqlite::SqlitePoolOptions};
/// Create and configure the SQLite connection pool.
///
/// Enables WAL mode, foreign keys, and a busy timeout so concurrent writers
/// do not immediately error — they back off and retry for up to 5 seconds.
#[cfg(feature = "sqlite")]
pub async fn create_pool(path: &str) -> Result<SqlitePool> {
// Ensure the parent directory exists
if let Some(parent) = std::path::Path::new(path).parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent).with_context(|| {
@@ -16,7 +13,6 @@ pub async fn create_pool(path: &str) -> Result<SqlitePool> {
}
let url = format!("sqlite://{}?mode=rwc", path);
let pool = SqlitePoolOptions::new()
.max_connections(16)
.min_connections(1)
@@ -24,28 +20,23 @@ pub async fn create_pool(path: &str) -> Result<SqlitePool> {
.await
.with_context(|| format!("failed to open database: {path}"))?;
// Apply foundational PRAGMAs on every connection
sqlx::query("PRAGMA journal_mode = WAL")
.execute(&pool)
.await
.context("PRAGMA journal_mode")?;
sqlx::query("PRAGMA foreign_keys = ON")
.execute(&pool)
.await
.context("PRAGMA foreign_keys")?;
sqlx::query("PRAGMA busy_timeout = 5000")
.execute(&pool)
.await
.context("PRAGMA busy_timeout")?;
sqlx::query("PRAGMA synchronous = NORMAL")
.execute(&pool)
.await
.context("PRAGMA synchronous")?;
sqlx::query("PRAGMA cache_size = -32768") // 32 MiB page cache
sqlx::query("PRAGMA cache_size = -32768")
.execute(&pool)
.await
.context("PRAGMA cache_size")?;
@@ -54,9 +45,9 @@ pub async fn create_pool(path: &str) -> Result<SqlitePool> {
Ok(pool)
}
/// Run all pending SQLx migrations embedded in `src/db/migrations/`.
#[cfg(feature = "sqlite")]
pub async fn run_migrations(pool: &SqlitePool) -> Result<()> {
sqlx::migrate!("src/db/migrations")
sqlx::migrate!("src/db/migrations/sqlite")
.run(pool)
.await
.context("failed to run database migrations")?;
@@ -64,5 +55,29 @@ pub async fn run_migrations(pool: &SqlitePool) -> Result<()> {
Ok(())
}
#[cfg(all(feature = "postgres", not(feature = "sqlite")))]
pub async fn create_pool(url: &str) -> Result<PgPool> {
let pool = PgPoolOptions::new()
.max_connections(16)
.min_connections(1)
.connect(url)
.await
.with_context(|| format!("failed to open database: {url}"))?;
tracing::info!(url = url, "postgres pool opened");
Ok(pool)
}
#[cfg(all(feature = "postgres", not(feature = "sqlite")))]
pub async fn run_migrations(pool: &PgPool) -> Result<()> {
sqlx::migrate!("src/db/migrations/postgres")
.run(pool)
.await
.context("failed to run postgres migrations")?;
tracing::info!("postgres migrations applied");
Ok(())
}
pub mod models;
pub mod provider;
pub mod repository;
+4 -1
View File
@@ -6,8 +6,11 @@ pub struct Application {
pub id: String,
pub tenant_id: String,
pub name: String,
pub slug: String,
pub description: Option<String>,
pub slug: Option<String>,
pub enabled: bool,
pub client_secret_hash: Option<String>,
pub redirect_uris: Option<String>,
pub created_at: String,
pub updated_at: String,
}
+12
View File
@@ -0,0 +1,12 @@
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct Group {
pub id: String,
pub tenant_id: String,
pub name: String,
pub description: Option<String>,
pub created_at: String,
pub updated_at: String,
}
+2
View File
@@ -18,3 +18,5 @@ pub use service_account::ServiceAccount;
pub use session::Session;
pub use tenant::Tenant;
pub use user::{User, UserStatus};
pub mod group;
pub use group::Group;
+1
View File
@@ -3,6 +3,7 @@ use sqlx::FromRow;
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct Permission {
pub slug: Option<String>,
pub id: String,
pub name: String,
pub description: Option<String>,
+1
View File
@@ -3,6 +3,7 @@ use sqlx::FromRow;
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct Role {
pub slug: Option<String>,
pub id: String,
pub name: String,
pub description: Option<String>,
+1
View File
@@ -3,6 +3,7 @@ use sqlx::FromRow;
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct ServiceAccount {
pub slug: Option<String>,
pub id: String,
pub tenant_id: String,
pub name: String,
+1 -1
View File
@@ -5,7 +5,7 @@ use sqlx::FromRow;
pub struct Tenant {
pub id: String,
pub name: String,
pub slug: String,
pub slug: Option<String>,
pub enabled: bool,
pub created_at: String,
pub updated_at: String,
+1
View File
@@ -43,6 +43,7 @@ impl std::fmt::Display for UserStatus {
/// A user account row from the `users` table.
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct User {
pub slug: Option<String>,
pub id: String,
pub tenant_id: String,
pub username: String,
+208
View File
@@ -0,0 +1,208 @@
#[cfg(feature = "postgres")]
use sqlx::PgPool;
use sqlx::SqlitePool;
use crate::db::repository::traits::*;
#[async_trait::async_trait]
pub trait DatabaseProvider: Send + Sync {
fn users(&self) -> Box<dyn UsersRepository>;
fn applications(&self) -> Box<dyn ApplicationsRepository>;
fn audit(&self) -> Box<dyn AuditRepository>;
fn permissions(&self) -> Box<dyn PermissionsRepository>;
fn refresh_tokens(&self) -> Box<dyn RefreshTokensRepository>;
fn roles(&self) -> Box<dyn RolesRepository>;
fn service_accounts(&self) -> Box<dyn ServiceAccountsRepository>;
fn sessions(&self) -> Box<dyn SessionsRepository>;
fn tokens(&self) -> Box<dyn TokensRepository>;
fn tenants(&self) -> Box<dyn TenantsRepository>;
fn groups(&self) -> Box<dyn GroupsRepository>;
}
#[cfg(feature = "sqlite")]
pub struct SqliteProvider {
pub pool: SqlitePool,
}
#[cfg(feature = "sqlite")]
impl SqliteProvider {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
}
#[cfg(feature = "sqlite")]
#[async_trait::async_trait]
impl DatabaseProvider for SqliteProvider {
fn users(&self) -> Box<dyn UsersRepository> {
Box::new(
crate::db::repository::sqlite::users::SqliteUsersRepository {
pool: self.pool.clone(),
},
)
}
fn applications(&self) -> Box<dyn ApplicationsRepository> {
Box::new(
crate::db::repository::sqlite::applications::SqliteApplicationsRepository {
pool: self.pool.clone(),
},
)
}
fn audit(&self) -> Box<dyn AuditRepository> {
Box::new(
crate::db::repository::sqlite::audit::SqliteAuditRepository {
pool: self.pool.clone(),
},
)
}
fn permissions(&self) -> Box<dyn PermissionsRepository> {
Box::new(
crate::db::repository::sqlite::permissions::SqlitePermissionsRepository {
pool: self.pool.clone(),
},
)
}
fn refresh_tokens(&self) -> Box<dyn RefreshTokensRepository> {
Box::new(
crate::db::repository::sqlite::refresh_tokens::SqliteRefreshTokensRepository {
pool: self.pool.clone(),
},
)
}
fn roles(&self) -> Box<dyn RolesRepository> {
Box::new(
crate::db::repository::sqlite::roles::SqliteRolesRepository {
pool: self.pool.clone(),
},
)
}
fn service_accounts(&self) -> Box<dyn ServiceAccountsRepository> {
Box::new(
crate::db::repository::sqlite::service_accounts::SqliteServiceAccountsRepository {
pool: self.pool.clone(),
},
)
}
fn sessions(&self) -> Box<dyn SessionsRepository> {
Box::new(
crate::db::repository::sqlite::sessions::SqliteSessionsRepository {
pool: self.pool.clone(),
},
)
}
fn tokens(&self) -> Box<dyn TokensRepository> {
Box::new(
crate::db::repository::sqlite::tokens::SqliteTokensRepository {
pool: self.pool.clone(),
},
)
}
fn tenants(&self) -> Box<dyn TenantsRepository> {
Box::new(
crate::db::repository::sqlite::tenants::SqliteTenantsRepository {
pool: self.pool.clone(),
},
)
}
fn groups(&self) -> Box<dyn GroupsRepository> {
Box::new(
crate::db::repository::sqlite::groups::SqliteGroupsRepository {
pool: self.pool.clone(),
},
)
}
}
#[cfg(feature = "postgres")]
pub struct PostgresProvider {
pub pool: PgPool,
}
#[cfg(feature = "postgres")]
impl PostgresProvider {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[cfg(feature = "postgres")]
#[async_trait::async_trait]
impl DatabaseProvider for PostgresProvider {
fn users(&self) -> Box<dyn UsersRepository> {
Box::new(
crate::db::repository::postgres::users::PostgresUsersRepository {
pool: self.pool.clone(),
},
)
}
fn applications(&self) -> Box<dyn ApplicationsRepository> {
Box::new(
crate::db::repository::postgres::applications::PostgresApplicationsRepository {
pool: self.pool.clone(),
},
)
}
fn audit(&self) -> Box<dyn AuditRepository> {
Box::new(
crate::db::repository::postgres::audit::PostgresAuditRepository {
pool: self.pool.clone(),
},
)
}
fn permissions(&self) -> Box<dyn PermissionsRepository> {
Box::new(
crate::db::repository::postgres::permissions::PostgresPermissionsRepository {
pool: self.pool.clone(),
},
)
}
fn refresh_tokens(&self) -> Box<dyn RefreshTokensRepository> {
Box::new(
crate::db::repository::postgres::refresh_tokens::PostgresRefreshTokensRepository {
pool: self.pool.clone(),
},
)
}
fn roles(&self) -> Box<dyn RolesRepository> {
Box::new(
crate::db::repository::postgres::roles::PostgresRolesRepository {
pool: self.pool.clone(),
},
)
}
fn service_accounts(&self) -> Box<dyn ServiceAccountsRepository> {
Box::new(
crate::db::repository::postgres::service_accounts::PostgresServiceAccountsRepository {
pool: self.pool.clone(),
},
)
}
fn sessions(&self) -> Box<dyn SessionsRepository> {
Box::new(
crate::db::repository::postgres::sessions::PostgresSessionsRepository {
pool: self.pool.clone(),
},
)
}
fn tokens(&self) -> Box<dyn TokensRepository> {
Box::new(
crate::db::repository::postgres::tokens::PostgresTokensRepository {
pool: self.pool.clone(),
},
)
}
fn tenants(&self) -> Box<dyn TenantsRepository> {
Box::new(
crate::db::repository::postgres::tenants::PostgresTenantsRepository {
pool: self.pool.clone(),
},
)
}
fn groups(&self) -> Box<dyn GroupsRepository> {
Box::new(
crate::db::repository::postgres::groups::PostgresGroupsRepository {
pool: self.pool.clone(),
},
)
}
}
+1 -60
View File
@@ -1,60 +1 @@
use sqlx::SqlitePool;
use crate::db::models::Application;
pub async fn create(
pool: &SqlitePool,
id: &str,
tenant_id: &str,
name: &str,
slug: &str,
) -> Result<Application, sqlx::Error> {
sqlx::query_as::<_, Application>(
r#"
INSERT INTO applications (id, tenant_id, name, slug)
VALUES (?, ?, ?, ?)
RETURNING *
"#,
)
.bind(id)
.bind(tenant_id)
.bind(name)
.bind(slug)
.fetch_one(pool)
.await
}
pub async fn find_by_slug(
pool: &SqlitePool,
slug: &str,
) -> Result<Option<Application>, sqlx::Error> {
sqlx::query_as::<_, Application>("SELECT * FROM applications WHERE slug = ?")
.bind(slug)
.fetch_optional(pool)
.await
}
pub async fn find_by_id(pool: &SqlitePool, id: &str) -> Result<Option<Application>, sqlx::Error> {
sqlx::query_as::<_, Application>("SELECT * FROM applications WHERE id = ?")
.bind(id)
.fetch_optional(pool)
.await
}
pub async fn list(pool: &SqlitePool, tenant_id: &str) -> Result<Vec<Application>, sqlx::Error> {
sqlx::query_as::<_, Application>("SELECT * FROM applications WHERE tenant_id = ? ORDER BY name")
.bind(tenant_id)
.fetch_all(pool)
.await
}
pub async fn set_enabled(pool: &SqlitePool, id: &str, enabled: bool) -> Result<(), sqlx::Error> {
sqlx::query(
"UPDATE applications SET enabled = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?",
)
.bind(enabled)
.bind(id)
.execute(pool)
.await?;
Ok(())
}
pub use crate::db::repository::sqlite::applications::*;
+35 -30
View File
@@ -1,10 +1,14 @@
use sqlx::SqlitePool;
pub use crate::db::repository::sqlite::audit::*;
use crate::db::models::AuditLog;
use crate::db::provider::DatabaseProvider;
use std::sync::Arc;
// Removed direct import of AuditFilter to avoid conflict with traits version
/// Insert an audit log entry using the provided DatabaseProvider.
#[allow(clippy::too_many_arguments)]
pub async fn insert(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
provider: &Arc<dyn DatabaseProvider>,
id: &str,
actor_user_id: Option<&str>,
target_user_id: Option<&str>,
@@ -16,34 +20,35 @@ pub async fn insert(
user_agent: Option<&str>,
metadata_json: Option<&str>,
) -> Result<AuditLog, sqlx::Error> {
sqlx::query_as::<_, AuditLog>(
r#"
INSERT INTO audit_logs (
id, actor_user_id, target_user_id,
action, resource_type, resource_id,
severity, ip_address, user_agent, metadata_json
provider
.audit()
.insert(
id,
actor_user_id,
target_user_id,
action,
resource_type,
resource_id,
severity,
ip_address,
user_agent,
metadata_json,
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING *
"#,
)
.bind(id)
.bind(actor_user_id)
.bind(target_user_id)
.bind(action)
.bind(resource_type)
.bind(resource_id)
.bind(severity)
.bind(ip_address)
.bind(user_agent)
.bind(metadata_json)
.fetch_one(&mut **tx)
.await
}
pub async fn list_recent(pool: &SqlitePool, limit: i64) -> Result<Vec<AuditLog>, sqlx::Error> {
sqlx::query_as::<_, AuditLog>("SELECT * FROM audit_logs ORDER BY created_at DESC LIMIT ?")
.bind(limit)
.fetch_all(pool)
.await
}
/// Count filtered audit logs using the provided DatabaseProvider.
pub async fn count_filtered(
provider: &Arc<dyn DatabaseProvider>,
filter: &AuditFilter,
) -> Result<i64, sqlx::Error> {
provider.audit().count_filtered(filter).await
}
/// List filtered audit logs using the provided DatabaseProvider.
pub async fn list_filtered(
provider: &Arc<dyn DatabaseProvider>,
filter: &AuditFilter,
) -> Result<Vec<AuditLog>, sqlx::Error> {
provider.audit().list_filtered(filter).await
}
+13 -6
View File
@@ -1,8 +1,15 @@
pub mod applications;
pub mod traits;
pub use traits::*;
#[cfg(feature = "sqlite")]
pub mod sqlite;
#[cfg(feature = "sqlite")]
pub use sqlite::*;
#[cfg(feature = "postgres")]
pub mod postgres;
#[cfg(all(feature = "postgres", not(feature = "sqlite")))]
pub use postgres::*;
pub mod audit;
pub mod permissions;
pub mod roles;
pub mod service_accounts;
pub mod sessions;
pub mod tokens;
pub mod users;
+1 -41
View File
@@ -1,41 +1 @@
use sqlx::SqlitePool;
/// Return all permission names held by a user (via their roles).
pub async fn list_for_user(pool: &SqlitePool, user_id: &str) -> Result<Vec<String>, sqlx::Error> {
let rows: Vec<(String,)> = sqlx::query_as(
r#"
SELECT DISTINCT p.name
FROM permissions p
JOIN role_permissions rp ON rp.permission_id = p.id
JOIN user_roles ur ON ur.role_id = rp.role_id
WHERE ur.user_id = ?
ORDER BY p.name
"#,
)
.bind(user_id)
.fetch_all(pool)
.await?;
Ok(rows.into_iter().map(|(name,)| name).collect())
}
/// Check if a user holds a specific named permission.
pub async fn user_has_permission(
pool: &SqlitePool,
user_id: &str,
permission_name: &str,
) -> Result<bool, sqlx::Error> {
let row: (i64,) = sqlx::query_as(
r#"
SELECT COUNT(*)
FROM permissions p
JOIN role_permissions rp ON rp.permission_id = p.id
JOIN user_roles ur ON ur.role_id = rp.role_id
WHERE ur.user_id = ? AND p.name = ?
"#,
)
.bind(user_id)
.bind(permission_name)
.fetch_one(pool)
.await?;
Ok(row.0 > 0)
}
pub use crate::db::repository::sqlite::permissions::*;
+108
View File
@@ -0,0 +1,108 @@
use crate::db::repository::traits::ApplicationsRepository;
use async_trait::async_trait;
use sqlx::PgPool;
use crate::db::models::Application;
pub struct PostgresApplicationsRepository {
pub pool: PgPool,
}
#[async_trait]
impl ApplicationsRepository for PostgresApplicationsRepository {
async fn create(
&self,
id: &str,
tenant_id: &str,
name: &str,
slug: &str,
) -> Result<Application, sqlx::Error> {
sqlx::query_as::<_, Application>(
r#"
INSERT INTO applications (id, tenant_id, name, slug)
VALUES ($1, $2, $3, $4)
RETURNING *
"#,
)
.bind(id)
.bind(tenant_id)
.bind(name)
.bind(slug)
.fetch_one(&self.pool)
.await
}
async fn find_by_slug(&self, slug: &str) -> Result<Option<Application>, sqlx::Error> {
sqlx::query_as::<_, Application>("SELECT * FROM applications WHERE slug = $1")
.bind(slug)
.fetch_optional(&self.pool)
.await
}
async fn find_by_id(&self, id: &str) -> Result<Option<Application>, sqlx::Error> {
sqlx::query_as::<_, Application>("SELECT * FROM applications WHERE id = $1")
.bind(id)
.fetch_optional(&self.pool)
.await
}
async fn list(&self, tenant_id: &str) -> Result<Vec<Application>, sqlx::Error> {
sqlx::query_as::<_, Application>(
"SELECT * FROM applications WHERE tenant_id = $1 ORDER BY name",
)
.bind(tenant_id)
.fetch_all(&self.pool)
.await
}
async fn set_enabled(&self, id: &str, enabled: bool) -> Result<(), sqlx::Error> {
sqlx::query(
"UPDATE applications SET enabled = $1, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = $2",
)
.bind(enabled)
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
async fn update(
&self,
id: &str,
name: &str,
slug: &str,
enabled: bool,
) -> Result<(), sqlx::Error> {
sqlx::query(
r#"
UPDATE applications
SET name = $1, slug = $2, enabled = $3,
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
WHERE id = $4
"#,
)
.bind(name)
.bind(slug)
.bind(enabled)
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
async fn delete(&self, id: &str) -> Result<(), sqlx::Error> {
sqlx::query("DELETE FROM applications WHERE id = $1")
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
async fn count(&self, tenant_id: &str) -> Result<i64, sqlx::Error> {
let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM applications WHERE tenant_id = $1")
.bind(tenant_id)
.fetch_one(&self.pool)
.await?;
Ok(row.0)
}
}
+146
View File
@@ -0,0 +1,146 @@
use crate::db::repository::traits::AuditRepository;
use async_trait::async_trait;
use sqlx::PgPool;
use crate::db::models::AuditLog;
pub struct PostgresAuditRepository {
pub pool: PgPool,
}
use crate::db::repository::sqlite::audit::AuditFilter;
#[async_trait]
impl AuditRepository for PostgresAuditRepository {
async fn count(&self) -> Result<i64, sqlx::Error> {
let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM audit_logs")
.fetch_one(&self.pool)
.await?;
Ok(row.0)
}
#[allow(clippy::too_many_arguments)]
async fn insert(
&self,
id: &str,
actor_user_id: Option<&str>,
target_user_id: Option<&str>,
action: &str,
resource_type: &str,
resource_id: Option<&str>,
severity: &str,
ip_address: Option<&str>,
user_agent: Option<&str>,
metadata_json: Option<&str>,
) -> Result<AuditLog, sqlx::Error> {
sqlx::query_as::<_, AuditLog>(
r#"
INSERT INTO audit_logs (
id, actor_user_id, target_user_id,
action, resource_type, resource_id,
severity, ip_address, user_agent, metadata_json
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING *
"#,
)
.bind(id)
.bind(actor_user_id)
.bind(target_user_id)
.bind(action)
.bind(resource_type)
.bind(resource_id)
.bind(severity)
.bind(ip_address)
.bind(user_agent)
.bind(metadata_json)
.fetch_one(&self.pool)
.await
}
async fn list_recent(&self, limit: i64) -> Result<Vec<AuditLog>, sqlx::Error> {
sqlx::query_as::<_, AuditLog>("SELECT * FROM audit_logs ORDER BY created_at DESC LIMIT $1")
.bind(limit)
.fetch_all(&self.pool)
.await
}
async fn list_filtered(&self, filter: &AuditFilter) -> Result<Vec<AuditLog>, sqlx::Error> {
// Build a dynamic but simple filter using COALESCE-style optional matches.
// Empty optionals are treated as wildcards via OR IS NULL pattern with bind of None.
let search_like = filter
.search
.as_ref()
.map(|s| format!("%{}%", s.replace('%', "\\%")));
sqlx::query_as::<_, AuditLog>(
r#"
SELECT * FROM audit_logs
WHERE ($11 IS NULL OR actor_user_id = $21)
AND ($32 IS NULL OR action = $42)
AND ($53 IS NULL OR resource_type = $63)
AND ($74 IS NULL OR severity = $84)
AND ($95 IS NULL OR created_at >= $105)
AND ($116 IS NULL OR created_at <= $126)
AND (
$137 IS NULL
OR action LIKE $147 ESCAPE '\'
OR resource_type LIKE $157 ESCAPE '\'
OR resource_id LIKE $167 ESCAPE '\'
OR ip_address LIKE $177 ESCAPE '\'
OR metadata_json LIKE $187 ESCAPE '\'
)
ORDER BY created_at DESC
LIMIT $198 OFFSET $209
"#,
)
.bind(filter.actor_user_id.as_deref())
.bind(filter.action.as_deref())
.bind(filter.resource_type.as_deref())
.bind(filter.severity.as_deref())
.bind(filter.since.as_deref())
.bind(filter.until.as_deref())
.bind(search_like.as_deref())
.bind(filter.limit)
.bind(filter.offset)
.fetch_all(&self.pool)
.await
}
async fn count_filtered(&self, filter: &AuditFilter) -> Result<i64, sqlx::Error> {
let search_like = filter
.search
.as_ref()
.map(|s| format!("%{}%", s.replace('%', "\\%")));
let row: (i64,) = sqlx::query_as(
r#"
SELECT COUNT(*) FROM audit_logs
WHERE ($11 IS NULL OR actor_user_id = $21)
AND ($32 IS NULL OR action = $42)
AND ($53 IS NULL OR resource_type = $63)
AND ($74 IS NULL OR severity = $84)
AND ($95 IS NULL OR created_at >= $105)
AND ($116 IS NULL OR created_at <= $126)
AND (
$137 IS NULL
OR action LIKE $147 ESCAPE '\'
OR resource_type LIKE $157 ESCAPE '\'
OR resource_id LIKE $167 ESCAPE '\'
OR ip_address LIKE $177 ESCAPE '\'
OR metadata_json LIKE $187 ESCAPE '\'
)
"#,
)
.bind(filter.actor_user_id.as_deref())
.bind(filter.action.as_deref())
.bind(filter.resource_type.as_deref())
.bind(filter.severity.as_deref())
.bind(filter.since.as_deref())
.bind(filter.until.as_deref())
.bind(search_like.as_deref())
.fetch_one(&self.pool)
.await?;
Ok(row.0)
}
}
+62
View File
@@ -0,0 +1,62 @@
use crate::db::models::{Group, User};
use crate::db::repository::traits::GroupsRepository;
use async_trait::async_trait;
use sqlx::PgPool;
pub struct PostgresGroupsRepository {
pub pool: PgPool,
}
#[async_trait]
impl GroupsRepository for PostgresGroupsRepository {
async fn list(&self, _tenant_id: &str) -> Result<Vec<Group>, sqlx::Error> {
unimplemented!()
}
async fn find_by_id(&self, _id: &str) -> Result<Option<Group>, sqlx::Error> {
unimplemented!()
}
async fn create(
&self,
_id: &str,
_tenant_id: &str,
_name: &str,
_description: Option<&str>,
) -> Result<Group, sqlx::Error> {
unimplemented!()
}
async fn update(
&self,
_id: &str,
_name: &str,
_description: Option<&str>,
) -> Result<(), sqlx::Error> {
unimplemented!()
}
async fn delete(&self, _id: &str) -> Result<(), sqlx::Error> {
unimplemented!()
}
async fn count_members(&self, _group_id: &str) -> Result<i64, sqlx::Error> {
unimplemented!()
}
async fn list_members(&self, _group_id: &str) -> Result<Vec<User>, sqlx::Error> {
unimplemented!()
}
async fn add_member(&self, _group_id: &str, _user_id: &str) -> Result<(), sqlx::Error> {
unimplemented!()
}
async fn remove_member(&self, _group_id: &str, _user_id: &str) -> Result<(), sqlx::Error> {
unimplemented!()
}
async fn count(&self, _tenant_id: &str) -> Result<i64, sqlx::Error> {
unimplemented!()
}
}
+11
View File
@@ -0,0 +1,11 @@
pub mod applications;
pub mod audit;
pub mod groups;
pub mod permissions;
pub mod refresh_tokens;
pub mod roles;
pub mod service_accounts;
pub mod sessions;
pub mod tenants;
pub mod tokens;
pub mod users;
+125
View File
@@ -0,0 +1,125 @@
use crate::db::repository::traits::PermissionsRepository;
use async_trait::async_trait;
use sqlx::PgPool;
use crate::db::models::Permission;
pub struct PostgresPermissionsRepository {
pub pool: PgPool,
}
#[async_trait]
impl PermissionsRepository for PostgresPermissionsRepository {
/// List all permissions defined in the system.
async fn list_all(&self) -> Result<Vec<Permission>, sqlx::Error> {
sqlx::query_as::<_, Permission>("SELECT * FROM permissions ORDER BY name")
.fetch_all(&self.pool)
.await
}
/// List permissions assigned to a role.
async fn list_for_role(&self, role_id: &str) -> Result<Vec<Permission>, sqlx::Error> {
sqlx::query_as::<_, Permission>(
r#"
SELECT p.* FROM permissions p
JOIN role_permissions rp ON rp.permission_id = p.id
WHERE rp.role_id = $1
ORDER BY p.name
"#,
)
.bind(role_id)
.fetch_all(&self.pool)
.await
}
/// Assign a permission to a role (no-op if already assigned).
async fn assign_to_role(&self, role_id: &str, permission_id: &str) -> Result<(), sqlx::Error> {
sqlx::query(
"INSERT OR IGNORE INTO role_permissions (role_id, permission_id) VALUES ($1, $2)",
)
.bind(role_id)
.bind(permission_id)
.execute(&self.pool)
.await?;
Ok(())
}
/// Remove a permission from a role.
async fn remove_from_role(
&self,
role_id: &str,
permission_id: &str,
) -> Result<(), sqlx::Error> {
sqlx::query("DELETE FROM role_permissions WHERE role_id = $1 AND permission_id = $2")
.bind(role_id)
.bind(permission_id)
.execute(&self.pool)
.await?;
Ok(())
}
/// Clear all permissions for a role.
async fn clear_for_role(&self, role_id: &str) -> Result<(), sqlx::Error> {
sqlx::query("DELETE FROM role_permissions WHERE role_id = $1")
.bind(role_id)
.execute(&self.pool)
.await?;
Ok(())
}
/// Find a permission by name.
async fn find_by_name(&self, name: &str) -> Result<Option<Permission>, sqlx::Error> {
sqlx::query_as::<_, Permission>("SELECT * FROM permissions WHERE name = $1")
.bind(name)
.fetch_optional(&self.pool)
.await
}
/// Find a permission by id.
async fn find_by_id(&self, id: &str) -> Result<Option<Permission>, sqlx::Error> {
sqlx::query_as::<_, Permission>("SELECT * FROM permissions WHERE id = $1")
.bind(id)
.fetch_optional(&self.pool)
.await
}
/// Return all permission names held by a user (via their roles).
async fn list_for_user(&self, user_id: &str) -> Result<Vec<String>, sqlx::Error> {
let rows: Vec<(String,)> = sqlx::query_as(
r#"
SELECT DISTINCT p.name
FROM permissions p
JOIN role_permissions rp ON rp.permission_id = p.id
JOIN user_roles ur ON ur.role_id = rp.role_id
WHERE ur.user_id = $1
ORDER BY p.name
"#,
)
.bind(user_id)
.fetch_all(&self.pool)
.await?;
Ok(rows.into_iter().map(|(name,)| name).collect())
}
/// Check if a user holds a specific named permission.
async fn user_has_permission(
&self,
user_id: &str,
permission_name: &str,
) -> Result<bool, sqlx::Error> {
let row: (i64,) = sqlx::query_as(
r#"
SELECT COUNT(*)
FROM permissions p
JOIN role_permissions rp ON rp.permission_id = p.id
JOIN user_roles ur ON ur.role_id = rp.role_id
WHERE ur.user_id = $1 AND p.name = $2
"#,
)
.bind(user_id)
.bind(permission_name)
.fetch_one(&self.pool)
.await?;
Ok(row.0 > 0)
}
}
@@ -0,0 +1,59 @@
use crate::db::repository::traits::RefreshTokensRepository;
use async_trait::async_trait;
use sqlx::PgPool;
pub struct PostgresRefreshTokensRepository {
pub pool: PgPool,
}
use crate::db::repository::sqlite::refresh_tokens::RefreshToken;
#[async_trait]
impl RefreshTokensRepository for PostgresRefreshTokensRepository {
async fn create(
&self,
id: &str,
user_id: &str,
token_hash: &str,
expires_at: &str,
) -> Result<RefreshToken, sqlx::Error> {
sqlx::query_as::<_, RefreshToken>(
r#"
INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at)
VALUES ($1, $2, $3, $4)
RETURNING *
"#,
)
.bind(id)
.bind(user_id)
.bind(token_hash)
.bind(expires_at)
.fetch_one(&self.pool)
.await
}
async fn find_by_hash(&self, token_hash: &str) -> Result<Option<RefreshToken>, sqlx::Error> {
sqlx::query_as::<_, RefreshToken>(
"SELECT * FROM refresh_tokens WHERE token_hash = $1 AND revoked = 0",
)
.bind(token_hash)
.fetch_optional(&self.pool)
.await
}
async fn revoke(&self, id: &str) -> Result<(), sqlx::Error> {
sqlx::query("UPDATE refresh_tokens SET revoked = 1 WHERE id = $1")
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
async fn revoke_all_for_user(&self, user_id: &str) -> Result<(), sqlx::Error> {
sqlx::query("UPDATE refresh_tokens SET revoked = 1 WHERE user_id = $1")
.bind(user_id)
.execute(&self.pool)
.await?;
Ok(())
}
}
+127
View File
@@ -0,0 +1,127 @@
use crate::db::repository::traits::RolesRepository;
use async_trait::async_trait;
use sqlx::PgPool;
use crate::db::models::Role;
pub struct PostgresRolesRepository {
pub pool: PgPool,
}
#[async_trait]
impl RolesRepository for PostgresRolesRepository {
async fn list_all(&self) -> Result<Vec<Role>, sqlx::Error> {
sqlx::query_as::<_, Role>("SELECT * FROM roles ORDER BY name")
.fetch_all(&self.pool)
.await
}
async fn find_by_name(&self, name: &str) -> Result<Option<Role>, sqlx::Error> {
sqlx::query_as::<_, Role>("SELECT * FROM roles WHERE name = $1")
.bind(name)
.fetch_optional(&self.pool)
.await
}
async fn find_by_id(&self, id: &str) -> Result<Option<Role>, sqlx::Error> {
sqlx::query_as::<_, Role>("SELECT * FROM roles WHERE id = $1")
.bind(id)
.fetch_optional(&self.pool)
.await
}
async fn list_for_user(&self, user_id: &str) -> Result<Vec<Role>, sqlx::Error> {
sqlx::query_as::<_, Role>(
r#"
SELECT r.* FROM roles r
JOIN user_roles ur ON ur.role_id = r.id
WHERE ur.user_id = $1
ORDER BY r.name
"#,
)
.bind(user_id)
.fetch_all(&self.pool)
.await
}
async fn assign_to_user(&self, user_id: &str, role_id: &str) -> Result<(), sqlx::Error> {
sqlx::query("INSERT OR IGNORE INTO user_roles (user_id, role_id) VALUES ($1, $2)")
.bind(user_id)
.bind(role_id)
.execute(&self.pool)
.await?;
Ok(())
}
async fn remove_from_user(&self, user_id: &str, role_id: &str) -> Result<(), sqlx::Error> {
sqlx::query("DELETE FROM user_roles WHERE user_id = $1 AND role_id = $2")
.bind(user_id)
.bind(role_id)
.execute(&self.pool)
.await?;
Ok(())
}
async fn admin_role_exists(&self) -> Result<bool, sqlx::Error> {
let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM roles WHERE name = 'admin'")
.fetch_one(&self.pool)
.await?;
Ok(row.0 > 0)
}
/// Create a new role.
async fn create(
&self,
id: &str,
name: &str,
description: Option<&str>,
) -> Result<Role, sqlx::Error> {
sqlx::query_as::<_, Role>(
r#"
INSERT INTO roles (id, name, description)
VALUES ($1, $2, $3)
RETURNING *
"#,
)
.bind(id)
.bind(name)
.bind(description)
.fetch_one(&self.pool)
.await
}
/// Update role name/description.
async fn update(
&self,
id: &str,
name: &str,
description: Option<&str>,
) -> Result<(), sqlx::Error> {
sqlx::query("UPDATE roles SET name = $1, description = $2 WHERE id = $3")
.bind(name)
.bind(description)
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
/// Delete a role by id.
async fn delete(&self, id: &str) -> Result<(), sqlx::Error> {
sqlx::query("DELETE FROM roles WHERE id = $1")
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
/// List user ids that hold a given role.
async fn list_user_ids_for_role(&self, role_id: &str) -> Result<Vec<String>, sqlx::Error> {
let rows: Vec<(String,)> =
sqlx::query_as("SELECT user_id FROM user_roles WHERE role_id = $1 ORDER BY user_id")
.bind(role_id)
.fetch_all(&self.pool)
.await?;
Ok(rows.into_iter().map(|(id,)| id).collect())
}
}
@@ -0,0 +1,78 @@
use crate::db::repository::traits::ServiceAccountsRepository;
use async_trait::async_trait;
use sqlx::PgPool;
use crate::db::models::ServiceAccount;
pub struct PostgresServiceAccountsRepository {
pub pool: PgPool,
}
#[async_trait]
impl ServiceAccountsRepository for PostgresServiceAccountsRepository {
async fn create(
&self,
id: &str,
tenant_id: &str,
name: &str,
description: Option<&str>,
) -> Result<ServiceAccount, sqlx::Error> {
sqlx::query_as::<_, ServiceAccount>(
r#"
INSERT INTO service_accounts (id, tenant_id, name, description)
VALUES ($1, $2, $3, $4)
RETURNING *
"#,
)
.bind(id)
.bind(tenant_id)
.bind(name)
.bind(description)
.fetch_one(&self.pool)
.await
}
async fn find_by_id(&self, id: &str) -> Result<Option<ServiceAccount>, sqlx::Error> {
sqlx::query_as::<_, ServiceAccount>("SELECT * FROM service_accounts WHERE id = $1")
.bind(id)
.fetch_optional(&self.pool)
.await
}
async fn list(&self, tenant_id: &str) -> Result<Vec<ServiceAccount>, sqlx::Error> {
sqlx::query_as::<_, ServiceAccount>(
"SELECT * FROM service_accounts WHERE tenant_id = $1 ORDER BY name",
)
.bind(tenant_id)
.fetch_all(&self.pool)
.await
}
async fn set_enabled(&self, id: &str, enabled: bool) -> Result<(), sqlx::Error> {
sqlx::query(
"UPDATE service_accounts SET enabled = $1, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = $2",
)
.bind(enabled)
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
async fn delete(&self, id: &str) -> Result<(), sqlx::Error> {
sqlx::query("DELETE FROM service_accounts WHERE id = $1")
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
async fn count(&self, tenant_id: &str) -> Result<i64, sqlx::Error> {
let row: (i64,) =
sqlx::query_as("SELECT COUNT(*) FROM service_accounts WHERE tenant_id = $1")
.bind(tenant_id)
.fetch_one(&self.pool)
.await?;
Ok(row.0)
}
}
+123
View File
@@ -0,0 +1,123 @@
use crate::db::repository::traits::SessionsRepository;
use async_trait::async_trait;
use sqlx::PgPool;
use crate::db::models::Session;
pub struct PostgresSessionsRepository {
pub pool: PgPool,
}
#[async_trait]
impl SessionsRepository for PostgresSessionsRepository {
async fn create(
&self,
id: &str,
user_id: &str,
token_hash: &str,
ip_address: Option<&str>,
user_agent: Option<&str>,
expires_at: &str,
) -> Result<Session, sqlx::Error> {
sqlx::query_as::<_, Session>(
r#"
INSERT INTO sessions (id, user_id, token_hash, ip_address, user_agent, expires_at)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *
"#,
)
.bind(id)
.bind(user_id)
.bind(token_hash)
.bind(ip_address)
.bind(user_agent)
.bind(expires_at)
.fetch_one(&self.pool)
.await
}
async fn find_by_token_hash(&self, token_hash: &str) -> Result<Option<Session>, sqlx::Error> {
sqlx::query_as::<_, Session>("SELECT * FROM sessions WHERE token_hash = $1 AND revoked = 0")
.bind(token_hash)
.fetch_optional(&self.pool)
.await
}
async fn revoke(&self, id: &str) -> Result<(), sqlx::Error> {
sqlx::query("UPDATE sessions SET revoked = 1 WHERE id = $1")
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
async fn revoke_all_for_user(&self, user_id: &str) -> Result<(), sqlx::Error> {
sqlx::query("UPDATE sessions SET revoked = 1 WHERE user_id = $1")
.bind(user_id)
.execute(&self.pool)
.await?;
Ok(())
}
async fn update_last_seen(&self, id: &str) -> Result<(), sqlx::Error> {
sqlx::query(
"UPDATE sessions SET last_seen_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = $1",
)
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
/// List active (non-revoked, non-expired) sessions for a user.
async fn list_active_for_user(&self, user_id: &str) -> Result<Vec<Session>, sqlx::Error> {
sqlx::query_as::<_, Session>(
r#"
SELECT * FROM sessions
WHERE user_id = $1
AND revoked = 0
AND expires_at >= strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
ORDER BY last_seen_at DESC
"#,
)
.bind(user_id)
.fetch_all(&self.pool)
.await
}
async fn list_all_active(&self) -> Result<Vec<Session>, sqlx::Error> {
unimplemented!()
}
/// Count active sessions system-wide.
async fn count_active(&self) -> Result<i64, sqlx::Error> {
let row: (i64,) = sqlx::query_as(
r#"
SELECT COUNT(*) FROM sessions
WHERE revoked = 0
AND expires_at >= strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
"#,
)
.fetch_one(&self.pool)
.await?;
Ok(row.0)
}
/// Delete sessions that are expired or revoked. Called once at startup.
async fn cleanup_expired(&self) -> Result<u64, sqlx::Error> {
let result = sqlx::query(
r#"
DELETE FROM sessions
WHERE revoked = 1
OR expires_at < strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
"#,
)
.execute(&self.pool)
.await?;
Ok(result.rows_affected())
}
async fn revoke_others(&self, _user_id: &str, _except_id: &str) -> Result<u64, sqlx::Error> {
unimplemented!()
}
}
+108
View File
@@ -0,0 +1,108 @@
use sqlx::PgPool;
use crate::db::models::Tenant;
use crate::db::repository::traits::TenantsRepository;
pub struct PostgresTenantsRepository {
pub pool: PgPool,
}
#[async_trait::async_trait]
impl TenantsRepository for PostgresTenantsRepository {
async fn find_by_id(&self, id: &str) -> Result<Option<Tenant>, sqlx::Error> {
let row = sqlx::query_as::<_, Tenant>(
"SELECT id, name, slug, enabled, created_at::text, updated_at::text FROM tenants WHERE id = $1",
)
.bind(id)
.fetch_optional(&self.pool)
.await?;
Ok(row)
}
async fn find_by_slug(&self, slug: &str) -> Result<Option<Tenant>, sqlx::Error> {
let row = sqlx::query_as::<_, Tenant>(
"SELECT id, name, slug, enabled, created_at::text, updated_at::text FROM tenants WHERE slug = $1",
)
.bind(slug)
.fetch_optional(&self.pool)
.await?;
Ok(row)
}
async fn list(&self) -> Result<Vec<Tenant>, sqlx::Error> {
let rows = sqlx::query_as::<_, Tenant>(
"SELECT id, name, slug, enabled, created_at::text, updated_at::text FROM tenants ORDER BY name ASC",
)
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
async fn create(
&self,
id: &str,
name: &str,
slug: Option<&str>,
) -> Result<Tenant, sqlx::Error> {
let slug = slug.unwrap_or(id);
let row = sqlx::query_as::<_, Tenant>(
r#"
INSERT INTO tenants (id, name, slug, enabled)
VALUES ($1, $2, $3, true)
RETURNING id, name, slug, enabled, created_at::text, updated_at::text
"#,
)
.bind(id)
.bind(name)
.bind(slug)
.fetch_one(&self.pool)
.await?;
Ok(row)
}
async fn update(&self, id: &str, name: &str, slug: Option<&str>) -> Result<(), sqlx::Error> {
let slug = slug.unwrap_or(name);
sqlx::query(
r#"
UPDATE tenants
SET name = $1, slug = $2, updated_at = CURRENT_TIMESTAMP
WHERE id = $3
"#,
)
.bind(name)
.bind(slug)
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
async fn set_enabled(&self, id: &str, enabled: bool) -> Result<(), sqlx::Error> {
sqlx::query(
r#"
UPDATE tenants
SET enabled = $1, updated_at = CURRENT_TIMESTAMP
WHERE id = $2
"#,
)
.bind(enabled)
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
async fn delete(&self, id: &str) -> Result<(), sqlx::Error> {
sqlx::query("DELETE FROM tenants WHERE id = $1")
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
}
+79
View File
@@ -0,0 +1,79 @@
use crate::db::repository::traits::TokensRepository;
use async_trait::async_trait;
use sqlx::PgPool;
use crate::db::models::ApiToken;
pub struct PostgresTokensRepository {
pub pool: PgPool,
}
#[async_trait]
impl TokensRepository for PostgresTokensRepository {
async fn create(
&self,
id: &str,
user_id: &str,
name: &str,
token_hash: &str,
expires_at: Option<&str>,
) -> Result<ApiToken, sqlx::Error> {
sqlx::query_as::<_, ApiToken>(
r#"
INSERT INTO api_tokens (id, user_id, name, token_hash, expires_at)
VALUES ($1, $2, $3, $4, $5)
RETURNING *
"#,
)
.bind(id)
.bind(user_id)
.bind(name)
.bind(token_hash)
.bind(expires_at)
.fetch_one(&self.pool)
.await
}
async fn find_by_hash(&self, token_hash: &str) -> Result<Option<ApiToken>, sqlx::Error> {
sqlx::query_as::<_, ApiToken>(
"SELECT * FROM api_tokens WHERE token_hash = $1 AND revoked = 0",
)
.bind(token_hash)
.fetch_optional(&self.pool)
.await
}
async fn list_for_user(&self, user_id: &str) -> Result<Vec<ApiToken>, sqlx::Error> {
sqlx::query_as::<_, ApiToken>(
"SELECT * FROM api_tokens WHERE user_id = $1 ORDER BY created_at DESC",
)
.bind(user_id)
.fetch_all(&self.pool)
.await
}
async fn find_by_id(&self, id: &str) -> Result<Option<ApiToken>, sqlx::Error> {
sqlx::query_as::<_, ApiToken>("SELECT * FROM api_tokens WHERE id = $1")
.bind(id)
.fetch_optional(&self.pool)
.await
}
async fn revoke(&self, id: &str) -> Result<(), sqlx::Error> {
sqlx::query("UPDATE api_tokens SET revoked = 1 WHERE id = $1")
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
async fn update_last_used(&self, id: &str) -> Result<(), sqlx::Error> {
sqlx::query(
"UPDATE api_tokens SET last_used_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = $1",
)
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
}
+163
View File
@@ -0,0 +1,163 @@
use crate::db::repository::traits::UsersRepository;
use async_trait::async_trait;
use sqlx::PgPool;
use crate::db::models::User;
pub struct PostgresUsersRepository {
pub pool: PgPool,
}
use crate::db::repository::sqlite::users::UserProfile;
#[async_trait]
impl UsersRepository for PostgresUsersRepository {
async fn count_admins(&self) -> Result<i64, sqlx::Error> {
let row: (i64,) = sqlx::query_as(
r#"
SELECT COUNT(DISTINCT ur.user_id)
FROM user_roles ur
JOIN roles r ON r.id = ur.role_id
WHERE r.name = 'admin'
"#,
)
.fetch_one(&self.pool)
.await?;
Ok(row.0)
}
async fn count(&self, tenant_id: &str) -> Result<i64, sqlx::Error> {
let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users WHERE tenant_id = $1")
.bind(tenant_id)
.fetch_one(&self.pool)
.await?;
Ok(row.0)
}
async fn count_by_status(&self, tenant_id: &str, status: i32) -> Result<i64, sqlx::Error> {
let row: (i64,) =
sqlx::query_as("SELECT COUNT(*) FROM users WHERE tenant_id = $1 AND status = $2")
.bind(tenant_id)
.bind(status)
.fetch_one(&self.pool)
.await?;
Ok(row.0)
}
async fn find_by_id(&self, id: &str) -> Result<Option<User>, sqlx::Error> {
sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1")
.bind(id)
.fetch_optional(&self.pool)
.await
}
async fn find_by_username(&self, username: &str) -> Result<Option<User>, sqlx::Error> {
sqlx::query_as::<_, User>("SELECT * FROM users WHERE username = $1")
.bind(username)
.fetch_optional(&self.pool)
.await
}
async fn list(&self, tenant_id: &str) -> Result<Vec<User>, sqlx::Error> {
sqlx::query_as::<_, User>(
"SELECT * FROM users WHERE tenant_id = $1 ORDER BY created_at DESC",
)
.bind(tenant_id)
.fetch_all(&self.pool)
.await
}
async fn create(
&self,
id: &str,
tenant_id: &str,
username: &str,
password_hash: &str,
) -> Result<User, sqlx::Error> {
sqlx::query_as::<_, User>(
r#"
INSERT INTO users (id, tenant_id, username, password_hash, status)
VALUES ($1, $2, $3, $4, 1)
RETURNING *
"#,
)
.bind(id)
.bind(tenant_id)
.bind(username)
.bind(password_hash)
.fetch_one(&self.pool)
.await
}
async fn update_status(&self, id: &str, status: i32) -> Result<(), sqlx::Error> {
sqlx::query(
"UPDATE users SET status = $1, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = $2",
)
.bind(status)
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
async fn update_password_hash(&self, id: &str, password_hash: &str) -> Result<(), sqlx::Error> {
sqlx::query(
"UPDATE users SET password_hash = $1, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = $2",
)
.bind(password_hash)
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
async fn set_last_login(&self, id: &str) -> Result<(), sqlx::Error> {
sqlx::query(
"UPDATE users SET last_login_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = $1",
)
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
async fn username_exists(&self, tenant_id: &str, username: &str) -> Result<bool, sqlx::Error> {
let row: (i64,) =
sqlx::query_as("SELECT COUNT(*) FROM users WHERE tenant_id = $1 AND username = $2")
.bind(tenant_id)
.bind(username)
.fetch_one(&self.pool)
.await?;
Ok(row.0 > 0)
}
async fn get_profile(&self, user_id: &str) -> Result<Option<UserProfile>, sqlx::Error> {
sqlx::query_as::<_, UserProfile>("SELECT * FROM user_profiles WHERE user_id = $1")
.bind(user_id)
.fetch_optional(&self.pool)
.await
}
async fn upsert_profile(
&self,
user_id: &str,
email: Option<&str>,
full_name: Option<&str>,
) -> Result<UserProfile, sqlx::Error> {
sqlx::query_as::<_, UserProfile>(
r#"
INSERT INTO user_profiles (user_id, email, full_name)
VALUES ($1, $2, $3)
ON CONFLICT(user_id) DO UPDATE SET
email = excluded.email,
full_name = excluded.full_name
RETURNING *
"#,
)
.bind(user_id)
.bind(email)
.bind(full_name)
.fetch_one(&self.pool)
.await
}
}
+1 -70
View File
@@ -1,70 +1 @@
use sqlx::SqlitePool;
use crate::db::models::Role;
pub async fn list_all(pool: &SqlitePool) -> Result<Vec<Role>, sqlx::Error> {
sqlx::query_as::<_, Role>("SELECT * FROM roles ORDER BY name")
.fetch_all(pool)
.await
}
pub async fn find_by_name(pool: &SqlitePool, name: &str) -> Result<Option<Role>, sqlx::Error> {
sqlx::query_as::<_, Role>("SELECT * FROM roles WHERE name = ?")
.bind(name)
.fetch_optional(pool)
.await
}
pub async fn find_by_id(pool: &SqlitePool, id: &str) -> Result<Option<Role>, sqlx::Error> {
sqlx::query_as::<_, Role>("SELECT * FROM roles WHERE id = ?")
.bind(id)
.fetch_optional(pool)
.await
}
pub async fn list_for_user(pool: &SqlitePool, user_id: &str) -> Result<Vec<Role>, sqlx::Error> {
sqlx::query_as::<_, Role>(
r#"
SELECT r.* FROM roles r
JOIN user_roles ur ON ur.role_id = r.id
WHERE ur.user_id = ?
ORDER BY r.name
"#,
)
.bind(user_id)
.fetch_all(pool)
.await
}
pub async fn assign_to_user(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
user_id: &str,
role_id: &str,
) -> Result<(), sqlx::Error> {
sqlx::query("INSERT OR IGNORE INTO user_roles (user_id, role_id) VALUES (?, ?)")
.bind(user_id)
.bind(role_id)
.execute(&mut **tx)
.await?;
Ok(())
}
pub async fn remove_from_user(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
user_id: &str,
role_id: &str,
) -> Result<(), sqlx::Error> {
sqlx::query("DELETE FROM user_roles WHERE user_id = ? AND role_id = ?")
.bind(user_id)
.bind(role_id)
.execute(&mut **tx)
.await?;
Ok(())
}
pub async fn admin_role_exists(pool: &SqlitePool) -> Result<bool, sqlx::Error> {
let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM roles WHERE name = 'admin'")
.fetch_one(pool)
.await?;
Ok(row.0 > 0)
}
pub use crate::db::repository::sqlite::roles::*;
-59
View File
@@ -1,59 +0,0 @@
use sqlx::SqlitePool;
use crate::db::models::ServiceAccount;
pub async fn create(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
id: &str,
tenant_id: &str,
name: &str,
description: Option<&str>,
) -> Result<ServiceAccount, sqlx::Error> {
sqlx::query_as::<_, ServiceAccount>(
r#"
INSERT INTO service_accounts (id, tenant_id, name, description)
VALUES (?, ?, ?, ?)
RETURNING *
"#,
)
.bind(id)
.bind(tenant_id)
.bind(name)
.bind(description)
.fetch_one(&mut **tx)
.await
}
pub async fn find_by_id(
pool: &SqlitePool,
id: &str,
) -> Result<Option<ServiceAccount>, sqlx::Error> {
sqlx::query_as::<_, ServiceAccount>("SELECT * FROM service_accounts WHERE id = ?")
.bind(id)
.fetch_optional(pool)
.await
}
pub async fn list(pool: &SqlitePool, tenant_id: &str) -> Result<Vec<ServiceAccount>, sqlx::Error> {
sqlx::query_as::<_, ServiceAccount>(
"SELECT * FROM service_accounts WHERE tenant_id = ? ORDER BY name",
)
.bind(tenant_id)
.fetch_all(pool)
.await
}
pub async fn set_enabled(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
id: &str,
enabled: bool,
) -> Result<(), sqlx::Error> {
sqlx::query(
"UPDATE service_accounts SET enabled = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?",
)
.bind(enabled)
.bind(id)
.execute(&mut **tx)
.await?;
Ok(())
}
-79
View File
@@ -1,79 +0,0 @@
use sqlx::SqlitePool;
use crate::db::models::Session;
pub async fn create(
pool: &SqlitePool,
id: &str,
user_id: &str,
token_hash: &str,
ip_address: Option<&str>,
user_agent: Option<&str>,
expires_at: &str,
) -> Result<Session, sqlx::Error> {
sqlx::query_as::<_, Session>(
r#"
INSERT INTO sessions (id, user_id, token_hash, ip_address, user_agent, expires_at)
VALUES (?, ?, ?, ?, ?, ?)
RETURNING *
"#,
)
.bind(id)
.bind(user_id)
.bind(token_hash)
.bind(ip_address)
.bind(user_agent)
.bind(expires_at)
.fetch_one(pool)
.await
}
pub async fn find_by_token_hash(
pool: &SqlitePool,
token_hash: &str,
) -> Result<Option<Session>, sqlx::Error> {
sqlx::query_as::<_, Session>("SELECT * FROM sessions WHERE token_hash = ? AND revoked = 0")
.bind(token_hash)
.fetch_optional(pool)
.await
}
pub async fn revoke(pool: &SqlitePool, id: &str) -> Result<(), sqlx::Error> {
sqlx::query("UPDATE sessions SET revoked = 1 WHERE id = ?")
.bind(id)
.execute(pool)
.await?;
Ok(())
}
pub async fn revoke_all_for_user(pool: &SqlitePool, user_id: &str) -> Result<(), sqlx::Error> {
sqlx::query("UPDATE sessions SET revoked = 1 WHERE user_id = ?")
.bind(user_id)
.execute(pool)
.await?;
Ok(())
}
pub async fn update_last_seen(pool: &SqlitePool, id: &str) -> Result<(), sqlx::Error> {
sqlx::query(
"UPDATE sessions SET last_seen_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?",
)
.bind(id)
.execute(pool)
.await?;
Ok(())
}
/// Delete sessions that are expired or revoked. Called once at startup.
pub async fn cleanup_expired(pool: &SqlitePool) -> Result<u64, sqlx::Error> {
let result = sqlx::query(
r#"
DELETE FROM sessions
WHERE revoked = 1
OR expires_at < strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
"#,
)
.execute(pool)
.await?;
Ok(result.rows_affected())
}
+108
View File
@@ -0,0 +1,108 @@
use crate::db::repository::traits::ApplicationsRepository;
use async_trait::async_trait;
use sqlx::SqlitePool;
use crate::db::models::Application;
pub struct SqliteApplicationsRepository {
pub pool: SqlitePool,
}
#[async_trait]
impl ApplicationsRepository for SqliteApplicationsRepository {
async fn create(
&self,
id: &str,
tenant_id: &str,
name: &str,
slug: &str,
) -> Result<Application, sqlx::Error> {
sqlx::query_as::<_, Application>(
r#"
INSERT INTO applications (id, tenant_id, name, slug)
VALUES (?, ?, ?, ?)
RETURNING id, tenant_id, name, slug, enabled, created_at, updated_at, NULL as description, NULL as client_secret_hash, NULL as redirect_uris
"#,
)
.bind(id)
.bind(tenant_id)
.bind(name)
.bind(slug)
.fetch_one(&self.pool)
.await
}
async fn find_by_slug(&self, slug: &str) -> Result<Option<Application>, sqlx::Error> {
sqlx::query_as::<_, Application>("SELECT id, tenant_id, name, slug, enabled, created_at, updated_at, NULL as description, NULL as client_secret_hash, NULL as redirect_uris FROM applications WHERE slug = ?")
.bind(slug)
.fetch_optional(&self.pool)
.await
}
async fn find_by_id(&self, id: &str) -> Result<Option<Application>, sqlx::Error> {
sqlx::query_as::<_, Application>("SELECT id, tenant_id, name, slug, enabled, created_at, updated_at, NULL as description, NULL as client_secret_hash, NULL as redirect_uris FROM applications WHERE id = ?")
.bind(id)
.fetch_optional(&self.pool)
.await
}
async fn list(&self, tenant_id: &str) -> Result<Vec<Application>, sqlx::Error> {
sqlx::query_as::<_, Application>(
"SELECT id, tenant_id, name, slug, enabled, created_at, updated_at, NULL as description, NULL as client_secret_hash, NULL as redirect_uris FROM applications WHERE tenant_id = ? ORDER BY name",
)
.bind(tenant_id)
.fetch_all(&self.pool)
.await
}
async fn set_enabled(&self, id: &str, enabled: bool) -> Result<(), sqlx::Error> {
sqlx::query(
"UPDATE applications SET enabled = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?",
)
.bind(enabled)
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
async fn update(
&self,
id: &str,
name: &str,
slug: &str,
enabled: bool,
) -> Result<(), sqlx::Error> {
sqlx::query(
r#"
UPDATE applications
SET name = ?, slug = ?, enabled = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
WHERE id = ?
"#,
)
.bind(name)
.bind(slug)
.bind(enabled)
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
async fn delete(&self, id: &str) -> Result<(), sqlx::Error> {
sqlx::query("DELETE FROM applications WHERE id = ?")
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
async fn count(&self, tenant_id: &str) -> Result<i64, sqlx::Error> {
let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM applications WHERE tenant_id = ?")
.bind(tenant_id)
.fetch_one(&self.pool)
.await?;
Ok(row.0)
}
}
+159
View File
@@ -0,0 +1,159 @@
use crate::db::repository::traits::AuditRepository;
use async_trait::async_trait;
use sqlx::SqlitePool;
use crate::db::models::AuditLog;
pub struct SqliteAuditRepository {
pub pool: SqlitePool,
}
/// Filtered audit log query. All filters are optional.
#[derive(Debug, Default)]
pub struct AuditFilter {
pub actor_user_id: Option<String>,
pub action: Option<String>,
pub resource_type: Option<String>,
pub severity: Option<String>,
pub since: Option<String>,
pub until: Option<String>,
pub search: Option<String>,
pub limit: i64,
pub offset: i64,
}
#[async_trait]
impl AuditRepository for SqliteAuditRepository {
/// Count all audit log entries.
async fn count(&self) -> Result<i64, sqlx::Error> {
let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM audit_logs")
.fetch_one(&self.pool)
.await?;
Ok(row.0)
}
#[allow(clippy::too_many_arguments)]
#[allow(clippy::too_many_arguments)]
async fn insert(
&self,
id: &str,
actor_user_id: Option<&str>,
target_user_id: Option<&str>,
action: &str,
resource_type: &str,
resource_id: Option<&str>,
severity: &str,
ip_address: Option<&str>,
user_agent: Option<&str>,
metadata_json: Option<&str>,
) -> Result<AuditLog, sqlx::Error> {
sqlx::query_as::<_, AuditLog>(
r#"
INSERT INTO audit_logs (
id, actor_user_id, target_user_id,
action, resource_type, resource_id,
severity, ip_address, user_agent, metadata_json
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING *
"#,
)
.bind(id)
.bind(actor_user_id)
.bind(target_user_id)
.bind(action)
.bind(resource_type)
.bind(resource_id)
.bind(severity)
.bind(ip_address)
.bind(user_agent)
.bind(metadata_json)
.fetch_one(&self.pool)
.await
}
async fn list_recent(&self, limit: i64) -> Result<Vec<AuditLog>, sqlx::Error> {
sqlx::query_as::<_, AuditLog>("SELECT * FROM audit_logs ORDER BY created_at DESC LIMIT ?")
.bind(limit)
.fetch_all(&self.pool)
.await
}
async fn list_filtered(&self, filter: &AuditFilter) -> Result<Vec<AuditLog>, sqlx::Error> {
// Build a dynamic but simple filter using COALESCE-style optional matches.
// Empty optionals are treated as wildcards via OR IS NULL pattern with bind of None.
let search_like = filter
.search
.as_ref()
.map(|s| format!("%{}%", s.replace('%', "\\%")));
sqlx::query_as::<_, AuditLog>(
r#"
SELECT * FROM audit_logs
WHERE (?1 IS NULL OR actor_user_id = ?1)
AND (?2 IS NULL OR action = ?2)
AND (?3 IS NULL OR resource_type = ?3)
AND (?4 IS NULL OR severity = ?4)
AND (?5 IS NULL OR created_at >= ?5)
AND (?6 IS NULL OR created_at <= ?6)
AND (
?7 IS NULL
OR action LIKE ?7 ESCAPE '\'
OR resource_type LIKE ?7 ESCAPE '\'
OR resource_id LIKE ?7 ESCAPE '\'
OR ip_address LIKE ?7 ESCAPE '\'
OR metadata_json LIKE ?7 ESCAPE '\'
)
ORDER BY created_at DESC
LIMIT ?8 OFFSET ?9
"#,
)
.bind(filter.actor_user_id.as_deref())
.bind(filter.action.as_deref())
.bind(filter.resource_type.as_deref())
.bind(filter.severity.as_deref())
.bind(filter.since.as_deref())
.bind(filter.until.as_deref())
.bind(search_like.as_deref())
.bind(filter.limit)
.bind(filter.offset)
.fetch_all(&self.pool)
.await
}
async fn count_filtered(&self, filter: &AuditFilter) -> Result<i64, sqlx::Error> {
let search_like = filter
.search
.as_ref()
.map(|s| format!("%{}%", s.replace('%', "\\%")));
let row: (i64,) = sqlx::query_as(
r#"
SELECT COUNT(*) FROM audit_logs
WHERE (?1 IS NULL OR actor_user_id = ?1)
AND (?2 IS NULL OR action = ?2)
AND (?3 IS NULL OR resource_type = ?3)
AND (?4 IS NULL OR severity = ?4)
AND (?5 IS NULL OR created_at >= ?5)
AND (?6 IS NULL OR created_at <= ?6)
AND (
?7 IS NULL
OR action LIKE ?7 ESCAPE '\'
OR resource_type LIKE ?7 ESCAPE '\'
OR resource_id LIKE ?7 ESCAPE '\'
OR ip_address LIKE ?7 ESCAPE '\'
OR metadata_json LIKE ?7 ESCAPE '\'
)
"#,
)
.bind(filter.actor_user_id.as_deref())
.bind(filter.action.as_deref())
.bind(filter.resource_type.as_deref())
.bind(filter.severity.as_deref())
.bind(filter.since.as_deref())
.bind(filter.until.as_deref())
.bind(search_like.as_deref())
.fetch_one(&self.pool)
.await?;
Ok(row.0)
}
}
+139
View File
@@ -0,0 +1,139 @@
use crate::db::models::{Group, User};
use crate::db::repository::traits::GroupsRepository;
use async_trait::async_trait;
use sqlx::SqlitePool;
pub struct SqliteGroupsRepository {
pub pool: SqlitePool,
}
#[async_trait]
impl GroupsRepository for SqliteGroupsRepository {
async fn list(&self, tenant_id: &str) -> Result<Vec<Group>, sqlx::Error> {
let rows = sqlx::query_as::<_, Group>(
r#"
SELECT * FROM groups
WHERE tenant_id = ?
ORDER BY name ASC
"#,
)
.bind(tenant_id)
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
async fn find_by_id(&self, id: &str) -> Result<Option<Group>, sqlx::Error> {
sqlx::query_as::<_, Group>("SELECT * FROM groups WHERE id = ?")
.bind(id)
.fetch_optional(&self.pool)
.await
}
async fn create(
&self,
id: &str,
tenant_id: &str,
name: &str,
description: Option<&str>,
) -> Result<Group, sqlx::Error> {
sqlx::query_as::<_, Group>(
r#"
INSERT INTO groups (id, tenant_id, name, description)
VALUES (?, ?, ?, ?)
RETURNING *
"#,
)
.bind(id)
.bind(tenant_id)
.bind(name)
.bind(description)
.fetch_one(&self.pool)
.await
}
async fn update(
&self,
id: &str,
name: &str,
description: Option<&str>,
) -> Result<(), sqlx::Error> {
sqlx::query(
r#"
UPDATE groups
SET name = ?, description = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
WHERE id = ?
"#,
)
.bind(name)
.bind(description)
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
async fn delete(&self, id: &str) -> Result<(), sqlx::Error> {
sqlx::query("DELETE FROM groups WHERE id = ?")
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
async fn count_members(&self, group_id: &str) -> Result<i64, sqlx::Error> {
let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM user_groups WHERE group_id = ?")
.bind(group_id)
.fetch_one(&self.pool)
.await?;
Ok(row.0)
}
async fn list_members(&self, group_id: &str) -> Result<Vec<User>, sqlx::Error> {
sqlx::query_as::<_, User>(
r#"
SELECT u.*
FROM users u
JOIN user_groups ug ON u.id = ug.user_id
WHERE ug.group_id = ?
ORDER BY u.username ASC
"#,
)
.bind(group_id)
.fetch_all(&self.pool)
.await
}
async fn add_member(&self, group_id: &str, user_id: &str) -> Result<(), sqlx::Error> {
sqlx::query(
r#"
INSERT INTO user_groups (user_id, group_id)
VALUES (?, ?)
ON CONFLICT (user_id, group_id) DO NOTHING
"#,
)
.bind(user_id)
.bind(group_id)
.execute(&self.pool)
.await?;
Ok(())
}
async fn remove_member(&self, group_id: &str, user_id: &str) -> Result<(), sqlx::Error> {
sqlx::query("DELETE FROM user_groups WHERE user_id = ? AND group_id = ?")
.bind(user_id)
.bind(group_id)
.execute(&self.pool)
.await?;
Ok(())
}
async fn count(&self, tenant_id: &str) -> Result<i64, sqlx::Error> {
let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM groups WHERE tenant_id = ?")
.bind(tenant_id)
.fetch_one(&self.pool)
.await?;
Ok(row.0)
}
}
+11
View File
@@ -0,0 +1,11 @@
pub mod applications;
pub mod audit;
pub mod groups;
pub mod permissions;
pub mod refresh_tokens;
pub mod roles;
pub mod service_accounts;
pub mod sessions;
pub mod tenants;
pub mod tokens;
pub mod users;
+122
View File
@@ -0,0 +1,122 @@
use crate::db::repository::traits::PermissionsRepository;
use async_trait::async_trait;
use sqlx::SqlitePool;
use crate::db::models::Permission;
pub struct SqlitePermissionsRepository {
pub pool: SqlitePool,
}
#[async_trait]
impl PermissionsRepository for SqlitePermissionsRepository {
/// List all permissions defined in the system.
async fn list_all(&self) -> Result<Vec<Permission>, sqlx::Error> {
sqlx::query_as::<_, Permission>("SELECT * FROM permissions ORDER BY name")
.fetch_all(&self.pool)
.await
}
/// List permissions assigned to a role.
async fn list_for_role(&self, role_id: &str) -> Result<Vec<Permission>, sqlx::Error> {
sqlx::query_as::<_, Permission>(
r#"
SELECT p.* FROM permissions p
JOIN role_permissions rp ON rp.permission_id = p.id
WHERE rp.role_id = ?
ORDER BY p.name
"#,
)
.bind(role_id)
.fetch_all(&self.pool)
.await
}
/// Assign a permission to a role (no-op if already assigned).
async fn assign_to_role(&self, role_id: &str, permission_id: &str) -> Result<(), sqlx::Error> {
sqlx::query(
"INSERT OR IGNORE INTO role_permissions (role_id, permission_id) VALUES (?, ?)",
)
.bind(role_id)
.bind(permission_id)
.execute(&self.pool)
.await?;
Ok(())
}
/// Remove a permission from a role.
async fn remove_from_role(
&self,
role_id: &str,
permission_id: &str,
) -> Result<(), sqlx::Error> {
sqlx::query("DELETE FROM role_permissions WHERE role_id = ? AND permission_id = ?")
.bind(role_id)
.bind(permission_id)
.execute(&self.pool)
.await?;
Ok(())
}
async fn clear_for_role(&self, role_id: &str) -> Result<(), sqlx::Error> {
sqlx::query("DELETE FROM role_permissions WHERE role_id = ?")
.bind(role_id)
.execute(&self.pool)
.await?;
Ok(())
}
async fn find_by_name(&self, name: &str) -> Result<Option<Permission>, sqlx::Error> {
sqlx::query_as::<_, Permission>("SELECT * FROM permissions WHERE name = ?")
.bind(name)
.fetch_optional(&self.pool)
.await
}
/// Find a permission by id.
async fn find_by_id(&self, id: &str) -> Result<Option<Permission>, sqlx::Error> {
sqlx::query_as::<_, Permission>("SELECT * FROM permissions WHERE id = ?")
.bind(id)
.fetch_optional(&self.pool)
.await
}
/// Return all permission names held by a user (via their roles).
async fn list_for_user(&self, user_id: &str) -> Result<Vec<String>, sqlx::Error> {
let rows: Vec<(String,)> = sqlx::query_as(
r#"
SELECT DISTINCT p.name
FROM permissions p
JOIN role_permissions rp ON rp.permission_id = p.id
JOIN user_roles ur ON ur.role_id = rp.role_id
WHERE ur.user_id = ?
ORDER BY p.name
"#,
)
.bind(user_id)
.fetch_all(&self.pool)
.await?;
Ok(rows.into_iter().map(|(name,)| name).collect())
}
/// Check if a user holds a specific named permission.
async fn user_has_permission(
&self,
user_id: &str,
permission_name: &str,
) -> Result<bool, sqlx::Error> {
let row: (i64,) = sqlx::query_as(
r#"
SELECT COUNT(*)
FROM permissions p
JOIN role_permissions rp ON rp.permission_id = p.id
JOIN user_roles ur ON ur.role_id = rp.role_id
WHERE ur.user_id = ? AND p.name = ?
"#,
)
.bind(user_id)
.bind(permission_name)
.fetch_one(&self.pool)
.await?;
Ok(row.0 > 0)
}
}
@@ -0,0 +1,67 @@
use crate::db::repository::traits::RefreshTokensRepository;
use async_trait::async_trait;
use sqlx::SqlitePool;
pub struct SqliteRefreshTokensRepository {
pub pool: SqlitePool,
}
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct RefreshToken {
pub id: String,
pub user_id: String,
pub token_hash: String,
pub expires_at: String,
pub revoked: bool,
pub created_at: String,
}
#[async_trait]
impl RefreshTokensRepository for SqliteRefreshTokensRepository {
async fn create(
&self,
id: &str,
user_id: &str,
token_hash: &str,
expires_at: &str,
) -> Result<RefreshToken, sqlx::Error> {
sqlx::query_as::<_, RefreshToken>(
r#"
INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at)
VALUES (?, ?, ?, ?)
RETURNING *
"#,
)
.bind(id)
.bind(user_id)
.bind(token_hash)
.bind(expires_at)
.fetch_one(&self.pool)
.await
}
async fn find_by_hash(&self, token_hash: &str) -> Result<Option<RefreshToken>, sqlx::Error> {
sqlx::query_as::<_, RefreshToken>(
"SELECT * FROM refresh_tokens WHERE token_hash = ? AND revoked = 0",
)
.bind(token_hash)
.fetch_optional(&self.pool)
.await
}
async fn revoke(&self, id: &str) -> Result<(), sqlx::Error> {
sqlx::query("UPDATE refresh_tokens SET revoked = 1 WHERE id = ?")
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
async fn revoke_all_for_user(&self, user_id: &str) -> Result<(), sqlx::Error> {
sqlx::query("UPDATE refresh_tokens SET revoked = 1 WHERE user_id = ?")
.bind(user_id)
.execute(&self.pool)
.await?;
Ok(())
}
}
+127
View File
@@ -0,0 +1,127 @@
use crate::db::repository::traits::RolesRepository;
use async_trait::async_trait;
use sqlx::SqlitePool;
use crate::db::models::Role;
pub struct SqliteRolesRepository {
pub pool: SqlitePool,
}
#[async_trait]
impl RolesRepository for SqliteRolesRepository {
async fn list_all(&self) -> Result<Vec<Role>, sqlx::Error> {
sqlx::query_as::<_, Role>("SELECT * FROM roles ORDER BY name")
.fetch_all(&self.pool)
.await
}
async fn find_by_name(&self, name: &str) -> Result<Option<Role>, sqlx::Error> {
sqlx::query_as::<_, Role>("SELECT * FROM roles WHERE name = ?")
.bind(name)
.fetch_optional(&self.pool)
.await
}
async fn find_by_id(&self, id: &str) -> Result<Option<Role>, sqlx::Error> {
sqlx::query_as::<_, Role>("SELECT * FROM roles WHERE id = ?")
.bind(id)
.fetch_optional(&self.pool)
.await
}
async fn list_for_user(&self, user_id: &str) -> Result<Vec<Role>, sqlx::Error> {
sqlx::query_as::<_, Role>(
r#"
SELECT r.* FROM roles r
JOIN user_roles ur ON ur.role_id = r.id
WHERE ur.user_id = ?
ORDER BY r.name
"#,
)
.bind(user_id)
.fetch_all(&self.pool)
.await
}
async fn assign_to_user(&self, user_id: &str, role_id: &str) -> Result<(), sqlx::Error> {
sqlx::query("INSERT OR IGNORE INTO user_roles (user_id, role_id) VALUES (?, ?)")
.bind(user_id)
.bind(role_id)
.execute(&self.pool)
.await?;
Ok(())
}
async fn remove_from_user(&self, user_id: &str, role_id: &str) -> Result<(), sqlx::Error> {
sqlx::query("DELETE FROM user_roles WHERE user_id = ? AND role_id = ?")
.bind(user_id)
.bind(role_id)
.execute(&self.pool)
.await?;
Ok(())
}
async fn admin_role_exists(&self) -> Result<bool, sqlx::Error> {
let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM roles WHERE name = 'admin'")
.fetch_one(&self.pool)
.await?;
Ok(row.0 > 0)
}
/// Create a new role.
async fn create(
&self,
id: &str,
name: &str,
description: Option<&str>,
) -> Result<Role, sqlx::Error> {
sqlx::query_as::<_, Role>(
r#"
INSERT INTO roles (id, name, description)
VALUES (?, ?, ?)
RETURNING *
"#,
)
.bind(id)
.bind(name)
.bind(description)
.fetch_one(&self.pool)
.await
}
/// Update role name/description.
async fn update(
&self,
id: &str,
name: &str,
description: Option<&str>,
) -> Result<(), sqlx::Error> {
sqlx::query("UPDATE roles SET name = ?, description = ? WHERE id = ?")
.bind(name)
.bind(description)
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
/// Delete a role by id.
async fn delete(&self, id: &str) -> Result<(), sqlx::Error> {
sqlx::query("DELETE FROM roles WHERE id = ?")
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
/// List user ids that hold a given role.
async fn list_user_ids_for_role(&self, role_id: &str) -> Result<Vec<String>, sqlx::Error> {
let rows: Vec<(String,)> =
sqlx::query_as("SELECT user_id FROM user_roles WHERE role_id = ? ORDER BY user_id")
.bind(role_id)
.fetch_all(&self.pool)
.await?;
Ok(rows.into_iter().map(|(id,)| id).collect())
}
}
@@ -0,0 +1,78 @@
use crate::db::repository::traits::ServiceAccountsRepository;
use async_trait::async_trait;
use sqlx::SqlitePool;
use crate::db::models::ServiceAccount;
pub struct SqliteServiceAccountsRepository {
pub pool: SqlitePool,
}
#[async_trait]
impl ServiceAccountsRepository for SqliteServiceAccountsRepository {
async fn create(
&self,
id: &str,
tenant_id: &str,
name: &str,
description: Option<&str>,
) -> Result<ServiceAccount, sqlx::Error> {
sqlx::query_as::<_, ServiceAccount>(
r#"
INSERT INTO service_accounts (id, tenant_id, name, description)
VALUES (?, ?, ?, ?)
RETURNING *
"#,
)
.bind(id)
.bind(tenant_id)
.bind(name)
.bind(description)
.fetch_one(&self.pool)
.await
}
async fn find_by_id(&self, id: &str) -> Result<Option<ServiceAccount>, sqlx::Error> {
sqlx::query_as::<_, ServiceAccount>("SELECT * FROM service_accounts WHERE id = ?")
.bind(id)
.fetch_optional(&self.pool)
.await
}
async fn list(&self, tenant_id: &str) -> Result<Vec<ServiceAccount>, sqlx::Error> {
sqlx::query_as::<_, ServiceAccount>(
"SELECT * FROM service_accounts WHERE tenant_id = ? ORDER BY name",
)
.bind(tenant_id)
.fetch_all(&self.pool)
.await
}
async fn set_enabled(&self, id: &str, enabled: bool) -> Result<(), sqlx::Error> {
sqlx::query(
"UPDATE service_accounts SET enabled = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?",
)
.bind(enabled)
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
async fn delete(&self, id: &str) -> Result<(), sqlx::Error> {
sqlx::query("DELETE FROM service_accounts WHERE id = ?")
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
async fn count(&self, tenant_id: &str) -> Result<i64, sqlx::Error> {
let row: (i64,) =
sqlx::query_as("SELECT COUNT(*) FROM service_accounts WHERE tenant_id = ?")
.bind(tenant_id)
.fetch_one(&self.pool)
.await?;
Ok(row.0)
}
}
+141
View File
@@ -0,0 +1,141 @@
use crate::db::repository::traits::SessionsRepository;
use async_trait::async_trait;
use sqlx::SqlitePool;
use crate::db::models::Session;
pub struct SqliteSessionsRepository {
pub pool: SqlitePool,
}
#[async_trait]
impl SessionsRepository for SqliteSessionsRepository {
async fn create(
&self,
id: &str,
user_id: &str,
token_hash: &str,
ip_address: Option<&str>,
user_agent: Option<&str>,
expires_at: &str,
) -> Result<Session, sqlx::Error> {
sqlx::query_as::<_, Session>(
r#"
INSERT INTO sessions (id, user_id, token_hash, ip_address, user_agent, expires_at)
VALUES (?, ?, ?, ?, ?, ?)
RETURNING *
"#,
)
.bind(id)
.bind(user_id)
.bind(token_hash)
.bind(ip_address)
.bind(user_agent)
.bind(expires_at)
.fetch_one(&self.pool)
.await
}
async fn find_by_token_hash(&self, token_hash: &str) -> Result<Option<Session>, sqlx::Error> {
sqlx::query_as::<_, Session>("SELECT * FROM sessions WHERE token_hash = ? AND revoked = 0")
.bind(token_hash)
.fetch_optional(&self.pool)
.await
}
async fn revoke(&self, id: &str) -> Result<(), sqlx::Error> {
sqlx::query("UPDATE sessions SET revoked = 1 WHERE id = ?")
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
async fn revoke_all_for_user(&self, user_id: &str) -> Result<(), sqlx::Error> {
sqlx::query("UPDATE sessions SET revoked = 1 WHERE user_id = ?")
.bind(user_id)
.execute(&self.pool)
.await?;
Ok(())
}
async fn update_last_seen(&self, id: &str) -> Result<(), sqlx::Error> {
sqlx::query(
"UPDATE sessions SET last_seen_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?",
)
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
/// List active (non-revoked, non-expired) sessions for a user.
async fn list_active_for_user(&self, user_id: &str) -> Result<Vec<Session>, sqlx::Error> {
sqlx::query_as::<_, Session>(
r#"
SELECT * FROM sessions
WHERE user_id = ?
AND revoked = 0
AND expires_at >= strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
ORDER BY last_seen_at DESC
"#,
)
.bind(user_id)
.fetch_all(&self.pool)
.await
}
/// List ALL active sessions system-wide (admin only)
async fn list_all_active(&self) -> Result<Vec<Session>, sqlx::Error> {
sqlx::query_as::<_, Session>(
r#"
SELECT * FROM sessions
WHERE revoked = 0
AND expires_at >= strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
ORDER BY last_seen_at DESC
"#,
)
.fetch_all(&self.pool)
.await
}
/// Count active sessions system-wide.
async fn count_active(&self) -> Result<i64, sqlx::Error> {
let row: (i64,) = sqlx::query_as(
r#"
SELECT COUNT(*) FROM sessions
WHERE revoked = 0
AND expires_at >= strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
"#,
)
.fetch_one(&self.pool)
.await?;
Ok(row.0)
}
/// Delete sessions that are expired or revoked. Called once at startup.
async fn cleanup_expired(&self) -> Result<u64, sqlx::Error> {
let result = sqlx::query(
r#"
DELETE FROM sessions
WHERE revoked = 1
OR expires_at < strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
"#,
)
.execute(&self.pool)
.await?;
Ok(result.rows_affected())
}
/// Revoke all sessions for a user EXCEPT the given session_id
async fn revoke_others(&self, user_id: &str, except_id: &str) -> Result<u64, sqlx::Error> {
let result = sqlx::query(
"UPDATE sessions SET revoked = 1 WHERE user_id = ? AND id != ? AND revoked = 0",
)
.bind(user_id)
.bind(except_id)
.execute(&self.pool)
.await?;
Ok(result.rows_affected())
}
}
+108
View File
@@ -0,0 +1,108 @@
use sqlx::SqlitePool;
use crate::db::models::Tenant;
use crate::db::repository::traits::TenantsRepository;
pub struct SqliteTenantsRepository {
pub pool: SqlitePool,
}
#[async_trait::async_trait]
impl TenantsRepository for SqliteTenantsRepository {
async fn find_by_id(&self, id: &str) -> Result<Option<Tenant>, sqlx::Error> {
let row = sqlx::query_as::<_, Tenant>(
"SELECT id, name, slug, enabled, created_at, updated_at FROM tenants WHERE id = ?",
)
.bind(id)
.fetch_optional(&self.pool)
.await?;
Ok(row)
}
async fn find_by_slug(&self, slug: &str) -> Result<Option<Tenant>, sqlx::Error> {
let row = sqlx::query_as::<_, Tenant>(
"SELECT id, name, slug, enabled, created_at, updated_at FROM tenants WHERE slug = ?",
)
.bind(slug)
.fetch_optional(&self.pool)
.await?;
Ok(row)
}
async fn list(&self) -> Result<Vec<Tenant>, sqlx::Error> {
let rows = sqlx::query_as::<_, Tenant>(
"SELECT id, name, slug, enabled, created_at, updated_at FROM tenants ORDER BY name ASC",
)
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
async fn create(
&self,
id: &str,
name: &str,
slug: Option<&str>,
) -> Result<Tenant, sqlx::Error> {
let slug = slug.unwrap_or(id);
let row = sqlx::query_as::<_, Tenant>(
r#"
INSERT INTO tenants (id, name, slug, enabled)
VALUES (?, ?, ?, 1)
RETURNING id, name, slug, enabled, created_at, updated_at
"#,
)
.bind(id)
.bind(name)
.bind(slug)
.fetch_one(&self.pool)
.await?;
Ok(row)
}
async fn update(&self, id: &str, name: &str, slug: Option<&str>) -> Result<(), sqlx::Error> {
let slug = slug.unwrap_or(name);
sqlx::query(
r#"
UPDATE tenants
SET name = ?, slug = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
WHERE id = ?
"#,
)
.bind(name)
.bind(slug)
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
async fn set_enabled(&self, id: &str, enabled: bool) -> Result<(), sqlx::Error> {
sqlx::query(
r#"
UPDATE tenants
SET enabled = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
WHERE id = ?
"#,
)
.bind(enabled as i32)
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
async fn delete(&self, id: &str) -> Result<(), sqlx::Error> {
sqlx::query("DELETE FROM tenants WHERE id = ?")
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
}
+79
View File
@@ -0,0 +1,79 @@
use crate::db::repository::traits::TokensRepository;
use async_trait::async_trait;
use sqlx::SqlitePool;
use crate::db::models::ApiToken;
pub struct SqliteTokensRepository {
pub pool: SqlitePool,
}
#[async_trait]
impl TokensRepository for SqliteTokensRepository {
async fn create(
&self,
id: &str,
user_id: &str,
name: &str,
token_hash: &str,
expires_at: Option<&str>,
) -> Result<ApiToken, sqlx::Error> {
sqlx::query_as::<_, ApiToken>(
r#"
INSERT INTO api_tokens (id, user_id, name, token_hash, expires_at)
VALUES (?, ?, ?, ?, ?)
RETURNING *
"#,
)
.bind(id)
.bind(user_id)
.bind(name)
.bind(token_hash)
.bind(expires_at)
.fetch_one(&self.pool)
.await
}
async fn find_by_hash(&self, token_hash: &str) -> Result<Option<ApiToken>, sqlx::Error> {
sqlx::query_as::<_, ApiToken>(
"SELECT * FROM api_tokens WHERE token_hash = ? AND revoked = 0",
)
.bind(token_hash)
.fetch_optional(&self.pool)
.await
}
async fn list_for_user(&self, user_id: &str) -> Result<Vec<ApiToken>, sqlx::Error> {
sqlx::query_as::<_, ApiToken>(
"SELECT * FROM api_tokens WHERE user_id = ? ORDER BY created_at DESC",
)
.bind(user_id)
.fetch_all(&self.pool)
.await
}
async fn find_by_id(&self, id: &str) -> Result<Option<ApiToken>, sqlx::Error> {
sqlx::query_as::<_, ApiToken>("SELECT * FROM api_tokens WHERE id = ?")
.bind(id)
.fetch_optional(&self.pool)
.await
}
async fn revoke(&self, id: &str) -> Result<(), sqlx::Error> {
sqlx::query("UPDATE api_tokens SET revoked = 1 WHERE id = ?")
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
async fn update_last_used(&self, id: &str) -> Result<(), sqlx::Error> {
sqlx::query(
"UPDATE api_tokens SET last_used_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?",
)
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
}
+173
View File
@@ -0,0 +1,173 @@
use crate::db::repository::traits::UsersRepository;
use async_trait::async_trait;
use sqlx::SqlitePool;
use crate::db::models::User;
pub struct SqliteUsersRepository {
pub pool: SqlitePool,
}
/// User profile fields from `user_profiles`.
#[derive(Debug, Clone, sqlx::FromRow, serde::Serialize, serde::Deserialize)]
pub struct UserProfile {
pub user_id: String,
pub email: Option<String>,
pub full_name: Option<String>,
pub avatar_url: Option<String>,
pub metadata_json: Option<String>,
}
#[async_trait]
impl UsersRepository for SqliteUsersRepository {
/// Count users with a given status in a tenant.
async fn count_by_status(&self, tenant_id: &str, status: i32) -> Result<i64, sqlx::Error> {
let row: (i64,) =
sqlx::query_as("SELECT COUNT(*) FROM users WHERE tenant_id = ? AND status = ?")
.bind(tenant_id)
.bind(status)
.fetch_one(&self.pool)
.await?;
Ok(row.0)
}
/// Count all users in a tenant.
async fn count(&self, tenant_id: &str) -> Result<i64, sqlx::Error> {
let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users WHERE tenant_id = ?")
.bind(tenant_id)
.fetch_one(&self.pool)
.await?;
Ok(row.0)
}
/// Count users that have the admin role.
async fn count_admins(&self) -> Result<i64, sqlx::Error> {
let row: (i64,) = sqlx::query_as(
r#"
SELECT COUNT(DISTINCT ur.user_id)
FROM user_roles ur
JOIN roles r ON r.id = ur.role_id
WHERE r.name = 'admin'
"#,
)
.fetch_one(&self.pool)
.await?;
Ok(row.0)
}
async fn find_by_id(&self, id: &str) -> Result<Option<User>, sqlx::Error> {
sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
.bind(id)
.fetch_optional(&self.pool)
.await
}
async fn find_by_username(&self, username: &str) -> Result<Option<User>, sqlx::Error> {
sqlx::query_as::<_, User>("SELECT * FROM users WHERE username = ?")
.bind(username)
.fetch_optional(&self.pool)
.await
}
async fn list(&self, tenant_id: &str) -> Result<Vec<User>, sqlx::Error> {
sqlx::query_as::<_, User>(
"SELECT * FROM users WHERE tenant_id = ? ORDER BY created_at DESC",
)
.bind(tenant_id)
.fetch_all(&self.pool)
.await
}
async fn create(
&self,
id: &str,
tenant_id: &str,
username: &str,
password_hash: &str,
) -> Result<User, sqlx::Error> {
sqlx::query_as::<_, User>(
r#"
INSERT INTO users (id, tenant_id, username, password_hash, status)
VALUES (?, ?, ?, ?, 1)
RETURNING *
"#,
)
.bind(id)
.bind(tenant_id)
.bind(username)
.bind(password_hash)
.fetch_one(&self.pool)
.await
}
async fn update_status(&self, id: &str, status: i32) -> Result<(), sqlx::Error> {
sqlx::query(
"UPDATE users SET status = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?",
)
.bind(status)
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
async fn update_password_hash(&self, id: &str, password_hash: &str) -> Result<(), sqlx::Error> {
sqlx::query(
"UPDATE users SET password_hash = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?",
)
.bind(password_hash)
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
async fn set_last_login(&self, id: &str) -> Result<(), sqlx::Error> {
sqlx::query(
"UPDATE users SET last_login_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?",
)
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
async fn username_exists(&self, tenant_id: &str, username: &str) -> Result<bool, sqlx::Error> {
let row: (i64,) =
sqlx::query_as("SELECT COUNT(*) FROM users WHERE tenant_id = ? AND username = ?")
.bind(tenant_id)
.bind(username)
.fetch_one(&self.pool)
.await?;
Ok(row.0 > 0)
}
async fn get_profile(&self, user_id: &str) -> Result<Option<UserProfile>, sqlx::Error> {
sqlx::query_as::<_, UserProfile>("SELECT * FROM user_profiles WHERE user_id = ?")
.bind(user_id)
.fetch_optional(&self.pool)
.await
}
async fn upsert_profile(
&self,
user_id: &str,
email: Option<&str>,
full_name: Option<&str>,
) -> Result<UserProfile, sqlx::Error> {
sqlx::query_as::<_, UserProfile>(
r#"
INSERT INTO user_profiles (user_id, email, full_name)
VALUES (?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
email = excluded.email,
full_name = excluded.full_name
RETURNING *
"#,
)
.bind(user_id)
.bind(email)
.bind(full_name)
.fetch_one(&self.pool)
.await
}
}
+13 -66
View File
@@ -1,74 +1,21 @@
use sqlx::SqlitePool;
pub use crate::db::repository::sqlite::tokens::*;
use crate::db::models::ApiToken;
use crate::db::provider::DatabaseProvider;
use std::sync::Arc;
pub async fn create(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
id: &str,
/// List tokens for a user using the provided DatabaseProvider.
pub async fn list_for_user(
provider: &Arc<dyn DatabaseProvider>,
user_id: &str,
name: &str,
token_hash: &str,
expires_at: Option<&str>,
) -> Result<ApiToken, sqlx::Error> {
sqlx::query_as::<_, ApiToken>(
r#"
INSERT INTO api_tokens (id, user_id, name, token_hash, expires_at)
VALUES (?, ?, ?, ?, ?)
RETURNING *
"#,
)
.bind(id)
.bind(user_id)
.bind(name)
.bind(token_hash)
.bind(expires_at)
.fetch_one(&mut **tx)
.await
) -> Result<Vec<ApiToken>, sqlx::Error> {
provider.tokens().list_for_user(user_id).await
}
pub async fn find_by_hash(
pool: &SqlitePool,
token_hash: &str,
) -> Result<Option<ApiToken>, sqlx::Error> {
sqlx::query_as::<_, ApiToken>("SELECT * FROM api_tokens WHERE token_hash = ? AND revoked = 0")
.bind(token_hash)
.fetch_optional(pool)
.await
}
pub async fn list_for_user(pool: &SqlitePool, user_id: &str) -> Result<Vec<ApiToken>, sqlx::Error> {
sqlx::query_as::<_, ApiToken>(
"SELECT * FROM api_tokens WHERE user_id = ? ORDER BY created_at DESC",
)
.bind(user_id)
.fetch_all(pool)
.await
}
pub async fn find_by_id(pool: &SqlitePool, id: &str) -> Result<Option<ApiToken>, sqlx::Error> {
sqlx::query_as::<_, ApiToken>("SELECT * FROM api_tokens WHERE id = ?")
.bind(id)
.fetch_optional(pool)
.await
}
pub async fn revoke(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
/// Find a token by its ID using the provided DatabaseProvider.
pub async fn find_by_id(
provider: &Arc<dyn DatabaseProvider>,
id: &str,
) -> Result<(), sqlx::Error> {
sqlx::query("UPDATE api_tokens SET revoked = 1 WHERE id = ?")
.bind(id)
.execute(&mut **tx)
.await?;
Ok(())
}
pub async fn update_last_used(pool: &SqlitePool, id: &str) -> Result<(), sqlx::Error> {
sqlx::query(
"UPDATE api_tokens SET last_used_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?",
)
.bind(id)
.execute(pool)
.await?;
Ok(())
) -> Result<Option<ApiToken>, sqlx::Error> {
provider.tokens().find_by_id(id).await
}
+256
View File
@@ -0,0 +1,256 @@
use crate::db::models::{
ApiToken, Application, AuditLog, Group, Permission, Role, ServiceAccount, Session, Tenant, User,
};
use crate::db::repository::sqlite::audit::AuditFilter;
use crate::db::repository::sqlite::refresh_tokens::RefreshToken;
use crate::db::repository::sqlite::users::UserProfile;
#[async_trait::async_trait]
pub trait UsersRepository: Send + Sync {
async fn find_by_id(&self, id: &str) -> Result<Option<User>, sqlx::Error>;
async fn find_by_username(&self, username: &str) -> Result<Option<User>, sqlx::Error>;
async fn list(&self, tenant_id: &str) -> Result<Vec<User>, sqlx::Error>;
async fn create(
&self,
id: &str,
tenant_id: &str,
username: &str,
password_hash: &str,
) -> Result<User, sqlx::Error>;
async fn update_status(&self, id: &str, status: i32) -> Result<(), sqlx::Error>;
async fn update_password_hash(&self, id: &str, password_hash: &str) -> Result<(), sqlx::Error>;
async fn set_last_login(&self, id: &str) -> Result<(), sqlx::Error>;
async fn username_exists(&self, tenant_id: &str, username: &str) -> Result<bool, sqlx::Error>;
async fn count_admins(&self) -> Result<i64, sqlx::Error>;
async fn count(&self, tenant_id: &str) -> Result<i64, sqlx::Error>;
async fn count_by_status(&self, tenant_id: &str, status: i32) -> Result<i64, sqlx::Error>;
async fn get_profile(&self, user_id: &str) -> Result<Option<UserProfile>, sqlx::Error>;
async fn upsert_profile(
&self,
user_id: &str,
email: Option<&str>,
full_name: Option<&str>,
) -> Result<UserProfile, sqlx::Error>;
}
#[async_trait::async_trait]
pub trait ServiceAccountsRepository: Send + Sync {
async fn create(
&self,
id: &str,
tenant_id: &str,
name: &str,
description: Option<&str>,
) -> Result<ServiceAccount, sqlx::Error>;
async fn find_by_id(&self, id: &str) -> Result<Option<ServiceAccount>, sqlx::Error>;
async fn list(&self, tenant_id: &str) -> Result<Vec<ServiceAccount>, sqlx::Error>;
async fn set_enabled(&self, id: &str, enabled: bool) -> Result<(), sqlx::Error>;
async fn delete(&self, id: &str) -> Result<(), sqlx::Error>;
async fn count(&self, tenant_id: &str) -> Result<i64, sqlx::Error>;
}
#[async_trait::async_trait]
pub trait RolesRepository: Send + Sync {
async fn list_all(&self) -> Result<Vec<Role>, sqlx::Error>;
async fn find_by_name(&self, name: &str) -> Result<Option<Role>, sqlx::Error>;
async fn find_by_id(&self, id: &str) -> Result<Option<Role>, sqlx::Error>;
async fn list_for_user(&self, user_id: &str) -> Result<Vec<Role>, sqlx::Error>;
async fn assign_to_user(&self, user_id: &str, role_id: &str) -> Result<(), sqlx::Error>;
async fn remove_from_user(&self, user_id: &str, role_id: &str) -> Result<(), sqlx::Error>;
async fn admin_role_exists(&self) -> Result<bool, sqlx::Error>;
async fn create(
&self,
id: &str,
name: &str,
description: Option<&str>,
) -> Result<Role, sqlx::Error>;
async fn update(
&self,
id: &str,
name: &str,
description: Option<&str>,
) -> Result<(), sqlx::Error>;
async fn delete(&self, id: &str) -> Result<(), sqlx::Error>;
async fn list_user_ids_for_role(&self, role_id: &str) -> Result<Vec<String>, sqlx::Error>;
}
#[async_trait::async_trait]
pub trait SessionsRepository: Send + Sync {
async fn create(
&self,
id: &str,
user_id: &str,
token_hash: &str,
ip_address: Option<&str>,
user_agent: Option<&str>,
expires_at: &str,
) -> Result<Session, sqlx::Error>;
async fn find_by_token_hash(&self, token_hash: &str) -> Result<Option<Session>, sqlx::Error>;
async fn revoke(&self, id: &str) -> Result<(), sqlx::Error>;
async fn revoke_all_for_user(&self, user_id: &str) -> Result<(), sqlx::Error>;
async fn update_last_seen(&self, id: &str) -> Result<(), sqlx::Error>;
async fn list_active_for_user(&self, user_id: &str) -> Result<Vec<Session>, sqlx::Error>;
async fn list_all_active(&self) -> Result<Vec<Session>, sqlx::Error>;
async fn count_active(&self) -> Result<i64, sqlx::Error>;
async fn cleanup_expired(&self) -> Result<u64, sqlx::Error>;
async fn revoke_others(&self, user_id: &str, except_id: &str) -> Result<u64, sqlx::Error>;
}
#[async_trait::async_trait]
pub trait ApplicationsRepository: Send + Sync {
async fn create(
&self,
id: &str,
tenant_id: &str,
name: &str,
slug: &str,
) -> Result<Application, sqlx::Error>;
async fn find_by_slug(&self, slug: &str) -> Result<Option<Application>, sqlx::Error>;
async fn find_by_id(&self, id: &str) -> Result<Option<Application>, sqlx::Error>;
async fn list(&self, tenant_id: &str) -> Result<Vec<Application>, sqlx::Error>;
async fn set_enabled(&self, id: &str, enabled: bool) -> Result<(), sqlx::Error>;
async fn update(
&self,
id: &str,
name: &str,
slug: &str,
enabled: bool,
) -> Result<(), sqlx::Error>;
async fn delete(&self, id: &str) -> Result<(), sqlx::Error>;
async fn count(&self, tenant_id: &str) -> Result<i64, sqlx::Error>;
}
#[async_trait::async_trait]
pub trait RefreshTokensRepository: Send + Sync {
async fn create(
&self,
id: &str,
user_id: &str,
token_hash: &str,
expires_at: &str,
) -> Result<RefreshToken, sqlx::Error>;
async fn find_by_hash(&self, token_hash: &str) -> Result<Option<RefreshToken>, sqlx::Error>;
async fn revoke(&self, id: &str) -> Result<(), sqlx::Error>;
async fn revoke_all_for_user(&self, user_id: &str) -> Result<(), sqlx::Error>;
}
#[async_trait::async_trait]
pub trait AuditRepository: Send + Sync {
#[allow(clippy::too_many_arguments)]
async fn insert(
&self,
id: &str,
actor_user_id: Option<&str>,
target_user_id: Option<&str>,
action: &str,
resource_type: &str,
resource_id: Option<&str>,
severity: &str,
ip_address: Option<&str>,
user_agent: Option<&str>,
metadata_json: Option<&str>,
) -> Result<AuditLog, sqlx::Error>;
async fn list_recent(&self, limit: i64) -> Result<Vec<AuditLog>, sqlx::Error>;
async fn count(&self) -> Result<i64, sqlx::Error>;
async fn list_filtered(&self, filter: &AuditFilter) -> Result<Vec<AuditLog>, sqlx::Error>;
async fn count_filtered(&self, filter: &AuditFilter) -> Result<i64, sqlx::Error>;
}
#[async_trait::async_trait]
pub trait AuditRepositoryExt: Send + Sync {
async fn log(&self, event: crate::audit::AuditEvent<'_>) -> Result<AuditLog, sqlx::Error>;
}
#[async_trait::async_trait]
impl<T: ?Sized + AuditRepository> AuditRepositoryExt for T {
async fn log(&self, event: crate::audit::AuditEvent<'_>) -> Result<AuditLog, sqlx::Error> {
self.insert(
&uuid::Uuid::new_v4().to_string(),
event.actor_id,
event.target_id,
event.action,
event.resource_type,
event.resource_id,
event.severity.as_str(),
event.ip,
event.ua,
event.metadata,
)
.await
}
}
#[async_trait::async_trait]
pub trait TokensRepository: Send + Sync {
async fn create(
&self,
id: &str,
user_id: &str,
name: &str,
token_hash: &str,
expires_at: Option<&str>,
) -> Result<ApiToken, sqlx::Error>;
async fn find_by_hash(&self, token_hash: &str) -> Result<Option<ApiToken>, sqlx::Error>;
async fn list_for_user(&self, user_id: &str) -> Result<Vec<ApiToken>, sqlx::Error>;
async fn find_by_id(&self, id: &str) -> Result<Option<ApiToken>, sqlx::Error>;
async fn revoke(&self, id: &str) -> Result<(), sqlx::Error>;
async fn update_last_used(&self, id: &str) -> Result<(), sqlx::Error>;
}
#[async_trait::async_trait]
pub trait PermissionsRepository: Send + Sync {
async fn list_all(&self) -> Result<Vec<Permission>, sqlx::Error>;
async fn list_for_role(&self, role_id: &str) -> Result<Vec<Permission>, sqlx::Error>;
async fn assign_to_role(&self, role_id: &str, permission_id: &str) -> Result<(), sqlx::Error>;
async fn remove_from_role(&self, role_id: &str, permission_id: &str)
-> Result<(), sqlx::Error>;
async fn clear_for_role(&self, role_id: &str) -> Result<(), sqlx::Error>;
async fn find_by_name(&self, name: &str) -> Result<Option<Permission>, sqlx::Error>;
async fn find_by_id(&self, id: &str) -> Result<Option<Permission>, sqlx::Error>;
async fn list_for_user(&self, user_id: &str) -> Result<Vec<String>, sqlx::Error>;
async fn user_has_permission(
&self,
user_id: &str,
permission_name: &str,
) -> Result<bool, sqlx::Error>;
}
#[async_trait::async_trait]
pub trait TenantsRepository: Send + Sync {
async fn find_by_id(&self, id: &str) -> Result<Option<Tenant>, sqlx::Error>;
async fn find_by_slug(&self, slug: &str) -> Result<Option<Tenant>, sqlx::Error>;
async fn list(&self) -> Result<Vec<Tenant>, sqlx::Error>;
async fn create(&self, id: &str, name: &str, slug: Option<&str>)
-> Result<Tenant, sqlx::Error>;
async fn update(&self, id: &str, name: &str, slug: Option<&str>) -> Result<(), sqlx::Error>;
async fn set_enabled(&self, id: &str, enabled: bool) -> Result<(), sqlx::Error>;
async fn delete(&self, id: &str) -> Result<(), sqlx::Error>;
}
#[async_trait::async_trait]
pub trait GroupsRepository: Send + Sync {
async fn list(&self, tenant_id: &str) -> Result<Vec<Group>, sqlx::Error>;
async fn find_by_id(&self, id: &str) -> Result<Option<Group>, sqlx::Error>;
async fn create(
&self,
id: &str,
tenant_id: &str,
name: &str,
description: Option<&str>,
) -> Result<Group, sqlx::Error>;
async fn update(
&self,
id: &str,
name: &str,
description: Option<&str>,
) -> Result<(), sqlx::Error>;
async fn delete(&self, id: &str) -> Result<(), sqlx::Error>;
async fn count_members(&self, group_id: &str) -> Result<i64, sqlx::Error>;
async fn list_members(
&self,
group_id: &str,
) -> Result<Vec<crate::db::models::User>, sqlx::Error>;
async fn add_member(&self, group_id: &str, user_id: &str) -> Result<(), sqlx::Error>;
async fn remove_member(&self, group_id: &str, user_id: &str) -> Result<(), sqlx::Error>;
async fn count(&self, tenant_id: &str) -> Result<i64, sqlx::Error>;
}
+1 -121
View File
@@ -1,121 +1 @@
use sqlx::SqlitePool;
use crate::db::models::User;
pub async fn find_by_id(pool: &SqlitePool, id: &str) -> Result<Option<User>, sqlx::Error> {
sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
.bind(id)
.fetch_optional(pool)
.await
}
pub async fn find_by_username(
pool: &SqlitePool,
username: &str,
) -> Result<Option<User>, sqlx::Error> {
sqlx::query_as::<_, User>("SELECT * FROM users WHERE username = ?")
.bind(username)
.fetch_optional(pool)
.await
}
pub async fn list(pool: &SqlitePool, tenant_id: &str) -> Result<Vec<User>, sqlx::Error> {
sqlx::query_as::<_, User>("SELECT * FROM users WHERE tenant_id = ? ORDER BY created_at DESC")
.bind(tenant_id)
.fetch_all(pool)
.await
}
pub async fn create(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
id: &str,
tenant_id: &str,
username: &str,
password_hash: &str,
) -> Result<User, sqlx::Error> {
sqlx::query_as::<_, User>(
r#"
INSERT INTO users (id, tenant_id, username, password_hash, status)
VALUES (?, ?, ?, ?, 1)
RETURNING *
"#,
)
.bind(id)
.bind(tenant_id)
.bind(username)
.bind(password_hash)
.fetch_one(&mut **tx)
.await
}
pub async fn update_status(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
id: &str,
status: i32,
) -> Result<(), sqlx::Error> {
sqlx::query(
"UPDATE users SET status = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?",
)
.bind(status)
.bind(id)
.execute(&mut **tx)
.await?;
Ok(())
}
pub async fn update_password_hash(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
id: &str,
password_hash: &str,
) -> Result<(), sqlx::Error> {
sqlx::query(
"UPDATE users SET password_hash = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?",
)
.bind(password_hash)
.bind(id)
.execute(&mut **tx)
.await?;
Ok(())
}
pub async fn set_last_login(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
id: &str,
) -> Result<(), sqlx::Error> {
sqlx::query(
"UPDATE users SET last_login_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?",
)
.bind(id)
.execute(&mut **tx)
.await?;
Ok(())
}
pub async fn username_exists(
pool: &SqlitePool,
tenant_id: &str,
username: &str,
) -> Result<bool, sqlx::Error> {
let row: (i64,) =
sqlx::query_as("SELECT COUNT(*) FROM users WHERE tenant_id = ? AND username = ?")
.bind(tenant_id)
.bind(username)
.fetch_one(pool)
.await?;
Ok(row.0 > 0)
}
/// Count users that have the admin role.
pub async fn count_admins(pool: &SqlitePool) -> Result<i64, sqlx::Error> {
let row: (i64,) = sqlx::query_as(
r#"
SELECT COUNT(DISTINCT ur.user_id)
FROM user_roles ur
JOIN roles r ON r.id = ur.role_id
WHERE r.name = 'admin'
"#,
)
.fetch_one(pool)
.await?;
Ok(row.0)
}
pub use crate::db::repository::sqlite::users::*;
+13 -3
View File
@@ -16,9 +16,14 @@ pub enum AppError {
#[error("resource not found")]
NotFound,
#[error("invalid credentials")]
/// Generic authentication failure (missing/invalid session or token).
#[error("unauthorized")]
Unauthorized,
/// Login failure — deliberately non-enumerating message.
#[error("Invalid username or password.")]
InvalidCredentials,
#[error("insufficient permissions")]
Forbidden,
@@ -41,9 +46,10 @@ impl IntoResponse for AppError {
AppError::Database(_) => (StatusCode::INTERNAL_SERVER_ERROR, "internal_error"),
AppError::NotFound => (StatusCode::NOT_FOUND, "not_found"),
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized"),
AppError::InvalidCredentials => (StatusCode::UNAUTHORIZED, "invalid_credentials"),
AppError::Forbidden => (StatusCode::FORBIDDEN, "forbidden"),
AppError::Conflict(_) => (StatusCode::CONFLICT, "conflict"),
AppError::InvalidInput(_) => (StatusCode::UNPROCESSABLE_ENTITY, "invalid_input"),
AppError::InvalidInput(_) => (StatusCode::BAD_REQUEST, "invalid_input"),
AppError::RateLimited => (StatusCode::TOO_MANY_REQUESTS, "rate_limited"),
AppError::Internal => (StatusCode::INTERNAL_SERVER_ERROR, "internal_error"),
};
@@ -85,6 +91,10 @@ mod tests {
let resp = err_unauthorized.into_response();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
let err_creds = AppError::InvalidCredentials;
let resp = err_creds.into_response();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
let err_forbidden = AppError::Forbidden;
let resp = err_forbidden.into_response();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
@@ -95,7 +105,7 @@ mod tests {
let err_invalid = AppError::InvalidInput("bad value".into());
let resp = err_invalid.into_response();
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let err_rate = AppError::RateLimited;
let resp = err_rate.into_response();
Loaded 100 of 202 files, more files were not shown because too many files have changed in this diff. Show more