feat: complete Phase 0 Enterprise IAM
This commit is contained in:
1 parent
3d2d291006
commit
c2f5ba3f54
202 files changed
+21371
-1360
No files matched your search
@@ -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 })))
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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 })))
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
})))
|
||||
}
|
||||
@@ -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 })))
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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.",
|
||||
})))
|
||||
}
|
||||
@@ -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 })))
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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<_>>(),
|
||||
})))
|
||||
}
|
||||
Reference in new issue
Block a user