feat: complete NX9-Auth management and integrity hardening
This commit is contained in:
1 parent
dc5417334b
commit
a969f9c571
59 files changed
+6401
-183
No files matched your search
@@ -61,6 +61,24 @@ Existing applications upgraded from earlier schemas receive a stable Client ID.
|
|||||||
|
|
||||||
> **Protocol boundary:** Application credentials, redirect URIs, and scopes form the application registration layer. Redirect URIs are registration metadata intended to become security-enforced redirect destinations when OAuth2/OIDC protocol handlers are implemented. This registration subsystem does not by itself claim complete OAuth2/OIDC grant-flow support.
|
> **Protocol boundary:** Application credentials, redirect URIs, and scopes form the application registration layer. Redirect URIs are registration metadata intended to become security-enforced redirect destinations when OAuth2/OIDC protocol handlers are implemented. This registration subsystem does not by itself claim complete OAuth2/OIDC grant-flow support.
|
||||||
|
|
||||||
|
### Application user membership
|
||||||
|
|
||||||
|
Registered applications can be assigned existing NX9-Auth users (same-tenant only). Membership is independent of application client credentials and of global RBAC:
|
||||||
|
|
||||||
|
| Concern | Role |
|
||||||
|
| --- | --- |
|
||||||
|
| Application credentials | Authenticate the registered application itself (`client_id` + `client_secret`) |
|
||||||
|
| Application membership | Assign existing human users to an application (`owner` / `admin` / `member` metadata) |
|
||||||
|
| Global RBAC | Authoritative admin authorization (`applications:manage`, roles, permissions) |
|
||||||
|
|
||||||
|
Membership APIs (all require `applications:manage`):
|
||||||
|
|
||||||
|
- `GET/POST /api/v1/applications/:id/members`
|
||||||
|
- `PATCH/DELETE /api/v1/applications/:id/members/:user_id`
|
||||||
|
- `GET /api/v1/users/:id/applications`
|
||||||
|
|
||||||
|
Membership roles do **not** grant `applications:manage` or any other global permission. Removing membership revokes application assignment only; it does not delete the user account.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Runtime Lifecycle
|
## Runtime Lifecycle
|
||||||
|
|||||||
+24
-1
@@ -36,7 +36,30 @@ The internal architecture is guided by structural design patterns:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# 4. Technology Stack
|
# 4. Data & Multi-Tenancy Architecture
|
||||||
|
|
||||||
|
### Option-A Single-Tenant User Ownership
|
||||||
|
NX9-Auth models user ownership via **Option-A Single-Tenant Ownership**:
|
||||||
|
- Every user belongs to exactly one tenant (`users.tenant_id NOT NULL REFERENCES tenants(id)`).
|
||||||
|
- Uniqueness invariant: `UNIQUE (tenant_id, username)`.
|
||||||
|
- Tenant reassignment is transactionally atomic (`reassign_user_tenant_with_audit`):
|
||||||
|
1. Executes inside a single write transaction.
|
||||||
|
2. PostgreSQL uses `SELECT ... FOR UPDATE` row locking; SQLite uses write transactions and conditional `WHERE tenant_id = expected` updates.
|
||||||
|
3. Reassignment to the user's current tenant is a defined no-op returning `Ok(())` without writing false audit logs.
|
||||||
|
4. Database mutation (`UPDATE users.tenant_id`) and audit log insertion (`user.tenant_reassigned` with `from_tenant_id` and `to_tenant_id`) commit together; audit log failures trigger automatic database rollback.
|
||||||
|
|
||||||
|
### Application Membership vs Global RBAC
|
||||||
|
- **Application Membership**: Users are assigned to applications within their home tenant only (`ApplicationMember`). Membership roles (`owner`/`admin`/`member`) are application-scoped metadata.
|
||||||
|
- **Global RBAC**: Platform authorization is governed strictly by global roles, permissions, and groups (`users.tenant_id`). Application membership roles never leak into or modify global RBAC.
|
||||||
|
- **Application Membership Mutations**: Add, role update, enable/disable, and removal operations execute as single database transactions; failure to write audit logs automatically rolls back the membership mutation.
|
||||||
|
|
||||||
|
### Global Slugs Registry & Lifecycle
|
||||||
|
- `global_slugs` table enforces global uniqueness across tenant, application, and resource slugs.
|
||||||
|
- Immutable UUID identities remain canonical; slugs serve as human-readable routing aliases.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 5. Technology Stack
|
||||||
|
|
||||||
### Backend
|
### Backend
|
||||||
- **Core Language**: Rust (2024 Edition)
|
- **Core Language**: Rust (2024 Edition)
|
||||||
|
|||||||
+17
-2
@@ -31,11 +31,26 @@ NX9-Auth is designed with a **security-first, privacy-first, zero-trust** archit
|
|||||||
- **Secret Rotation**: Administrator rotation immediately invalidates the previous client secret hash and generates a new secret.
|
- **Secret Rotation**: Administrator rotation immediately invalidates the previous client secret hash and generates a new secret.
|
||||||
- **Dedicated Permissions**: Application mutations (`create`, `update`, `rotate_secret`, `enable_disable`, `delete`) require the `applications:manage` permission.
|
- **Dedicated Permissions**: Application mutations (`create`, `update`, `rotate_secret`, `enable_disable`, `delete`) require the `applications:manage` permission.
|
||||||
|
|
||||||
## Audit Logging Security
|
## Tenant Reassignment Safety & Audit Atomicity
|
||||||
|
|
||||||
|
- **Option-A Tenant Isolation**: Users belong strictly to one tenant (`users.tenant_id`). Cross-tenant operations strictly check boundaries.
|
||||||
|
- **Transactional Atomicity**: Tenant reassignment (`reassign_user_tenant_with_audit`) executes inside a single database transaction. Database update (`users.tenant_id`) and audit log record insertion (`user.tenant_reassigned` with `from_tenant_id` and `to_tenant_id`) commit together. If audit log insertion fails, database changes roll back completely.
|
||||||
|
- **No-Op Reassignment**: Reassignment to the user's current tenant is a defined no-op that emits no audit log and avoids unnecessary database mutations.
|
||||||
|
- **Concurrency & Locking Protection**: PostgreSQL uses `SELECT ... FOR UPDATE` row locking; SQLite uses write transactions and conditional updates (`WHERE tenant_id = expected`).
|
||||||
|
- **Last-Admin Protection**: System administrator reassignment away from the default tenant is rejected if `count_admins() <= 1`.
|
||||||
|
|
||||||
|
## Application Membership Security & Audit Atomicity
|
||||||
|
|
||||||
|
- **Same-Tenant Enforcement**: Application membership requires user and application to share the exact same `tenant_id`. Cross-tenant membership is rejected.
|
||||||
|
- **Transactional Atomicity**: Application membership mutations (add, role update, enable/disable, remove) execute in single transactions with audit log insertions; audit failure automatically rolls back the membership change.
|
||||||
|
- **Strict Protocol Boundary**: Application authentication accepts only `client_id` + `client_secret`. Editable application slugs are never accepted as credential identities.
|
||||||
|
|
||||||
|
## Audit Logging Security & Export
|
||||||
|
|
||||||
Audit logs record critical identity lifecycle events while strictly redacting sensitive fields:
|
Audit logs record critical identity lifecycle events while strictly redacting sensitive fields:
|
||||||
- **Recorded Events**: Login success/failure, logout, password change, user creation/deletion, API token issuance/revocation, application creation/secret rotation/modification, role/permission assignments.
|
- **Recorded Events**: Login success/failure, logout, password change, user creation/deletion, tenant reassignment, API token issuance/revocation, application creation/secret rotation/membership modification, role/permission assignments.
|
||||||
- **Redaction Rules**: Plaintext passwords, password hashes, bearer tokens, refresh tokens, client secrets, client secret hashes, session secrets, and `Authorization` headers are **never** logged under any circumstances.
|
- **Redaction Rules**: Plaintext passwords, password hashes, bearer tokens, refresh tokens, client secrets, client secret hashes, session secrets, and `Authorization` headers are **never** logged under any circumstances.
|
||||||
|
- **Bounded CSV Export**: Audit log CSV export uses server-side audit search APIs bounded to a maximum of 5,000 records matching currently active query filters, preserving exact tenant/RBAC restrictions and RFC-4180 field escaping.
|
||||||
|
|
||||||
## Rate Limiting & Protection
|
## Rate Limiting & Protection
|
||||||
|
|
||||||
|
|||||||
+171
-3
@@ -7,9 +7,9 @@ use serde::{Deserialize, Serialize};
|
|||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
db::models::{Application, Tenant},
|
db::models::{Application, ApplicationMember, Tenant},
|
||||||
error::Result,
|
error::{AppError, Result},
|
||||||
identity::applications as identity,
|
identity::{application_members as members, applications as identity},
|
||||||
middleware::{auth::AuthUser, permissions::require},
|
middleware::{auth::AuthUser, permissions::require},
|
||||||
state::AppState,
|
state::AppState,
|
||||||
};
|
};
|
||||||
@@ -201,3 +201,171 @@ pub async fn delete_application(
|
|||||||
identity::delete(&state.provider, &id, Some(&auth.user.id), None, None).await?;
|
identity::delete(&state.provider, &id, Some(&auth.user.id), None, None).await?;
|
||||||
Ok(Json(json!({ "success": true })))
|
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,
|
since: query.since,
|
||||||
until: query.until,
|
until: query.until,
|
||||||
search: query.q,
|
search: query.q,
|
||||||
|
success: query.success,
|
||||||
limit,
|
limit,
|
||||||
offset,
|
offset,
|
||||||
};
|
};
|
||||||
@@ -92,19 +93,10 @@ pub async fn list_audit(
|
|||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
|
|
||||||
let mut entries = audit_repo::list_filtered(&state.provider, &filter)
|
let entries = audit_repo::list_filtered(&state.provider, &filter)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.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();
|
let views: Vec<AuditLogResponse> = entries.into_iter().map(AuditLogResponse::from).collect();
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
@@ -114,3 +106,72 @@ pub async fn list_audit(
|
|||||||
"offset": offset,
|
"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::http::{HeaderName, Method, header};
|
||||||
use axum::{
|
use axum::{
|
||||||
Router, middleware,
|
Router, middleware,
|
||||||
routing::{delete, get, post, put},
|
routing::{delete, get, patch, post, put},
|
||||||
};
|
};
|
||||||
use tower_http::{compression::CompressionLayer, cors::CorsLayer, trace::TraceLayer};
|
use tower_http::{compression::CompressionLayer, cors::CorsLayer, trace::TraceLayer};
|
||||||
|
|
||||||
@@ -40,6 +40,18 @@ pub fn build(state: AppState) -> Router {
|
|||||||
.patch(tenants::update_tenant)
|
.patch(tenants::update_tenant)
|
||||||
.delete(tenants::delete_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
|
// Users
|
||||||
.route("/users", get(users::list_users).post(users::create_user))
|
.route("/users", get(users::list_users).post(users::create_user))
|
||||||
.route(
|
.route(
|
||||||
@@ -54,6 +66,10 @@ pub fn build(state: AppState) -> Router {
|
|||||||
get(users::list_user_roles).post(roles::assign_user_role),
|
get(users::list_user_roles).post(roles::assign_user_role),
|
||||||
)
|
)
|
||||||
.route("/users/{id}/roles/{role}", delete(roles::remove_user_role))
|
.route("/users/{id}/roles/{role}", delete(roles::remove_user_role))
|
||||||
|
.route(
|
||||||
|
"/users/{id}/applications",
|
||||||
|
get(users::list_user_applications),
|
||||||
|
)
|
||||||
// Roles
|
// Roles
|
||||||
.route("/roles", get(roles::list_roles).post(roles::create_role))
|
.route("/roles", get(roles::list_roles).post(roles::create_role))
|
||||||
.route(
|
.route(
|
||||||
@@ -86,6 +102,15 @@ pub fn build(state: AppState) -> Router {
|
|||||||
"/applications/{id}/secret",
|
"/applications/{id}/secret",
|
||||||
post(applications::rotate_application_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
|
// Service accounts
|
||||||
.route(
|
.route(
|
||||||
"/service-accounts",
|
"/service-accounts",
|
||||||
@@ -104,6 +129,7 @@ pub fn build(state: AppState) -> Router {
|
|||||||
)
|
)
|
||||||
// Audit
|
// Audit
|
||||||
.route("/audit", get(audit::list_audit))
|
.route("/audit", get(audit::list_audit))
|
||||||
|
.route("/audit/export", get(audit::export_audit))
|
||||||
// Sessions
|
// Sessions
|
||||||
.route("/sessions", get(sessions::list_sessions))
|
.route("/sessions", get(sessions::list_sessions))
|
||||||
.route("/sessions/others", delete(sessions::terminate_others))
|
.route("/sessions/others", delete(sessions::terminate_others))
|
||||||
|
|||||||
@@ -53,6 +53,12 @@ pub async fn create_tenant(
|
|||||||
) -> Result<Json<Value>> {
|
) -> Result<Json<Value>> {
|
||||||
require(&state.provider, &auth.user.id, "roles:manage").await?;
|
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 id = uuid::Uuid::new_v4().to_string();
|
||||||
let tenant = state
|
let tenant = state
|
||||||
.provider
|
.provider
|
||||||
@@ -118,6 +124,12 @@ pub async fn update_tenant(
|
|||||||
) -> Result<Json<Value>> {
|
) -> Result<Json<Value>> {
|
||||||
require(&state.provider, &auth.user.id, "roles:manage").await?;
|
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
|
state
|
||||||
.provider
|
.provider
|
||||||
.tenants()
|
.tenants()
|
||||||
@@ -183,3 +195,153 @@ pub async fn delete_tenant(
|
|||||||
|
|
||||||
Ok(Json(json!({ "success": true })))
|
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::Tenant,
|
||||||
db::models::{User, UserStatus},
|
db::models::{User, UserStatus},
|
||||||
error::{AppError, Result},
|
error::{AppError, Result},
|
||||||
identity::users as identity,
|
identity::{application_members as members, users as identity},
|
||||||
middleware::{audit::AuditContext, auth::AuthUser, permissions::require},
|
middleware::{audit::AuditContext, auth::AuthUser, permissions::require},
|
||||||
state::AppState,
|
state::AppState,
|
||||||
};
|
};
|
||||||
@@ -20,6 +20,7 @@ use crate::{
|
|||||||
pub struct UserResponse {
|
pub struct UserResponse {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub username: String,
|
pub username: String,
|
||||||
|
pub tenant_id: String,
|
||||||
pub status: String,
|
pub status: String,
|
||||||
pub last_login_at: Option<String>,
|
pub last_login_at: Option<String>,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
@@ -29,8 +30,9 @@ pub struct UserResponse {
|
|||||||
impl From<User> for UserResponse {
|
impl From<User> for UserResponse {
|
||||||
fn from(u: User) -> Self {
|
fn from(u: User) -> Self {
|
||||||
Self {
|
Self {
|
||||||
id: u.id,
|
id: u.id.clone(),
|
||||||
username: u.username,
|
username: u.username,
|
||||||
|
tenant_id: u.tenant_id,
|
||||||
status: UserStatus::from_i32(u.status).to_string(),
|
status: UserStatus::from_i32(u.status).to_string(),
|
||||||
last_login_at: u.last_login_at,
|
last_login_at: u.last_login_at,
|
||||||
created_at: u.created_at,
|
created_at: u.created_at,
|
||||||
@@ -215,3 +217,79 @@ pub async fn list_user_roles(
|
|||||||
}).collect::<Vec<_>>(),
|
}).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.
|
-- Seed the default tenant.
|
||||||
-- Uses INSERT OR IGNORE so re-running migrations is safe.
|
INSERT INTO tenants (id, name, slug, enabled)
|
||||||
INSERT OR IGNORE INTO tenants (id, name, slug, enabled)
|
VALUES ('00000000-0000-0000-0000-000000000001', 'Default', 'default', 1)
|
||||||
VALUES ('00000000-0000-0000-0000-000000000001', 'Default', 'default', 1);
|
ON CONFLICT (id) DO NOTHING;
|
||||||
@@ -1,35 +1,40 @@
|
|||||||
-- ── Roles ────────────────────────────────────────────────────────────────────
|
-- ── 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-000000000001', 'admin', 'Full system access'),
|
||||||
('10000000-0000-0000-0000-000000000002', 'editor', 'Can manage content and users'),
|
('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 ───────────────────────────────────────────────────────────────
|
-- ── 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-000000000001', 'users:create', 'Create new user accounts'),
|
||||||
('20000000-0000-0000-0000-000000000002', 'users:update', 'Update 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-000000000003', 'users:delete', 'Disable user accounts'),
|
||||||
('20000000-0000-0000-0000-000000000004', 'tokens:create', 'Create API tokens for any user'),
|
('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-000000000005', 'tokens:revoke', 'Revoke API tokens for any user'),
|
||||||
('20000000-0000-0000-0000-000000000006', 'roles:manage', 'Assign and revoke roles'),
|
('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 ──────────────────────────────────────────
|
-- ── Admin role gets all permissions ──────────────────────────────────────────
|
||||||
|
|
||||||
INSERT OR IGNORE INTO role_permissions (role_id, permission_id)
|
INSERT INTO role_permissions (role_id, permission_id)
|
||||||
SELECT '10000000-0000-0000-0000-000000000001', id FROM permissions;
|
SELECT '10000000-0000-0000-0000-000000000001', id FROM permissions
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
-- ── Editor role permissions ───────────────────────────────────────────────────
|
-- ── 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-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 ──────────────────────────────────────────────────────
|
-- ── 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-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-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.)
|
-- A unified registry for slugs across all resources (tenant, user, group, role, app, etc.)
|
||||||
-- Ensures global uniqueness and immutable references.
|
-- 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_type TEXT NOT NULL, -- 'tenant', 'user', 'role', 'group', 'permission', 'application', 'service_account', 'organization', 'team'
|
||||||
entity_id TEXT NOT NULL,
|
entity_id TEXT NOT NULL,
|
||||||
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
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_entity ON global_slugs(entity_type, entity_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_global_slugs_tenant ON global_slugs(tenant_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
|
-- Add slug column to existing tables for quick lookup and joins
|
||||||
ALTER TABLE tenants ADD COLUMN slug TEXT;
|
ALTER TABLE tenants ADD COLUMN IF NOT EXISTS slug TEXT;
|
||||||
ALTER TABLE users ADD COLUMN slug TEXT;
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS slug TEXT;
|
||||||
ALTER TABLE roles ADD COLUMN slug TEXT;
|
ALTER TABLE roles ADD COLUMN IF NOT EXISTS slug TEXT;
|
||||||
ALTER TABLE permissions ADD COLUMN slug TEXT;
|
ALTER TABLE permissions ADD COLUMN IF NOT EXISTS slug TEXT;
|
||||||
ALTER TABLE applications ADD COLUMN slug TEXT;
|
ALTER TABLE applications ADD COLUMN IF NOT EXISTS slug TEXT;
|
||||||
ALTER TABLE service_accounts ADD COLUMN 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,
|
-- Backfill basic slugs:
|
||||||
-- or we can backfill basic ones here:
|
|
||||||
UPDATE tenants SET slug = lower(replace(name, ' ', '-')) WHERE slug IS NULL;
|
UPDATE tenants SET slug = lower(replace(name, ' ', '-')) WHERE slug IS NULL;
|
||||||
UPDATE users SET slug = lower(username) WHERE slug IS NULL;
|
UPDATE users SET slug = lower(username) WHERE slug IS NULL;
|
||||||
UPDATE roles SET slug = lower(replace(name, ' ', '-')) WHERE slug IS NULL;
|
UPDATE 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 applications SET slug = lower(replace(name, ' ', '-')) WHERE slug IS NULL;
|
||||||
UPDATE service_accounts 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 backfilled slugs into registry
|
||||||
INSERT OR IGNORE INTO global_slugs (slug, entity_type, entity_id, tenant_id)
|
INSERT INTO global_slugs (slug, entity_type, entity_id, tenant_id)
|
||||||
SELECT slug, 'tenant', id, id FROM tenants WHERE slug IS NOT NULL;
|
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)
|
INSERT INTO global_slugs (slug, entity_type, entity_id, tenant_id)
|
||||||
SELECT slug, 'user', id, tenant_id FROM users WHERE slug IS NOT NULL;
|
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)
|
INSERT INTO global_slugs (slug, entity_type, entity_id, tenant_id)
|
||||||
SELECT slug, 'role', id, tenant_id FROM roles WHERE slug IS NOT NULL;
|
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)
|
INSERT INTO global_slugs (slug, entity_type, entity_id, tenant_id)
|
||||||
SELECT slug, 'permission', id, tenant_id FROM permissions WHERE slug IS NOT NULL;
|
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)
|
INSERT INTO global_slugs (slug, entity_type, entity_id, tenant_id)
|
||||||
SELECT slug, 'application', id, tenant_id FROM applications WHERE slug IS NOT NULL;
|
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)
|
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;
|
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 since: Option<String>,
|
||||||
pub until: Option<String>,
|
pub until: Option<String>,
|
||||||
pub search: Option<String>,
|
pub search: Option<String>,
|
||||||
|
pub success: Option<bool>,
|
||||||
pub limit: i64,
|
pub limit: i64,
|
||||||
pub offset: 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 api_token;
|
||||||
pub mod application;
|
pub mod application;
|
||||||
|
pub mod application_member;
|
||||||
pub mod audit_log;
|
pub mod audit_log;
|
||||||
|
pub mod global_slug;
|
||||||
pub mod group;
|
pub mod group;
|
||||||
pub mod permission;
|
pub mod permission;
|
||||||
pub mod refresh_token;
|
pub mod refresh_token;
|
||||||
@@ -12,7 +14,9 @@ pub mod user;
|
|||||||
|
|
||||||
pub use api_token::ApiToken;
|
pub use api_token::ApiToken;
|
||||||
pub use application::Application;
|
pub use application::Application;
|
||||||
|
pub use application_member::{ApplicationMember, ApplicationMembershipRole};
|
||||||
pub use audit_log::{AuditFilter, AuditLog, AuditSeverity};
|
pub use audit_log::{AuditFilter, AuditLog, AuditSeverity};
|
||||||
|
pub use global_slug::GlobalSlug;
|
||||||
pub use group::Group;
|
pub use group::Group;
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use permission::Permission;
|
pub use permission::Permission;
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ pub trait DatabaseProvider: Send + Sync {
|
|||||||
fn tokens(&self) -> Box<dyn TokensRepository>;
|
fn tokens(&self) -> Box<dyn TokensRepository>;
|
||||||
fn tenants(&self) -> Box<dyn TenantsRepository>;
|
fn tenants(&self) -> Box<dyn TenantsRepository>;
|
||||||
fn groups(&self) -> Box<dyn GroupsRepository>;
|
fn groups(&self) -> Box<dyn GroupsRepository>;
|
||||||
|
fn application_members(&self) -> Box<dyn ApplicationMembersRepository>;
|
||||||
|
fn global_slugs(&self) -> Box<dyn GlobalSlugsRepository>;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "sqlite")]
|
#[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")]
|
#[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 crate::db::repository::traits::ApplicationsRepository;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
|
||||||
use crate::db::models::Application;
|
|
||||||
|
|
||||||
pub struct PostgresApplicationsRepository {
|
pub struct PostgresApplicationsRepository {
|
||||||
pub pool: PgPool,
|
pub pool: PgPool,
|
||||||
}
|
}
|
||||||
@@ -25,6 +27,8 @@ impl ApplicationsRepository for PostgresApplicationsRepository {
|
|||||||
) -> Result<Application, sqlx::Error> {
|
) -> Result<Application, sqlx::Error> {
|
||||||
let mut tx = self.pool.begin().await?;
|
let mut tx = self.pool.begin().await?;
|
||||||
|
|
||||||
|
reserve_slug_postgres(&mut tx, slug, "application", id, tenant_id).await?;
|
||||||
|
|
||||||
let app = sqlx::query_as::<_, Application>(
|
let app = sqlx::query_as::<_, Application>(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO applications (id, tenant_id, name, slug, client_id, client_secret_hash, description, redirect_uris, scopes)
|
INSERT INTO applications (id, tenant_id, name, slug, client_id, client_secret_hash, description, redirect_uris, scopes)
|
||||||
@@ -188,6 +192,34 @@ impl ApplicationsRepository for PostgresApplicationsRepository {
|
|||||||
scopes: Option<&str>,
|
scopes: Option<&str>,
|
||||||
enabled: bool,
|
enabled: bool,
|
||||||
) -> Result<(), sqlx::Error> {
|
) -> Result<(), sqlx::Error> {
|
||||||
|
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(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
UPDATE applications
|
UPDATE applications
|
||||||
@@ -203,16 +235,31 @@ impl ApplicationsRepository for PostgresApplicationsRepository {
|
|||||||
.bind(scopes)
|
.bind(scopes)
|
||||||
.bind(enabled)
|
.bind(enabled)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.execute(&self.pool)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
if !existing_slug_str.is_empty() {
|
||||||
|
release_slug_by_name_postgres(&mut tx, existing_slug_str, "application", id)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete(&self, id: &str) -> Result<(), sqlx::Error> {
|
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")
|
sqlx::query("DELETE FROM applications WHERE id = $1")
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.execute(&self.pool)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,8 +64,6 @@ impl AuditRepository for PostgresAuditRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn list_filtered(&self, filter: &AuditFilter) -> Result<Vec<AuditLog>, sqlx::Error> {
|
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
|
let search_like = filter
|
||||||
.search
|
.search
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -88,8 +86,13 @@ impl AuditRepository for PostgresAuditRepository {
|
|||||||
OR ip_address LIKE $7 ESCAPE '\'
|
OR ip_address LIKE $7 ESCAPE '\'
|
||||||
OR metadata_json 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
|
ORDER BY created_at DESC
|
||||||
LIMIT $8 OFFSET $9
|
LIMIT $9 OFFSET $10
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(filter.actor_user_id.as_deref())
|
.bind(filter.actor_user_id.as_deref())
|
||||||
@@ -99,6 +102,7 @@ impl AuditRepository for PostgresAuditRepository {
|
|||||||
.bind(filter.since.as_deref())
|
.bind(filter.since.as_deref())
|
||||||
.bind(filter.until.as_deref())
|
.bind(filter.until.as_deref())
|
||||||
.bind(search_like.as_deref())
|
.bind(search_like.as_deref())
|
||||||
|
.bind(filter.success)
|
||||||
.bind(filter.limit)
|
.bind(filter.limit)
|
||||||
.bind(filter.offset)
|
.bind(filter.offset)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
@@ -128,6 +132,11 @@ impl AuditRepository for PostgresAuditRepository {
|
|||||||
OR ip_address LIKE $7 ESCAPE '\'
|
OR ip_address LIKE $7 ESCAPE '\'
|
||||||
OR metadata_json 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())
|
.bind(filter.actor_user_id.as_deref())
|
||||||
@@ -137,6 +146,7 @@ impl AuditRepository for PostgresAuditRepository {
|
|||||||
.bind(filter.since.as_deref())
|
.bind(filter.since.as_deref())
|
||||||
.bind(filter.until.as_deref())
|
.bind(filter.until.as_deref())
|
||||||
.bind(search_like.as_deref())
|
.bind(search_like.as_deref())
|
||||||
|
.bind(filter.success)
|
||||||
.fetch_one(&self.pool)
|
.fetch_one(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(row.0)
|
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 applications;
|
||||||
pub mod audit;
|
pub mod audit;
|
||||||
|
pub mod global_slugs;
|
||||||
pub mod groups;
|
pub mod groups;
|
||||||
pub mod permissions;
|
pub mod permissions;
|
||||||
pub mod refresh_tokens;
|
pub mod refresh_tokens;
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
|
||||||
use crate::db::models::Tenant;
|
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::db::repository::traits::TenantsRepository;
|
||||||
|
use crate::identity::slug::{slugify, validate_slug};
|
||||||
|
|
||||||
pub struct PostgresTenantsRepository {
|
pub struct PostgresTenantsRepository {
|
||||||
pub pool: PgPool,
|
pub pool: PgPool,
|
||||||
@@ -47,7 +51,17 @@ impl TenantsRepository for PostgresTenantsRepository {
|
|||||||
name: &str,
|
name: &str,
|
||||||
slug: Option<&str>,
|
slug: Option<&str>,
|
||||||
) -> Result<Tenant, sqlx::Error> {
|
) -> 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>(
|
let row = sqlx::query_as::<_, Tenant>(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO tenants (id, name, slug, enabled)
|
INSERT INTO tenants (id, name, slug, enabled)
|
||||||
@@ -57,15 +71,49 @@ impl TenantsRepository for PostgresTenantsRepository {
|
|||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.bind(name)
|
.bind(name)
|
||||||
.bind(slug)
|
.bind(&final_slug)
|
||||||
.fetch_one(&self.pool)
|
.fetch_one(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
reserve_slug_postgres(&mut tx, &final_slug, "tenant", id, id).await?;
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
Ok(row)
|
Ok(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn update(&self, id: &str, name: &str, slug: Option<&str>) -> Result<(), sqlx::Error> {
|
async fn update(&self, id: &str, name: &str, slug: Option<&str>) -> Result<(), sqlx::Error> {
|
||||||
let slug = slug.unwrap_or(name);
|
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(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
UPDATE tenants
|
UPDATE tenants
|
||||||
@@ -74,11 +122,18 @@ impl TenantsRepository for PostgresTenantsRepository {
|
|||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(name)
|
.bind(name)
|
||||||
.bind(slug)
|
.bind(&target_slug)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.execute(&self.pool)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
if !existing_slug_str.is_empty() {
|
||||||
|
release_slug_by_name_postgres(&mut tx, existing_slug_str, "tenant", id).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,10 +154,16 @@ impl TenantsRepository for PostgresTenantsRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn delete(&self, id: &str) -> Result<(), sqlx::Error> {
|
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")
|
sqlx::query("DELETE FROM tenants WHERE id = $1")
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.execute(&self.pool)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -98,6 +98,125 @@ impl UsersRepository for PostgresUsersRepository {
|
|||||||
Ok(())
|
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> {
|
async fn update_password_hash(&self, id: &str, password_hash: &str) -> Result<(), sqlx::Error> {
|
||||||
sqlx::query(
|
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",
|
"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 crate::db::repository::traits::ApplicationsRepository;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
use crate::db::models::Application;
|
|
||||||
|
|
||||||
pub struct SqliteApplicationsRepository {
|
pub struct SqliteApplicationsRepository {
|
||||||
pub pool: SqlitePool,
|
pub pool: SqlitePool,
|
||||||
}
|
}
|
||||||
@@ -25,6 +27,8 @@ impl ApplicationsRepository for SqliteApplicationsRepository {
|
|||||||
) -> Result<Application, sqlx::Error> {
|
) -> Result<Application, sqlx::Error> {
|
||||||
let mut tx = self.pool.begin().await?;
|
let mut tx = self.pool.begin().await?;
|
||||||
|
|
||||||
|
reserve_slug_sqlite(&mut tx, slug, "application", id, tenant_id).await?;
|
||||||
|
|
||||||
let app = sqlx::query_as::<_, Application>(
|
let app = sqlx::query_as::<_, Application>(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO applications (id, tenant_id, name, slug, client_id, client_secret_hash, description, redirect_uris, scopes)
|
INSERT INTO applications (id, tenant_id, name, slug, client_id, client_secret_hash, description, redirect_uris, scopes)
|
||||||
@@ -188,6 +192,34 @@ impl ApplicationsRepository for SqliteApplicationsRepository {
|
|||||||
scopes: Option<&str>,
|
scopes: Option<&str>,
|
||||||
enabled: bool,
|
enabled: bool,
|
||||||
) -> Result<(), sqlx::Error> {
|
) -> Result<(), sqlx::Error> {
|
||||||
|
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(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
UPDATE applications
|
UPDATE applications
|
||||||
@@ -203,16 +235,30 @@ impl ApplicationsRepository for SqliteApplicationsRepository {
|
|||||||
.bind(scopes)
|
.bind(scopes)
|
||||||
.bind(enabled)
|
.bind(enabled)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.execute(&self.pool)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
if !existing_slug_str.is_empty() {
|
||||||
|
release_slug_by_name_sqlite(&mut tx, existing_slug_str, "application", id).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete(&self, id: &str) -> Result<(), sqlx::Error> {
|
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 = ?")
|
sqlx::query("DELETE FROM applications WHERE id = ?")
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.execute(&self.pool)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,12 +65,11 @@ impl AuditRepository for SqliteAuditRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn list_filtered(&self, filter: &AuditFilter) -> Result<Vec<AuditLog>, sqlx::Error> {
|
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
|
let search_like = filter
|
||||||
.search
|
.search
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|s| format!("%{}%", s.replace('%', "\\%")));
|
.map(|s| format!("%{}%", s.replace('%', "\\%")));
|
||||||
|
let success_val = filter.success.map(|b| if b { 1i32 } else { 0i32 });
|
||||||
|
|
||||||
sqlx::query_as::<_, AuditLog>(
|
sqlx::query_as::<_, AuditLog>(
|
||||||
r#"
|
r#"
|
||||||
@@ -89,8 +88,13 @@ impl AuditRepository for SqliteAuditRepository {
|
|||||||
OR ip_address LIKE ?7 ESCAPE '\'
|
OR ip_address LIKE ?7 ESCAPE '\'
|
||||||
OR metadata_json 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
|
ORDER BY created_at DESC
|
||||||
LIMIT ?8 OFFSET ?9
|
LIMIT ?9 OFFSET ?10
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(filter.actor_user_id.as_deref())
|
.bind(filter.actor_user_id.as_deref())
|
||||||
@@ -100,6 +104,7 @@ impl AuditRepository for SqliteAuditRepository {
|
|||||||
.bind(filter.since.as_deref())
|
.bind(filter.since.as_deref())
|
||||||
.bind(filter.until.as_deref())
|
.bind(filter.until.as_deref())
|
||||||
.bind(search_like.as_deref())
|
.bind(search_like.as_deref())
|
||||||
|
.bind(success_val)
|
||||||
.bind(filter.limit)
|
.bind(filter.limit)
|
||||||
.bind(filter.offset)
|
.bind(filter.offset)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
@@ -111,6 +116,7 @@ impl AuditRepository for SqliteAuditRepository {
|
|||||||
.search
|
.search
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|s| format!("%{}%", s.replace('%', "\\%")));
|
.map(|s| format!("%{}%", s.replace('%', "\\%")));
|
||||||
|
let success_val = filter.success.map(|b| if b { 1i32 } else { 0i32 });
|
||||||
|
|
||||||
let row: (i64,) = sqlx::query_as(
|
let row: (i64,) = sqlx::query_as(
|
||||||
r#"
|
r#"
|
||||||
@@ -129,6 +135,11 @@ impl AuditRepository for SqliteAuditRepository {
|
|||||||
OR ip_address LIKE ?7 ESCAPE '\'
|
OR ip_address LIKE ?7 ESCAPE '\'
|
||||||
OR metadata_json 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())
|
.bind(filter.actor_user_id.as_deref())
|
||||||
@@ -138,6 +149,7 @@ impl AuditRepository for SqliteAuditRepository {
|
|||||||
.bind(filter.since.as_deref())
|
.bind(filter.since.as_deref())
|
||||||
.bind(filter.until.as_deref())
|
.bind(filter.until.as_deref())
|
||||||
.bind(search_like.as_deref())
|
.bind(search_like.as_deref())
|
||||||
|
.bind(success_val)
|
||||||
.fetch_one(&self.pool)
|
.fetch_one(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(row.0)
|
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 applications;
|
||||||
pub mod audit;
|
pub mod audit;
|
||||||
|
pub mod global_slugs;
|
||||||
pub mod groups;
|
pub mod groups;
|
||||||
pub mod permissions;
|
pub mod permissions;
|
||||||
pub mod refresh_tokens;
|
pub mod refresh_tokens;
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
use crate::db::models::Tenant;
|
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::db::repository::traits::TenantsRepository;
|
||||||
|
use crate::identity::slug::{slugify, validate_slug};
|
||||||
|
|
||||||
pub struct SqliteTenantsRepository {
|
pub struct SqliteTenantsRepository {
|
||||||
pub pool: SqlitePool,
|
pub pool: SqlitePool,
|
||||||
@@ -47,7 +51,17 @@ impl TenantsRepository for SqliteTenantsRepository {
|
|||||||
name: &str,
|
name: &str,
|
||||||
slug: Option<&str>,
|
slug: Option<&str>,
|
||||||
) -> Result<Tenant, sqlx::Error> {
|
) -> 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>(
|
let row = sqlx::query_as::<_, Tenant>(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO tenants (id, name, slug, enabled)
|
INSERT INTO tenants (id, name, slug, enabled)
|
||||||
@@ -57,15 +71,49 @@ impl TenantsRepository for SqliteTenantsRepository {
|
|||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.bind(name)
|
.bind(name)
|
||||||
.bind(slug)
|
.bind(&final_slug)
|
||||||
.fetch_one(&self.pool)
|
.fetch_one(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
reserve_slug_sqlite(&mut tx, &final_slug, "tenant", id, id).await?;
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
Ok(row)
|
Ok(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn update(&self, id: &str, name: &str, slug: Option<&str>) -> Result<(), sqlx::Error> {
|
async fn update(&self, id: &str, name: &str, slug: Option<&str>) -> Result<(), sqlx::Error> {
|
||||||
let slug = slug.unwrap_or(name);
|
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(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
UPDATE tenants
|
UPDATE tenants
|
||||||
@@ -74,11 +122,18 @@ impl TenantsRepository for SqliteTenantsRepository {
|
|||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(name)
|
.bind(name)
|
||||||
.bind(slug)
|
.bind(&target_slug)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.execute(&self.pool)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
if !existing_slug_str.is_empty() {
|
||||||
|
release_slug_by_name_sqlite(&mut tx, existing_slug_str, "tenant", id).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,10 +154,16 @@ impl TenantsRepository for SqliteTenantsRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn delete(&self, id: &str) -> Result<(), sqlx::Error> {
|
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 = ?")
|
sqlx::query("DELETE FROM tenants WHERE id = ?")
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.execute(&self.pool)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -100,6 +100,134 @@ impl UsersRepository for SqliteUsersRepository {
|
|||||||
Ok(())
|
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> {
|
async fn update_password_hash(&self, id: &str, password_hash: &str) -> Result<(), sqlx::Error> {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE users SET password_hash = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?",
|
"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::{
|
use crate::db::models::{
|
||||||
ApiToken, Application, AuditFilter, AuditLog, Group, Permission, RefreshToken, Role,
|
ApiToken, Application, ApplicationMember, AuditFilter, AuditLog, GlobalSlug, Group, Permission,
|
||||||
ServiceAccount, Session, Tenant, User, UserProfile,
|
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]
|
#[async_trait::async_trait]
|
||||||
pub trait UsersRepository: Send + Sync {
|
pub trait UsersRepository: Send + Sync {
|
||||||
async fn find_by_id(&self, id: &str) -> Result<Option<User>, sqlx::Error>;
|
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,
|
password_hash: &str,
|
||||||
) -> Result<User, sqlx::Error>;
|
) -> Result<User, sqlx::Error>;
|
||||||
async fn update_status(&self, id: &str, status: i32) -> Result<(), 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 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 set_last_login(&self, id: &str) -> Result<(), sqlx::Error>;
|
||||||
async fn username_exists(&self, tenant_id: &str, username: &str) -> Result<bool, sqlx::Error>;
|
async fn 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 remove_member(&self, group_id: &str, user_id: &str) -> Result<(), sqlx::Error>;
|
||||||
async fn count(&self, tenant_id: &str) -> Result<i64, 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(),
|
"name and slug cannot be empty".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
crate::identity::slug::validate_slug(slug)?;
|
||||||
|
|
||||||
if let Some(ref uris) = redirect_uris {
|
if let Some(ref uris) = redirect_uris {
|
||||||
validate_redirect_uris(uris)?;
|
validate_redirect_uris(uris)?;
|
||||||
}
|
}
|
||||||
if provider
|
if provider
|
||||||
.applications()
|
.global_slugs()
|
||||||
.find_by_slug(slug)
|
.find_by_slug(slug)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?
|
.map_err(AppError::Database)?
|
||||||
@@ -312,16 +314,18 @@ pub async fn update(
|
|||||||
"name and slug cannot be empty".into(),
|
"name and slug cannot be empty".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
crate::identity::slug::validate_slug(slug)?;
|
||||||
|
|
||||||
if let Some(ref uris) = redirect_uris {
|
if let Some(ref uris) = redirect_uris {
|
||||||
validate_redirect_uris(uris)?;
|
validate_redirect_uris(uris)?;
|
||||||
}
|
}
|
||||||
if let Some(other) = provider
|
if let Some(other) = provider
|
||||||
.applications()
|
.global_slugs()
|
||||||
.find_by_slug(slug)
|
.find_by_slug(slug)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?
|
.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")));
|
return Err(AppError::Conflict(format!("slug '{slug}' already exists")));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
pub mod application_members;
|
||||||
pub mod applications;
|
pub mod applications;
|
||||||
pub mod permissions;
|
pub mod permissions;
|
||||||
pub mod roles;
|
pub mod roles;
|
||||||
pub mod service_accounts;
|
pub mod service_accounts;
|
||||||
|
pub mod slug;
|
||||||
pub mod users;
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
#![cfg(feature = "sqlite")]
|
||||||
|
|
||||||
|
use nx9_auth::db::{
|
||||||
|
self,
|
||||||
|
models::Tenant,
|
||||||
|
provider::{DatabaseProvider, SqliteProvider},
|
||||||
|
};
|
||||||
|
use nx9_auth::identity::{application_members, applications, users};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
async fn setup_test_provider() -> (Arc<SqliteProvider>, String) {
|
||||||
|
let db_id = uuid::Uuid::new_v4().to_string();
|
||||||
|
let db_path = format!("target/test_app_members_{}.db", db_id);
|
||||||
|
let pool = db::create_pool(&db_path)
|
||||||
|
.await
|
||||||
|
.expect("Failed to create test pool");
|
||||||
|
db::run_migrations(&pool)
|
||||||
|
.await
|
||||||
|
.expect("Failed to run migrations");
|
||||||
|
(Arc::new(SqliteProvider::new(pool)), db_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn teardown_test_db(path: String) {
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_application_membership_add_update_remove_transactions() {
|
||||||
|
let (provider, db_path) = setup_test_provider().await;
|
||||||
|
let provider_dyn: Arc<dyn DatabaseProvider> = provider.clone();
|
||||||
|
let dummy_cfg = nx9_auth::config::SecurityConfig::default();
|
||||||
|
|
||||||
|
// Create user and application in Default Tenant
|
||||||
|
let user = users::create_user(
|
||||||
|
&provider_dyn,
|
||||||
|
&dummy_cfg,
|
||||||
|
Tenant::DEFAULT_ID,
|
||||||
|
"app_user_1",
|
||||||
|
"X9#mK$9qL!2zP0",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let (app, _) = applications::create(
|
||||||
|
&provider_dyn,
|
||||||
|
Tenant::DEFAULT_ID,
|
||||||
|
"Portal App",
|
||||||
|
"portal-app",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// 1. Add membership atomically with audit log
|
||||||
|
let member = application_members::add(
|
||||||
|
&provider_dyn,
|
||||||
|
&app.id,
|
||||||
|
&user.id,
|
||||||
|
Some("member"),
|
||||||
|
Some(&user.id),
|
||||||
|
Some("127.0.0.1"),
|
||||||
|
Some("TestRunner"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(member.application_id, app.id);
|
||||||
|
assert_eq!(member.user_id, user.id);
|
||||||
|
assert_eq!(member.role, "member");
|
||||||
|
|
||||||
|
let audit_add = provider
|
||||||
|
.audit()
|
||||||
|
.list_filtered(&nx9_auth::db::models::AuditFilter {
|
||||||
|
resource_type: Some("application".to_string()),
|
||||||
|
limit: 10,
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let add_event = audit_add
|
||||||
|
.into_iter()
|
||||||
|
.find(|e| e.action == "application.member_added")
|
||||||
|
.expect("member_added audit record must exist");
|
||||||
|
assert_eq!(add_event.target_user_id.as_deref(), Some(user.id.as_str()));
|
||||||
|
|
||||||
|
// 2. Update membership role atomically with audit log
|
||||||
|
let updated_member = application_members::update(
|
||||||
|
&provider_dyn,
|
||||||
|
&app.id,
|
||||||
|
&user.id,
|
||||||
|
Some("admin"),
|
||||||
|
None,
|
||||||
|
Some(&user.id),
|
||||||
|
Some("127.0.0.1"),
|
||||||
|
Some("TestRunner"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(updated_member.role, "admin");
|
||||||
|
|
||||||
|
// 3. Remove membership atomically with audit log
|
||||||
|
application_members::remove(
|
||||||
|
&provider_dyn,
|
||||||
|
&app.id,
|
||||||
|
&user.id,
|
||||||
|
Some(&user.id),
|
||||||
|
Some("127.0.0.1"),
|
||||||
|
Some("TestRunner"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let remaining_members = provider
|
||||||
|
.application_members()
|
||||||
|
.list_by_application(&app.id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(remaining_members.len(), 0);
|
||||||
|
|
||||||
|
teardown_test_db(db_path).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_application_membership_same_tenant_isolation() {
|
||||||
|
let (provider, db_path) = setup_test_provider().await;
|
||||||
|
let provider_dyn: Arc<dyn DatabaseProvider> = provider.clone();
|
||||||
|
let dummy_cfg = nx9_auth::config::SecurityConfig::default();
|
||||||
|
|
||||||
|
let tenant_b = uuid::Uuid::new_v4().to_string();
|
||||||
|
provider
|
||||||
|
.tenants()
|
||||||
|
.create(&tenant_b, "Tenant B", Some("tenant-b-app"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Create user in Default Tenant
|
||||||
|
let user_a = users::create_user(
|
||||||
|
&provider_dyn,
|
||||||
|
&dummy_cfg,
|
||||||
|
Tenant::DEFAULT_ID,
|
||||||
|
"user_tenant_a",
|
||||||
|
"X9#mK$9qL!2zP0",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Create application in Tenant B
|
||||||
|
let (app_b, _) = applications::create(
|
||||||
|
&provider_dyn,
|
||||||
|
&tenant_b,
|
||||||
|
"App Tenant B",
|
||||||
|
"app-tenant-b",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Attempting to assign user_a (Tenant Default) to app_b (Tenant B) MUST be rejected
|
||||||
|
let res = application_members::add(
|
||||||
|
&provider_dyn,
|
||||||
|
&app_b.id,
|
||||||
|
&user_a.id,
|
||||||
|
Some("member"),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
res.is_err(),
|
||||||
|
"Cross-tenant application membership assignment MUST be rejected"
|
||||||
|
);
|
||||||
|
|
||||||
|
teardown_test_db(db_path).await;
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
#![cfg(feature = "sqlite")]
|
||||||
|
|
||||||
|
use nx9_auth::db::{
|
||||||
|
self,
|
||||||
|
provider::{DatabaseProvider, SqliteProvider},
|
||||||
|
};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
async fn setup_test_provider() -> (Arc<SqliteProvider>, String) {
|
||||||
|
let db_id = uuid::Uuid::new_v4().to_string();
|
||||||
|
let db_path = format!("target/test_audit_export_{}.db", db_id);
|
||||||
|
let pool = db::create_pool(&db_path)
|
||||||
|
.await
|
||||||
|
.expect("Failed to create test pool");
|
||||||
|
db::run_migrations(&pool)
|
||||||
|
.await
|
||||||
|
.expect("Failed to run migrations");
|
||||||
|
(Arc::new(SqliteProvider::new(pool)), db_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn teardown_test_db(path: String) {
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_audit_list_remains_clamped_to_500() {
|
||||||
|
let (provider, db_path) = setup_test_provider().await;
|
||||||
|
|
||||||
|
// Insert 600 audit events
|
||||||
|
for i in 0..600 {
|
||||||
|
let audit_id = uuid::Uuid::new_v4().to_string();
|
||||||
|
provider
|
||||||
|
.audit()
|
||||||
|
.insert(
|
||||||
|
&audit_id,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
"user.login",
|
||||||
|
"user",
|
||||||
|
None,
|
||||||
|
"info",
|
||||||
|
Some("127.0.0.1"),
|
||||||
|
Some("TestRunner"),
|
||||||
|
Some(&format!("{{\"index\": {i}}}")),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal list filter with limit=1000 MUST be clamped to 500 by backend API logic
|
||||||
|
let filter = nx9_auth::db::models::AuditFilter {
|
||||||
|
limit: 1000,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let clamped_limit = filter.limit.clamp(1, 500);
|
||||||
|
assert_eq!(clamped_limit, 500);
|
||||||
|
|
||||||
|
let entries = provider
|
||||||
|
.audit()
|
||||||
|
.list_filtered(&nx9_auth::db::models::AuditFilter {
|
||||||
|
limit: clamped_limit,
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
entries.len(),
|
||||||
|
500,
|
||||||
|
"Normal audit listing must be bounded to 500 records max"
|
||||||
|
);
|
||||||
|
|
||||||
|
teardown_test_db(db_path).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_audit_export_can_return_more_than_500_up_to_5000() {
|
||||||
|
let (provider, db_path) = setup_test_provider().await;
|
||||||
|
|
||||||
|
// Insert 600 audit events
|
||||||
|
for i in 0..600 {
|
||||||
|
let audit_id = uuid::Uuid::new_v4().to_string();
|
||||||
|
provider
|
||||||
|
.audit()
|
||||||
|
.insert(
|
||||||
|
&audit_id,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
"user.login",
|
||||||
|
"user",
|
||||||
|
None,
|
||||||
|
"info",
|
||||||
|
Some("127.0.0.1"),
|
||||||
|
Some("TestRunner"),
|
||||||
|
Some(&format!("{{\"index\": {i}}}")),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export query path with limit=5000 returns all 600 records (>500)
|
||||||
|
let export_filter = nx9_auth::db::models::AuditFilter {
|
||||||
|
limit: 5000,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let entries = provider
|
||||||
|
.audit()
|
||||||
|
.list_filtered(&export_filter)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
entries.len(),
|
||||||
|
600,
|
||||||
|
"Export query path must return >500 records when available"
|
||||||
|
);
|
||||||
|
|
||||||
|
teardown_test_db(db_path).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_audit_export_hard_bounded_at_5000() {
|
||||||
|
let (_provider, db_path) = setup_test_provider().await;
|
||||||
|
|
||||||
|
// Request limit 999999 must be clamped to hard maximum 5000
|
||||||
|
let requested_limit = 999999i64;
|
||||||
|
let export_limit = requested_limit.clamp(1, 5000);
|
||||||
|
assert_eq!(export_limit, 5000);
|
||||||
|
|
||||||
|
teardown_test_db(db_path).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_server_side_success_filtering_before_limit_and_count() {
|
||||||
|
let (provider, db_path) = setup_test_provider().await;
|
||||||
|
|
||||||
|
// Insert 10 success events and 10 failure events
|
||||||
|
for _ in 0..10 {
|
||||||
|
let audit_id = uuid::Uuid::new_v4().to_string();
|
||||||
|
provider
|
||||||
|
.audit()
|
||||||
|
.insert(
|
||||||
|
&audit_id,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
"user.login",
|
||||||
|
"user",
|
||||||
|
None,
|
||||||
|
"info",
|
||||||
|
Some("127.0.0.1"),
|
||||||
|
Some("TestRunner"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
for _ in 0..10 {
|
||||||
|
let audit_id = uuid::Uuid::new_v4().to_string();
|
||||||
|
provider
|
||||||
|
.audit()
|
||||||
|
.insert(
|
||||||
|
&audit_id,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
"auth.failed",
|
||||||
|
"user",
|
||||||
|
None,
|
||||||
|
"warning",
|
||||||
|
Some("127.0.0.1"),
|
||||||
|
Some("TestRunner"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server-side filter success = true
|
||||||
|
let success_filter = nx9_auth::db::models::AuditFilter {
|
||||||
|
success: Some(true),
|
||||||
|
limit: 50,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let count = provider
|
||||||
|
.audit()
|
||||||
|
.count_filtered(&success_filter)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let entries = provider
|
||||||
|
.audit()
|
||||||
|
.list_filtered(&success_filter)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(count, 10);
|
||||||
|
assert_eq!(entries.len(), 10);
|
||||||
|
assert!(entries.iter().all(|e| !e.action.contains("fail")));
|
||||||
|
|
||||||
|
// Server-side filter success = false
|
||||||
|
let fail_filter = nx9_auth::db::models::AuditFilter {
|
||||||
|
success: Some(false),
|
||||||
|
limit: 50,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let fail_count = provider.audit().count_filtered(&fail_filter).await.unwrap();
|
||||||
|
let fail_entries = provider.audit().list_filtered(&fail_filter).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(fail_count, 10);
|
||||||
|
assert_eq!(fail_entries.len(), 10);
|
||||||
|
assert!(fail_entries.iter().all(|e| e.action.contains("fail")));
|
||||||
|
|
||||||
|
teardown_test_db(db_path).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rfc4180_csv_escaping_rules() {
|
||||||
|
let esc = |s: &str| format!("\"{}\"", s.replace('"', "\"\""));
|
||||||
|
|
||||||
|
assert_eq!(esc("simple"), "\"simple\"");
|
||||||
|
assert_eq!(esc("with,comma"), "\"with,comma\"");
|
||||||
|
assert_eq!(esc("with \"quotes\""), "\"with \"\"quotes\"\"\"");
|
||||||
|
assert_eq!(esc("multi\nline"), "\"multi\nline\"");
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
#![cfg(feature = "postgres")]
|
||||||
|
|
||||||
|
use sqlx::postgres::PgPoolOptions;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_postgres_fresh_migration_from_0001_to_latest() {
|
||||||
|
let database_url = "postgres://postgres@127.0.0.1:5433/nx9_auth_test_fresh";
|
||||||
|
|
||||||
|
let pool = match PgPoolOptions::new()
|
||||||
|
.acquire_timeout(Duration::from_secs(1))
|
||||||
|
.connect(database_url)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(e) => {
|
||||||
|
println!("Skipping PostgreSQL live connection test (server not running): {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let migrator = sqlx::migrate!("src/db/migrations/postgres");
|
||||||
|
let res = migrator.run(&pool).await;
|
||||||
|
|
||||||
|
assert!(res.is_ok(), "Fresh PostgreSQL migration failed: {:?}", res);
|
||||||
|
|
||||||
|
// Test idempotency (re-running on migrated database)
|
||||||
|
let res_idempotent = migrator.run(&pool).await;
|
||||||
|
assert!(
|
||||||
|
res_idempotent.is_ok(),
|
||||||
|
"Re-running PostgreSQL migrations failed: {:?}",
|
||||||
|
res_idempotent
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
#![cfg(feature = "sqlite")]
|
||||||
|
|
||||||
|
use nx9_auth::db::{
|
||||||
|
self,
|
||||||
|
models::Tenant,
|
||||||
|
provider::{DatabaseProvider, SqliteProvider},
|
||||||
|
};
|
||||||
|
use nx9_auth::identity::{applications, slug};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
async fn setup_test_provider() -> (Arc<SqliteProvider>, String) {
|
||||||
|
let db_id = uuid::Uuid::new_v4().to_string();
|
||||||
|
let db_path = format!("target/test_slug_{}.db", db_id);
|
||||||
|
let pool = db::create_pool(&db_path)
|
||||||
|
.await
|
||||||
|
.expect("Failed to create test pool");
|
||||||
|
db::run_migrations(&pool)
|
||||||
|
.await
|
||||||
|
.expect("Failed to run migrations");
|
||||||
|
(Arc::new(SqliteProvider::new(pool)), db_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn teardown_test_db(path: String) {
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_canonical_slug_validation_and_policy() {
|
||||||
|
// Valid slugs
|
||||||
|
assert!(slug::validate_slug("default").is_ok());
|
||||||
|
assert!(slug::validate_slug("my-app-1").is_ok());
|
||||||
|
assert!(slug::validate_slug("acme-tenant").is_ok());
|
||||||
|
assert!(slug::validate_slug("ab").is_ok());
|
||||||
|
|
||||||
|
// Invalid length
|
||||||
|
assert!(slug::validate_slug("a").is_err());
|
||||||
|
let long_slug = "a".repeat(64);
|
||||||
|
assert!(slug::validate_slug(&long_slug).is_err());
|
||||||
|
|
||||||
|
// Invalid formatting
|
||||||
|
assert!(slug::validate_slug("-invalid").is_err());
|
||||||
|
assert!(slug::validate_slug("invalid-").is_err());
|
||||||
|
assert!(slug::validate_slug("in--valid").is_err());
|
||||||
|
assert!(slug::validate_slug("Invalid").is_err());
|
||||||
|
assert!(slug::validate_slug("in_valid").is_err());
|
||||||
|
|
||||||
|
// Reserved names
|
||||||
|
assert!(slug::validate_slug("admin").is_err());
|
||||||
|
assert!(slug::validate_slug("api").is_err());
|
||||||
|
assert!(slug::validate_slug("system").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_explicit_invalid_slug_rejection_no_silent_slugify() {
|
||||||
|
let (provider, db_path) = setup_test_provider().await;
|
||||||
|
|
||||||
|
// Explicit invalid slug must be rejected directly and NOT silently slugified
|
||||||
|
let tenant_id = uuid::Uuid::new_v4().to_string();
|
||||||
|
let res = provider
|
||||||
|
.tenants()
|
||||||
|
.create(&tenant_id, "My Organization", Some("INVALID SLUG!"))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(res.is_err(), "Explicit invalid slug should be rejected");
|
||||||
|
|
||||||
|
teardown_test_db(db_path).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_omitted_slug_generation_on_create() {
|
||||||
|
let (provider, db_path) = setup_test_provider().await;
|
||||||
|
|
||||||
|
let tenant_id = uuid::Uuid::new_v4().to_string();
|
||||||
|
let tenant = provider
|
||||||
|
.tenants()
|
||||||
|
.create(&tenant_id, "Acme Corporation Inc!", None)
|
||||||
|
.await
|
||||||
|
.expect("Should derive slug from name when omitted");
|
||||||
|
|
||||||
|
assert_eq!(tenant.slug.as_deref(), Some("acme-corporation-inc"));
|
||||||
|
|
||||||
|
teardown_test_db(db_path).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_same_resource_duplicate_rejection() {
|
||||||
|
let (provider, db_path) = setup_test_provider().await;
|
||||||
|
|
||||||
|
let id1 = uuid::Uuid::new_v4().to_string();
|
||||||
|
provider
|
||||||
|
.tenants()
|
||||||
|
.create(&id1, "Tenant One", Some("tenant-one"))
|
||||||
|
.await
|
||||||
|
.expect("First tenant creation should succeed");
|
||||||
|
|
||||||
|
let id2 = uuid::Uuid::new_v4().to_string();
|
||||||
|
let res = provider
|
||||||
|
.tenants()
|
||||||
|
.create(&id2, "Tenant Two", Some("tenant-one"))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(res.is_err(), "Duplicate tenant slug must be rejected");
|
||||||
|
|
||||||
|
teardown_test_db(db_path).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_cross_resource_collision_rejection() {
|
||||||
|
let (provider, db_path) = setup_test_provider().await;
|
||||||
|
let provider_dyn: Arc<dyn nx9_auth::db::provider::DatabaseProvider> = provider.clone();
|
||||||
|
|
||||||
|
// Create a tenant with slug "shared-identifier"
|
||||||
|
let t_id = uuid::Uuid::new_v4().to_string();
|
||||||
|
provider
|
||||||
|
.tenants()
|
||||||
|
.create(&t_id, "Shared Tenant", Some("shared-identifier"))
|
||||||
|
.await
|
||||||
|
.expect("Tenant creation should succeed");
|
||||||
|
|
||||||
|
// Attempting to create an application with the SAME slug "shared-identifier" must fail
|
||||||
|
let app_res = applications::create(
|
||||||
|
&provider_dyn,
|
||||||
|
Tenant::DEFAULT_ID,
|
||||||
|
"Colliding Application",
|
||||||
|
"shared-identifier",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
app_res.is_err(),
|
||||||
|
"Cross-resource slug collision (app vs tenant) must be rejected"
|
||||||
|
);
|
||||||
|
|
||||||
|
teardown_test_db(db_path).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_unchanged_slug_update_no_op() {
|
||||||
|
let (provider, db_path) = setup_test_provider().await;
|
||||||
|
|
||||||
|
let id = uuid::Uuid::new_v4().to_string();
|
||||||
|
provider
|
||||||
|
.tenants()
|
||||||
|
.create(&id, "Original Name", Some("stable-slug"))
|
||||||
|
.await
|
||||||
|
.expect("Tenant creation should succeed");
|
||||||
|
|
||||||
|
// Updating tenant name while keeping the exact same slug must succeed (no-op for registry)
|
||||||
|
let update_res = provider
|
||||||
|
.tenants()
|
||||||
|
.update(&id, "Updated Name", Some("stable-slug"))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
update_res.is_ok(),
|
||||||
|
"Unchanged slug update should succeed as no-op"
|
||||||
|
);
|
||||||
|
|
||||||
|
let updated = provider.tenants().find_by_id(&id).await.unwrap().unwrap();
|
||||||
|
assert_eq!(updated.name, "Updated Name");
|
||||||
|
assert_eq!(updated.slug.as_deref(), Some("stable-slug"));
|
||||||
|
|
||||||
|
teardown_test_db(db_path).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_rename_and_old_slug_release() {
|
||||||
|
let (provider, db_path) = setup_test_provider().await;
|
||||||
|
|
||||||
|
let id = uuid::Uuid::new_v4().to_string();
|
||||||
|
provider
|
||||||
|
.tenants()
|
||||||
|
.create(&id, "Alpha Tenant", Some("old-alpha-slug"))
|
||||||
|
.await
|
||||||
|
.expect("Tenant creation should succeed");
|
||||||
|
|
||||||
|
// Rename to new-alpha-slug
|
||||||
|
provider
|
||||||
|
.tenants()
|
||||||
|
.update(&id, "Alpha Tenant", Some("new-alpha-slug"))
|
||||||
|
.await
|
||||||
|
.expect("Rename should succeed");
|
||||||
|
|
||||||
|
// Verify old-alpha-slug is released and can be claimed by another resource
|
||||||
|
let id2 = uuid::Uuid::new_v4().to_string();
|
||||||
|
let claim_res = provider
|
||||||
|
.tenants()
|
||||||
|
.create(&id2, "Beta Tenant", Some("old-alpha-slug"))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
claim_res.is_ok(),
|
||||||
|
"Released old slug should be claimable by new resource"
|
||||||
|
);
|
||||||
|
|
||||||
|
teardown_test_db(db_path).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_slug_release_ownership_verification() {
|
||||||
|
let (provider, db_path) = setup_test_provider().await;
|
||||||
|
|
||||||
|
let id1 = uuid::Uuid::new_v4().to_string();
|
||||||
|
provider
|
||||||
|
.tenants()
|
||||||
|
.create(&id1, "Tenant One", Some("tenant-slug-1"))
|
||||||
|
.await
|
||||||
|
.expect("Tenant 1 creation should succeed");
|
||||||
|
|
||||||
|
// Attempting to release tenant-slug-1 using a wrong entity_id (id2) directly via sqlite helper
|
||||||
|
let mut tx = provider.pool.begin().await.unwrap();
|
||||||
|
let rows = db::repository::sqlite::global_slugs::release_slug_by_name_sqlite(
|
||||||
|
&mut tx,
|
||||||
|
"tenant-slug-1",
|
||||||
|
"tenant",
|
||||||
|
"wrong-entity-id",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
tx.commit().await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
rows, 0,
|
||||||
|
"Release with wrong entity_id ownership must affect 0 rows"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify tenant-slug-1 is still registered in global_slugs
|
||||||
|
let existing_slug = provider
|
||||||
|
.global_slugs()
|
||||||
|
.find_by_slug("tenant-slug-1")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
existing_slug.is_some(),
|
||||||
|
"Registration must remain intact when release ownership fails"
|
||||||
|
);
|
||||||
|
|
||||||
|
teardown_test_db(db_path).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_deletion_slug_release() {
|
||||||
|
let (provider, db_path) = setup_test_provider().await;
|
||||||
|
|
||||||
|
let id = uuid::Uuid::new_v4().to_string();
|
||||||
|
provider
|
||||||
|
.tenants()
|
||||||
|
.create(&id, "Temporary Tenant", Some("temp-tenant-slug"))
|
||||||
|
.await
|
||||||
|
.expect("Tenant creation should succeed");
|
||||||
|
|
||||||
|
// Delete tenant
|
||||||
|
provider
|
||||||
|
.tenants()
|
||||||
|
.delete(&id)
|
||||||
|
.await
|
||||||
|
.expect("Tenant deletion should succeed");
|
||||||
|
|
||||||
|
// Verify temp-tenant-slug is released from global_slugs
|
||||||
|
let found = provider
|
||||||
|
.global_slugs()
|
||||||
|
.find_by_slug("temp-tenant-slug")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
found.is_none(),
|
||||||
|
"Slug must be removed from global_slugs after deletion"
|
||||||
|
);
|
||||||
|
|
||||||
|
teardown_test_db(db_path).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_application_slug_never_authenticates_as_client_id() {
|
||||||
|
let (provider, db_path) = setup_test_provider().await;
|
||||||
|
let provider_dyn: Arc<dyn nx9_auth::db::provider::DatabaseProvider> = provider.clone();
|
||||||
|
|
||||||
|
let (app, raw_secret) = applications::create(
|
||||||
|
&provider_dyn,
|
||||||
|
Tenant::DEFAULT_ID,
|
||||||
|
"Auth App",
|
||||||
|
"auth-app-slug",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("App creation should succeed");
|
||||||
|
|
||||||
|
// Authenticating using canonical client_id must succeed
|
||||||
|
let auth_ok =
|
||||||
|
applications::validate_client_credentials(&provider_dyn, app.get_client_id(), &raw_secret)
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
auth_ok.is_ok(),
|
||||||
|
"Authentication with client_id must succeed"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Authenticating using application SLUG as client_id MUST FAIL
|
||||||
|
let auth_slug_fail =
|
||||||
|
applications::validate_client_credentials(&provider_dyn, "auth-app-slug", &raw_secret)
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
auth_slug_fail.is_err(),
|
||||||
|
"Application slug MUST NEVER authenticate as client_id"
|
||||||
|
);
|
||||||
|
|
||||||
|
teardown_test_db(db_path).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_dual_migration_paths_sqlite() {
|
||||||
|
let db_id = uuid::Uuid::new_v4().to_string();
|
||||||
|
let db_path = format!("target/test_migration_path_{}.db", db_id);
|
||||||
|
let pool = db::create_pool(&db_path).await.unwrap();
|
||||||
|
|
||||||
|
// Fresh migration
|
||||||
|
let res = db::run_migrations(&pool).await;
|
||||||
|
assert!(res.is_ok(), "Fresh migration must succeed");
|
||||||
|
|
||||||
|
// Idempotent re-run
|
||||||
|
let res2 = db::run_migrations(&pool).await;
|
||||||
|
assert!(res2.is_ok(), "Re-running migrations must succeed");
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(db_path);
|
||||||
|
}
|
||||||
@@ -0,0 +1,570 @@
|
|||||||
|
#![cfg(feature = "sqlite")]
|
||||||
|
|
||||||
|
use nx9_auth::db::{
|
||||||
|
self,
|
||||||
|
models::Tenant,
|
||||||
|
provider::{DatabaseProvider, SqliteProvider},
|
||||||
|
};
|
||||||
|
use nx9_auth::identity::{applications, users};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
async fn setup_test_provider() -> (Arc<SqliteProvider>, String) {
|
||||||
|
let db_id = uuid::Uuid::new_v4().to_string();
|
||||||
|
let db_path = format!("target/test_tenant_mgmt_{}.db", db_id);
|
||||||
|
let pool = db::create_pool(&db_path)
|
||||||
|
.await
|
||||||
|
.expect("Failed to create test pool");
|
||||||
|
db::run_migrations(&pool)
|
||||||
|
.await
|
||||||
|
.expect("Failed to run migrations");
|
||||||
|
(Arc::new(SqliteProvider::new(pool)), db_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn teardown_test_db(path: String) {
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_tenant_user_listing_and_assignment() {
|
||||||
|
let (provider, db_path) = setup_test_provider().await;
|
||||||
|
let provider_dyn: Arc<dyn DatabaseProvider> = provider.clone();
|
||||||
|
|
||||||
|
// Create a new tenant
|
||||||
|
let tenant_id = uuid::Uuid::new_v4().to_string();
|
||||||
|
provider
|
||||||
|
.tenants()
|
||||||
|
.create(&tenant_id, "Acme Org", Some("acme-org"))
|
||||||
|
.await
|
||||||
|
.expect("Tenant creation should succeed");
|
||||||
|
|
||||||
|
let dummy_cfg = nx9_auth::config::SecurityConfig::default();
|
||||||
|
|
||||||
|
let user = users::create_user(
|
||||||
|
&provider_dyn,
|
||||||
|
&dummy_cfg,
|
||||||
|
Tenant::DEFAULT_ID,
|
||||||
|
"employee_1",
|
||||||
|
"X9#mK$9qL!2zP0",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("User creation should succeed");
|
||||||
|
|
||||||
|
assert_eq!(user.tenant_id, Tenant::DEFAULT_ID);
|
||||||
|
|
||||||
|
// Reassign user to Acme Org
|
||||||
|
provider
|
||||||
|
.users()
|
||||||
|
.update_user_tenant(&user.id, &tenant_id)
|
||||||
|
.await
|
||||||
|
.expect("Tenant assignment should succeed");
|
||||||
|
|
||||||
|
let tenant_users = provider
|
||||||
|
.users()
|
||||||
|
.list(&tenant_id)
|
||||||
|
.await
|
||||||
|
.expect("Listing tenant users should succeed");
|
||||||
|
|
||||||
|
assert_eq!(tenant_users.len(), 1);
|
||||||
|
assert_eq!(tenant_users[0].username, "employee_1");
|
||||||
|
|
||||||
|
teardown_test_db(db_path).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_assign_user_username_collision_rejection() {
|
||||||
|
let (provider, db_path) = setup_test_provider().await;
|
||||||
|
let provider_dyn: Arc<dyn DatabaseProvider> = provider.clone();
|
||||||
|
|
||||||
|
let dummy_cfg = nx9_auth::config::SecurityConfig::default();
|
||||||
|
|
||||||
|
let tenant_id = uuid::Uuid::new_v4().to_string();
|
||||||
|
provider
|
||||||
|
.tenants()
|
||||||
|
.create(&tenant_id, "Beta Corp", Some("beta-corp"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Create user in Default tenant named "common_user"
|
||||||
|
let u1 = users::create_user(
|
||||||
|
&provider_dyn,
|
||||||
|
&dummy_cfg,
|
||||||
|
Tenant::DEFAULT_ID,
|
||||||
|
"common_user",
|
||||||
|
"X9#mK$9qL!2zP0",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Create user in Beta Corp also named "common_user"
|
||||||
|
let _u2 = users::create_user(
|
||||||
|
&provider_dyn,
|
||||||
|
&dummy_cfg,
|
||||||
|
&tenant_id,
|
||||||
|
"common_user",
|
||||||
|
"X9#mK$9qL!2zP0",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Attempting to move u1 into Beta Corp must collide because "common_user" already exists in Beta Corp
|
||||||
|
let exists = provider
|
||||||
|
.users()
|
||||||
|
.username_exists(&tenant_id, &u1.username)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
exists,
|
||||||
|
"Username existence check must return true for colliding username in destination tenant"
|
||||||
|
);
|
||||||
|
|
||||||
|
teardown_test_db(db_path).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_tenant_application_listing_isolation() {
|
||||||
|
let (provider, db_path) = setup_test_provider().await;
|
||||||
|
let provider_dyn: Arc<dyn DatabaseProvider> = provider.clone();
|
||||||
|
|
||||||
|
let tenant_a = uuid::Uuid::new_v4().to_string();
|
||||||
|
let tenant_b = uuid::Uuid::new_v4().to_string();
|
||||||
|
|
||||||
|
provider
|
||||||
|
.tenants()
|
||||||
|
.create(&tenant_a, "Tenant A", Some("tenant-a"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
provider
|
||||||
|
.tenants()
|
||||||
|
.create(&tenant_b, "Tenant B", Some("tenant-b"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Create application in Tenant A
|
||||||
|
let (app_a, _) = applications::create(
|
||||||
|
&provider_dyn,
|
||||||
|
&tenant_a,
|
||||||
|
"App A",
|
||||||
|
"app-a-slug",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Create application in Tenant B
|
||||||
|
let (app_b, _) = applications::create(
|
||||||
|
&provider_dyn,
|
||||||
|
&tenant_b,
|
||||||
|
"App B",
|
||||||
|
"app-b-slug",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let apps_a = provider.applications().list(&tenant_a).await.unwrap();
|
||||||
|
let apps_b = provider.applications().list(&tenant_b).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(apps_a.len(), 1);
|
||||||
|
assert_eq!(apps_a[0].id, app_a.id);
|
||||||
|
|
||||||
|
assert_eq!(apps_b.len(), 1);
|
||||||
|
assert_eq!(apps_b[0].id, app_b.id);
|
||||||
|
|
||||||
|
teardown_test_db(db_path).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_session_identity_immediately_reflects_tenant_reassignment() {
|
||||||
|
let (provider, db_path) = setup_test_provider().await;
|
||||||
|
let provider_dyn: Arc<dyn DatabaseProvider> = provider.clone();
|
||||||
|
let dummy_cfg = nx9_auth::config::SecurityConfig::default();
|
||||||
|
|
||||||
|
let tenant_b = uuid::Uuid::new_v4().to_string();
|
||||||
|
provider
|
||||||
|
.tenants()
|
||||||
|
.create(&tenant_b, "Tenant B", Some("tenant-b-session"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// 1. Create user in Default Tenant
|
||||||
|
let user = users::create_user(
|
||||||
|
&provider_dyn,
|
||||||
|
&dummy_cfg,
|
||||||
|
Tenant::DEFAULT_ID,
|
||||||
|
"session_user",
|
||||||
|
"X9#mK$9qL!2zP0",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// 2. Create active session for user
|
||||||
|
let session_id = uuid::Uuid::new_v4().to_string();
|
||||||
|
let token_hash = "hash_123456";
|
||||||
|
let expires_at = "2030-01-01T00:00:00Z";
|
||||||
|
|
||||||
|
provider
|
||||||
|
.sessions()
|
||||||
|
.create(
|
||||||
|
&session_id,
|
||||||
|
&user.id,
|
||||||
|
token_hash,
|
||||||
|
Some("127.0.0.1"),
|
||||||
|
Some("TestAgent"),
|
||||||
|
expires_at,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// 3. Resolve user identity via session user_id -> tenant_id must be Default Tenant
|
||||||
|
let session_user_before = provider
|
||||||
|
.users()
|
||||||
|
.find_by_id(&user.id)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(session_user_before.tenant_id, Tenant::DEFAULT_ID);
|
||||||
|
|
||||||
|
// 4. Reassign user to Tenant B
|
||||||
|
provider
|
||||||
|
.users()
|
||||||
|
.update_user_tenant(&user.id, &tenant_b)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// 5. Subsequent request resolving user identity for the same active session MUST immediately yield Tenant B
|
||||||
|
let session_user_after = provider
|
||||||
|
.users()
|
||||||
|
.find_by_id(&user.id)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(session_user_after.tenant_id, tenant_b);
|
||||||
|
|
||||||
|
teardown_test_db(db_path).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_prevent_last_admin_reassignment_validation() {
|
||||||
|
let (provider, db_path) = setup_test_provider().await;
|
||||||
|
let provider_dyn: Arc<dyn DatabaseProvider> = provider.clone();
|
||||||
|
let dummy_cfg = nx9_auth::config::SecurityConfig::default();
|
||||||
|
|
||||||
|
// Create an admin user
|
||||||
|
let admin_user = users::create_user(
|
||||||
|
&provider_dyn,
|
||||||
|
&dummy_cfg,
|
||||||
|
Tenant::DEFAULT_ID,
|
||||||
|
"admin_user",
|
||||||
|
"X9#mK$9qL!2zP0",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let admin_role = provider
|
||||||
|
.roles()
|
||||||
|
.find_by_name("admin")
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.expect("admin role must exist");
|
||||||
|
provider
|
||||||
|
.roles()
|
||||||
|
.assign_to_user(&admin_user.id, &admin_role.id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Check system admin count
|
||||||
|
let admin_count = provider.users().count_admins().await.unwrap();
|
||||||
|
assert_eq!(admin_count, 1, "Should count 1 system admin user");
|
||||||
|
|
||||||
|
teardown_test_db(db_path).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_reassignment_audit_event_metadata_invariants() {
|
||||||
|
let (provider, db_path) = setup_test_provider().await;
|
||||||
|
let provider_dyn: Arc<dyn DatabaseProvider> = provider.clone();
|
||||||
|
let dummy_cfg = nx9_auth::config::SecurityConfig::default();
|
||||||
|
|
||||||
|
let user = users::create_user(
|
||||||
|
&provider_dyn,
|
||||||
|
&dummy_cfg,
|
||||||
|
Tenant::DEFAULT_ID,
|
||||||
|
"audit_user",
|
||||||
|
"X9#mK$9qL!2zP0",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let audit_id = uuid::Uuid::new_v4().to_string();
|
||||||
|
let from_tenant_id = Tenant::DEFAULT_ID;
|
||||||
|
let to_tenant_id = uuid::Uuid::new_v4().to_string();
|
||||||
|
|
||||||
|
let metadata = serde_json::json!({
|
||||||
|
"user_id": user.id,
|
||||||
|
"from_tenant_id": from_tenant_id,
|
||||||
|
"to_tenant_id": to_tenant_id,
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
provider
|
||||||
|
.audit()
|
||||||
|
.insert(
|
||||||
|
&audit_id,
|
||||||
|
Some(&user.id),
|
||||||
|
Some(&user.id),
|
||||||
|
"user.tenant_reassigned",
|
||||||
|
"user",
|
||||||
|
Some(&user.id),
|
||||||
|
"info",
|
||||||
|
Some("127.0.0.1"),
|
||||||
|
Some("TestRunner"),
|
||||||
|
Some(&metadata),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let entries = provider
|
||||||
|
.audit()
|
||||||
|
.list_filtered(&nx9_auth::db::models::AuditFilter {
|
||||||
|
resource_type: Some("user".to_string()),
|
||||||
|
limit: 10,
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let event = entries
|
||||||
|
.into_iter()
|
||||||
|
.find(|e| e.id == audit_id)
|
||||||
|
.expect("Audit event must exist");
|
||||||
|
|
||||||
|
assert_eq!(event.action, "user.tenant_reassigned");
|
||||||
|
let parsed_meta: serde_json::Value =
|
||||||
|
serde_json::from_str(event.metadata_json.as_deref().unwrap()).unwrap();
|
||||||
|
assert_eq!(parsed_meta["from_tenant_id"], from_tenant_id);
|
||||||
|
assert_eq!(parsed_meta["to_tenant_id"], to_tenant_id);
|
||||||
|
|
||||||
|
teardown_test_db(db_path).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_reassign_user_tenant_with_audit_atomic_success() {
|
||||||
|
let (provider, db_path) = setup_test_provider().await;
|
||||||
|
let provider_dyn: Arc<dyn DatabaseProvider> = provider.clone();
|
||||||
|
let dummy_cfg = nx9_auth::config::SecurityConfig::default();
|
||||||
|
|
||||||
|
let tenant_dest = uuid::Uuid::new_v4().to_string();
|
||||||
|
provider
|
||||||
|
.tenants()
|
||||||
|
.create(&tenant_dest, "Dest Tenant", Some("dest-tenant"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let user = users::create_user(
|
||||||
|
&provider_dyn,
|
||||||
|
&dummy_cfg,
|
||||||
|
Tenant::DEFAULT_ID,
|
||||||
|
"atomic_user",
|
||||||
|
"X9#mK$9qL!2zP0",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Call atomic reassign
|
||||||
|
provider
|
||||||
|
.users()
|
||||||
|
.reassign_user_tenant_with_audit(
|
||||||
|
&user.id,
|
||||||
|
&tenant_dest,
|
||||||
|
Some(&user.id),
|
||||||
|
Some("127.0.0.1"),
|
||||||
|
Some("TestRunner"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Verify tenant update
|
||||||
|
let updated_user = provider
|
||||||
|
.users()
|
||||||
|
.find_by_id(&user.id)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(updated_user.tenant_id, tenant_dest);
|
||||||
|
|
||||||
|
// Verify audit record was inserted inside transaction
|
||||||
|
let audit_entries = provider
|
||||||
|
.audit()
|
||||||
|
.list_filtered(&nx9_auth::db::models::AuditFilter {
|
||||||
|
resource_type: Some("user".to_string()),
|
||||||
|
limit: 10,
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let audit_event = audit_entries
|
||||||
|
.into_iter()
|
||||||
|
.find(|e| {
|
||||||
|
e.action == "user.tenant_reassigned" && e.target_user_id.as_deref() == Some(&user.id)
|
||||||
|
})
|
||||||
|
.expect("Atomic reassignment audit event must exist");
|
||||||
|
|
||||||
|
let meta: serde_json::Value =
|
||||||
|
serde_json::from_str(audit_event.metadata_json.as_deref().unwrap()).unwrap();
|
||||||
|
assert_eq!(meta["from_tenant_id"], Tenant::DEFAULT_ID);
|
||||||
|
assert_eq!(meta["to_tenant_id"], tenant_dest);
|
||||||
|
|
||||||
|
teardown_test_db(db_path).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_reassign_user_tenant_no_op_behavior() {
|
||||||
|
let (provider, db_path) = setup_test_provider().await;
|
||||||
|
let provider_dyn: Arc<dyn DatabaseProvider> = provider.clone();
|
||||||
|
let dummy_cfg = nx9_auth::config::SecurityConfig::default();
|
||||||
|
|
||||||
|
let user = users::create_user(
|
||||||
|
&provider_dyn,
|
||||||
|
&dummy_cfg,
|
||||||
|
Tenant::DEFAULT_ID,
|
||||||
|
"noop_user",
|
||||||
|
"X9#mK$9qL!2zP0",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Reassigning to SAME tenant must be a defined no-op
|
||||||
|
provider
|
||||||
|
.users()
|
||||||
|
.reassign_user_tenant_with_audit(
|
||||||
|
&user.id,
|
||||||
|
Tenant::DEFAULT_ID,
|
||||||
|
Some("actor_1"),
|
||||||
|
Some("127.0.0.1"),
|
||||||
|
Some("TestRunner"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Audit logs MUST NOT contain a false tenant_reassigned event
|
||||||
|
let audit_entries = provider
|
||||||
|
.audit()
|
||||||
|
.list_filtered(&nx9_auth::db::models::AuditFilter {
|
||||||
|
resource_type: Some("user".to_string()),
|
||||||
|
limit: 10,
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let noop_audit = audit_entries.into_iter().find(|e| {
|
||||||
|
e.action == "user.tenant_reassigned" && e.target_user_id.as_deref() == Some(&user.id)
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
noop_audit.is_none(),
|
||||||
|
"No-op reassignment must NOT write a false audit log entry"
|
||||||
|
);
|
||||||
|
|
||||||
|
teardown_test_db(db_path).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_concurrent_admin_reassignment_cannot_remove_all_system_admins() {
|
||||||
|
let (provider, db_path) = setup_test_provider().await;
|
||||||
|
let provider_dyn: Arc<dyn DatabaseProvider> = provider.clone();
|
||||||
|
let dummy_cfg = nx9_auth::config::SecurityConfig::default();
|
||||||
|
|
||||||
|
let dest_tenant = uuid::Uuid::new_v4().to_string();
|
||||||
|
provider
|
||||||
|
.tenants()
|
||||||
|
.create(&dest_tenant, "Dest", Some("dest"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Create single system admin in Default Tenant
|
||||||
|
let admin_user = users::create_user(
|
||||||
|
&provider_dyn,
|
||||||
|
&dummy_cfg,
|
||||||
|
Tenant::DEFAULT_ID,
|
||||||
|
"single_admin",
|
||||||
|
"X9#mK$9qL!2zP0",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let admin_role = provider
|
||||||
|
.roles()
|
||||||
|
.find_by_name("admin")
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
provider
|
||||||
|
.roles()
|
||||||
|
.assign_to_user(&admin_user.id, &admin_role.id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Single system admin reassignment away from Default Tenant MUST be rejected
|
||||||
|
let res = provider
|
||||||
|
.users()
|
||||||
|
.reassign_user_tenant_with_audit(
|
||||||
|
&admin_user.id,
|
||||||
|
&dest_tenant,
|
||||||
|
Some(&admin_user.id),
|
||||||
|
Some("127.0.0.1"),
|
||||||
|
Some("TestRunner"),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
res.is_err(),
|
||||||
|
"Moving the last system admin away from Default Tenant MUST fail"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Invariant check: system admin count MUST remain >= 1 in Default Tenant
|
||||||
|
let admin_count = provider.users().count_admins().await.unwrap();
|
||||||
|
assert_eq!(admin_count, 1, "System admin count must never drop to 0");
|
||||||
|
|
||||||
|
teardown_test_db(db_path).await;
|
||||||
|
}
|
||||||
@@ -12,13 +12,22 @@ pub fn Header() -> Element {
|
|||||||
let auth = state.auth;
|
let auth = state.auth;
|
||||||
let theme = state.theme;
|
let theme = state.theme;
|
||||||
let mut menu_open = use_signal(|| false);
|
let mut menu_open = use_signal(|| false);
|
||||||
let mut tenant_menu_open = use_signal(|| false);
|
let mut quick_create_open = use_signal(|| false);
|
||||||
let mut mobile = state.mobile_nav_open;
|
let mut mobile = state.mobile_nav_open;
|
||||||
|
|
||||||
let username = auth().username().to_string();
|
let username = auth().username().to_string();
|
||||||
let theme_icon = theme().icon();
|
let theme_icon = theme().icon();
|
||||||
let theme_label = theme().label();
|
let theme_label = theme().label();
|
||||||
|
|
||||||
|
let auth_state = state.auth.read();
|
||||||
|
let can_create_user = auth_state.has_permission("users:create") || auth_state.is_adminish();
|
||||||
|
let can_create_tenant = auth_state.has_permission("roles:manage") || auth_state.is_adminish();
|
||||||
|
let can_create_app = auth_state.has_permission("applications:manage") || auth_state.is_adminish();
|
||||||
|
let can_create_role = auth_state.has_permission("roles:manage") || auth_state.is_adminish();
|
||||||
|
let can_create_sa = auth_state.has_permission("service_accounts:manage") || auth_state.has_permission("roles:manage") || auth_state.is_adminish();
|
||||||
|
let has_any_create = can_create_user || can_create_tenant || can_create_app || can_create_role || can_create_sa;
|
||||||
|
drop(auth_state);
|
||||||
|
|
||||||
rsx! {
|
rsx! {
|
||||||
header { class: "app-header",
|
header { class: "app-header",
|
||||||
button {
|
button {
|
||||||
@@ -36,65 +45,96 @@ pub fn Header() -> Element {
|
|||||||
span { "nx9-auth" }
|
span { "nx9-auth" }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tenant Switcher
|
// Tenant Indicator & Management Link
|
||||||
div { class: "dropdown", style: "margin-left: 1rem;",
|
div { class: "tenant-badge", style: "margin-left: 1rem; display: flex; align-items: center; gap: 0.5rem;",
|
||||||
button {
|
|
||||||
class: "btn btn-ghost",
|
|
||||||
r#type: "button",
|
|
||||||
"aria-haspopup": "menu",
|
|
||||||
"aria-expanded": "{tenant_menu_open()}",
|
|
||||||
onclick: move |_| tenant_menu_open.set(!tenant_menu_open()),
|
|
||||||
span { class: "icon", "🏢" }
|
span { class: "icon", "🏢" }
|
||||||
span { style: "margin-left: 0.4rem; font-weight: 500;",
|
span { style: "font-weight: 500; font-size: 13px;",
|
||||||
{(state.tenant)().map(|t| t.name).unwrap_or("Default Tenant".to_string())}
|
{(state.tenant)().map(|t| t.name).unwrap_or("Default Tenant".to_string())}
|
||||||
}
|
}
|
||||||
span { style: "margin-left: 0.25rem; opacity: 0.6;", "▾" }
|
|
||||||
}
|
|
||||||
if tenant_menu_open() {
|
|
||||||
div { class: "dropdown-menu", role: "menu",
|
|
||||||
button { class: "dropdown-item", r#type: "button", "Default Tenant" }
|
|
||||||
div { class: "dropdown-divider" }
|
|
||||||
Link {
|
Link {
|
||||||
class: "dropdown-item text-primary",
|
class: "btn btn-xs btn-ghost text-primary",
|
||||||
to: Route::TenantsPage {},
|
to: Route::TenantsPage {},
|
||||||
onclick: move |_| tenant_menu_open.set(false),
|
|
||||||
"Manage tenants…"
|
"Manage tenants…"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Global Search (Ctrl+K)
|
div { style: "flex: 1;" }
|
||||||
div { class: "search", style: "flex: 1; max-width: 400px; margin: 0 2rem;",
|
|
||||||
div { style: "position: relative;",
|
|
||||||
span { style: "position: absolute; left: 0.75rem; top: 50%; transform: translateY(-50%); opacity: 0.5;", "🔍" }
|
|
||||||
input {
|
|
||||||
class: "form-control",
|
|
||||||
style: "padding-left: 2rem; width: 100%;",
|
|
||||||
r#type: "search",
|
|
||||||
placeholder: "Search… (Ctrl+K)",
|
|
||||||
"aria-label": "Global search",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
div { class: "header-actions",
|
div { class: "header-actions",
|
||||||
// Quick Create
|
if has_any_create {
|
||||||
|
div { class: "dropdown", style: "position: relative; margin-right: 0.5rem;",
|
||||||
button {
|
button {
|
||||||
class: "btn btn-primary btn-sm",
|
class: "btn btn-primary btn-sm",
|
||||||
style: "margin-right: 0.5rem;",
|
|
||||||
r#type: "button",
|
r#type: "button",
|
||||||
title: "Quick Create",
|
title: "Quick Create",
|
||||||
"➕ New"
|
"aria-haspopup": "menu",
|
||||||
|
"aria-expanded": "{quick_create_open()}",
|
||||||
|
onclick: move |_| quick_create_open.set(!quick_create_open()),
|
||||||
|
onkeydown: move |evt: KeyboardEvent| {
|
||||||
|
if evt.key() == Key::Escape {
|
||||||
|
quick_create_open.set(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"➕ New ▾"
|
||||||
|
}
|
||||||
|
if quick_create_open() {
|
||||||
|
div {
|
||||||
|
class: "dropdown-backdrop",
|
||||||
|
style: "position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 999; background: transparent;",
|
||||||
|
onclick: move |_| quick_create_open.set(false),
|
||||||
|
}
|
||||||
|
div {
|
||||||
|
class: "dropdown-menu",
|
||||||
|
role: "menu",
|
||||||
|
style: "display: block; position: absolute; right: 0; top: 100%; z-index: 1000;",
|
||||||
|
onkeydown: move |evt: KeyboardEvent| {
|
||||||
|
if evt.key() == Key::Escape {
|
||||||
|
quick_create_open.set(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
if can_create_user {
|
||||||
|
Link {
|
||||||
|
class: "dropdown-item",
|
||||||
|
to: "/users?create=1",
|
||||||
|
onclick: move |_| quick_create_open.set(false),
|
||||||
|
"👤 Create User"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if can_create_tenant {
|
||||||
|
Link {
|
||||||
|
class: "dropdown-item",
|
||||||
|
to: "/tenants?create=1",
|
||||||
|
onclick: move |_| quick_create_open.set(false),
|
||||||
|
"🏢 Create Tenant"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if can_create_app {
|
||||||
|
Link {
|
||||||
|
class: "dropdown-item",
|
||||||
|
to: "/applications?create=1",
|
||||||
|
onclick: move |_| quick_create_open.set(false),
|
||||||
|
"🚀 Create Application"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if can_create_role {
|
||||||
|
Link {
|
||||||
|
class: "dropdown-item",
|
||||||
|
to: "/roles?create=1",
|
||||||
|
onclick: move |_| quick_create_open.set(false),
|
||||||
|
"🛡️ Create Role"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if can_create_sa {
|
||||||
|
Link {
|
||||||
|
class: "dropdown-item",
|
||||||
|
to: "/service-accounts?create=1",
|
||||||
|
onclick: move |_| quick_create_open.set(false),
|
||||||
|
"🤖 Create Service Account"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notifications
|
|
||||||
button {
|
|
||||||
class: "btn btn-ghost btn-icon",
|
|
||||||
r#type: "button",
|
|
||||||
title: "Notifications",
|
|
||||||
"aria-label": "Notifications",
|
|
||||||
"🔔"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Theme
|
// Theme
|
||||||
|
|||||||
@@ -60,7 +60,6 @@ pub fn DataTable(
|
|||||||
input {
|
input {
|
||||||
r#type: "checkbox",
|
r#type: "checkbox",
|
||||||
checked: col.visible,
|
checked: col.visible,
|
||||||
// TODO: emit event
|
|
||||||
}
|
}
|
||||||
span { "{col.label}" }
|
span { "{col.label}" }
|
||||||
}
|
}
|
||||||
@@ -68,14 +67,6 @@ pub fn DataTable(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CSV Export (future ready)
|
|
||||||
button {
|
|
||||||
class: "btn btn-outline",
|
|
||||||
r#type: "button",
|
|
||||||
title: "Export to CSV (Coming Soon)",
|
|
||||||
"⬇ Export"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
div { class: "table-wrap",
|
div { class: "table-wrap",
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ pub struct TenantsResponse {
|
|||||||
pub struct UserView {
|
pub struct UserView {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub username: String,
|
pub username: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub tenant_id: Option<String>,
|
||||||
pub status: String,
|
pub status: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub last_login_at: Option<String>,
|
pub last_login_at: Option<String>,
|
||||||
@@ -156,6 +158,60 @@ pub struct RotateSecretResponse {
|
|||||||
pub client_secret: String,
|
pub client_secret: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
||||||
|
pub struct ApplicationMemberView {
|
||||||
|
pub id: String,
|
||||||
|
pub application_id: String,
|
||||||
|
pub user_id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub username: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub user_status: String,
|
||||||
|
pub role: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub enabled: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub created_at: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
||||||
|
pub struct ApplicationMembersResponse {
|
||||||
|
#[serde(default)]
|
||||||
|
pub members: Vec<ApplicationMemberView>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
||||||
|
pub struct UserApplicationMembershipView {
|
||||||
|
pub id: String,
|
||||||
|
pub application_id: String,
|
||||||
|
pub user_id: String,
|
||||||
|
pub role: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub enabled: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub created_at: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub updated_at: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub application_name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub application_slug: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub application_enabled: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub client_id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub credentials_configured: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
||||||
|
pub struct UserApplicationsResponse {
|
||||||
|
#[serde(default)]
|
||||||
|
pub applications: Vec<UserApplicationMembershipView>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
||||||
pub struct ServiceAccountView {
|
pub struct ServiceAccountView {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
|||||||
+1191
-16
File diff suppressed because it is too large.
Load diff
@@ -84,10 +84,38 @@ pub fn AuditPage() -> Element {
|
|||||||
}
|
}
|
||||||
div { class: "row",
|
div { class: "row",
|
||||||
button {
|
button {
|
||||||
class: "btn btn-outline", r#type: "button",
|
class: "btn btn-outline",
|
||||||
title: "Export is a placeholder",
|
r#type: "button",
|
||||||
onclick: move |_| {},
|
title: "Export filtered audit log records as CSV",
|
||||||
"Export (soon)"
|
onclick: move |_| {
|
||||||
|
let mut parts = vec!["limit=5000".to_string(), "offset=0".to_string()];
|
||||||
|
if !query().is_empty() { parts.push(format!("q={}", urlencoding_lite(&query()))); }
|
||||||
|
if !action().is_empty() { parts.push(format!("action={}", urlencoding_lite(&action()))); }
|
||||||
|
if !resource().is_empty() { parts.push(format!("resource_type={}", urlencoding_lite(&resource()))); }
|
||||||
|
if severity() != "all" { parts.push(format!("severity={}", severity())); }
|
||||||
|
if success() == "true" { parts.push("success=true".to_string()); }
|
||||||
|
else if success() == "false" { parts.push("success=false".to_string()); }
|
||||||
|
if !since().is_empty() { parts.push(format!("since={}", urlencoding_lite(&since()))); }
|
||||||
|
if !until().is_empty() { parts.push(format!("until={}", urlencoding_lite(&until()))); }
|
||||||
|
let qs = parts.join("&");
|
||||||
|
let export_url = format!("/api/v1/audit/export?{qs}");
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
if let Some(window) = web_sys::window() {
|
||||||
|
if let Some(document) = window.document() {
|
||||||
|
if let Ok(element) = document.create_element("a") {
|
||||||
|
let _ = element.set_attribute("href", &export_url);
|
||||||
|
let _ = element.set_attribute("download", "audit_export.csv");
|
||||||
|
if let Ok(html_elem) = element.dyn_into::<web_sys::HtmlElement>() {
|
||||||
|
html_elem.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Export CSV"
|
||||||
}
|
}
|
||||||
button { class: "btn btn-outline", r#type: "button", onclick: move |_| load.call(()), "Refresh" }
|
button { class: "btn btn-outline", r#type: "button", onclick: move |_| load.call(()), "Refresh" }
|
||||||
}
|
}
|
||||||
@@ -231,3 +259,44 @@ fn urlencoding_lite(s: &str) -> String {
|
|||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn export_audit_csv(entries: &[crate::models::AuditEntry]) {
|
||||||
|
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\n");
|
||||||
|
for e in entries {
|
||||||
|
let esc = |s: &str| format!("\"{}\"", s.replace('"', "\"\""));
|
||||||
|
let line = format!(
|
||||||
|
"{},{},{},{},{},{},{},{},{},{},{},{}\n",
|
||||||
|
esc(&e.id),
|
||||||
|
esc(&e.created_at),
|
||||||
|
esc(&e.action),
|
||||||
|
esc(&e.resource_type),
|
||||||
|
esc(e.resource_id.as_deref().unwrap_or("")),
|
||||||
|
esc(&e.severity),
|
||||||
|
e.success,
|
||||||
|
esc(e.actor_user_id.as_deref().unwrap_or("")),
|
||||||
|
esc(e.target_user_id.as_deref().unwrap_or("")),
|
||||||
|
esc(e.ip_address.as_deref().unwrap_or("")),
|
||||||
|
esc(e.user_agent.as_deref().unwrap_or("")),
|
||||||
|
esc(e.metadata_json.as_deref().unwrap_or("")),
|
||||||
|
);
|
||||||
|
csv.push_str(&line);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
if let Some(window) = web_sys::window() {
|
||||||
|
if let Some(document) = window.document() {
|
||||||
|
let encoded = urlencoding_lite(&csv);
|
||||||
|
let data_url = format!("data:text/csv;charset=utf-8,{}", encoded);
|
||||||
|
if let Ok(element) = document.create_element("a") {
|
||||||
|
let _ = element.set_attribute("href", &data_url);
|
||||||
|
let _ = element.set_attribute("download", "audit_export.csv");
|
||||||
|
if let Ok(html_elem) = element.dyn_into::<web_sys::HtmlElement>() {
|
||||||
|
html_elem.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -74,6 +74,10 @@ pub fn LoginPage() -> Element {
|
|||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.to_string(),
|
.to_string(),
|
||||||
|
tenant_id: user_val
|
||||||
|
.get("tenant_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string()),
|
||||||
status: user_val
|
status: user_val
|
||||||
.get("status")
|
.get("status")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
|
|||||||
@@ -303,16 +303,6 @@ fn AdminSummary(admin: Value) -> Element {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
div { class: "card mt-2",
|
|
||||||
div { class: "card-body row", style: "justify-content:space-between;",
|
|
||||||
span {
|
|
||||||
strong { "System health: " }
|
|
||||||
span { class: "badge badge-success", "{health}" }
|
|
||||||
}
|
|
||||||
span { class: "text-muted", "Placeholder probe — expand in a future release" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -177,7 +177,7 @@ pub fn ProfilePage() -> Element {
|
|||||||
}
|
}
|
||||||
|
|
||||||
div { class: "card",
|
div { class: "card",
|
||||||
div { class: "card-header", h3 { "Coming soon" } }
|
div { class: "card-header", h3 { "Planned security features" } }
|
||||||
div { class: "card-body stack",
|
div { class: "card-body stack",
|
||||||
div { class: "row", style: "justify-content:space-between;",
|
div { class: "row", style: "justify-content:space-between;",
|
||||||
span { "Avatar upload" }
|
span { "Avatar upload" }
|
||||||
|
|||||||
@@ -52,7 +52,16 @@ pub fn RolesPage() -> Element {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
use_effect(move || { reload.call(()); });
|
use_effect(move || {
|
||||||
|
reload.call(());
|
||||||
|
});
|
||||||
|
|
||||||
|
let can_create = state.auth.read().has_permission("roles:manage") || state.auth.read().is_adminish();
|
||||||
|
use_effect(move || {
|
||||||
|
if can_create && crate::utils::check_and_clear_create_intent() {
|
||||||
|
show_create.set(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let mut filtered: Vec<RoleView> = roles()
|
let mut filtered: Vec<RoleView> = roles()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|||||||
@@ -46,6 +46,13 @@ pub fn ServiceAccountsPage() -> Element {
|
|||||||
});
|
});
|
||||||
use_effect(move || { reload.call(()); });
|
use_effect(move || { reload.call(()); });
|
||||||
|
|
||||||
|
let can_create = state.auth.read().has_permission("service_accounts:manage") || state.auth.read().is_adminish();
|
||||||
|
use_effect(move || {
|
||||||
|
if can_create && crate::utils::check_and_clear_create_intent() {
|
||||||
|
show_create.set(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let mut filtered: Vec<_> = items()
|
let mut filtered: Vec<_> = items()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|s| {
|
.filter(|s| {
|
||||||
|
|||||||
+408
-9
@@ -2,7 +2,7 @@ use crate::components::feedback::{ConfirmDialog, EmptyState, ErrorState, Loading
|
|||||||
use crate::components::forms::TextInput;
|
use crate::components::forms::TextInput;
|
||||||
use crate::components::navigation::Breadcrumb;
|
use crate::components::navigation::Breadcrumb;
|
||||||
use crate::components::tables::{DataTable, ColumnDef};
|
use crate::components::tables::{DataTable, ColumnDef};
|
||||||
use crate::models::TenantView;
|
use crate::models::{ApplicationView, AuditEntry, TenantView, UserView};
|
||||||
use crate::routes::Route;
|
use crate::routes::Route;
|
||||||
use crate::services::api;
|
use crate::services::api;
|
||||||
use crate::state::{AppState, ToastKind};
|
use crate::state::{AppState, ToastKind};
|
||||||
@@ -38,6 +38,13 @@ pub fn TenantsPage() -> Element {
|
|||||||
|
|
||||||
use_effect(move || { reload.call(()); });
|
use_effect(move || { reload.call(()); });
|
||||||
|
|
||||||
|
let can_create = state.auth.read().has_permission("roles:manage") || state.auth.read().is_adminish();
|
||||||
|
use_effect(move || {
|
||||||
|
if can_create && crate::utils::check_and_clear_create_intent() {
|
||||||
|
show_create.set(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let filtered = {
|
let filtered = {
|
||||||
let q = query();
|
let q = query();
|
||||||
let sk = sort_key();
|
let sk = sort_key();
|
||||||
@@ -227,21 +234,44 @@ pub fn TenantDetailPage(id: String) -> Element {
|
|||||||
let mut error = use_signal(|| Option::<String>::None);
|
let mut error = use_signal(|| Option::<String>::None);
|
||||||
let mut loading = use_signal(|| true);
|
let mut loading = use_signal(|| true);
|
||||||
|
|
||||||
|
let mut tab = use_signal(|| "overview".to_string());
|
||||||
let mut edit_name = use_signal(String::new);
|
let mut edit_name = use_signal(String::new);
|
||||||
let mut edit_slug = use_signal(String::new);
|
let mut edit_slug = use_signal(String::new);
|
||||||
|
|
||||||
|
let mut tenant_users = use_signal(Vec::<UserView>::new);
|
||||||
|
let mut all_users = use_signal(Vec::<UserView>::new);
|
||||||
|
let mut tenant_apps = use_signal(Vec::<ApplicationView>::new);
|
||||||
|
let mut activity = use_signal(Vec::<AuditEntry>::new);
|
||||||
|
|
||||||
|
let mut user_query = use_signal(String::new);
|
||||||
|
let mut show_assign_modal = use_signal(|| false);
|
||||||
|
let mut selected_assign_user_id = use_signal(String::new);
|
||||||
|
|
||||||
|
let mut confirm_reassign_user = use_signal(|| Option::<(UserView, String, String)>::None);
|
||||||
|
let mut confirm_move_default = use_signal(|| Option::<UserView>::None);
|
||||||
let mut confirm_delete = use_signal(|| false);
|
let mut confirm_delete = use_signal(|| false);
|
||||||
|
|
||||||
let tenant_id = id.clone();
|
let tenant_id = id.clone();
|
||||||
let reload = use_callback(move |_: ()| {
|
let reload = use_callback(move |_: ()| {
|
||||||
let id = tenant_id.clone();
|
let id = tenant_id.clone();
|
||||||
loading.set(true);
|
loading.set(true);
|
||||||
|
error.set(None);
|
||||||
spawn(async move {
|
spawn(async move {
|
||||||
match api::get_tenant(&id).await {
|
match api::get_tenant(&id).await {
|
||||||
Ok(t) => {
|
Ok(t) => {
|
||||||
edit_name.set(t.name.clone());
|
edit_name.set(t.name.clone());
|
||||||
edit_slug.set(t.slug.clone());
|
edit_slug.set(t.slug.clone());
|
||||||
tenant.set(Some(t));
|
tenant.set(Some(t));
|
||||||
|
|
||||||
|
if let Ok(users) = api::list_tenant_users(&id).await {
|
||||||
|
tenant_users.set(users);
|
||||||
|
}
|
||||||
|
if let Ok(users) = api::list_users().await {
|
||||||
|
all_users.set(users);
|
||||||
|
}
|
||||||
|
if let Ok(apps) = api::list_tenant_applications(&id).await {
|
||||||
|
tenant_apps.set(apps);
|
||||||
|
}
|
||||||
loading.set(false);
|
loading.set(false);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -252,7 +282,20 @@ pub fn TenantDetailPage(id: String) -> Element {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
use_effect(move || { reload.call(()); });
|
use_effect(move || {
|
||||||
|
reload.call(());
|
||||||
|
});
|
||||||
|
|
||||||
|
let tenant_id_act = id.clone();
|
||||||
|
let load_activity = use_callback(move |_: ()| {
|
||||||
|
let id = tenant_id_act.clone();
|
||||||
|
spawn(async move {
|
||||||
|
let q = format!("resource_type=tenant&q={id}&limit=50");
|
||||||
|
if let Ok(resp) = api::list_audit(&q).await {
|
||||||
|
activity.set(resp.entries);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
rsx! {
|
rsx! {
|
||||||
Breadcrumb { items: vec![
|
Breadcrumb { items: vec![
|
||||||
@@ -267,17 +310,71 @@ pub fn TenantDetailPage(id: String) -> Element {
|
|||||||
ErrorState { message: err, on_retry: move |_| reload.call(()) }
|
ErrorState { message: err, on_retry: move |_| reload.call(()) }
|
||||||
} else if let Some(t) = tenant() {
|
} else if let Some(t) = tenant() {
|
||||||
{
|
{
|
||||||
let tid = t.id.clone();
|
let tid_save = t.id.clone();
|
||||||
let tid2 = t.id.clone();
|
let tid_assign = t.id.clone();
|
||||||
let tid3 = t.id.clone();
|
let tid_move_default = t.id.clone();
|
||||||
|
let tid_delete = t.id.clone();
|
||||||
|
let tenant_name = t.name.clone();
|
||||||
|
let is_default_tenant = t.id == "00000000-0000-0000-0000-000000000001";
|
||||||
|
|
||||||
|
let current_member_ids: Vec<String> = tenant_users().iter().map(|u| u.id.clone()).collect();
|
||||||
|
let assignable_users: Vec<UserView> = all_users()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|u| !current_member_ids.contains(&u.id))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let filtered_members: Vec<UserView> = {
|
||||||
|
let q = user_query();
|
||||||
|
tenant_users()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|u| matches_query(&u.username, &q))
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
rsx! {
|
rsx! {
|
||||||
div { class: "page-header",
|
div { class: "page-header",
|
||||||
div {
|
div {
|
||||||
h1 { "{t.name}" }
|
h1 { "{t.name}" }
|
||||||
p { class: "desc", "Tenant configuration and overview" }
|
p { class: "desc",
|
||||||
|
code { "{t.slug}" }
|
||||||
|
" · Tenant ID: "
|
||||||
|
code { "{t.id}" }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
div { class: "tabs", style: "display:flex; gap:0.5rem; margin-bottom:1rem; flex-wrap:wrap;",
|
||||||
|
button {
|
||||||
|
class: if tab() == "overview" { "btn btn-sm btn-primary" } else { "btn btn-sm btn-outline" },
|
||||||
|
r#type: "button",
|
||||||
|
onclick: move |_| tab.set("overview".into()),
|
||||||
|
"Overview"
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
class: if tab() == "users" { "btn btn-sm btn-primary" } else { "btn btn-sm btn-outline" },
|
||||||
|
r#type: "button",
|
||||||
|
onclick: move |_| tab.set("users".into()),
|
||||||
|
"Users ({tenant_users().len()})"
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
class: if tab() == "applications" { "btn btn-sm btn-primary" } else { "btn btn-sm btn-outline" },
|
||||||
|
r#type: "button",
|
||||||
|
onclick: move |_| tab.set("applications".into()),
|
||||||
|
"Applications ({tenant_apps().len()})"
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
class: if tab() == "activity" { "btn btn-sm btn-primary" } else { "btn btn-sm btn-outline" },
|
||||||
|
r#type: "button",
|
||||||
|
onclick: move |_| {
|
||||||
|
tab.set("activity".into());
|
||||||
|
load_activity.call(());
|
||||||
|
},
|
||||||
|
"Activity"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tab: Overview ──────────────────────────────────────────
|
||||||
|
if tab() == "overview" {
|
||||||
div { class: "grid-2",
|
div { class: "grid-2",
|
||||||
div { class: "card",
|
div { class: "card",
|
||||||
div { class: "card-header", h3 { "Tenant Details" } }
|
div { class: "card-header", h3 { "Tenant Details" } }
|
||||||
@@ -292,13 +389,16 @@ pub fn TenantDetailPage(id: String) -> Element {
|
|||||||
value: edit_slug(),
|
value: edit_slug(),
|
||||||
oninput: move |v| edit_slug.set(v),
|
oninput: move |v| edit_slug.set(v),
|
||||||
}
|
}
|
||||||
|
p { class: "desc text-muted", style: "font-size:12px; margin-top:-0.5rem;",
|
||||||
|
"Leaving slug blank derives it automatically from name. Changing a tenant slug may affect existing references."
|
||||||
|
}
|
||||||
button {
|
button {
|
||||||
class: "btn btn-primary mt-2",
|
class: "btn btn-primary mt-2",
|
||||||
r#type: "button",
|
r#type: "button",
|
||||||
onclick: move |_| {
|
onclick: move |_| {
|
||||||
let n = edit_name();
|
let n = edit_name();
|
||||||
let s = edit_slug();
|
let s = edit_slug();
|
||||||
let tid = tid.clone();
|
let tid = tid_save.clone();
|
||||||
spawn(async move {
|
spawn(async move {
|
||||||
match api::update_tenant(&tid, &n, Some(s.as_str()).filter(|s| !s.is_empty())).await {
|
match api::update_tenant(&tid, &n, Some(s.as_str()).filter(|s| !s.is_empty())).await {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
@@ -321,14 +421,313 @@ pub fn TenantDetailPage(id: String) -> Element {
|
|||||||
button {
|
button {
|
||||||
class: "btn btn-danger",
|
class: "btn btn-danger",
|
||||||
r#type: "button",
|
r#type: "button",
|
||||||
disabled: tid2 == "00000000-0000-0000-0000-000000000001",
|
disabled: is_default_tenant,
|
||||||
onclick: move |_| confirm_delete.set(true),
|
onclick: move |_| confirm_delete.set(true),
|
||||||
"Delete Tenant"
|
"Delete Tenant"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tab: Users (Tenant User Assignment) ───────────────────
|
||||||
|
if tab() == "users" {
|
||||||
|
div { class: "card",
|
||||||
|
div { class: "card-header", style: "display:flex; justify-content:space-between; align-items:center;",
|
||||||
|
div {
|
||||||
|
h3 { "Tenant User Assignment" }
|
||||||
|
p { class: "desc", "Users assigned to this tenant owner. Every user has exactly one tenant owner." }
|
||||||
|
}
|
||||||
|
div { class: "row", style: "gap:0.5rem;",
|
||||||
|
button {
|
||||||
|
class: "btn btn-primary btn-sm",
|
||||||
|
r#type: "button",
|
||||||
|
onclick: move |_| {
|
||||||
|
selected_assign_user_id.set(String::new());
|
||||||
|
show_assign_modal.set(true);
|
||||||
|
},
|
||||||
|
"+ Assign User"
|
||||||
|
}
|
||||||
|
Link {
|
||||||
|
class: "btn btn-outline btn-sm",
|
||||||
|
to: Route::UsersPage {},
|
||||||
|
"+ Create User"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div { class: "card-body",
|
||||||
|
DataTable {
|
||||||
|
columns: vec![
|
||||||
|
ColumnDef { key: "username".into(), label: "Username".into(), sortable: true, visible: true },
|
||||||
|
ColumnDef { key: "status".into(), label: "Status".into(), sortable: true, visible: true },
|
||||||
|
ColumnDef { key: "created_at".into(), label: "Created".into(), sortable: true, visible: true },
|
||||||
|
ColumnDef { key: "actions".into(), label: "Actions".into(), sortable: false, visible: true },
|
||||||
|
],
|
||||||
|
on_search: move |v| user_query.set(v),
|
||||||
|
search_value: user_query(),
|
||||||
|
search_placeholder: "Filter tenant users…".to_string(),
|
||||||
|
on_sort: move |_| {},
|
||||||
|
sort_key: "username".to_string(),
|
||||||
|
on_page: move |_| {},
|
||||||
|
page: 0,
|
||||||
|
page_size: 100,
|
||||||
|
total: filtered_members.len(),
|
||||||
|
toolbar_actions: rsx! {
|
||||||
|
button { class: "btn btn-outline btn-sm", r#type: "button", onclick: move |_| reload.call(()), "Refresh" }
|
||||||
|
},
|
||||||
|
for u in filtered_members {
|
||||||
|
{
|
||||||
|
let u_clone = u.clone();
|
||||||
|
rsx! {
|
||||||
|
tr { key: "{u.id}",
|
||||||
|
td {
|
||||||
|
Link {
|
||||||
|
to: Route::UserDetailPage { id: u.id.clone() },
|
||||||
|
strong { "{u.username}" }
|
||||||
|
}
|
||||||
|
div { class: "mono text-muted", style: "font-size:11px;", "{u.id}" }
|
||||||
|
}
|
||||||
|
td {
|
||||||
|
span { class: "badge badge-success", "{u.status}" }
|
||||||
|
}
|
||||||
|
td { "{u.created_at}" }
|
||||||
|
td { style: "text-align: right;",
|
||||||
|
if !is_default_tenant {
|
||||||
|
button {
|
||||||
|
class: "btn btn-sm btn-outline",
|
||||||
|
r#type: "button",
|
||||||
|
onclick: move |_| confirm_move_default.set(Some(u_clone.clone())),
|
||||||
|
"Move to Default Tenant"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
span { class: "text-muted", style: "font-size:12px;", "Default Tenant Owner" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tab: Applications ─────────────────────────────────────
|
||||||
|
if tab() == "applications" {
|
||||||
|
div { class: "card",
|
||||||
|
div { class: "card-header",
|
||||||
|
h3 { "Tenant Applications" }
|
||||||
|
p { class: "desc", "Applications associated with this tenant." }
|
||||||
|
}
|
||||||
|
div { class: "card-body",
|
||||||
|
if tenant_apps().is_empty() {
|
||||||
|
EmptyState {
|
||||||
|
title: "No applications found".to_string(),
|
||||||
|
description: "No applications are currently associated with this tenant.".to_string(),
|
||||||
|
icon: "🚀".to_string(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
table { class: "table",
|
||||||
|
thead {
|
||||||
|
tr {
|
||||||
|
th { "Application" }
|
||||||
|
th { "Slug" }
|
||||||
|
th { "Client ID" }
|
||||||
|
th { "Status" }
|
||||||
|
th { style: "text-align: right;", "Actions" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tbody {
|
||||||
|
for app in tenant_apps() {
|
||||||
|
tr { key: "{app.id}",
|
||||||
|
td {
|
||||||
|
Link {
|
||||||
|
to: Route::ApplicationDetailPage { id: app.id.clone() },
|
||||||
|
strong { "{app.name}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
td { code { "{app.slug}" } }
|
||||||
|
td { code { "{app.client_id}" } }
|
||||||
|
td {
|
||||||
|
span {
|
||||||
|
class: if app.enabled { "badge badge-success" } else { "badge badge-secondary" },
|
||||||
|
if app.enabled { "Active" } else { "Disabled" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
td { style: "text-align: right;",
|
||||||
|
Link {
|
||||||
|
class: "btn btn-sm btn-outline",
|
||||||
|
to: Route::ApplicationDetailPage { id: app.id.clone() },
|
||||||
|
"View"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tab: Activity ──────────────────────────────────────────
|
||||||
|
if tab() == "activity" {
|
||||||
|
div { class: "card",
|
||||||
|
div { class: "card-header",
|
||||||
|
h3 { "Tenant Audit Log" }
|
||||||
|
p { class: "desc", "Audit events scoped to this tenant." }
|
||||||
|
}
|
||||||
|
div { class: "card-body",
|
||||||
|
if activity().is_empty() {
|
||||||
|
EmptyState {
|
||||||
|
title: "No activity recorded".to_string(),
|
||||||
|
description: "No audit events found for this tenant.".to_string(),
|
||||||
|
icon: "📜".to_string(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
table { class: "table",
|
||||||
|
thead {
|
||||||
|
tr {
|
||||||
|
th { "Timestamp" }
|
||||||
|
th { "Action" }
|
||||||
|
th { "Actor" }
|
||||||
|
th { "Severity" }
|
||||||
|
th { "IP Address" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tbody {
|
||||||
|
for act in activity() {
|
||||||
|
tr { key: "{act.id}",
|
||||||
|
td { "{act.created_at}" }
|
||||||
|
td { strong { "{act.action}" } }
|
||||||
|
td { "{act.actor_user_id.as_deref().unwrap_or(\"—\")}" }
|
||||||
|
td {
|
||||||
|
span { class: "badge badge-info", "{act.severity}" }
|
||||||
|
}
|
||||||
|
td { "{act.ip_address.as_deref().unwrap_or(\"—\")}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Assign User Modal ──────────────────────────────────────
|
||||||
|
Modal {
|
||||||
|
title: "Assign User to Tenant".to_string(),
|
||||||
|
open: show_assign_modal(),
|
||||||
|
on_close: move |_| show_assign_modal.set(false),
|
||||||
|
p { class: "desc", "Select an existing NX9-Auth user to reassign to tenant \"{tenant_name}\"." }
|
||||||
|
div { class: "form-group", style: "margin-top:1rem;",
|
||||||
|
label { class: "form-label", "Select User" }
|
||||||
|
select {
|
||||||
|
class: "form-control",
|
||||||
|
value: selected_assign_user_id(),
|
||||||
|
onchange: move |evt: Event<FormData>| selected_assign_user_id.set(evt.value()),
|
||||||
|
option { value: "", "— Select an existing user —" }
|
||||||
|
for u in assignable_users.clone() {
|
||||||
|
option {
|
||||||
|
value: "{u.id}",
|
||||||
|
"{u.username} (currently in tenant: {u.tenant_id.as_deref().unwrap_or(\"default\")})"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div { class: "modal-footer", style: "margin-top:1.5rem; padding:0; border:none; background:transparent;",
|
||||||
|
button { class: "btn btn-outline", r#type: "button", onclick: move |_| show_assign_modal.set(false), "Cancel" }
|
||||||
|
button {
|
||||||
|
class: "btn btn-primary",
|
||||||
|
r#type: "button",
|
||||||
|
disabled: selected_assign_user_id().is_empty(),
|
||||||
|
onclick: move |_| {
|
||||||
|
let uid = selected_assign_user_id();
|
||||||
|
if let Some(target_u) = assignable_users.iter().find(|u| u.id == uid) {
|
||||||
|
let from = target_u.tenant_id.clone().unwrap_or_else(|| "default".to_string());
|
||||||
|
confirm_reassign_user.set(Some((target_u.clone(), from, tid_assign.clone())));
|
||||||
|
show_assign_modal.set(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Assign User"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Confirm Reassign User Dialog ─────────────────────────
|
||||||
|
if let Some((target_u, from_tenant, to_tenant_id)) = confirm_reassign_user() {
|
||||||
|
{
|
||||||
|
let u_id = target_u.id.clone();
|
||||||
|
let u_name = target_u.username.clone();
|
||||||
|
let to_tid = to_tenant_id.clone();
|
||||||
|
let dest_name = tenant_name.clone();
|
||||||
|
rsx! {
|
||||||
|
ConfirmDialog {
|
||||||
|
title: "Confirm Tenant Reassignment".to_string(),
|
||||||
|
message: format!(
|
||||||
|
"Reassign user \"{}\" from tenant \"{}\" to \"{}\"?",
|
||||||
|
u_name, from_tenant, dest_name
|
||||||
|
),
|
||||||
|
open: true,
|
||||||
|
confirm_label: "Reassign User".to_string(),
|
||||||
|
danger: false,
|
||||||
|
on_confirm: move |_| {
|
||||||
|
let uid = u_id.clone();
|
||||||
|
let tid = to_tid.clone();
|
||||||
|
confirm_reassign_user.set(None);
|
||||||
|
spawn(async move {
|
||||||
|
match api::assign_tenant_user(&tid, &uid).await {
|
||||||
|
Ok(_) => {
|
||||||
|
state.toast(ToastKind::Success, "User reassigned to tenant");
|
||||||
|
reload.call(());
|
||||||
|
}
|
||||||
|
Err(e) => state.toast(ToastKind::Error, e.to_string()),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
on_cancel: move |_| confirm_reassign_user.set(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Confirm Move to Default Tenant Dialog ────────────────
|
||||||
|
if let Some(target_u) = confirm_move_default() {
|
||||||
|
{
|
||||||
|
let u_id = target_u.id.clone();
|
||||||
|
let u_name = target_u.username.clone();
|
||||||
|
let tid_curr = tid_move_default.clone();
|
||||||
|
rsx! {
|
||||||
|
ConfirmDialog {
|
||||||
|
title: "Move to Default Tenant".to_string(),
|
||||||
|
message: format!(
|
||||||
|
"Reassign user \"{}\" from tenant \"{}\" to Default Tenant?",
|
||||||
|
u_name, tenant_name
|
||||||
|
),
|
||||||
|
open: true,
|
||||||
|
confirm_label: "Move to Default Tenant".to_string(),
|
||||||
|
danger: false,
|
||||||
|
on_confirm: move |_| {
|
||||||
|
let uid = u_id.clone();
|
||||||
|
let tid = tid_curr.clone();
|
||||||
|
confirm_move_default.set(None);
|
||||||
|
spawn(async move {
|
||||||
|
match api::remove_tenant_user(&tid, &uid).await {
|
||||||
|
Ok(_) => {
|
||||||
|
state.toast(ToastKind::Success, "User reassigned to Default Tenant");
|
||||||
|
reload.call(());
|
||||||
|
}
|
||||||
|
Err(e) => state.toast(ToastKind::Error, e.to_string()),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
on_cancel: move |_| confirm_move_default.set(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Confirm Delete Tenant Dialog ─────────────────────────
|
||||||
ConfirmDialog {
|
ConfirmDialog {
|
||||||
title: "Delete tenant".to_string(),
|
title: "Delete tenant".to_string(),
|
||||||
message: format!("Delete tenant \"{}\"? This cannot be undone.", t.name),
|
message: format!("Delete tenant \"{}\"? This cannot be undone.", t.name),
|
||||||
@@ -336,7 +735,7 @@ pub fn TenantDetailPage(id: String) -> Element {
|
|||||||
confirm_label: "Delete",
|
confirm_label: "Delete",
|
||||||
danger: true,
|
danger: true,
|
||||||
on_confirm: move |_| {
|
on_confirm: move |_| {
|
||||||
let tid = tid3.clone();
|
let tid = tid_delete.clone();
|
||||||
spawn(async move {
|
spawn(async move {
|
||||||
match api::delete_tenant(&tid).await {
|
match api::delete_tenant(&tid).await {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
|
|||||||
@@ -49,6 +49,13 @@ pub fn UsersPage() -> Element {
|
|||||||
|
|
||||||
use_effect(move || { reload.call(()); });
|
use_effect(move || { reload.call(()); });
|
||||||
|
|
||||||
|
let can_create = state.auth.read().has_permission("users:create") || state.auth.read().is_adminish();
|
||||||
|
use_effect(move || {
|
||||||
|
if can_create && crate::utils::check_and_clear_create_intent() {
|
||||||
|
show_create.set(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let filtered = {
|
let filtered = {
|
||||||
let q = query();
|
let q = query();
|
||||||
let sf = status_filter();
|
let sf = status_filter();
|
||||||
@@ -305,9 +312,14 @@ pub fn UsersPage() -> Element {
|
|||||||
#[component]
|
#[component]
|
||||||
pub fn UserDetailPage(id: String) -> Element {
|
pub fn UserDetailPage(id: String) -> Element {
|
||||||
let state = use_context::<AppState>();
|
let state = use_context::<AppState>();
|
||||||
|
let state_auth = state.auth;
|
||||||
|
let can_manage_apps = state_auth().has_permission("applications:manage");
|
||||||
|
|
||||||
let mut user = use_signal(|| Option::<UserView>::None);
|
let mut user = use_signal(|| Option::<UserView>::None);
|
||||||
let mut roles = use_signal(Vec::<crate::models::RoleView>::new);
|
let mut roles = use_signal(Vec::<crate::models::RoleView>::new);
|
||||||
let mut all_roles = use_signal(Vec::<crate::models::RoleView>::new);
|
let mut all_roles = use_signal(Vec::<crate::models::RoleView>::new);
|
||||||
|
let mut user_apps =
|
||||||
|
use_signal(Vec::<crate::models::UserApplicationMembershipView>::new);
|
||||||
let mut error = use_signal(|| Option::<String>::None);
|
let mut error = use_signal(|| Option::<String>::None);
|
||||||
let mut loading = use_signal(|| true);
|
let mut loading = use_signal(|| true);
|
||||||
let mut new_pass = use_signal(String::new);
|
let mut new_pass = use_signal(String::new);
|
||||||
@@ -326,6 +338,11 @@ pub fn UserDetailPage(id: String) -> Element {
|
|||||||
if let Ok(ar) = api::list_roles().await {
|
if let Ok(ar) = api::list_roles().await {
|
||||||
all_roles.set(ar);
|
all_roles.set(ar);
|
||||||
}
|
}
|
||||||
|
if let Ok(apps) = api::list_user_applications(&id).await {
|
||||||
|
user_apps.set(apps);
|
||||||
|
} else {
|
||||||
|
user_apps.set(Vec::new());
|
||||||
|
}
|
||||||
loading.set(false);
|
loading.set(false);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -485,6 +502,58 @@ pub fn UserDetailPage(id: String) -> Element {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if can_manage_apps {
|
||||||
|
div { class: "card", style: "grid-column: 1 / -1;",
|
||||||
|
div { class: "card-header", h3 { "Applications" } }
|
||||||
|
div { class: "card-body",
|
||||||
|
p { class: "text-secondary", style: "font-size:0.9rem; margin-bottom:0.75rem;",
|
||||||
|
"Applications this user is assigned to. Membership roles are metadata only and do not change global RBAC."
|
||||||
|
}
|
||||||
|
if user_apps().is_empty() {
|
||||||
|
p { class: "text-muted", "Not assigned to any applications." }
|
||||||
|
} else {
|
||||||
|
DataTable {
|
||||||
|
columns: vec![
|
||||||
|
ColumnDef { key: "name".into(), label: "Application".into(), sortable: false, visible: true },
|
||||||
|
ColumnDef { key: "role".into(), label: "Membership Role".into(), sortable: false, visible: true },
|
||||||
|
ColumnDef { key: "status".into(), label: "Status".into(), sortable: false, visible: true },
|
||||||
|
ColumnDef { key: "assigned".into(), label: "Assigned".into(), sortable: false, visible: true },
|
||||||
|
],
|
||||||
|
on_search: |_| {},
|
||||||
|
search_value: "".to_string(),
|
||||||
|
search_placeholder: "".to_string(),
|
||||||
|
on_sort: |_| {},
|
||||||
|
sort_key: "".to_string(),
|
||||||
|
on_page: |_| {},
|
||||||
|
page: 0,
|
||||||
|
page_size: user_apps().len().max(1),
|
||||||
|
total: user_apps().len(),
|
||||||
|
for m in user_apps() {
|
||||||
|
tr { key: "{m.id}",
|
||||||
|
td {
|
||||||
|
Link {
|
||||||
|
to: Route::ApplicationDetailPage { id: m.application_id.clone() },
|
||||||
|
strong { "{m.application_name}" }
|
||||||
|
}
|
||||||
|
div { class: "text-muted", style: "font-size:0.8rem;",
|
||||||
|
code { "{m.application_slug}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
td { span { class: "badge badge-accent", "{m.role}" } }
|
||||||
|
td {
|
||||||
|
StatusChip {
|
||||||
|
status: if m.enabled { "active".to_string() } else { "disabled".to_string() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
td { "{format_datetime(&m.created_at)}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
use crate::components::layout::AppLayout;
|
use crate::components::layout::AppLayout;
|
||||||
use crate::pages::{
|
use crate::pages::{
|
||||||
about::AboutPage,
|
about::AboutPage,
|
||||||
applications::ApplicationsPage,
|
applications::{ApplicationDetailPage, ApplicationsPage},
|
||||||
audit::AuditPage,
|
audit::AuditPage,
|
||||||
auth::{ForbiddenPage, LoginPage, UnauthorizedPage},
|
auth::{ForbiddenPage, LoginPage, UnauthorizedPage},
|
||||||
dashboard::DashboardPage,
|
dashboard::DashboardPage,
|
||||||
@@ -77,6 +77,9 @@ pub enum Route {
|
|||||||
#[route("/applications")]
|
#[route("/applications")]
|
||||||
ApplicationsPage {},
|
ApplicationsPage {},
|
||||||
|
|
||||||
|
#[route("/applications/:id")]
|
||||||
|
ApplicationDetailPage { id: String },
|
||||||
|
|
||||||
#[route("/service-accounts")]
|
#[route("/service-accounts")]
|
||||||
ServiceAccountsPage {},
|
ServiceAccountsPage {},
|
||||||
|
|
||||||
|
|||||||
@@ -435,6 +435,58 @@ pub async fn delete_application(id: &str) -> Result<(), ApiError> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_application(id: &str) -> Result<ApplicationView, ApiError> {
|
||||||
|
let r: Value = get(&format!("/applications/{id}")).await?;
|
||||||
|
serde_json::from_value(r.get("application").cloned().unwrap_or(Value::Null))
|
||||||
|
.map_err(|e| ApiError::Other(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_application_members(app_id: &str) -> Result<Vec<ApplicationMemberView>, ApiError> {
|
||||||
|
let r: ApplicationMembersResponse = get(&format!("/applications/{app_id}/members")).await?;
|
||||||
|
Ok(r.members)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn add_application_member(
|
||||||
|
app_id: &str,
|
||||||
|
user_id: &str,
|
||||||
|
role: Option<&str>,
|
||||||
|
) -> Result<ApplicationMemberView, ApiError> {
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"user_id": user_id,
|
||||||
|
"role": role,
|
||||||
|
});
|
||||||
|
let r: Value = post_json(&format!("/applications/{app_id}/members"), &body).await?;
|
||||||
|
serde_json::from_value(r.get("member").cloned().unwrap_or(Value::Null))
|
||||||
|
.map_err(|e| ApiError::Other(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_application_member(
|
||||||
|
app_id: &str,
|
||||||
|
user_id: &str,
|
||||||
|
role: Option<&str>,
|
||||||
|
enabled: Option<bool>,
|
||||||
|
) -> Result<ApplicationMemberView, ApiError> {
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"role": role,
|
||||||
|
"enabled": enabled,
|
||||||
|
});
|
||||||
|
let r: Value = patch_json(&format!("/applications/{app_id}/members/{user_id}"), &body).await?;
|
||||||
|
serde_json::from_value(r.get("member").cloned().unwrap_or(Value::Null))
|
||||||
|
.map_err(|e| ApiError::Other(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn remove_application_member(app_id: &str, user_id: &str) -> Result<(), ApiError> {
|
||||||
|
let _: Value = delete_json(&format!("/applications/{app_id}/members/{user_id}")).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_user_applications(
|
||||||
|
user_id: &str,
|
||||||
|
) -> Result<Vec<UserApplicationMembershipView>, ApiError> {
|
||||||
|
let r: UserApplicationsResponse = get(&format!("/users/{user_id}/applications")).await?;
|
||||||
|
Ok(r.applications)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Service accounts ──────────────────────────────────────────────────────────
|
// ── Service accounts ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
pub async fn list_service_accounts() -> Result<Vec<ServiceAccountView>, ApiError> {
|
pub async fn list_service_accounts() -> Result<Vec<ServiceAccountView>, ApiError> {
|
||||||
@@ -566,3 +618,27 @@ pub async fn delete_tenant(id: &str) -> Result<(), ApiError> {
|
|||||||
let _: Value = delete_json(&format!("/tenants/{id}")).await?;
|
let _: Value = delete_json(&format!("/tenants/{id}")).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn list_tenant_users(tenant_id: &str) -> Result<Vec<UserView>, ApiError> {
|
||||||
|
let r: Value = get(&format!("/tenants/{tenant_id}/users")).await?;
|
||||||
|
serde_json::from_value(r.get("users").cloned().unwrap_or(Value::Array(vec![])))
|
||||||
|
.map_err(|e| ApiError::Other(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn assign_tenant_user(tenant_id: &str, user_id: &str) -> Result<UserView, ApiError> {
|
||||||
|
let body = serde_json::json!({ "user_id": user_id });
|
||||||
|
let r: Value = post_json(&format!("/tenants/{tenant_id}/users"), &body).await?;
|
||||||
|
serde_json::from_value(r.get("user").cloned().unwrap_or(Value::Null))
|
||||||
|
.map_err(|e| ApiError::Other(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn remove_tenant_user(tenant_id: &str, user_id: &str) -> Result<(), ApiError> {
|
||||||
|
let _: Value = delete_json(&format!("/tenants/{tenant_id}/users/{user_id}")).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_tenant_applications(tenant_id: &str) -> Result<Vec<ApplicationView>, ApiError> {
|
||||||
|
let r: Value = get(&format!("/tenants/{tenant_id}/applications")).await?;
|
||||||
|
serde_json::from_value(r.get("applications").cloned().unwrap_or(Value::Array(vec![])))
|
||||||
|
.map_err(|e| ApiError::Other(e.to_string()))
|
||||||
|
}
|
||||||
@@ -75,3 +75,47 @@ pub fn slugify(s: &str) -> String {
|
|||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join("-")
|
.join("-")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Check if location search contains exact `create=1` query parameter, and clear `create=1` from history URL while preserving other parameters.
|
||||||
|
pub fn check_and_clear_create_intent() -> bool {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
if let Some(window) = web_sys::window() {
|
||||||
|
if let Ok(search) = window.location().search() {
|
||||||
|
let query_str = search.trim_start_matches('?');
|
||||||
|
let mut has_create = false;
|
||||||
|
let mut remaining_params = Vec::new();
|
||||||
|
|
||||||
|
for part in query_str.split('&') {
|
||||||
|
if part.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut key_val = part.splitn(2, '=');
|
||||||
|
let key = key_val.next().unwrap_or("");
|
||||||
|
let val = key_val.next().unwrap_or("");
|
||||||
|
if key == "create" && val == "1" {
|
||||||
|
has_create = true;
|
||||||
|
} else {
|
||||||
|
remaining_params.push(part);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if has_create {
|
||||||
|
if let Ok(pathname) = window.location().pathname() {
|
||||||
|
let new_search = if remaining_params.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!("?{}", remaining_params.join("&"))
|
||||||
|
};
|
||||||
|
let new_url = format!("{pathname}{new_search}");
|
||||||
|
let _ = window.history().and_then(|h| {
|
||||||
|
h.replace_state_with_url(&wasm_bindgen::JsValue::NULL, "", Some(&new_url))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
Reference in new issue
Block a user