feat: complete NX9-Auth management and integrity hardening
This commit is contained in:
1 parent
dc5417334b
commit
a969f9c571
59 files changed
+6508
-290
No files matched your search
+171
-3
@@ -7,9 +7,9 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{
|
||||
db::models::{Application, Tenant},
|
||||
error::Result,
|
||||
identity::applications as identity,
|
||||
db::models::{Application, ApplicationMember, Tenant},
|
||||
error::{AppError, Result},
|
||||
identity::{application_members as members, applications as identity},
|
||||
middleware::{auth::AuthUser, permissions::require},
|
||||
state::AppState,
|
||||
};
|
||||
@@ -201,3 +201,171 @@ pub async fn delete_application(
|
||||
identity::delete(&state.provider, &id, Some(&auth.user.id), None, None).await?;
|
||||
Ok(Json(json!({ "success": true })))
|
||||
}
|
||||
|
||||
// ── Application membership ────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ApplicationMemberResponse {
|
||||
pub id: String,
|
||||
pub application_id: String,
|
||||
pub user_id: String,
|
||||
pub username: String,
|
||||
pub user_status: String,
|
||||
pub role: String,
|
||||
pub enabled: bool,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
impl ApplicationMemberResponse {
|
||||
fn from_member(member: ApplicationMember, username: String, user_status: String) -> Self {
|
||||
Self {
|
||||
id: member.id,
|
||||
application_id: member.application_id,
|
||||
user_id: member.user_id,
|
||||
username,
|
||||
user_status,
|
||||
role: member.role,
|
||||
enabled: member.enabled,
|
||||
created_at: member.created_at,
|
||||
updated_at: member.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn enrich_member(
|
||||
state: &AppState,
|
||||
member: ApplicationMember,
|
||||
) -> Result<ApplicationMemberResponse> {
|
||||
let user = state
|
||||
.provider
|
||||
.users()
|
||||
.find_by_id(&member.user_id)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let (username, user_status) = match user {
|
||||
Some(u) => (
|
||||
u.username,
|
||||
if u.status == 1 {
|
||||
"active".to_string()
|
||||
} else if u.status == 3 {
|
||||
"locked".to_string()
|
||||
} else {
|
||||
"disabled".to_string()
|
||||
},
|
||||
),
|
||||
None => ("unknown".to_string(), "unknown".to_string()),
|
||||
};
|
||||
|
||||
Ok(ApplicationMemberResponse::from_member(
|
||||
member,
|
||||
username,
|
||||
user_status,
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CreateMemberRequest {
|
||||
pub user_id: String,
|
||||
pub role: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct UpdateMemberRequest {
|
||||
pub role: Option<String>,
|
||||
pub enabled: Option<bool>,
|
||||
}
|
||||
|
||||
/// GET /api/v1/applications/:id/members
|
||||
pub async fn list_application_members(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<Value>> {
|
||||
require(&state.provider, &auth.user.id, MANAGE_PERM).await?;
|
||||
|
||||
let members_list = members::list_by_application(&state.provider, &id).await?;
|
||||
let mut views = Vec::with_capacity(members_list.len());
|
||||
for m in members_list {
|
||||
views.push(enrich_member(&state, m).await?);
|
||||
}
|
||||
|
||||
Ok(Json(json!({ "members": views })))
|
||||
}
|
||||
|
||||
/// POST /api/v1/applications/:id/members
|
||||
pub async fn add_application_member(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
Json(body): Json<CreateMemberRequest>,
|
||||
) -> Result<Json<Value>> {
|
||||
require(&state.provider, &auth.user.id, MANAGE_PERM).await?;
|
||||
|
||||
if body.user_id.trim().is_empty() {
|
||||
return Err(AppError::InvalidInput("user_id is required".into()));
|
||||
}
|
||||
|
||||
let member = members::add(
|
||||
&state.provider,
|
||||
&id,
|
||||
body.user_id.trim(),
|
||||
body.role.as_deref(),
|
||||
Some(&auth.user.id),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let view = enrich_member(&state, member).await?;
|
||||
Ok(Json(json!({ "member": view })))
|
||||
}
|
||||
|
||||
/// PATCH /api/v1/applications/:id/members/:user_id
|
||||
pub async fn update_application_member(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path((id, user_id)): Path<(String, String)>,
|
||||
Json(body): Json<UpdateMemberRequest>,
|
||||
) -> Result<Json<Value>> {
|
||||
require(&state.provider, &auth.user.id, MANAGE_PERM).await?;
|
||||
|
||||
let member = members::update(
|
||||
&state.provider,
|
||||
&id,
|
||||
&user_id,
|
||||
body.role.as_deref(),
|
||||
body.enabled,
|
||||
Some(&auth.user.id),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let view = enrich_member(&state, member).await?;
|
||||
Ok(Json(json!({ "member": view })))
|
||||
}
|
||||
|
||||
/// DELETE /api/v1/applications/:id/members/:user_id
|
||||
pub async fn remove_application_member(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path((id, user_id)): Path<(String, String)>,
|
||||
) -> Result<Json<Value>> {
|
||||
require(&state.provider, &auth.user.id, MANAGE_PERM).await?;
|
||||
|
||||
members::remove(
|
||||
&state.provider,
|
||||
&id,
|
||||
&user_id,
|
||||
Some(&auth.user.id),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({ "success": true })))
|
||||
}
|
||||
+71
-10
@@ -84,6 +84,7 @@ pub async fn list_audit(
|
||||
since: query.since,
|
||||
until: query.until,
|
||||
search: query.q,
|
||||
success: query.success,
|
||||
limit,
|
||||
offset,
|
||||
};
|
||||
@@ -92,19 +93,10 @@ pub async fn list_audit(
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let mut entries = audit_repo::list_filtered(&state.provider, &filter)
|
||||
let 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!({
|
||||
@@ -114,3 +106,72 @@ pub async fn list_audit(
|
||||
"offset": offset,
|
||||
})))
|
||||
}
|
||||
|
||||
/// GET /api/v1/audit/export
|
||||
pub async fn export_audit(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Query(query): Query<AuditQuery>,
|
||||
) -> Result<axum::response::Response> {
|
||||
use axum::response::IntoResponse;
|
||||
|
||||
require(&state.provider, &auth.user.id, "audit:view").await?;
|
||||
|
||||
let limit = query.limit.unwrap_or(5000).clamp(1, 5000);
|
||||
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,
|
||||
success: query.success,
|
||||
limit,
|
||||
offset,
|
||||
};
|
||||
|
||||
let entries = audit_repo::list_filtered(&state.provider, &filter)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let mut csv = String::from(
|
||||
"id,created_at,action,resource_type,resource_id,severity,success,actor_user_id,target_user_id,ip_address,user_agent,metadata_json\r\n",
|
||||
);
|
||||
for e in entries {
|
||||
let resp = AuditLogResponse::from(e);
|
||||
let esc = |s: &str| format!("\"{}\"", s.replace('"', "\"\""));
|
||||
let line = format!(
|
||||
"{},{},{},{},{},{},{},{},{},{},{},{}\r\n",
|
||||
esc(&resp.id),
|
||||
esc(&resp.created_at),
|
||||
esc(&resp.action),
|
||||
esc(&resp.resource_type),
|
||||
esc(resp.resource_id.as_deref().unwrap_or("")),
|
||||
esc(&resp.severity),
|
||||
resp.success,
|
||||
esc(resp.actor_user_id.as_deref().unwrap_or("")),
|
||||
esc(resp.target_user_id.as_deref().unwrap_or("")),
|
||||
esc(resp.ip_address.as_deref().unwrap_or("")),
|
||||
esc(resp.user_agent.as_deref().unwrap_or("")),
|
||||
esc(resp.metadata_json.as_deref().unwrap_or("")),
|
||||
);
|
||||
csv.push_str(&line);
|
||||
}
|
||||
|
||||
let response = (
|
||||
[
|
||||
(axum::http::header::CONTENT_TYPE, "text/csv; charset=utf-8"),
|
||||
(
|
||||
axum::http::header::CONTENT_DISPOSITION,
|
||||
"attachment; filename=\"audit_export.csv\"",
|
||||
),
|
||||
],
|
||||
csv,
|
||||
)
|
||||
.into_response();
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
+27
-1
@@ -1,7 +1,7 @@
|
||||
use axum::http::{HeaderName, Method, header};
|
||||
use axum::{
|
||||
Router, middleware,
|
||||
routing::{delete, get, post, put},
|
||||
routing::{delete, get, patch, post, put},
|
||||
};
|
||||
use tower_http::{compression::CompressionLayer, cors::CorsLayer, trace::TraceLayer};
|
||||
|
||||
@@ -40,6 +40,18 @@ pub fn build(state: AppState) -> Router {
|
||||
.patch(tenants::update_tenant)
|
||||
.delete(tenants::delete_tenant),
|
||||
)
|
||||
.route(
|
||||
"/tenants/{id}/users",
|
||||
get(tenants::list_tenant_users).post(tenants::assign_tenant_user),
|
||||
)
|
||||
.route(
|
||||
"/tenants/{id}/users/{user_id}",
|
||||
delete(tenants::remove_tenant_user),
|
||||
)
|
||||
.route(
|
||||
"/tenants/{id}/applications",
|
||||
get(tenants::list_tenant_applications),
|
||||
)
|
||||
// Users
|
||||
.route("/users", get(users::list_users).post(users::create_user))
|
||||
.route(
|
||||
@@ -54,6 +66,10 @@ pub fn build(state: AppState) -> Router {
|
||||
get(users::list_user_roles).post(roles::assign_user_role),
|
||||
)
|
||||
.route("/users/{id}/roles/{role}", delete(roles::remove_user_role))
|
||||
.route(
|
||||
"/users/{id}/applications",
|
||||
get(users::list_user_applications),
|
||||
)
|
||||
// Roles
|
||||
.route("/roles", get(roles::list_roles).post(roles::create_role))
|
||||
.route(
|
||||
@@ -86,6 +102,15 @@ pub fn build(state: AppState) -> Router {
|
||||
"/applications/{id}/secret",
|
||||
post(applications::rotate_application_secret),
|
||||
)
|
||||
.route(
|
||||
"/applications/{id}/members",
|
||||
get(applications::list_application_members).post(applications::add_application_member),
|
||||
)
|
||||
.route(
|
||||
"/applications/{id}/members/{user_id}",
|
||||
patch(applications::update_application_member)
|
||||
.delete(applications::remove_application_member),
|
||||
)
|
||||
// Service accounts
|
||||
.route(
|
||||
"/service-accounts",
|
||||
@@ -104,6 +129,7 @@ pub fn build(state: AppState) -> Router {
|
||||
)
|
||||
// Audit
|
||||
.route("/audit", get(audit::list_audit))
|
||||
.route("/audit/export", get(audit::export_audit))
|
||||
// Sessions
|
||||
.route("/sessions", get(sessions::list_sessions))
|
||||
.route("/sessions/others", delete(sessions::terminate_others))
|
||||
|
||||
@@ -53,6 +53,12 @@ pub async fn create_tenant(
|
||||
) -> Result<Json<Value>> {
|
||||
require(&state.provider, &auth.user.id, "roles:manage").await?;
|
||||
|
||||
if let Some(ref s) = body.slug {
|
||||
if !s.trim().is_empty() {
|
||||
crate::identity::slug::validate_slug(s)?;
|
||||
}
|
||||
}
|
||||
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let tenant = state
|
||||
.provider
|
||||
@@ -118,6 +124,12 @@ pub async fn update_tenant(
|
||||
) -> Result<Json<Value>> {
|
||||
require(&state.provider, &auth.user.id, "roles:manage").await?;
|
||||
|
||||
if let Some(ref s) = body.slug {
|
||||
if !s.trim().is_empty() {
|
||||
crate::identity::slug::validate_slug(s)?;
|
||||
}
|
||||
}
|
||||
|
||||
state
|
||||
.provider
|
||||
.tenants()
|
||||
@@ -183,3 +195,153 @@ pub async fn delete_tenant(
|
||||
|
||||
Ok(Json(json!({ "success": true })))
|
||||
}
|
||||
|
||||
/// GET /api/v1/tenants/:id/users
|
||||
pub async fn list_tenant_users(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<Value>> {
|
||||
require(&state.provider, &auth.user.id, "roles:manage").await?;
|
||||
|
||||
let users = state.provider.users().list(&id).await?;
|
||||
let views: Vec<crate::api::users::UserResponse> = users
|
||||
.into_iter()
|
||||
.map(crate::api::users::UserResponse::from)
|
||||
.collect();
|
||||
Ok(Json(json!({ "users": views })))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AssignTenantUserRequest {
|
||||
pub user_id: String,
|
||||
}
|
||||
|
||||
/// POST /api/v1/tenants/:id/users
|
||||
pub async fn assign_tenant_user(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
ctx: AuditContext,
|
||||
Path(id): Path<String>,
|
||||
Json(body): Json<AssignTenantUserRequest>,
|
||||
) -> 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)?;
|
||||
|
||||
let user = state
|
||||
.provider
|
||||
.users()
|
||||
.find_by_id(&body.user_id)
|
||||
.await?
|
||||
.ok_or(crate::error::AppError::NotFound)?;
|
||||
|
||||
let from_tenant_id = user.tenant_id.clone();
|
||||
if from_tenant_id == id {
|
||||
return Ok(Json(
|
||||
json!({ "user": crate::api::users::UserResponse::from(user) }),
|
||||
));
|
||||
}
|
||||
|
||||
if state
|
||||
.provider
|
||||
.users()
|
||||
.username_exists(&id, &user.username)
|
||||
.await?
|
||||
{
|
||||
return Err(crate::error::AppError::Conflict(format!(
|
||||
"username '{}' already exists in target tenant",
|
||||
user.username
|
||||
)));
|
||||
}
|
||||
|
||||
state
|
||||
.provider
|
||||
.users()
|
||||
.reassign_user_tenant_with_audit(
|
||||
&user.id,
|
||||
&id,
|
||||
Some(&auth.user.id),
|
||||
ctx.ip_address.as_deref(),
|
||||
ctx.user_agent.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let updated_user = state
|
||||
.provider
|
||||
.users()
|
||||
.find_by_id(&user.id)
|
||||
.await?
|
||||
.ok_or(crate::error::AppError::NotFound)?;
|
||||
|
||||
Ok(Json(
|
||||
json!({ "user": crate::api::users::UserResponse::from(updated_user) }),
|
||||
))
|
||||
}
|
||||
|
||||
/// DELETE /api/v1/tenants/:id/users/:user_id
|
||||
pub async fn remove_tenant_user(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
ctx: AuditContext,
|
||||
Path((id, user_id)): Path<(String, String)>,
|
||||
) -> Result<Json<Value>> {
|
||||
require(&state.provider, &auth.user.id, "roles:manage").await?;
|
||||
|
||||
if id == Tenant::DEFAULT_ID {
|
||||
return Err(crate::error::AppError::InvalidInput(
|
||||
"users cannot be moved out of default tenant without specifying a destination tenant"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
|
||||
let user = state
|
||||
.provider
|
||||
.users()
|
||||
.find_by_id(&user_id)
|
||||
.await?
|
||||
.ok_or(crate::error::AppError::NotFound)?;
|
||||
|
||||
if user.tenant_id != id {
|
||||
return Err(crate::error::AppError::InvalidInput(
|
||||
"user does not belong to the specified tenant".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let target_tenant = Tenant::DEFAULT_ID;
|
||||
|
||||
state
|
||||
.provider
|
||||
.users()
|
||||
.reassign_user_tenant_with_audit(
|
||||
&user.id,
|
||||
target_tenant,
|
||||
Some(&auth.user.id),
|
||||
ctx.ip_address.as_deref(),
|
||||
ctx.user_agent.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({ "success": true })))
|
||||
}
|
||||
|
||||
/// GET /api/v1/tenants/:id/applications
|
||||
pub async fn list_tenant_applications(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<Value>> {
|
||||
require(&state.provider, &auth.user.id, "roles:manage").await?;
|
||||
|
||||
let apps = state.provider.applications().list(&id).await?;
|
||||
let views: Vec<crate::api::applications::ApplicationResponse> = apps
|
||||
.into_iter()
|
||||
.map(crate::api::applications::ApplicationResponse::from)
|
||||
.collect();
|
||||
Ok(Json(json!({ "applications": views })))
|
||||
}
|
||||
+80
-2
@@ -9,7 +9,7 @@ use crate::{
|
||||
db::models::Tenant,
|
||||
db::models::{User, UserStatus},
|
||||
error::{AppError, Result},
|
||||
identity::users as identity,
|
||||
identity::{application_members as members, users as identity},
|
||||
middleware::{audit::AuditContext, auth::AuthUser, permissions::require},
|
||||
state::AppState,
|
||||
};
|
||||
@@ -20,6 +20,7 @@ use crate::{
|
||||
pub struct UserResponse {
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
pub tenant_id: String,
|
||||
pub status: String,
|
||||
pub last_login_at: Option<String>,
|
||||
pub created_at: String,
|
||||
@@ -29,8 +30,9 @@ pub struct UserResponse {
|
||||
impl From<User> for UserResponse {
|
||||
fn from(u: User) -> Self {
|
||||
Self {
|
||||
id: u.id,
|
||||
id: u.id.clone(),
|
||||
username: u.username,
|
||||
tenant_id: u.tenant_id,
|
||||
status: UserStatus::from_i32(u.status).to_string(),
|
||||
last_login_at: u.last_login_at,
|
||||
created_at: u.created_at,
|
||||
@@ -215,3 +217,79 @@ pub async fn list_user_roles(
|
||||
}).collect::<Vec<_>>(),
|
||||
})))
|
||||
}
|
||||
|
||||
/// GET /api/v1/users/:id/applications
|
||||
///
|
||||
/// Reverse lookup: list applications assigned to a user via membership.
|
||||
/// Requires `applications:manage` (membership administration).
|
||||
pub async fn list_user_applications(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<Value>> {
|
||||
require(
|
||||
&state.provider,
|
||||
&auth.user.id,
|
||||
crate::api::applications::MANAGE_PERM,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let memberships = members::list_by_user(&state.provider, &id).await?;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct UserApplicationView {
|
||||
id: String,
|
||||
application_id: String,
|
||||
user_id: String,
|
||||
role: String,
|
||||
enabled: bool,
|
||||
created_at: String,
|
||||
updated_at: String,
|
||||
application_name: String,
|
||||
application_slug: String,
|
||||
application_enabled: bool,
|
||||
client_id: String,
|
||||
credentials_configured: bool,
|
||||
}
|
||||
|
||||
let mut views = Vec::with_capacity(memberships.len());
|
||||
for m in memberships {
|
||||
let app = state
|
||||
.provider
|
||||
.applications()
|
||||
.find_by_id(&m.application_id)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let (name, slug, app_enabled, client_id, credentials_configured) = match app {
|
||||
Some(a) => {
|
||||
let credentials_configured = a.has_credentials();
|
||||
(
|
||||
a.name,
|
||||
a.slug.unwrap_or_default(),
|
||||
a.enabled,
|
||||
a.client_id,
|
||||
credentials_configured,
|
||||
)
|
||||
}
|
||||
None => continue,
|
||||
};
|
||||
|
||||
views.push(UserApplicationView {
|
||||
id: m.id,
|
||||
application_id: m.application_id,
|
||||
user_id: m.user_id,
|
||||
role: m.role,
|
||||
enabled: m.enabled,
|
||||
created_at: m.created_at,
|
||||
updated_at: m.updated_at,
|
||||
application_name: name,
|
||||
application_slug: slug,
|
||||
application_enabled: app_enabled,
|
||||
client_id,
|
||||
credentials_configured,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Json(json!({ "applications": views })))
|
||||
}
|
||||
@@ -1,4 +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);
|
||||
INSERT INTO tenants (id, name, slug, enabled)
|
||||
VALUES ('00000000-0000-0000-0000-000000000001', 'Default', 'default', 1)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
@@ -1,35 +1,40 @@
|
||||
-- ── Roles ────────────────────────────────────────────────────────────────────
|
||||
|
||||
INSERT OR IGNORE INTO roles (id, name, description) VALUES
|
||||
INSERT 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');
|
||||
('10000000-0000-0000-0000-000000000003', 'viewer', 'Read-only access')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- ── Permissions ───────────────────────────────────────────────────────────────
|
||||
|
||||
INSERT OR IGNORE INTO permissions (id, name, description) VALUES
|
||||
INSERT 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');
|
||||
('20000000-0000-0000-0000-000000000007', 'audit:view', 'View audit log entries')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- ── Admin role gets all permissions ──────────────────────────────────────────
|
||||
|
||||
INSERT OR IGNORE INTO role_permissions (role_id, permission_id)
|
||||
SELECT '10000000-0000-0000-0000-000000000001', id FROM permissions;
|
||||
INSERT INTO role_permissions (role_id, permission_id)
|
||||
SELECT '10000000-0000-0000-0000-000000000001', id FROM permissions
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- ── Editor role permissions ───────────────────────────────────────────────────
|
||||
|
||||
INSERT OR IGNORE INTO role_permissions (role_id, permission_id) VALUES
|
||||
INSERT 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');
|
||||
('10000000-0000-0000-0000-000000000002', '20000000-0000-0000-0000-000000000002')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- ── Default applications ──────────────────────────────────────────────────────
|
||||
|
||||
INSERT OR IGNORE INTO applications (id, tenant_id, name, slug, enabled) VALUES
|
||||
INSERT 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);
|
||||
('30000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000001', 'nx9-dns', 'nx9-dns', 1)
|
||||
ON CONFLICT DO NOTHING;
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Application membership: assign existing NX9-Auth users to registered applications.
|
||||
-- Membership roles (owner/admin/member) are lightweight metadata only and do not
|
||||
-- grant global RBAC permissions such as applications:manage.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS application_members (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
application_id TEXT NOT NULL REFERENCES applications(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL DEFAULT 'member'
|
||||
CHECK (role IN ('owner', 'admin', 'member')),
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TEXT NOT NULL DEFAULT (to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')),
|
||||
updated_at TEXT NOT NULL DEFAULT (to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')),
|
||||
UNIQUE (application_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_application_members_application
|
||||
ON application_members(application_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_application_members_user
|
||||
ON application_members(user_id);
|
||||
@@ -1,4 +1,4 @@
|
||||
-- nx9-auth: Global Slugs implementation
|
||||
-- nx9-auth: Global Slugs implementation (PostgreSQL)
|
||||
-- A unified registry for slugs across all resources (tenant, user, group, role, app, etc.)
|
||||
-- Ensures global uniqueness and immutable references.
|
||||
|
||||
@@ -7,22 +7,21 @@ CREATE TABLE IF NOT EXISTS global_slugs (
|
||||
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 (to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z\'))
|
||||
created_at TEXT NOT NULL DEFAULT (to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'))
|
||||
);
|
||||
|
||||
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;
|
||||
ALTER TABLE tenants ADD COLUMN IF NOT EXISTS slug TEXT;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS slug TEXT;
|
||||
ALTER TABLE roles ADD COLUMN IF NOT EXISTS slug TEXT;
|
||||
ALTER TABLE permissions ADD COLUMN IF NOT EXISTS slug TEXT;
|
||||
ALTER TABLE applications ADD COLUMN IF NOT EXISTS slug TEXT;
|
||||
ALTER TABLE service_accounts ADD COLUMN IF NOT EXISTS slug TEXT;
|
||||
|
||||
-- We will backfill slugs in Rust on startup or through a data migration script,
|
||||
-- or we can backfill basic ones here:
|
||||
-- Backfill basic slugs:
|
||||
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;
|
||||
@@ -30,21 +29,27 @@ 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 backfilled slugs into registry
|
||||
INSERT INTO global_slugs (slug, entity_type, entity_id, tenant_id)
|
||||
SELECT slug, 'tenant', id, id FROM tenants WHERE slug IS NOT NULL
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
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 INTO global_slugs (slug, entity_type, entity_id, tenant_id)
|
||||
SELECT slug, 'user', id, tenant_id FROM users WHERE slug IS NOT NULL
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
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 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
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
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 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
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
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 INTO global_slugs (slug, entity_type, entity_id, tenant_id)
|
||||
SELECT slug, 'application', id, tenant_id FROM applications WHERE slug IS NOT NULL
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
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;
|
||||
INSERT 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
|
||||
ON CONFLICT DO NOTHING;
|
||||
@@ -0,0 +1,27 @@
|
||||
-- nx9-auth: Global Slugs Hardening & Parity Alignment (PostgreSQL)
|
||||
-- Ensures unified global_slugs registry table, indices, and legacy data integrity.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS global_slugs (
|
||||
slug TEXT PRIMARY KEY NOT NULL,
|
||||
entity_type TEXT NOT NULL,
|
||||
entity_id TEXT NOT NULL,
|
||||
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL DEFAULT (to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'))
|
||||
);
|
||||
|
||||
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);
|
||||
|
||||
-- Explicit backfill for tenants that are not yet in global_slugs.
|
||||
-- Fails immediately if cross-resource slug collision exists.
|
||||
INSERT INTO global_slugs (slug, entity_type, entity_id, tenant_id)
|
||||
SELECT slug, 'tenant', id, id
|
||||
FROM tenants
|
||||
WHERE slug IS NOT NULL AND slug NOT IN (SELECT slug FROM global_slugs);
|
||||
|
||||
-- Explicit backfill for applications that are not yet in global_slugs.
|
||||
-- Fails immediately if cross-resource slug collision exists.
|
||||
INSERT INTO global_slugs (slug, entity_type, entity_id, tenant_id)
|
||||
SELECT slug, 'application', id, tenant_id
|
||||
FROM applications
|
||||
WHERE slug IS NOT NULL AND slug NOT IN (SELECT slug FROM global_slugs);
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Application membership: assign existing NX9-Auth users to registered applications.
|
||||
-- Membership roles (owner/admin/member) are lightweight metadata only and do not
|
||||
-- grant global RBAC permissions such as applications:manage.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS application_members (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
application_id TEXT NOT NULL REFERENCES applications(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL DEFAULT 'member'
|
||||
CHECK (role IN ('owner', 'admin', 'member')),
|
||||
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 (application_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_application_members_application
|
||||
ON application_members(application_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_application_members_user
|
||||
ON application_members(user_id);
|
||||
@@ -0,0 +1,27 @@
|
||||
-- nx9-auth: Global Slugs Hardening & Parity Alignment
|
||||
-- Ensures unified global_slugs registry table, indices, and legacy data integrity.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS global_slugs (
|
||||
slug TEXT PRIMARY KEY NOT NULL,
|
||||
entity_type TEXT NOT NULL,
|
||||
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);
|
||||
|
||||
-- Explicit backfill for tenants that are not yet in global_slugs.
|
||||
-- Fails immediately if cross-resource slug collision exists.
|
||||
INSERT INTO global_slugs (slug, entity_type, entity_id, tenant_id)
|
||||
SELECT slug, 'tenant', id, id
|
||||
FROM tenants
|
||||
WHERE slug IS NOT NULL AND slug NOT IN (SELECT slug FROM global_slugs);
|
||||
|
||||
-- Explicit backfill for applications that are not yet in global_slugs.
|
||||
-- Fails immediately if cross-resource slug collision exists.
|
||||
INSERT INTO global_slugs (slug, entity_type, entity_id, tenant_id)
|
||||
SELECT slug, 'application', id, tenant_id
|
||||
FROM applications
|
||||
WHERE slug IS NOT NULL AND slug NOT IN (SELECT slug FROM global_slugs);
|
||||
@@ -0,0 +1,58 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::FromRow;
|
||||
|
||||
/// Allowed application membership roles (lightweight metadata only).
|
||||
///
|
||||
/// These do **not** grant global NX9-Auth RBAC permissions.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ApplicationMembershipRole {
|
||||
Owner,
|
||||
Admin,
|
||||
#[default]
|
||||
Member,
|
||||
}
|
||||
|
||||
impl ApplicationMembershipRole {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Owner => "owner",
|
||||
Self::Admin => "admin",
|
||||
Self::Member => "member",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a role string. Returns `None` for invalid values.
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
match s.trim().to_ascii_lowercase().as_str() {
|
||||
"owner" => Some(Self::Owner),
|
||||
"admin" => Some(Self::Admin),
|
||||
"member" => Some(Self::Member),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ApplicationMembershipRole {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// Assignment of an existing NX9-Auth user to a registered application.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct ApplicationMember {
|
||||
pub id: String,
|
||||
pub application_id: String,
|
||||
pub user_id: String,
|
||||
pub role: String,
|
||||
pub enabled: bool,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
impl ApplicationMember {
|
||||
pub fn membership_role(&self) -> Option<ApplicationMembershipRole> {
|
||||
ApplicationMembershipRole::parse(&self.role)
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,7 @@ pub struct AuditFilter {
|
||||
pub since: Option<String>,
|
||||
pub until: Option<String>,
|
||||
pub search: Option<String>,
|
||||
pub success: Option<bool>,
|
||||
pub limit: i64,
|
||||
pub offset: i64,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct GlobalSlug {
|
||||
pub slug: String,
|
||||
pub entity_type: String,
|
||||
pub entity_id: String,
|
||||
pub tenant_id: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
pub mod api_token;
|
||||
pub mod application;
|
||||
pub mod application_member;
|
||||
pub mod audit_log;
|
||||
pub mod global_slug;
|
||||
pub mod group;
|
||||
pub mod permission;
|
||||
pub mod refresh_token;
|
||||
@@ -12,7 +14,9 @@ pub mod user;
|
||||
|
||||
pub use api_token::ApiToken;
|
||||
pub use application::Application;
|
||||
pub use application_member::{ApplicationMember, ApplicationMembershipRole};
|
||||
pub use audit_log::{AuditFilter, AuditLog, AuditSeverity};
|
||||
pub use global_slug::GlobalSlug;
|
||||
pub use group::Group;
|
||||
#[allow(unused_imports)]
|
||||
pub use permission::Permission;
|
||||
|
||||
@@ -18,6 +18,8 @@ pub trait DatabaseProvider: Send + Sync {
|
||||
fn tokens(&self) -> Box<dyn TokensRepository>;
|
||||
fn tenants(&self) -> Box<dyn TenantsRepository>;
|
||||
fn groups(&self) -> Box<dyn GroupsRepository>;
|
||||
fn application_members(&self) -> Box<dyn ApplicationMembersRepository>;
|
||||
fn global_slugs(&self) -> Box<dyn GlobalSlugsRepository>;
|
||||
}
|
||||
|
||||
#[cfg(feature = "sqlite")]
|
||||
@@ -112,6 +114,20 @@ impl DatabaseProvider for SqliteProvider {
|
||||
},
|
||||
)
|
||||
}
|
||||
fn application_members(&self) -> Box<dyn ApplicationMembersRepository> {
|
||||
Box::new(
|
||||
crate::db::repository::sqlite::application_members::SqliteApplicationMembersRepository {
|
||||
pool: self.pool.clone(),
|
||||
},
|
||||
)
|
||||
}
|
||||
fn global_slugs(&self) -> Box<dyn GlobalSlugsRepository> {
|
||||
Box::new(
|
||||
crate::db::repository::sqlite::global_slugs::SqliteGlobalSlugsRepository {
|
||||
pool: self.pool.clone(),
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "postgres")]
|
||||
@@ -206,4 +222,18 @@ impl DatabaseProvider for PostgresProvider {
|
||||
},
|
||||
)
|
||||
}
|
||||
fn application_members(&self) -> Box<dyn ApplicationMembersRepository> {
|
||||
Box::new(
|
||||
crate::db::repository::postgres::application_members::PostgresApplicationMembersRepository {
|
||||
pool: self.pool.clone(),
|
||||
},
|
||||
)
|
||||
}
|
||||
fn global_slugs(&self) -> Box<dyn GlobalSlugsRepository> {
|
||||
Box::new(
|
||||
crate::db::repository::postgres::global_slugs::PostgresGlobalSlugsRepository {
|
||||
pool: self.pool.clone(),
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
use crate::db::models::ApplicationMember;
|
||||
use crate::db::repository::traits::ApplicationMembersRepository;
|
||||
use async_trait::async_trait;
|
||||
use sqlx::PgPool;
|
||||
|
||||
pub struct PostgresApplicationMembersRepository {
|
||||
pub pool: PgPool,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ApplicationMembersRepository for PostgresApplicationMembersRepository {
|
||||
async fn list_by_application(
|
||||
&self,
|
||||
application_id: &str,
|
||||
) -> Result<Vec<ApplicationMember>, sqlx::Error> {
|
||||
sqlx::query_as::<_, ApplicationMember>(
|
||||
r#"
|
||||
SELECT id, application_id, user_id, role, enabled, created_at, updated_at
|
||||
FROM application_members
|
||||
WHERE application_id = $1
|
||||
ORDER BY created_at ASC
|
||||
"#,
|
||||
)
|
||||
.bind(application_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn list_by_user(&self, user_id: &str) -> Result<Vec<ApplicationMember>, sqlx::Error> {
|
||||
sqlx::query_as::<_, ApplicationMember>(
|
||||
r#"
|
||||
SELECT id, application_id, user_id, role, enabled, created_at, updated_at
|
||||
FROM application_members
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at ASC
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn find(
|
||||
&self,
|
||||
application_id: &str,
|
||||
user_id: &str,
|
||||
) -> Result<Option<ApplicationMember>, sqlx::Error> {
|
||||
sqlx::query_as::<_, ApplicationMember>(
|
||||
r#"
|
||||
SELECT id, application_id, user_id, role, enabled, created_at, updated_at
|
||||
FROM application_members
|
||||
WHERE application_id = $1 AND user_id = $2
|
||||
"#,
|
||||
)
|
||||
.bind(application_id)
|
||||
.bind(user_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn add(
|
||||
&self,
|
||||
id: &str,
|
||||
application_id: &str,
|
||||
user_id: &str,
|
||||
role: &str,
|
||||
) -> Result<ApplicationMember, sqlx::Error> {
|
||||
sqlx::query_as::<_, ApplicationMember>(
|
||||
r#"
|
||||
INSERT INTO application_members (id, application_id, user_id, role, enabled)
|
||||
VALUES ($1, $2, $3, $4, TRUE)
|
||||
RETURNING id, application_id, user_id, role, enabled, created_at, updated_at
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(application_id)
|
||||
.bind(user_id)
|
||||
.bind(role)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn update_role(
|
||||
&self,
|
||||
application_id: &str,
|
||||
user_id: &str,
|
||||
role: &str,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE application_members
|
||||
SET role = $1,
|
||||
updated_at = to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')
|
||||
WHERE application_id = $2 AND user_id = $3
|
||||
"#,
|
||||
)
|
||||
.bind(role)
|
||||
.bind(application_id)
|
||||
.bind(user_id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_enabled(
|
||||
&self,
|
||||
application_id: &str,
|
||||
user_id: &str,
|
||||
enabled: bool,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE application_members
|
||||
SET enabled = $1,
|
||||
updated_at = to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')
|
||||
WHERE application_id = $2 AND user_id = $3
|
||||
"#,
|
||||
)
|
||||
.bind(enabled)
|
||||
.bind(application_id)
|
||||
.bind(user_id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove(&self, application_id: &str, user_id: &str) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
DELETE FROM application_members
|
||||
WHERE application_id = $1 AND user_id = $2
|
||||
"#,
|
||||
)
|
||||
.bind(application_id)
|
||||
.bind(user_id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn add_with_audit(
|
||||
&self,
|
||||
id: &str,
|
||||
application_id: &str,
|
||||
user_id: &str,
|
||||
role: &str,
|
||||
audit_event: Option<crate::audit::AuditEvent<'_>>,
|
||||
) -> Result<ApplicationMember, sqlx::Error> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
let app_tenant: Option<(String,)> =
|
||||
sqlx::query_as("SELECT tenant_id FROM applications WHERE id = $1")
|
||||
.bind(application_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let app_tenant_id = match app_tenant {
|
||||
Some(t) => t.0,
|
||||
None => return Err(sqlx::Error::RowNotFound),
|
||||
};
|
||||
|
||||
let user_tenant: Option<(String,)> =
|
||||
sqlx::query_as("SELECT tenant_id FROM users WHERE id = $1")
|
||||
.bind(user_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let user_tenant_id = match user_tenant {
|
||||
Some(t) => t.0,
|
||||
None => return Err(sqlx::Error::RowNotFound),
|
||||
};
|
||||
|
||||
if app_tenant_id != user_tenant_id {
|
||||
return Err(sqlx::Error::Protocol(
|
||||
"user and application must belong to the same tenant".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let existing: Option<(String,)> = sqlx::query_as(
|
||||
"SELECT id FROM application_members WHERE application_id = $1 AND user_id = $2",
|
||||
)
|
||||
.bind(application_id)
|
||||
.bind(user_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
if existing.is_some() {
|
||||
return Err(sqlx::Error::Protocol(
|
||||
"user is already a member of this application".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let member = sqlx::query_as::<_, ApplicationMember>(
|
||||
r#"
|
||||
INSERT INTO application_members (id, application_id, user_id, role, enabled)
|
||||
VALUES ($1, $2, $3, $4, TRUE)
|
||||
RETURNING id, application_id, user_id, role, enabled, created_at, updated_at
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(application_id)
|
||||
.bind(user_id)
|
||||
.bind(role)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if let Some(event) = audit_event {
|
||||
let audit_id = uuid::Uuid::new_v4().to_string();
|
||||
let severity_str = event.severity.to_string();
|
||||
sqlx::query(
|
||||
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)
|
||||
"#,
|
||||
)
|
||||
.bind(&audit_id)
|
||||
.bind(event.actor_id)
|
||||
.bind(event.target_id)
|
||||
.bind(event.action)
|
||||
.bind(event.resource_type)
|
||||
.bind(event.resource_id)
|
||||
.bind(&severity_str)
|
||||
.bind(event.ip)
|
||||
.bind(event.ua)
|
||||
.bind(event.metadata)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(member)
|
||||
}
|
||||
|
||||
async fn update_role_with_audit(
|
||||
&self,
|
||||
application_id: &str,
|
||||
user_id: &str,
|
||||
role: &str,
|
||||
audit_event: Option<crate::audit::AuditEvent<'_>>,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
let existing_member: Option<(String,)> = sqlx::query_as(
|
||||
"SELECT id FROM application_members WHERE application_id = $1 AND user_id = $2 FOR UPDATE",
|
||||
)
|
||||
.bind(application_id)
|
||||
.bind(user_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
if existing_member.is_none() {
|
||||
return Err(sqlx::Error::RowNotFound);
|
||||
}
|
||||
|
||||
let app_tenant: Option<(String,)> =
|
||||
sqlx::query_as("SELECT tenant_id FROM applications WHERE id = $1")
|
||||
.bind(application_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let app_tenant_id = match app_tenant {
|
||||
Some(t) => t.0,
|
||||
None => return Err(sqlx::Error::RowNotFound),
|
||||
};
|
||||
|
||||
let user_tenant: Option<(String,)> =
|
||||
sqlx::query_as("SELECT tenant_id FROM users WHERE id = $1")
|
||||
.bind(user_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let user_tenant_id = match user_tenant {
|
||||
Some(t) => t.0,
|
||||
None => return Err(sqlx::Error::RowNotFound),
|
||||
};
|
||||
|
||||
if app_tenant_id != user_tenant_id {
|
||||
return Err(sqlx::Error::Protocol(
|
||||
"user and application must belong to the same tenant".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE application_members
|
||||
SET role = $1,
|
||||
updated_at = to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')
|
||||
WHERE application_id = $2 AND user_id = $3
|
||||
"#,
|
||||
)
|
||||
.bind(role)
|
||||
.bind(application_id)
|
||||
.bind(user_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() != 1 {
|
||||
return Err(sqlx::Error::RowNotFound);
|
||||
}
|
||||
|
||||
if let Some(event) = audit_event {
|
||||
let audit_id = uuid::Uuid::new_v4().to_string();
|
||||
let severity_str = event.severity.to_string();
|
||||
sqlx::query(
|
||||
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)
|
||||
"#,
|
||||
)
|
||||
.bind(&audit_id)
|
||||
.bind(event.actor_id)
|
||||
.bind(event.target_id)
|
||||
.bind(event.action)
|
||||
.bind(event.resource_type)
|
||||
.bind(event.resource_id)
|
||||
.bind(&severity_str)
|
||||
.bind(event.ip)
|
||||
.bind(event.ua)
|
||||
.bind(event.metadata)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_enabled_with_audit(
|
||||
&self,
|
||||
application_id: &str,
|
||||
user_id: &str,
|
||||
enabled: bool,
|
||||
audit_event: Option<crate::audit::AuditEvent<'_>>,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
let existing_member: Option<(String,)> = sqlx::query_as(
|
||||
"SELECT id FROM application_members WHERE application_id = $1 AND user_id = $2 FOR UPDATE",
|
||||
)
|
||||
.bind(application_id)
|
||||
.bind(user_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
if existing_member.is_none() {
|
||||
return Err(sqlx::Error::RowNotFound);
|
||||
}
|
||||
|
||||
let app_tenant: Option<(String,)> =
|
||||
sqlx::query_as("SELECT tenant_id FROM applications WHERE id = $1")
|
||||
.bind(application_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let app_tenant_id = match app_tenant {
|
||||
Some(t) => t.0,
|
||||
None => return Err(sqlx::Error::RowNotFound),
|
||||
};
|
||||
|
||||
let user_tenant: Option<(String,)> =
|
||||
sqlx::query_as("SELECT tenant_id FROM users WHERE id = $1")
|
||||
.bind(user_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let user_tenant_id = match user_tenant {
|
||||
Some(t) => t.0,
|
||||
None => return Err(sqlx::Error::RowNotFound),
|
||||
};
|
||||
|
||||
if app_tenant_id != user_tenant_id {
|
||||
return Err(sqlx::Error::Protocol(
|
||||
"user and application must belong to the same tenant".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE application_members
|
||||
SET enabled = $1,
|
||||
updated_at = to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')
|
||||
WHERE application_id = $2 AND user_id = $3
|
||||
"#,
|
||||
)
|
||||
.bind(enabled)
|
||||
.bind(application_id)
|
||||
.bind(user_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() != 1 {
|
||||
return Err(sqlx::Error::RowNotFound);
|
||||
}
|
||||
|
||||
if let Some(event) = audit_event {
|
||||
let audit_id = uuid::Uuid::new_v4().to_string();
|
||||
let severity_str = event.severity.to_string();
|
||||
sqlx::query(
|
||||
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)
|
||||
"#,
|
||||
)
|
||||
.bind(&audit_id)
|
||||
.bind(event.actor_id)
|
||||
.bind(event.target_id)
|
||||
.bind(event.action)
|
||||
.bind(event.resource_type)
|
||||
.bind(event.resource_id)
|
||||
.bind(&severity_str)
|
||||
.bind(event.ip)
|
||||
.bind(event.ua)
|
||||
.bind(event.metadata)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_with_audit(
|
||||
&self,
|
||||
application_id: &str,
|
||||
user_id: &str,
|
||||
audit_event: Option<crate::audit::AuditEvent<'_>>,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
let existing_member: Option<(String,)> = sqlx::query_as(
|
||||
"SELECT id FROM application_members WHERE application_id = $1 AND user_id = $2 FOR UPDATE",
|
||||
)
|
||||
.bind(application_id)
|
||||
.bind(user_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
if existing_member.is_none() {
|
||||
return Err(sqlx::Error::RowNotFound);
|
||||
}
|
||||
|
||||
let app_tenant: Option<(String,)> =
|
||||
sqlx::query_as("SELECT tenant_id FROM applications WHERE id = $1")
|
||||
.bind(application_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let app_tenant_id = match app_tenant {
|
||||
Some(t) => t.0,
|
||||
None => return Err(sqlx::Error::RowNotFound),
|
||||
};
|
||||
|
||||
let user_tenant: Option<(String,)> =
|
||||
sqlx::query_as("SELECT tenant_id FROM users WHERE id = $1")
|
||||
.bind(user_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let user_tenant_id = match user_tenant {
|
||||
Some(t) => t.0,
|
||||
None => return Err(sqlx::Error::RowNotFound),
|
||||
};
|
||||
|
||||
if app_tenant_id != user_tenant_id {
|
||||
return Err(sqlx::Error::Protocol(
|
||||
"user and application must belong to the same tenant".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
DELETE FROM application_members
|
||||
WHERE application_id = $1 AND user_id = $2
|
||||
"#,
|
||||
)
|
||||
.bind(application_id)
|
||||
.bind(user_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() != 1 {
|
||||
return Err(sqlx::Error::RowNotFound);
|
||||
}
|
||||
|
||||
if let Some(event) = audit_event {
|
||||
let audit_id = uuid::Uuid::new_v4().to_string();
|
||||
let severity_str = event.severity.to_string();
|
||||
sqlx::query(
|
||||
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)
|
||||
"#,
|
||||
)
|
||||
.bind(&audit_id)
|
||||
.bind(event.actor_id)
|
||||
.bind(event.target_id)
|
||||
.bind(event.action)
|
||||
.bind(event.resource_type)
|
||||
.bind(event.resource_id)
|
||||
.bind(&severity_str)
|
||||
.bind(event.ip)
|
||||
.bind(event.ua)
|
||||
.bind(event.metadata)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
use crate::db::models::Application;
|
||||
use crate::db::repository::postgres::global_slugs::{
|
||||
release_slug_by_name_postgres, release_slug_postgres, reserve_slug_postgres,
|
||||
};
|
||||
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,
|
||||
}
|
||||
@@ -25,6 +27,8 @@ impl ApplicationsRepository for PostgresApplicationsRepository {
|
||||
) -> Result<Application, sqlx::Error> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
reserve_slug_postgres(&mut tx, slug, "application", id, tenant_id).await?;
|
||||
|
||||
let app = sqlx::query_as::<_, Application>(
|
||||
r#"
|
||||
INSERT INTO applications (id, tenant_id, name, slug, client_id, client_secret_hash, description, redirect_uris, scopes)
|
||||
@@ -188,31 +192,74 @@ impl ApplicationsRepository for PostgresApplicationsRepository {
|
||||
scopes: Option<&str>,
|
||||
enabled: bool,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE applications
|
||||
SET name = $1, slug = $2, description = $3, redirect_uris = $4, scopes = $5, enabled = $6,
|
||||
updated_at = to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')
|
||||
WHERE id = $7
|
||||
"#,
|
||||
)
|
||||
.bind(name)
|
||||
.bind(slug)
|
||||
.bind(description)
|
||||
.bind(redirect_uris)
|
||||
.bind(scopes)
|
||||
.bind(enabled)
|
||||
.bind(id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
let existing = self.find_by_id(id).await?.ok_or(sqlx::Error::RowNotFound)?;
|
||||
|
||||
let existing_slug_str = existing.slug.as_deref().unwrap_or("");
|
||||
|
||||
if slug == existing_slug_str {
|
||||
// Unchanged slug: registry no-op
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE applications
|
||||
SET name = $1, description = $2, redirect_uris = $3, scopes = $4, enabled = $5,
|
||||
updated_at = to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')
|
||||
WHERE id = $6
|
||||
"#,
|
||||
)
|
||||
.bind(name)
|
||||
.bind(description)
|
||||
.bind(redirect_uris)
|
||||
.bind(scopes)
|
||||
.bind(enabled)
|
||||
.bind(id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
} else {
|
||||
// Changed slug: single transaction reserve -> update -> release
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
reserve_slug_postgres(&mut tx, slug, "application", id, &existing.tenant_id).await?;
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE applications
|
||||
SET name = $1, slug = $2, description = $3, redirect_uris = $4, scopes = $5, enabled = $6,
|
||||
updated_at = to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')
|
||||
WHERE id = $7
|
||||
"#,
|
||||
)
|
||||
.bind(name)
|
||||
.bind(slug)
|
||||
.bind(description)
|
||||
.bind(redirect_uris)
|
||||
.bind(scopes)
|
||||
.bind(enabled)
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if !existing_slug_str.is_empty() {
|
||||
release_slug_by_name_postgres(&mut tx, existing_slug_str, "application", id)
|
||||
.await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, id: &str) -> Result<(), sqlx::Error> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
release_slug_postgres(&mut tx, "application", id).await?;
|
||||
|
||||
sqlx::query("DELETE FROM applications WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(&self.pool)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -64,8 +64,6 @@ impl AuditRepository for PostgresAuditRepository {
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -88,8 +86,13 @@ impl AuditRepository for PostgresAuditRepository {
|
||||
OR ip_address LIKE $7 ESCAPE '\'
|
||||
OR metadata_json LIKE $7 ESCAPE '\'
|
||||
)
|
||||
AND (
|
||||
$8::boolean IS NULL
|
||||
OR ($8::boolean = TRUE AND action NOT LIKE '%fail%' AND action NOT LIKE '%denied%' AND severity != 'critical')
|
||||
OR ($8::boolean = FALSE AND (action LIKE '%fail%' OR action LIKE '%denied%' OR severity = 'critical'))
|
||||
)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $8 OFFSET $9
|
||||
LIMIT $9 OFFSET $10
|
||||
"#,
|
||||
)
|
||||
.bind(filter.actor_user_id.as_deref())
|
||||
@@ -99,6 +102,7 @@ impl AuditRepository for PostgresAuditRepository {
|
||||
.bind(filter.since.as_deref())
|
||||
.bind(filter.until.as_deref())
|
||||
.bind(search_like.as_deref())
|
||||
.bind(filter.success)
|
||||
.bind(filter.limit)
|
||||
.bind(filter.offset)
|
||||
.fetch_all(&self.pool)
|
||||
@@ -128,6 +132,11 @@ impl AuditRepository for PostgresAuditRepository {
|
||||
OR ip_address LIKE $7 ESCAPE '\'
|
||||
OR metadata_json LIKE $7 ESCAPE '\'
|
||||
)
|
||||
AND (
|
||||
$8::boolean IS NULL
|
||||
OR ($8::boolean = TRUE AND action NOT LIKE '%fail%' AND action NOT LIKE '%denied%' AND severity != 'critical')
|
||||
OR ($8::boolean = FALSE AND (action LIKE '%fail%' OR action LIKE '%denied%' OR severity = 'critical'))
|
||||
)
|
||||
"#,
|
||||
)
|
||||
.bind(filter.actor_user_id.as_deref())
|
||||
@@ -137,6 +146,7 @@ impl AuditRepository for PostgresAuditRepository {
|
||||
.bind(filter.since.as_deref())
|
||||
.bind(filter.until.as_deref())
|
||||
.bind(search_like.as_deref())
|
||||
.bind(filter.success)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(row.0)
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
use crate::db::models::GlobalSlug;
|
||||
use crate::db::repository::traits::GlobalSlugsRepository;
|
||||
use sqlx::PgPool;
|
||||
|
||||
pub struct PostgresGlobalSlugsRepository {
|
||||
pub pool: PgPool,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl GlobalSlugsRepository for PostgresGlobalSlugsRepository {
|
||||
async fn find_by_slug(&self, slug: &str) -> Result<Option<GlobalSlug>, sqlx::Error> {
|
||||
sqlx::query_as::<_, GlobalSlug>(
|
||||
"SELECT slug, entity_type, entity_id, tenant_id, created_at::text FROM global_slugs WHERE slug = $1"
|
||||
)
|
||||
.bind(slug)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn reserve_slug_postgres(
|
||||
conn: &mut sqlx::PgConnection,
|
||||
slug: &str,
|
||||
entity_type: &str,
|
||||
entity_id: &str,
|
||||
tenant_id: &str,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
"INSERT INTO global_slugs (slug, entity_type, entity_id, tenant_id) VALUES ($1, $2, $3, $4)"
|
||||
)
|
||||
.bind(slug)
|
||||
.bind(entity_type)
|
||||
.bind(entity_id)
|
||||
.bind(tenant_id)
|
||||
.execute(conn)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn release_slug_postgres(
|
||||
conn: &mut sqlx::PgConnection,
|
||||
entity_type: &str,
|
||||
entity_id: &str,
|
||||
) -> Result<u64, sqlx::Error> {
|
||||
let res = sqlx::query("DELETE FROM global_slugs WHERE entity_type = $1 AND entity_id = $2")
|
||||
.bind(entity_type)
|
||||
.bind(entity_id)
|
||||
.execute(conn)
|
||||
.await?;
|
||||
Ok(res.rows_affected())
|
||||
}
|
||||
|
||||
pub async fn release_slug_by_name_postgres(
|
||||
conn: &mut sqlx::PgConnection,
|
||||
slug: &str,
|
||||
entity_type: &str,
|
||||
entity_id: &str,
|
||||
) -> Result<u64, sqlx::Error> {
|
||||
let res = sqlx::query(
|
||||
"DELETE FROM global_slugs WHERE slug = $1 AND entity_type = $2 AND entity_id = $3",
|
||||
)
|
||||
.bind(slug)
|
||||
.bind(entity_type)
|
||||
.bind(entity_id)
|
||||
.execute(conn)
|
||||
.await?;
|
||||
Ok(res.rows_affected())
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
pub mod application_members;
|
||||
pub mod applications;
|
||||
pub mod audit;
|
||||
pub mod global_slugs;
|
||||
pub mod groups;
|
||||
pub mod permissions;
|
||||
pub mod refresh_tokens;
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::db::models::Tenant;
|
||||
use crate::db::repository::postgres::global_slugs::{
|
||||
release_slug_by_name_postgres, release_slug_postgres, reserve_slug_postgres,
|
||||
};
|
||||
use crate::db::repository::traits::TenantsRepository;
|
||||
use crate::identity::slug::{slugify, validate_slug};
|
||||
|
||||
pub struct PostgresTenantsRepository {
|
||||
pub pool: PgPool,
|
||||
@@ -47,7 +51,17 @@ impl TenantsRepository for PostgresTenantsRepository {
|
||||
name: &str,
|
||||
slug: Option<&str>,
|
||||
) -> Result<Tenant, sqlx::Error> {
|
||||
let slug = slug.unwrap_or(id);
|
||||
let final_slug = match slug {
|
||||
Some(s) if !s.trim().is_empty() => {
|
||||
let trimmed = s.trim();
|
||||
validate_slug(trimmed).map_err(|e| sqlx::Error::Protocol(e.to_string()))?;
|
||||
trimmed.to_string()
|
||||
}
|
||||
_ => slugify(name).map_err(|e| sqlx::Error::Protocol(e.to_string()))?,
|
||||
};
|
||||
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
let row = sqlx::query_as::<_, Tenant>(
|
||||
r#"
|
||||
INSERT INTO tenants (id, name, slug, enabled)
|
||||
@@ -57,27 +71,68 @@ impl TenantsRepository for PostgresTenantsRepository {
|
||||
)
|
||||
.bind(id)
|
||||
.bind(name)
|
||||
.bind(slug)
|
||||
.fetch_one(&self.pool)
|
||||
.bind(&final_slug)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
reserve_slug_postgres(&mut tx, &final_slug, "tenant", id, id).await?;
|
||||
|
||||
tx.commit().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?;
|
||||
let existing = self.find_by_id(id).await?.ok_or(sqlx::Error::RowNotFound)?;
|
||||
|
||||
let target_slug = match slug {
|
||||
Some(s) if !s.trim().is_empty() => {
|
||||
let trimmed = s.trim();
|
||||
validate_slug(trimmed).map_err(|e| sqlx::Error::Protocol(e.to_string()))?;
|
||||
trimmed.to_string()
|
||||
}
|
||||
_ => existing.slug.clone().unwrap_or_else(|| id.to_string()),
|
||||
};
|
||||
|
||||
let existing_slug_str = existing.slug.as_deref().unwrap_or("");
|
||||
|
||||
if target_slug == existing_slug_str {
|
||||
// Unchanged slug: registry no-op
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE tenants
|
||||
SET name = $1, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $2
|
||||
"#,
|
||||
)
|
||||
.bind(name)
|
||||
.bind(id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
} else {
|
||||
// Changed slug: single transaction reserve -> update -> release
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
reserve_slug_postgres(&mut tx, &target_slug, "tenant", id, id).await?;
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE tenants
|
||||
SET name = $1, slug = $2, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $3
|
||||
"#,
|
||||
)
|
||||
.bind(name)
|
||||
.bind(&target_slug)
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if !existing_slug_str.is_empty() {
|
||||
release_slug_by_name_postgres(&mut tx, existing_slug_str, "tenant", id).await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -99,10 +154,16 @@ impl TenantsRepository for PostgresTenantsRepository {
|
||||
}
|
||||
|
||||
async fn delete(&self, id: &str) -> Result<(), sqlx::Error> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
release_slug_postgres(&mut tx, "tenant", id).await?;
|
||||
|
||||
sqlx::query("DELETE FROM tenants WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(&self.pool)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -98,6 +98,125 @@ impl UsersRepository for PostgresUsersRepository {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_user_tenant(&self, id: &str, tenant_id: &str) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
"UPDATE users SET tenant_id = $1, updated_at = to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') WHERE id = $2",
|
||||
)
|
||||
.bind(tenant_id)
|
||||
.bind(id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn reassign_user_tenant_with_audit(
|
||||
&self,
|
||||
user_id: &str,
|
||||
destination_tenant_id: &str,
|
||||
actor_id: Option<&str>,
|
||||
ip_address: Option<&str>,
|
||||
user_agent: Option<&str>,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1 FOR UPDATE")
|
||||
.bind(user_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
.ok_or(sqlx::Error::RowNotFound)?;
|
||||
|
||||
if user.tenant_id == destination_tenant_id {
|
||||
tx.commit().await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let from_tenant_id = user.tenant_id.clone();
|
||||
|
||||
if from_tenant_id == crate::db::models::Tenant::DEFAULT_ID {
|
||||
let admin_rows: Vec<(String,)> = sqlx::query_as(
|
||||
"SELECT ur.user_id FROM user_roles ur JOIN roles r ON ur.role_id = r.id WHERE r.name = 'admin' FOR UPDATE",
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let is_target_admin = admin_rows.iter().any(|r| r.0 == user_id);
|
||||
if is_target_admin && admin_rows.len() <= 1 {
|
||||
return Err(sqlx::Error::Protocol(
|
||||
"cannot reassign the last system administrator away from default tenant".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let dest_exists: Option<(String,)> = sqlx::query_as("SELECT id FROM tenants WHERE id = $1")
|
||||
.bind(destination_tenant_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
if dest_exists.is_none() {
|
||||
return Err(sqlx::Error::RowNotFound);
|
||||
}
|
||||
|
||||
let collision: Option<(i64,)> =
|
||||
sqlx::query_as("SELECT 1 FROM users WHERE tenant_id = $1 AND username = $2")
|
||||
.bind(destination_tenant_id)
|
||||
.bind(&user.username)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
if collision.is_some() {
|
||||
return Err(sqlx::Error::Protocol(format!(
|
||||
"username '{}' already exists in target tenant",
|
||||
user.username
|
||||
)));
|
||||
}
|
||||
|
||||
let result = sqlx::query(
|
||||
"UPDATE users SET tenant_id = $1, updated_at = to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') WHERE id = $2 AND tenant_id = $3",
|
||||
)
|
||||
.bind(destination_tenant_id)
|
||||
.bind(user_id)
|
||||
.bind(&from_tenant_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() != 1 {
|
||||
return Err(sqlx::Error::RowNotFound);
|
||||
}
|
||||
|
||||
let metadata = serde_json::json!({
|
||||
"user_id": user.id,
|
||||
"username": user.username,
|
||||
"from_tenant_id": from_tenant_id,
|
||||
"to_tenant_id": destination_tenant_id,
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let audit_id = uuid::Uuid::new_v4().to_string();
|
||||
sqlx::query(
|
||||
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)
|
||||
"#,
|
||||
)
|
||||
.bind(&audit_id)
|
||||
.bind(actor_id)
|
||||
.bind(Some(&user.id))
|
||||
.bind("user.tenant_reassigned")
|
||||
.bind("user")
|
||||
.bind(Some(&user.id))
|
||||
.bind("info")
|
||||
.bind(ip_address)
|
||||
.bind(user_agent)
|
||||
.bind(&metadata)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().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 = to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') WHERE id = $2",
|
||||
|
||||
@@ -0,0 +1,478 @@
|
||||
use crate::db::models::ApplicationMember;
|
||||
use crate::db::repository::traits::ApplicationMembersRepository;
|
||||
use async_trait::async_trait;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
pub struct SqliteApplicationMembersRepository {
|
||||
pub pool: SqlitePool,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ApplicationMembersRepository for SqliteApplicationMembersRepository {
|
||||
async fn list_by_application(
|
||||
&self,
|
||||
application_id: &str,
|
||||
) -> Result<Vec<ApplicationMember>, sqlx::Error> {
|
||||
sqlx::query_as::<_, ApplicationMember>(
|
||||
r#"
|
||||
SELECT id, application_id, user_id, role, enabled, created_at, updated_at
|
||||
FROM application_members
|
||||
WHERE application_id = ?
|
||||
ORDER BY created_at ASC
|
||||
"#,
|
||||
)
|
||||
.bind(application_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn list_by_user(&self, user_id: &str) -> Result<Vec<ApplicationMember>, sqlx::Error> {
|
||||
sqlx::query_as::<_, ApplicationMember>(
|
||||
r#"
|
||||
SELECT id, application_id, user_id, role, enabled, created_at, updated_at
|
||||
FROM application_members
|
||||
WHERE user_id = ?
|
||||
ORDER BY created_at ASC
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn find(
|
||||
&self,
|
||||
application_id: &str,
|
||||
user_id: &str,
|
||||
) -> Result<Option<ApplicationMember>, sqlx::Error> {
|
||||
sqlx::query_as::<_, ApplicationMember>(
|
||||
r#"
|
||||
SELECT id, application_id, user_id, role, enabled, created_at, updated_at
|
||||
FROM application_members
|
||||
WHERE application_id = ? AND user_id = ?
|
||||
"#,
|
||||
)
|
||||
.bind(application_id)
|
||||
.bind(user_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn add(
|
||||
&self,
|
||||
id: &str,
|
||||
application_id: &str,
|
||||
user_id: &str,
|
||||
role: &str,
|
||||
) -> Result<ApplicationMember, sqlx::Error> {
|
||||
sqlx::query_as::<_, ApplicationMember>(
|
||||
r#"
|
||||
INSERT INTO application_members (id, application_id, user_id, role, enabled)
|
||||
VALUES (?, ?, ?, ?, 1)
|
||||
RETURNING id, application_id, user_id, role, enabled, created_at, updated_at
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(application_id)
|
||||
.bind(user_id)
|
||||
.bind(role)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn update_role(
|
||||
&self,
|
||||
application_id: &str,
|
||||
user_id: &str,
|
||||
role: &str,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE application_members
|
||||
SET role = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||
WHERE application_id = ? AND user_id = ?
|
||||
"#,
|
||||
)
|
||||
.bind(role)
|
||||
.bind(application_id)
|
||||
.bind(user_id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_enabled(
|
||||
&self,
|
||||
application_id: &str,
|
||||
user_id: &str,
|
||||
enabled: bool,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE application_members
|
||||
SET enabled = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||
WHERE application_id = ? AND user_id = ?
|
||||
"#,
|
||||
)
|
||||
.bind(enabled)
|
||||
.bind(application_id)
|
||||
.bind(user_id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove(&self, application_id: &str, user_id: &str) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
DELETE FROM application_members
|
||||
WHERE application_id = ? AND user_id = ?
|
||||
"#,
|
||||
)
|
||||
.bind(application_id)
|
||||
.bind(user_id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn add_with_audit(
|
||||
&self,
|
||||
id: &str,
|
||||
application_id: &str,
|
||||
user_id: &str,
|
||||
role: &str,
|
||||
audit_event: Option<crate::audit::AuditEvent<'_>>,
|
||||
) -> Result<ApplicationMember, sqlx::Error> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
let app_tenant: Option<(String,)> =
|
||||
sqlx::query_as("SELECT tenant_id FROM applications WHERE id = ?")
|
||||
.bind(application_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let app_tenant_id = match app_tenant {
|
||||
Some(t) => t.0,
|
||||
None => return Err(sqlx::Error::RowNotFound),
|
||||
};
|
||||
|
||||
let user_tenant: Option<(String,)> =
|
||||
sqlx::query_as("SELECT tenant_id FROM users WHERE id = ?")
|
||||
.bind(user_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let user_tenant_id = match user_tenant {
|
||||
Some(t) => t.0,
|
||||
None => return Err(sqlx::Error::RowNotFound),
|
||||
};
|
||||
|
||||
if app_tenant_id != user_tenant_id {
|
||||
return Err(sqlx::Error::Protocol(
|
||||
"user and application must belong to the same tenant".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let existing: Option<(String,)> = sqlx::query_as(
|
||||
"SELECT id FROM application_members WHERE application_id = ? AND user_id = ?",
|
||||
)
|
||||
.bind(application_id)
|
||||
.bind(user_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
if existing.is_some() {
|
||||
return Err(sqlx::Error::Protocol(
|
||||
"user is already a member of this application".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let member = sqlx::query_as::<_, ApplicationMember>(
|
||||
r#"
|
||||
INSERT INTO application_members (id, application_id, user_id, role, enabled)
|
||||
VALUES (?, ?, ?, ?, 1)
|
||||
RETURNING id, application_id, user_id, role, enabled, created_at, updated_at
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(application_id)
|
||||
.bind(user_id)
|
||||
.bind(role)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if let Some(event) = audit_event {
|
||||
let audit_id = uuid::Uuid::new_v4().to_string();
|
||||
let severity_str = event.severity.to_string();
|
||||
sqlx::query(
|
||||
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
"#,
|
||||
)
|
||||
.bind(&audit_id)
|
||||
.bind(event.actor_id)
|
||||
.bind(event.target_id)
|
||||
.bind(event.action)
|
||||
.bind(event.resource_type)
|
||||
.bind(event.resource_id)
|
||||
.bind(&severity_str)
|
||||
.bind(event.ip)
|
||||
.bind(event.ua)
|
||||
.bind(event.metadata)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(member)
|
||||
}
|
||||
|
||||
async fn update_role_with_audit(
|
||||
&self,
|
||||
application_id: &str,
|
||||
user_id: &str,
|
||||
role: &str,
|
||||
audit_event: Option<crate::audit::AuditEvent<'_>>,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
let app_tenant: Option<(String,)> =
|
||||
sqlx::query_as("SELECT tenant_id FROM applications WHERE id = ?")
|
||||
.bind(application_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let app_tenant_id = match app_tenant {
|
||||
Some(t) => t.0,
|
||||
None => return Err(sqlx::Error::RowNotFound),
|
||||
};
|
||||
|
||||
let user_tenant: Option<(String,)> =
|
||||
sqlx::query_as("SELECT tenant_id FROM users WHERE id = ?")
|
||||
.bind(user_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let user_tenant_id = match user_tenant {
|
||||
Some(t) => t.0,
|
||||
None => return Err(sqlx::Error::RowNotFound),
|
||||
};
|
||||
|
||||
if app_tenant_id != user_tenant_id {
|
||||
return Err(sqlx::Error::Protocol(
|
||||
"user and application must belong to the same tenant".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE application_members
|
||||
SET role = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||
WHERE application_id = ? AND user_id = ?
|
||||
"#,
|
||||
)
|
||||
.bind(role)
|
||||
.bind(application_id)
|
||||
.bind(user_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() != 1 {
|
||||
return Err(sqlx::Error::RowNotFound);
|
||||
}
|
||||
|
||||
if let Some(event) = audit_event {
|
||||
let audit_id = uuid::Uuid::new_v4().to_string();
|
||||
let severity_str = event.severity.to_string();
|
||||
sqlx::query(
|
||||
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
"#,
|
||||
)
|
||||
.bind(&audit_id)
|
||||
.bind(event.actor_id)
|
||||
.bind(event.target_id)
|
||||
.bind(event.action)
|
||||
.bind(event.resource_type)
|
||||
.bind(event.resource_id)
|
||||
.bind(&severity_str)
|
||||
.bind(event.ip)
|
||||
.bind(event.ua)
|
||||
.bind(event.metadata)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_enabled_with_audit(
|
||||
&self,
|
||||
application_id: &str,
|
||||
user_id: &str,
|
||||
enabled: bool,
|
||||
audit_event: Option<crate::audit::AuditEvent<'_>>,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
let app_tenant: Option<(String,)> =
|
||||
sqlx::query_as("SELECT tenant_id FROM applications WHERE id = ?")
|
||||
.bind(application_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let app_tenant_id = match app_tenant {
|
||||
Some(t) => t.0,
|
||||
None => return Err(sqlx::Error::RowNotFound),
|
||||
};
|
||||
|
||||
let user_tenant: Option<(String,)> =
|
||||
sqlx::query_as("SELECT tenant_id FROM users WHERE id = ?")
|
||||
.bind(user_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let user_tenant_id = match user_tenant {
|
||||
Some(t) => t.0,
|
||||
None => return Err(sqlx::Error::RowNotFound),
|
||||
};
|
||||
|
||||
if app_tenant_id != user_tenant_id {
|
||||
return Err(sqlx::Error::Protocol(
|
||||
"user and application must belong to the same tenant".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE application_members
|
||||
SET enabled = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||
WHERE application_id = ? AND user_id = ?
|
||||
"#,
|
||||
)
|
||||
.bind(enabled)
|
||||
.bind(application_id)
|
||||
.bind(user_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() != 1 {
|
||||
return Err(sqlx::Error::RowNotFound);
|
||||
}
|
||||
|
||||
if let Some(event) = audit_event {
|
||||
let audit_id = uuid::Uuid::new_v4().to_string();
|
||||
let severity_str = event.severity.to_string();
|
||||
sqlx::query(
|
||||
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
"#,
|
||||
)
|
||||
.bind(&audit_id)
|
||||
.bind(event.actor_id)
|
||||
.bind(event.target_id)
|
||||
.bind(event.action)
|
||||
.bind(event.resource_type)
|
||||
.bind(event.resource_id)
|
||||
.bind(&severity_str)
|
||||
.bind(event.ip)
|
||||
.bind(event.ua)
|
||||
.bind(event.metadata)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_with_audit(
|
||||
&self,
|
||||
application_id: &str,
|
||||
user_id: &str,
|
||||
audit_event: Option<crate::audit::AuditEvent<'_>>,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
let app_tenant: Option<(String,)> =
|
||||
sqlx::query_as("SELECT tenant_id FROM applications WHERE id = ?")
|
||||
.bind(application_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let app_tenant_id = match app_tenant {
|
||||
Some(t) => t.0,
|
||||
None => return Err(sqlx::Error::RowNotFound),
|
||||
};
|
||||
|
||||
let user_tenant: Option<(String,)> =
|
||||
sqlx::query_as("SELECT tenant_id FROM users WHERE id = ?")
|
||||
.bind(user_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let user_tenant_id = match user_tenant {
|
||||
Some(t) => t.0,
|
||||
None => return Err(sqlx::Error::RowNotFound),
|
||||
};
|
||||
|
||||
if app_tenant_id != user_tenant_id {
|
||||
return Err(sqlx::Error::Protocol(
|
||||
"user and application must belong to the same tenant".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
DELETE FROM application_members
|
||||
WHERE application_id = ? AND user_id = ?
|
||||
"#,
|
||||
)
|
||||
.bind(application_id)
|
||||
.bind(user_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() != 1 {
|
||||
return Err(sqlx::Error::RowNotFound);
|
||||
}
|
||||
|
||||
if let Some(event) = audit_event {
|
||||
let audit_id = uuid::Uuid::new_v4().to_string();
|
||||
let severity_str = event.severity.to_string();
|
||||
sqlx::query(
|
||||
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
"#,
|
||||
)
|
||||
.bind(&audit_id)
|
||||
.bind(event.actor_id)
|
||||
.bind(event.target_id)
|
||||
.bind(event.action)
|
||||
.bind(event.resource_type)
|
||||
.bind(event.resource_id)
|
||||
.bind(&severity_str)
|
||||
.bind(event.ip)
|
||||
.bind(event.ua)
|
||||
.bind(event.metadata)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
use crate::db::models::Application;
|
||||
use crate::db::repository::sqlite::global_slugs::{
|
||||
release_slug_by_name_sqlite, release_slug_sqlite, reserve_slug_sqlite,
|
||||
};
|
||||
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,
|
||||
}
|
||||
@@ -25,6 +27,8 @@ impl ApplicationsRepository for SqliteApplicationsRepository {
|
||||
) -> Result<Application, sqlx::Error> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
reserve_slug_sqlite(&mut tx, slug, "application", id, tenant_id).await?;
|
||||
|
||||
let app = sqlx::query_as::<_, Application>(
|
||||
r#"
|
||||
INSERT INTO applications (id, tenant_id, name, slug, client_id, client_secret_hash, description, redirect_uris, scopes)
|
||||
@@ -188,31 +192,73 @@ impl ApplicationsRepository for SqliteApplicationsRepository {
|
||||
scopes: Option<&str>,
|
||||
enabled: bool,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE applications
|
||||
SET name = ?, slug = ?, description = ?, redirect_uris = ?, scopes = ?, enabled = ?,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||
WHERE id = ?
|
||||
"#,
|
||||
)
|
||||
.bind(name)
|
||||
.bind(slug)
|
||||
.bind(description)
|
||||
.bind(redirect_uris)
|
||||
.bind(scopes)
|
||||
.bind(enabled)
|
||||
.bind(id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
let existing = self.find_by_id(id).await?.ok_or(sqlx::Error::RowNotFound)?;
|
||||
|
||||
let existing_slug_str = existing.slug.as_deref().unwrap_or("");
|
||||
|
||||
if slug == existing_slug_str {
|
||||
// Unchanged slug: registry no-op
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE applications
|
||||
SET name = ?, description = ?, redirect_uris = ?, scopes = ?, enabled = ?,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||
WHERE id = ?
|
||||
"#,
|
||||
)
|
||||
.bind(name)
|
||||
.bind(description)
|
||||
.bind(redirect_uris)
|
||||
.bind(scopes)
|
||||
.bind(enabled)
|
||||
.bind(id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
} else {
|
||||
// Changed slug: single transaction reserve -> update -> release
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
reserve_slug_sqlite(&mut tx, slug, "application", id, &existing.tenant_id).await?;
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE applications
|
||||
SET name = ?, slug = ?, description = ?, redirect_uris = ?, scopes = ?, enabled = ?,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||
WHERE id = ?
|
||||
"#,
|
||||
)
|
||||
.bind(name)
|
||||
.bind(slug)
|
||||
.bind(description)
|
||||
.bind(redirect_uris)
|
||||
.bind(scopes)
|
||||
.bind(enabled)
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if !existing_slug_str.is_empty() {
|
||||
release_slug_by_name_sqlite(&mut tx, existing_slug_str, "application", id).await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, id: &str) -> Result<(), sqlx::Error> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
release_slug_sqlite(&mut tx, "application", id).await?;
|
||||
|
||||
sqlx::query("DELETE FROM applications WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(&self.pool)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -65,12 +65,11 @@ impl AuditRepository for SqliteAuditRepository {
|
||||
}
|
||||
|
||||
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('%', "\\%")));
|
||||
let success_val = filter.success.map(|b| if b { 1i32 } else { 0i32 });
|
||||
|
||||
sqlx::query_as::<_, AuditLog>(
|
||||
r#"
|
||||
@@ -89,8 +88,13 @@ impl AuditRepository for SqliteAuditRepository {
|
||||
OR ip_address LIKE ?7 ESCAPE '\'
|
||||
OR metadata_json LIKE ?7 ESCAPE '\'
|
||||
)
|
||||
AND (
|
||||
?8 IS NULL
|
||||
OR (?8 = 1 AND action NOT LIKE '%fail%' AND action NOT LIKE '%denied%' AND severity != 'critical')
|
||||
OR (?8 = 0 AND (action LIKE '%fail%' OR action LIKE '%denied%' OR severity = 'critical'))
|
||||
)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?8 OFFSET ?9
|
||||
LIMIT ?9 OFFSET ?10
|
||||
"#,
|
||||
)
|
||||
.bind(filter.actor_user_id.as_deref())
|
||||
@@ -100,6 +104,7 @@ impl AuditRepository for SqliteAuditRepository {
|
||||
.bind(filter.since.as_deref())
|
||||
.bind(filter.until.as_deref())
|
||||
.bind(search_like.as_deref())
|
||||
.bind(success_val)
|
||||
.bind(filter.limit)
|
||||
.bind(filter.offset)
|
||||
.fetch_all(&self.pool)
|
||||
@@ -111,6 +116,7 @@ impl AuditRepository for SqliteAuditRepository {
|
||||
.search
|
||||
.as_ref()
|
||||
.map(|s| format!("%{}%", s.replace('%', "\\%")));
|
||||
let success_val = filter.success.map(|b| if b { 1i32 } else { 0i32 });
|
||||
|
||||
let row: (i64,) = sqlx::query_as(
|
||||
r#"
|
||||
@@ -129,6 +135,11 @@ impl AuditRepository for SqliteAuditRepository {
|
||||
OR ip_address LIKE ?7 ESCAPE '\'
|
||||
OR metadata_json LIKE ?7 ESCAPE '\'
|
||||
)
|
||||
AND (
|
||||
?8 IS NULL
|
||||
OR (?8 = 1 AND action NOT LIKE '%fail%' AND action NOT LIKE '%denied%' AND severity != 'critical')
|
||||
OR (?8 = 0 AND (action LIKE '%fail%' OR action LIKE '%denied%' OR severity = 'critical'))
|
||||
)
|
||||
"#,
|
||||
)
|
||||
.bind(filter.actor_user_id.as_deref())
|
||||
@@ -138,6 +149,7 @@ impl AuditRepository for SqliteAuditRepository {
|
||||
.bind(filter.since.as_deref())
|
||||
.bind(filter.until.as_deref())
|
||||
.bind(search_like.as_deref())
|
||||
.bind(success_val)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(row.0)
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
use crate::db::models::GlobalSlug;
|
||||
use crate::db::repository::traits::GlobalSlugsRepository;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
pub struct SqliteGlobalSlugsRepository {
|
||||
pub pool: SqlitePool,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl GlobalSlugsRepository for SqliteGlobalSlugsRepository {
|
||||
async fn find_by_slug(&self, slug: &str) -> Result<Option<GlobalSlug>, sqlx::Error> {
|
||||
sqlx::query_as::<_, GlobalSlug>(
|
||||
"SELECT slug, entity_type, entity_id, tenant_id, created_at FROM global_slugs WHERE slug = ?"
|
||||
)
|
||||
.bind(slug)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn reserve_slug_sqlite(
|
||||
conn: &mut sqlx::SqliteConnection,
|
||||
slug: &str,
|
||||
entity_type: &str,
|
||||
entity_id: &str,
|
||||
tenant_id: &str,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
"INSERT INTO global_slugs (slug, entity_type, entity_id, tenant_id) VALUES (?, ?, ?, ?)",
|
||||
)
|
||||
.bind(slug)
|
||||
.bind(entity_type)
|
||||
.bind(entity_id)
|
||||
.bind(tenant_id)
|
||||
.execute(conn)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn release_slug_sqlite(
|
||||
conn: &mut sqlx::SqliteConnection,
|
||||
entity_type: &str,
|
||||
entity_id: &str,
|
||||
) -> Result<u64, sqlx::Error> {
|
||||
let res = sqlx::query("DELETE FROM global_slugs WHERE entity_type = ? AND entity_id = ?")
|
||||
.bind(entity_type)
|
||||
.bind(entity_id)
|
||||
.execute(conn)
|
||||
.await?;
|
||||
Ok(res.rows_affected())
|
||||
}
|
||||
|
||||
pub async fn release_slug_by_name_sqlite(
|
||||
conn: &mut sqlx::SqliteConnection,
|
||||
slug: &str,
|
||||
entity_type: &str,
|
||||
entity_id: &str,
|
||||
) -> Result<u64, sqlx::Error> {
|
||||
let res = sqlx::query(
|
||||
"DELETE FROM global_slugs WHERE slug = ? AND entity_type = ? AND entity_id = ?",
|
||||
)
|
||||
.bind(slug)
|
||||
.bind(entity_type)
|
||||
.bind(entity_id)
|
||||
.execute(conn)
|
||||
.await?;
|
||||
Ok(res.rows_affected())
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
pub mod application_members;
|
||||
pub mod applications;
|
||||
pub mod audit;
|
||||
pub mod global_slugs;
|
||||
pub mod groups;
|
||||
pub mod permissions;
|
||||
pub mod refresh_tokens;
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::db::models::Tenant;
|
||||
use crate::db::repository::sqlite::global_slugs::{
|
||||
release_slug_by_name_sqlite, release_slug_sqlite, reserve_slug_sqlite,
|
||||
};
|
||||
use crate::db::repository::traits::TenantsRepository;
|
||||
use crate::identity::slug::{slugify, validate_slug};
|
||||
|
||||
pub struct SqliteTenantsRepository {
|
||||
pub pool: SqlitePool,
|
||||
@@ -47,7 +51,17 @@ impl TenantsRepository for SqliteTenantsRepository {
|
||||
name: &str,
|
||||
slug: Option<&str>,
|
||||
) -> Result<Tenant, sqlx::Error> {
|
||||
let slug = slug.unwrap_or(id);
|
||||
let final_slug = match slug {
|
||||
Some(s) if !s.trim().is_empty() => {
|
||||
let trimmed = s.trim();
|
||||
validate_slug(trimmed).map_err(|e| sqlx::Error::Protocol(e.to_string()))?;
|
||||
trimmed.to_string()
|
||||
}
|
||||
_ => slugify(name).map_err(|e| sqlx::Error::Protocol(e.to_string()))?,
|
||||
};
|
||||
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
let row = sqlx::query_as::<_, Tenant>(
|
||||
r#"
|
||||
INSERT INTO tenants (id, name, slug, enabled)
|
||||
@@ -57,27 +71,68 @@ impl TenantsRepository for SqliteTenantsRepository {
|
||||
)
|
||||
.bind(id)
|
||||
.bind(name)
|
||||
.bind(slug)
|
||||
.fetch_one(&self.pool)
|
||||
.bind(&final_slug)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
reserve_slug_sqlite(&mut tx, &final_slug, "tenant", id, id).await?;
|
||||
|
||||
tx.commit().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?;
|
||||
let existing = self.find_by_id(id).await?.ok_or(sqlx::Error::RowNotFound)?;
|
||||
|
||||
let target_slug = match slug {
|
||||
Some(s) if !s.trim().is_empty() => {
|
||||
let trimmed = s.trim();
|
||||
validate_slug(trimmed).map_err(|e| sqlx::Error::Protocol(e.to_string()))?;
|
||||
trimmed.to_string()
|
||||
}
|
||||
_ => existing.slug.clone().unwrap_or_else(|| id.to_string()),
|
||||
};
|
||||
|
||||
let existing_slug_str = existing.slug.as_deref().unwrap_or("");
|
||||
|
||||
if target_slug == existing_slug_str {
|
||||
// Unchanged slug: registry no-op
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE tenants
|
||||
SET name = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||
WHERE id = ?
|
||||
"#,
|
||||
)
|
||||
.bind(name)
|
||||
.bind(id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
} else {
|
||||
// Changed slug: single transaction reserve -> update -> release
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
reserve_slug_sqlite(&mut tx, &target_slug, "tenant", id, id).await?;
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE tenants
|
||||
SET name = ?, slug = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||
WHERE id = ?
|
||||
"#,
|
||||
)
|
||||
.bind(name)
|
||||
.bind(&target_slug)
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if !existing_slug_str.is_empty() {
|
||||
release_slug_by_name_sqlite(&mut tx, existing_slug_str, "tenant", id).await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -99,10 +154,16 @@ impl TenantsRepository for SqliteTenantsRepository {
|
||||
}
|
||||
|
||||
async fn delete(&self, id: &str) -> Result<(), sqlx::Error> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
release_slug_sqlite(&mut tx, "tenant", id).await?;
|
||||
|
||||
sqlx::query("DELETE FROM tenants WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(&self.pool)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -100,6 +100,134 @@ impl UsersRepository for SqliteUsersRepository {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_user_tenant(&self, id: &str, tenant_id: &str) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
"UPDATE users SET tenant_id = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?",
|
||||
)
|
||||
.bind(tenant_id)
|
||||
.bind(id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn reassign_user_tenant_with_audit(
|
||||
&self,
|
||||
user_id: &str,
|
||||
destination_tenant_id: &str,
|
||||
actor_id: Option<&str>,
|
||||
ip_address: Option<&str>,
|
||||
user_agent: Option<&str>,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
|
||||
.bind(user_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
.ok_or(sqlx::Error::RowNotFound)?;
|
||||
|
||||
if user.tenant_id == destination_tenant_id {
|
||||
tx.commit().await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let from_tenant_id = user.tenant_id.clone();
|
||||
|
||||
if from_tenant_id == crate::db::models::Tenant::DEFAULT_ID {
|
||||
let is_admin: Option<(i64,)> = sqlx::query_as(
|
||||
"SELECT 1 FROM user_roles ur JOIN roles r ON ur.role_id = r.id WHERE ur.user_id = ? AND r.name = 'admin'",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if is_admin.is_some() {
|
||||
let admin_count: (i64,) = sqlx::query_as(
|
||||
"SELECT COUNT(DISTINCT ur.user_id) FROM user_roles ur JOIN roles r ON ur.role_id = r.id WHERE r.name = 'admin'",
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if admin_count.0 <= 1 {
|
||||
return Err(sqlx::Error::Protocol(
|
||||
"cannot reassign the last system administrator away from default tenant"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let dest_exists: Option<(String,)> = sqlx::query_as("SELECT id FROM tenants WHERE id = ?")
|
||||
.bind(destination_tenant_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
if dest_exists.is_none() {
|
||||
return Err(sqlx::Error::RowNotFound);
|
||||
}
|
||||
|
||||
let collision: Option<(i64,)> =
|
||||
sqlx::query_as("SELECT 1 FROM users WHERE tenant_id = ? AND username = ?")
|
||||
.bind(destination_tenant_id)
|
||||
.bind(&user.username)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
if collision.is_some() {
|
||||
return Err(sqlx::Error::Protocol(format!(
|
||||
"username '{}' already exists in target tenant",
|
||||
user.username
|
||||
)));
|
||||
}
|
||||
|
||||
let result = sqlx::query(
|
||||
"UPDATE users SET tenant_id = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ? AND tenant_id = ?",
|
||||
)
|
||||
.bind(destination_tenant_id)
|
||||
.bind(user_id)
|
||||
.bind(&from_tenant_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() != 1 {
|
||||
return Err(sqlx::Error::RowNotFound);
|
||||
}
|
||||
|
||||
let metadata = serde_json::json!({
|
||||
"user_id": user.id,
|
||||
"username": user.username,
|
||||
"from_tenant_id": from_tenant_id,
|
||||
"to_tenant_id": destination_tenant_id,
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let audit_id = uuid::Uuid::new_v4().to_string();
|
||||
sqlx::query(
|
||||
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
"#,
|
||||
)
|
||||
.bind(&audit_id)
|
||||
.bind(actor_id)
|
||||
.bind(Some(&user.id))
|
||||
.bind("user.tenant_reassigned")
|
||||
.bind("user")
|
||||
.bind(Some(&user.id))
|
||||
.bind("info")
|
||||
.bind(ip_address)
|
||||
.bind(user_agent)
|
||||
.bind(&metadata)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().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 = ?",
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
use crate::db::models::{
|
||||
ApiToken, Application, AuditFilter, AuditLog, Group, Permission, RefreshToken, Role,
|
||||
ServiceAccount, Session, Tenant, User, UserProfile,
|
||||
ApiToken, Application, ApplicationMember, AuditFilter, AuditLog, GlobalSlug, Group, Permission,
|
||||
RefreshToken, Role, ServiceAccount, Session, Tenant, User, UserProfile,
|
||||
};
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait GlobalSlugsRepository: Send + Sync {
|
||||
async fn find_by_slug(&self, slug: &str) -> Result<Option<GlobalSlug>, sqlx::Error>;
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait UsersRepository: Send + Sync {
|
||||
async fn find_by_id(&self, id: &str) -> Result<Option<User>, sqlx::Error>;
|
||||
@@ -16,6 +21,15 @@ pub trait UsersRepository: Send + Sync {
|
||||
password_hash: &str,
|
||||
) -> Result<User, sqlx::Error>;
|
||||
async fn update_status(&self, id: &str, status: i32) -> Result<(), sqlx::Error>;
|
||||
async fn update_user_tenant(&self, id: &str, tenant_id: &str) -> Result<(), sqlx::Error>;
|
||||
async fn reassign_user_tenant_with_audit(
|
||||
&self,
|
||||
user_id: &str,
|
||||
destination_tenant_id: &str,
|
||||
actor_id: Option<&str>,
|
||||
ip_address: Option<&str>,
|
||||
user_agent: Option<&str>,
|
||||
) -> 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>;
|
||||
@@ -271,3 +285,69 @@ pub trait GroupsRepository: Send + Sync {
|
||||
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>;
|
||||
}
|
||||
|
||||
/// Application membership repository (user ↔ application assignment).
|
||||
///
|
||||
/// Membership roles are lightweight metadata and do not modify global RBAC.
|
||||
#[async_trait::async_trait]
|
||||
pub trait ApplicationMembersRepository: Send + Sync {
|
||||
async fn list_by_application(
|
||||
&self,
|
||||
application_id: &str,
|
||||
) -> Result<Vec<ApplicationMember>, sqlx::Error>;
|
||||
async fn list_by_user(&self, user_id: &str) -> Result<Vec<ApplicationMember>, sqlx::Error>;
|
||||
async fn find(
|
||||
&self,
|
||||
application_id: &str,
|
||||
user_id: &str,
|
||||
) -> Result<Option<ApplicationMember>, sqlx::Error>;
|
||||
async fn add(
|
||||
&self,
|
||||
id: &str,
|
||||
application_id: &str,
|
||||
user_id: &str,
|
||||
role: &str,
|
||||
) -> Result<ApplicationMember, sqlx::Error>;
|
||||
async fn update_role(
|
||||
&self,
|
||||
application_id: &str,
|
||||
user_id: &str,
|
||||
role: &str,
|
||||
) -> Result<(), sqlx::Error>;
|
||||
async fn set_enabled(
|
||||
&self,
|
||||
application_id: &str,
|
||||
user_id: &str,
|
||||
enabled: bool,
|
||||
) -> Result<(), sqlx::Error>;
|
||||
async fn remove(&self, application_id: &str, user_id: &str) -> Result<(), sqlx::Error>;
|
||||
|
||||
async fn add_with_audit(
|
||||
&self,
|
||||
id: &str,
|
||||
application_id: &str,
|
||||
user_id: &str,
|
||||
role: &str,
|
||||
audit_event: Option<crate::audit::AuditEvent<'_>>,
|
||||
) -> Result<ApplicationMember, sqlx::Error>;
|
||||
async fn update_role_with_audit(
|
||||
&self,
|
||||
application_id: &str,
|
||||
user_id: &str,
|
||||
role: &str,
|
||||
audit_event: Option<crate::audit::AuditEvent<'_>>,
|
||||
) -> Result<(), sqlx::Error>;
|
||||
async fn set_enabled_with_audit(
|
||||
&self,
|
||||
application_id: &str,
|
||||
user_id: &str,
|
||||
enabled: bool,
|
||||
audit_event: Option<crate::audit::AuditEvent<'_>>,
|
||||
) -> Result<(), sqlx::Error>;
|
||||
async fn remove_with_audit(
|
||||
&self,
|
||||
application_id: &str,
|
||||
user_id: &str,
|
||||
audit_event: Option<crate::audit::AuditEvent<'_>>,
|
||||
) -> Result<(), sqlx::Error>;
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
//! Application membership domain logic.
|
||||
//!
|
||||
//! Assigns existing NX9-Auth users to registered applications.
|
||||
//! Membership roles (owner/admin/member) are lightweight metadata only and
|
||||
//! MUST NOT grant global RBAC permissions such as `applications:manage`.
|
||||
|
||||
use crate::db::models::{Application, ApplicationMember, ApplicationMembershipRole};
|
||||
use crate::error::AppError;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn parse_role(role: Option<&str>) -> Result<ApplicationMembershipRole, AppError> {
|
||||
match role {
|
||||
None | Some("") => Ok(ApplicationMembershipRole::Member),
|
||||
Some(r) => ApplicationMembershipRole::parse(r).ok_or_else(|| {
|
||||
AppError::InvalidInput(format!(
|
||||
"invalid membership role '{r}'; allowed values are owner, admin, member"
|
||||
))
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// List members of an application.
|
||||
pub async fn list_by_application(
|
||||
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
|
||||
application_id: &str,
|
||||
) -> Result<Vec<ApplicationMember>, AppError> {
|
||||
// Ensure application exists
|
||||
let _ = provider
|
||||
.applications()
|
||||
.find_by_id(application_id)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
|
||||
provider
|
||||
.application_members()
|
||||
.list_by_application(application_id)
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
/// List application memberships for a user.
|
||||
pub async fn list_by_user(
|
||||
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
|
||||
user_id: &str,
|
||||
) -> Result<Vec<ApplicationMember>, AppError> {
|
||||
let _ = provider
|
||||
.users()
|
||||
.find_by_id(user_id)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
|
||||
provider
|
||||
.application_members()
|
||||
.list_by_user(user_id)
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
/// Find a single membership.
|
||||
pub async fn find(
|
||||
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
|
||||
application_id: &str,
|
||||
user_id: &str,
|
||||
) -> Result<Option<ApplicationMember>, AppError> {
|
||||
provider
|
||||
.application_members()
|
||||
.find(application_id, user_id)
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
/// Assign an existing same-tenant user to an application.
|
||||
pub async fn add(
|
||||
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
|
||||
application_id: &str,
|
||||
user_id: &str,
|
||||
role: Option<&str>,
|
||||
audit_actor_id: Option<&str>,
|
||||
audit_ip: Option<&str>,
|
||||
audit_ua: Option<&str>,
|
||||
) -> Result<ApplicationMember, AppError> {
|
||||
let role = parse_role(role)?;
|
||||
|
||||
let app = provider
|
||||
.applications()
|
||||
.find_by_id(application_id)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
|
||||
let user = provider
|
||||
.users()
|
||||
.find_by_id(user_id)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
|
||||
// Tenant isolation: never allow cross-tenant assignment.
|
||||
if user.tenant_id != app.tenant_id {
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
|
||||
if provider
|
||||
.application_members()
|
||||
.find(application_id, user_id)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.is_some()
|
||||
{
|
||||
return Err(AppError::Conflict(
|
||||
"user is already a member of this application".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let id = Uuid::new_v4().to_string();
|
||||
|
||||
let metadata = serde_json::json!({
|
||||
"application_id": application_id,
|
||||
"user_id": user_id,
|
||||
"role": role.as_str(),
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let audit_event = crate::audit::AuditEvent {
|
||||
actor_id: audit_actor_id,
|
||||
target_id: Some(user_id),
|
||||
action: "application.member_added",
|
||||
resource_type: "application",
|
||||
resource_id: Some(application_id),
|
||||
severity: crate::db::models::AuditSeverity::Info,
|
||||
ip: audit_ip,
|
||||
ua: audit_ua,
|
||||
metadata: Some(&metadata),
|
||||
};
|
||||
|
||||
let member = provider
|
||||
.application_members()
|
||||
.add_with_audit(
|
||||
&id,
|
||||
application_id,
|
||||
user_id,
|
||||
role.as_str(),
|
||||
Some(audit_event),
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let _ = app;
|
||||
Ok(member)
|
||||
}
|
||||
|
||||
/// Update membership role and/or enabled state.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn update(
|
||||
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
|
||||
application_id: &str,
|
||||
user_id: &str,
|
||||
role: Option<&str>,
|
||||
enabled: Option<bool>,
|
||||
audit_actor_id: Option<&str>,
|
||||
audit_ip: Option<&str>,
|
||||
audit_ua: Option<&str>,
|
||||
) -> Result<ApplicationMember, AppError> {
|
||||
if role.is_none() && enabled.is_none() {
|
||||
return Err(AppError::InvalidInput(
|
||||
"at least one of role or enabled must be provided".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Ensure application exists
|
||||
let _ = provider
|
||||
.applications()
|
||||
.find_by_id(application_id)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
|
||||
let existing = provider
|
||||
.application_members()
|
||||
.find(application_id, user_id)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
|
||||
if let Some(role_str) = role {
|
||||
let new_role = parse_role(Some(role_str))?;
|
||||
if new_role.as_str() != existing.role {
|
||||
let previous_role = existing.role.clone();
|
||||
let metadata = serde_json::json!({
|
||||
"application_id": application_id,
|
||||
"user_id": user_id,
|
||||
"previous_role": previous_role,
|
||||
"new_role": new_role.as_str(),
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let audit_event = crate::audit::AuditEvent {
|
||||
actor_id: audit_actor_id,
|
||||
target_id: Some(user_id),
|
||||
action: "application.member_role_changed",
|
||||
resource_type: "application",
|
||||
resource_id: Some(application_id),
|
||||
severity: crate::db::models::AuditSeverity::Info,
|
||||
ip: audit_ip,
|
||||
ua: audit_ua,
|
||||
metadata: Some(&metadata),
|
||||
};
|
||||
|
||||
provider
|
||||
.application_members()
|
||||
.update_role_with_audit(
|
||||
application_id,
|
||||
user_id,
|
||||
new_role.as_str(),
|
||||
Some(audit_event),
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(new_enabled) = enabled {
|
||||
if new_enabled != existing.enabled {
|
||||
let action = if new_enabled {
|
||||
"application.member_enabled"
|
||||
} else {
|
||||
"application.member_disabled"
|
||||
};
|
||||
|
||||
let metadata = serde_json::json!({
|
||||
"application_id": application_id,
|
||||
"user_id": user_id,
|
||||
"role": existing.role,
|
||||
"enabled": new_enabled,
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let audit_event = crate::audit::AuditEvent {
|
||||
actor_id: audit_actor_id,
|
||||
target_id: Some(user_id),
|
||||
action,
|
||||
resource_type: "application",
|
||||
resource_id: Some(application_id),
|
||||
severity: crate::db::models::AuditSeverity::Info,
|
||||
ip: audit_ip,
|
||||
ua: audit_ua,
|
||||
metadata: Some(&metadata),
|
||||
};
|
||||
|
||||
provider
|
||||
.application_members()
|
||||
.set_enabled_with_audit(application_id, user_id, new_enabled, Some(audit_event))
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
}
|
||||
}
|
||||
|
||||
provider
|
||||
.application_members()
|
||||
.find(application_id, user_id)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound)
|
||||
}
|
||||
|
||||
/// Remove a user from an application (does not delete the user account).
|
||||
pub async fn remove(
|
||||
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
|
||||
application_id: &str,
|
||||
user_id: &str,
|
||||
audit_actor_id: Option<&str>,
|
||||
audit_ip: Option<&str>,
|
||||
audit_ua: Option<&str>,
|
||||
) -> Result<(), AppError> {
|
||||
// Ensure application exists
|
||||
let _ = provider
|
||||
.applications()
|
||||
.find_by_id(application_id)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
|
||||
let existing = provider
|
||||
.application_members()
|
||||
.find(application_id, user_id)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
|
||||
let metadata = serde_json::json!({
|
||||
"application_id": application_id,
|
||||
"user_id": user_id,
|
||||
"role": existing.role,
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let audit_event = crate::audit::AuditEvent {
|
||||
actor_id: audit_actor_id,
|
||||
target_id: Some(user_id),
|
||||
action: "application.member_removed",
|
||||
resource_type: "application",
|
||||
resource_id: Some(application_id),
|
||||
severity: crate::db::models::AuditSeverity::Info,
|
||||
ip: audit_ip,
|
||||
ua: audit_ua,
|
||||
metadata: Some(&metadata),
|
||||
};
|
||||
|
||||
provider
|
||||
.application_members()
|
||||
.remove_with_audit(application_id, user_id, Some(audit_event))
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Helper used by API responses that need application details for a membership.
|
||||
pub async fn load_application(
|
||||
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
|
||||
application_id: &str,
|
||||
) -> Result<Application, AppError> {
|
||||
provider
|
||||
.applications()
|
||||
.find_by_id(application_id)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound)
|
||||
}
|
||||
@@ -108,11 +108,13 @@ pub async fn create(
|
||||
"name and slug cannot be empty".into(),
|
||||
));
|
||||
}
|
||||
crate::identity::slug::validate_slug(slug)?;
|
||||
|
||||
if let Some(ref uris) = redirect_uris {
|
||||
validate_redirect_uris(uris)?;
|
||||
}
|
||||
if provider
|
||||
.applications()
|
||||
.global_slugs()
|
||||
.find_by_slug(slug)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
@@ -312,16 +314,18 @@ pub async fn update(
|
||||
"name and slug cannot be empty".into(),
|
||||
));
|
||||
}
|
||||
crate::identity::slug::validate_slug(slug)?;
|
||||
|
||||
if let Some(ref uris) = redirect_uris {
|
||||
validate_redirect_uris(uris)?;
|
||||
}
|
||||
if let Some(other) = provider
|
||||
.applications()
|
||||
.global_slugs()
|
||||
.find_by_slug(slug)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
{
|
||||
if other.id != id {
|
||||
if other.entity_id != id || other.entity_type != "application" {
|
||||
return Err(AppError::Conflict(format!("slug '{slug}' already exists")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
pub mod application_members;
|
||||
pub mod applications;
|
||||
pub mod permissions;
|
||||
pub mod roles;
|
||||
pub mod service_accounts;
|
||||
pub mod slug;
|
||||
pub mod users;
|
||||
@@ -0,0 +1,133 @@
|
||||
use crate::error::AppError;
|
||||
|
||||
pub const RESERVED_SLUGS: &[&str] = &[
|
||||
"admin",
|
||||
"api",
|
||||
"system",
|
||||
"auth",
|
||||
"login",
|
||||
"logout",
|
||||
"dashboard",
|
||||
"health",
|
||||
"metrics",
|
||||
"root",
|
||||
"public",
|
||||
"private",
|
||||
"null",
|
||||
"undefined",
|
||||
"config",
|
||||
"settings",
|
||||
"account",
|
||||
"accounts",
|
||||
"role",
|
||||
"roles",
|
||||
"permission",
|
||||
"permissions",
|
||||
"group",
|
||||
"groups",
|
||||
"service-account",
|
||||
"service-accounts",
|
||||
];
|
||||
|
||||
/// Validates an explicit or derived slug string according to server-side policy:
|
||||
/// - Must be 2..=63 characters in length.
|
||||
/// - Must consist only of lowercase ASCII alphanumeric characters ('a'..='z', '0'..='9') and hyphens ('-').
|
||||
/// - Cannot start or end with a hyphen.
|
||||
/// - Cannot contain consecutive hyphens ("--").
|
||||
/// - Cannot be one of the reserved slug names (except "default" which is preserved for built-in tenant).
|
||||
pub fn validate_slug(slug: &str) -> Result<(), AppError> {
|
||||
let s = slug.trim();
|
||||
if s.is_empty() {
|
||||
return Err(AppError::InvalidInput("slug cannot be empty".into()));
|
||||
}
|
||||
if s.len() < 2 || s.len() > 63 {
|
||||
return Err(AppError::InvalidInput(format!(
|
||||
"slug length must be between 2 and 63 characters, got {}",
|
||||
s.len()
|
||||
)));
|
||||
}
|
||||
if s.starts_with('-') || s.ends_with('-') {
|
||||
return Err(AppError::InvalidInput(
|
||||
"slug cannot start or end with a hyphen".into(),
|
||||
));
|
||||
}
|
||||
if s.contains("--") {
|
||||
return Err(AppError::InvalidInput(
|
||||
"slug cannot contain consecutive hyphens".into(),
|
||||
));
|
||||
}
|
||||
for ch in s.chars() {
|
||||
if !ch.is_ascii_lowercase() && !ch.is_ascii_digit() && ch != '-' {
|
||||
return Err(AppError::InvalidInput(format!(
|
||||
"slug contains invalid character '{ch}'; only lowercase alphanumeric characters and hyphens are allowed"
|
||||
)));
|
||||
}
|
||||
}
|
||||
if RESERVED_SLUGS.contains(&s) {
|
||||
return Err(AppError::InvalidInput(format!(
|
||||
"slug '{s}' is reserved by system"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Slugifies a display name when CREATE omits an explicit slug.
|
||||
/// Converts non-alphanumeric characters to hyphens, lowercases the string,
|
||||
/// collapses repeated hyphens, and validates the result.
|
||||
pub fn slugify(input: &str) -> Result<String, AppError> {
|
||||
let mut slug = String::with_capacity(input.len());
|
||||
let mut prev_hyphen = false;
|
||||
|
||||
for ch in input.chars() {
|
||||
if ch.is_ascii_alphanumeric() {
|
||||
slug.push(ch.to_ascii_lowercase());
|
||||
prev_hyphen = false;
|
||||
} else if !prev_hyphen && !slug.is_empty() {
|
||||
slug.push('-');
|
||||
prev_hyphen = true;
|
||||
}
|
||||
}
|
||||
|
||||
let trimmed = slug.trim_matches('-');
|
||||
if trimmed.is_empty() {
|
||||
return Err(AppError::InvalidInput(
|
||||
"unable to generate valid slug from provided name".into(),
|
||||
));
|
||||
}
|
||||
|
||||
validate_slug(trimmed)?;
|
||||
Ok(trimmed.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_valid_slugs() {
|
||||
assert!(validate_slug("default").is_ok());
|
||||
assert!(validate_slug("my-app-1").is_ok());
|
||||
assert!(validate_slug("acme-corp").is_ok());
|
||||
assert!(validate_slug("xy").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_slugs() {
|
||||
assert!(validate_slug("").is_err());
|
||||
assert!(validate_slug("a").is_err());
|
||||
assert!(validate_slug("-app").is_err());
|
||||
assert!(validate_slug("app-").is_err());
|
||||
assert!(validate_slug("my--app").is_err());
|
||||
assert!(validate_slug("My-App").is_err());
|
||||
assert!(validate_slug("my_app").is_err());
|
||||
assert!(validate_slug("admin").is_err());
|
||||
assert!(validate_slug("api").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_slugify() {
|
||||
assert_eq!(slugify("Acme Corp!").unwrap(), "acme-corp");
|
||||
assert_eq!(slugify("My App 123").unwrap(), "my-app-123");
|
||||
assert!(slugify("!!!").is_err());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user