diff --git a/README.md b/README.md index 8e87c4b..aa5c40d 100644 --- a/README.md +++ b/README.md @@ -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. +### 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 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2fd4985..53f9009 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 - **Core Language**: Rust (2024 Edition) diff --git a/docs/SECURITY.md b/docs/SECURITY.md index aa4df5e..9d947d0 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -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. - **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: -- **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. +- **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 diff --git a/src/api/applications.rs b/src/api/applications.rs index 89b7332..9fb14c3 100644 --- a/src/api/applications.rs +++ b/src/api/applications.rs @@ -7,9 +7,9 @@ use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use crate::{ - db::models::{Application, Tenant}, - error::Result, - identity::applications as identity, + db::models::{Application, ApplicationMember, Tenant}, + error::{AppError, Result}, + identity::{application_members as members, applications as identity}, middleware::{auth::AuthUser, permissions::require}, state::AppState, }; @@ -201,3 +201,171 @@ pub async fn delete_application( identity::delete(&state.provider, &id, Some(&auth.user.id), None, None).await?; Ok(Json(json!({ "success": true }))) } + +// ── Application membership ──────────────────────────────────────────────────── + +#[derive(Serialize)] +pub struct ApplicationMemberResponse { + pub id: String, + pub application_id: String, + pub user_id: String, + pub username: String, + pub user_status: String, + pub role: String, + pub enabled: bool, + pub created_at: String, + pub updated_at: String, +} + +impl ApplicationMemberResponse { + fn from_member(member: ApplicationMember, username: String, user_status: String) -> Self { + Self { + id: member.id, + application_id: member.application_id, + user_id: member.user_id, + username, + user_status, + role: member.role, + enabled: member.enabled, + created_at: member.created_at, + updated_at: member.updated_at, + } + } +} + +async fn enrich_member( + state: &AppState, + member: ApplicationMember, +) -> Result { + 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, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct UpdateMemberRequest { + pub role: Option, + pub enabled: Option, +} + +/// GET /api/v1/applications/:id/members +pub async fn list_application_members( + State(state): State, + auth: AuthUser, + Path(id): Path, +) -> Result> { + 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, + auth: AuthUser, + Path(id): Path, + Json(body): Json, +) -> Result> { + 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, + auth: AuthUser, + Path((id, user_id)): Path<(String, String)>, + Json(body): Json, +) -> Result> { + 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, + auth: AuthUser, + Path((id, user_id)): Path<(String, String)>, +) -> Result> { + 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 }))) +} diff --git a/src/api/audit.rs b/src/api/audit.rs index e24f52c..c97c182 100644 --- a/src/api/audit.rs +++ b/src/api/audit.rs @@ -84,6 +84,7 @@ pub async fn list_audit( since: query.since, until: query.until, search: query.q, + success: query.success, limit, offset, }; @@ -92,19 +93,10 @@ pub async fn list_audit( .await .map_err(AppError::Database)?; - let mut entries = audit_repo::list_filtered(&state.provider, &filter) + let entries = audit_repo::list_filtered(&state.provider, &filter) .await .map_err(AppError::Database)?; - if let Some(success) = query.success { - entries.retain(|e| { - let ok = !e.action.contains("fail") - && !e.action.contains("denied") - && e.severity != "critical"; - ok == success - }); - } - let views: Vec = entries.into_iter().map(AuditLogResponse::from).collect(); Ok(Json(json!({ @@ -114,3 +106,72 @@ pub async fn list_audit( "offset": offset, }))) } + +/// GET /api/v1/audit/export +pub async fn export_audit( + State(state): State, + auth: AuthUser, + Query(query): Query, +) -> Result { + 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) +} diff --git a/src/api/router.rs b/src/api/router.rs index f368092..da5deed 100644 --- a/src/api/router.rs +++ b/src/api/router.rs @@ -1,7 +1,7 @@ use axum::http::{HeaderName, Method, header}; use axum::{ Router, middleware, - routing::{delete, get, post, put}, + routing::{delete, get, patch, post, put}, }; use tower_http::{compression::CompressionLayer, cors::CorsLayer, trace::TraceLayer}; @@ -40,6 +40,18 @@ pub fn build(state: AppState) -> Router { .patch(tenants::update_tenant) .delete(tenants::delete_tenant), ) + .route( + "/tenants/{id}/users", + get(tenants::list_tenant_users).post(tenants::assign_tenant_user), + ) + .route( + "/tenants/{id}/users/{user_id}", + delete(tenants::remove_tenant_user), + ) + .route( + "/tenants/{id}/applications", + get(tenants::list_tenant_applications), + ) // Users .route("/users", get(users::list_users).post(users::create_user)) .route( @@ -54,6 +66,10 @@ pub fn build(state: AppState) -> Router { get(users::list_user_roles).post(roles::assign_user_role), ) .route("/users/{id}/roles/{role}", delete(roles::remove_user_role)) + .route( + "/users/{id}/applications", + get(users::list_user_applications), + ) // Roles .route("/roles", get(roles::list_roles).post(roles::create_role)) .route( @@ -86,6 +102,15 @@ pub fn build(state: AppState) -> Router { "/applications/{id}/secret", post(applications::rotate_application_secret), ) + .route( + "/applications/{id}/members", + get(applications::list_application_members).post(applications::add_application_member), + ) + .route( + "/applications/{id}/members/{user_id}", + patch(applications::update_application_member) + .delete(applications::remove_application_member), + ) // Service accounts .route( "/service-accounts", @@ -104,6 +129,7 @@ pub fn build(state: AppState) -> Router { ) // Audit .route("/audit", get(audit::list_audit)) + .route("/audit/export", get(audit::export_audit)) // Sessions .route("/sessions", get(sessions::list_sessions)) .route("/sessions/others", delete(sessions::terminate_others)) diff --git a/src/api/tenants.rs b/src/api/tenants.rs index 4ccc55e..bb5e748 100644 --- a/src/api/tenants.rs +++ b/src/api/tenants.rs @@ -53,6 +53,12 @@ pub async fn create_tenant( ) -> Result> { require(&state.provider, &auth.user.id, "roles:manage").await?; + if let Some(ref s) = body.slug { + if !s.trim().is_empty() { + crate::identity::slug::validate_slug(s)?; + } + } + let id = uuid::Uuid::new_v4().to_string(); let tenant = state .provider @@ -118,6 +124,12 @@ pub async fn update_tenant( ) -> Result> { require(&state.provider, &auth.user.id, "roles:manage").await?; + if let Some(ref s) = body.slug { + if !s.trim().is_empty() { + crate::identity::slug::validate_slug(s)?; + } + } + state .provider .tenants() @@ -183,3 +195,153 @@ pub async fn delete_tenant( Ok(Json(json!({ "success": true }))) } + +/// GET /api/v1/tenants/:id/users +pub async fn list_tenant_users( + State(state): State, + auth: AuthUser, + Path(id): Path, +) -> Result> { + require(&state.provider, &auth.user.id, "roles:manage").await?; + + let users = state.provider.users().list(&id).await?; + let views: Vec = 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, + auth: AuthUser, + ctx: AuditContext, + Path(id): Path, + Json(body): Json, +) -> Result> { + 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, + auth: AuthUser, + ctx: AuditContext, + Path((id, user_id)): Path<(String, String)>, +) -> Result> { + 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, + auth: AuthUser, + Path(id): Path, +) -> Result> { + require(&state.provider, &auth.user.id, "roles:manage").await?; + + let apps = state.provider.applications().list(&id).await?; + let views: Vec = apps + .into_iter() + .map(crate::api::applications::ApplicationResponse::from) + .collect(); + Ok(Json(json!({ "applications": views }))) +} diff --git a/src/api/users.rs b/src/api/users.rs index 9d4ea45..aa64ff5 100644 --- a/src/api/users.rs +++ b/src/api/users.rs @@ -9,7 +9,7 @@ use crate::{ db::models::Tenant, db::models::{User, UserStatus}, error::{AppError, Result}, - identity::users as identity, + identity::{application_members as members, users as identity}, middleware::{audit::AuditContext, auth::AuthUser, permissions::require}, state::AppState, }; @@ -20,6 +20,7 @@ use crate::{ pub struct UserResponse { pub id: String, pub username: String, + pub tenant_id: String, pub status: String, pub last_login_at: Option, pub created_at: String, @@ -29,8 +30,9 @@ pub struct UserResponse { impl From for UserResponse { fn from(u: User) -> Self { Self { - id: u.id, + id: u.id.clone(), username: u.username, + tenant_id: u.tenant_id, status: UserStatus::from_i32(u.status).to_string(), last_login_at: u.last_login_at, created_at: u.created_at, @@ -215,3 +217,79 @@ pub async fn list_user_roles( }).collect::>(), }))) } + +/// 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, + auth: AuthUser, + Path(id): Path, +) -> Result> { + 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 }))) +} diff --git a/src/db/migrations/postgres/0013_seed_default_tenant.sql b/src/db/migrations/postgres/0013_seed_default_tenant.sql index 9ed45a6..59c5b97 100644 --- a/src/db/migrations/postgres/0013_seed_default_tenant.sql +++ b/src/db/migrations/postgres/0013_seed_default_tenant.sql @@ -1,4 +1,4 @@ -- Seed the default tenant. --- Uses INSERT OR IGNORE so re-running migrations is safe. -INSERT OR IGNORE INTO tenants (id, name, slug, enabled) -VALUES ('00000000-0000-0000-0000-000000000001', 'Default', 'default', 1); +INSERT INTO tenants (id, name, slug, enabled) +VALUES ('00000000-0000-0000-0000-000000000001', 'Default', 'default', 1) +ON CONFLICT (id) DO NOTHING; diff --git a/src/db/migrations/postgres/0014_seed_roles_and_permissions.sql b/src/db/migrations/postgres/0014_seed_roles_and_permissions.sql index deefb1e..c8afb35 100644 --- a/src/db/migrations/postgres/0014_seed_roles_and_permissions.sql +++ b/src/db/migrations/postgres/0014_seed_roles_and_permissions.sql @@ -1,35 +1,40 @@ -- ── Roles ──────────────────────────────────────────────────────────────────── -INSERT OR IGNORE INTO roles (id, name, description) VALUES +INSERT INTO roles (id, name, description) VALUES ('10000000-0000-0000-0000-000000000001', 'admin', 'Full system access'), ('10000000-0000-0000-0000-000000000002', 'editor', 'Can manage content and users'), - ('10000000-0000-0000-0000-000000000003', 'viewer', 'Read-only access'); + ('10000000-0000-0000-0000-000000000003', 'viewer', 'Read-only access') +ON CONFLICT DO NOTHING; -- ── Permissions ─────────────────────────────────────────────────────────────── -INSERT OR IGNORE INTO permissions (id, name, description) VALUES +INSERT INTO permissions (id, name, description) VALUES ('20000000-0000-0000-0000-000000000001', 'users:create', 'Create new user accounts'), ('20000000-0000-0000-0000-000000000002', 'users:update', 'Update user accounts'), ('20000000-0000-0000-0000-000000000003', 'users:delete', 'Disable user accounts'), ('20000000-0000-0000-0000-000000000004', 'tokens:create', 'Create API tokens for any user'), ('20000000-0000-0000-0000-000000000005', 'tokens:revoke', 'Revoke API tokens for any user'), ('20000000-0000-0000-0000-000000000006', 'roles:manage', 'Assign and revoke roles'), - ('20000000-0000-0000-0000-000000000007', 'audit:view', 'View audit log entries'); + ('20000000-0000-0000-0000-000000000007', 'audit:view', 'View audit log entries') +ON CONFLICT DO NOTHING; -- ── Admin role gets all permissions ────────────────────────────────────────── -INSERT OR IGNORE INTO role_permissions (role_id, permission_id) -SELECT '10000000-0000-0000-0000-000000000001', id FROM permissions; +INSERT INTO role_permissions (role_id, permission_id) +SELECT '10000000-0000-0000-0000-000000000001', id FROM permissions +ON CONFLICT DO NOTHING; -- ── Editor role permissions ─────────────────────────────────────────────────── -INSERT OR IGNORE INTO role_permissions (role_id, permission_id) VALUES +INSERT INTO role_permissions (role_id, permission_id) VALUES ('10000000-0000-0000-0000-000000000002', '20000000-0000-0000-0000-000000000001'), - ('10000000-0000-0000-0000-000000000002', '20000000-0000-0000-0000-000000000002'); + ('10000000-0000-0000-0000-000000000002', '20000000-0000-0000-0000-000000000002') +ON CONFLICT DO NOTHING; -- ── Default applications ────────────────────────────────────────────────────── -INSERT OR IGNORE INTO applications (id, tenant_id, name, slug, enabled) VALUES +INSERT INTO applications (id, tenant_id, name, slug, enabled) VALUES ('30000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000001', 'BZOD', 'bzod', 1), ('30000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000001', 'ChronoSeal', 'chronoseal', 1), - ('30000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000001', 'nx9-dns', 'nx9-dns', 1); + ('30000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000001', 'nx9-dns', 'nx9-dns', 1) +ON CONFLICT DO NOTHING; diff --git a/src/db/migrations/postgres/0019_create_application_members.sql b/src/db/migrations/postgres/0019_create_application_members.sql new file mode 100644 index 0000000..77625b7 --- /dev/null +++ b/src/db/migrations/postgres/0019_create_application_members.sql @@ -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); diff --git a/src/db/migrations/postgres/20260718_add_global_slugs.sql b/src/db/migrations/postgres/20260718_add_global_slugs.sql index e1c82bb..3aebfd6 100644 --- a/src/db/migrations/postgres/20260718_add_global_slugs.sql +++ b/src/db/migrations/postgres/20260718_add_global_slugs.sql @@ -1,4 +1,4 @@ --- nx9-auth: Global Slugs implementation +-- nx9-auth: Global Slugs implementation (PostgreSQL) -- A unified registry for slugs across all resources (tenant, user, group, role, app, etc.) -- Ensures global uniqueness and immutable references. @@ -7,22 +7,21 @@ CREATE TABLE IF NOT EXISTS global_slugs ( entity_type TEXT NOT NULL, -- 'tenant', 'user', 'role', 'group', 'permission', 'application', 'service_account', 'organization', 'team' entity_id TEXT NOT NULL, tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, - created_at TEXT NOT NULL DEFAULT (to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z\')) + created_at TEXT NOT NULL DEFAULT (to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')) ); CREATE INDEX IF NOT EXISTS idx_global_slugs_entity ON global_slugs(entity_type, entity_id); CREATE INDEX IF NOT EXISTS idx_global_slugs_tenant ON global_slugs(tenant_id); -- Add slug column to existing tables for quick lookup and joins -ALTER TABLE tenants ADD COLUMN slug TEXT; -ALTER TABLE users ADD COLUMN slug TEXT; -ALTER TABLE roles ADD COLUMN slug TEXT; -ALTER TABLE permissions ADD COLUMN slug TEXT; -ALTER TABLE applications ADD COLUMN slug TEXT; -ALTER TABLE service_accounts ADD COLUMN slug TEXT; +ALTER TABLE tenants ADD COLUMN IF NOT EXISTS slug TEXT; +ALTER TABLE users ADD COLUMN IF NOT EXISTS slug TEXT; +ALTER TABLE roles ADD COLUMN IF NOT EXISTS slug TEXT; +ALTER TABLE permissions ADD COLUMN IF NOT EXISTS slug TEXT; +ALTER TABLE applications ADD COLUMN IF NOT EXISTS slug TEXT; +ALTER TABLE service_accounts ADD COLUMN IF NOT EXISTS slug TEXT; --- We will backfill slugs in Rust on startup or through a data migration script, --- or we can backfill basic ones here: +-- Backfill basic slugs: UPDATE tenants SET slug = lower(replace(name, ' ', '-')) WHERE slug IS NULL; UPDATE users SET slug = lower(username) WHERE slug IS NULL; UPDATE roles SET slug = lower(replace(name, ' ', '-')) WHERE slug IS NULL; @@ -30,21 +29,27 @@ UPDATE permissions SET slug = lower(replace(name, ' ', '-')) WHERE slug IS NULL; UPDATE applications SET slug = lower(replace(name, ' ', '-')) WHERE slug IS NULL; UPDATE service_accounts SET slug = lower(replace(name, ' ', '-')) WHERE slug IS NULL; --- Insert the backfilled slugs into the registry -INSERT OR IGNORE INTO global_slugs (slug, entity_type, entity_id, tenant_id) -SELECT slug, 'tenant', id, id FROM tenants WHERE slug IS NOT NULL; +-- Insert backfilled slugs into registry +INSERT INTO global_slugs (slug, entity_type, entity_id, tenant_id) +SELECT slug, 'tenant', id, id FROM tenants WHERE slug IS NOT NULL +ON CONFLICT DO NOTHING; -INSERT OR IGNORE INTO global_slugs (slug, entity_type, entity_id, tenant_id) -SELECT slug, 'user', id, tenant_id FROM users WHERE slug IS NOT NULL; +INSERT INTO global_slugs (slug, entity_type, entity_id, tenant_id) +SELECT slug, 'user', id, tenant_id FROM users WHERE slug IS NOT NULL +ON CONFLICT DO NOTHING; -INSERT OR IGNORE INTO global_slugs (slug, entity_type, entity_id, tenant_id) -SELECT slug, 'role', id, tenant_id FROM roles WHERE slug IS NOT NULL; +INSERT INTO global_slugs (slug, entity_type, entity_id, tenant_id) +SELECT slug, 'role', id, '00000000-0000-0000-0000-000000000001' FROM roles WHERE slug IS NOT NULL +ON CONFLICT DO NOTHING; -INSERT OR IGNORE INTO global_slugs (slug, entity_type, entity_id, tenant_id) -SELECT slug, 'permission', id, tenant_id FROM permissions WHERE slug IS NOT NULL; +INSERT INTO global_slugs (slug, entity_type, entity_id, tenant_id) +SELECT slug, 'permission', id, '00000000-0000-0000-0000-000000000001' FROM permissions WHERE slug IS NOT NULL +ON CONFLICT DO NOTHING; -INSERT OR IGNORE INTO global_slugs (slug, entity_type, entity_id, tenant_id) -SELECT slug, 'application', id, tenant_id FROM applications WHERE slug IS NOT NULL; +INSERT INTO global_slugs (slug, entity_type, entity_id, tenant_id) +SELECT slug, 'application', id, tenant_id FROM applications WHERE slug IS NOT NULL +ON CONFLICT DO NOTHING; -INSERT OR IGNORE INTO global_slugs (slug, entity_type, entity_id, tenant_id) -SELECT slug, 'service_account', id, tenant_id FROM service_accounts WHERE slug IS NOT NULL; +INSERT INTO global_slugs (slug, entity_type, entity_id, tenant_id) +SELECT slug, 'service_account', id, tenant_id FROM service_accounts WHERE slug IS NOT NULL +ON CONFLICT DO NOTHING; diff --git a/src/db/migrations/postgres/20260724_harden_global_slugs.sql b/src/db/migrations/postgres/20260724_harden_global_slugs.sql new file mode 100644 index 0000000..a8850e4 --- /dev/null +++ b/src/db/migrations/postgres/20260724_harden_global_slugs.sql @@ -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); diff --git a/src/db/migrations/sqlite/0019_create_application_members.sql b/src/db/migrations/sqlite/0019_create_application_members.sql new file mode 100644 index 0000000..c5817d7 --- /dev/null +++ b/src/db/migrations/sqlite/0019_create_application_members.sql @@ -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); diff --git a/src/db/migrations/sqlite/20260724_harden_global_slugs.sql b/src/db/migrations/sqlite/20260724_harden_global_slugs.sql new file mode 100644 index 0000000..eaa257e --- /dev/null +++ b/src/db/migrations/sqlite/20260724_harden_global_slugs.sql @@ -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); diff --git a/src/db/models/application_member.rs b/src/db/models/application_member.rs new file mode 100644 index 0000000..f2e3453 --- /dev/null +++ b/src/db/models/application_member.rs @@ -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 { + 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::parse(&self.role) + } +} diff --git a/src/db/models/audit_log.rs b/src/db/models/audit_log.rs index ba06f1e..94a3ee7 100644 --- a/src/db/models/audit_log.rs +++ b/src/db/models/audit_log.rs @@ -48,6 +48,7 @@ pub struct AuditFilter { pub since: Option, pub until: Option, pub search: Option, + pub success: Option, pub limit: i64, pub offset: i64, } diff --git a/src/db/models/global_slug.rs b/src/db/models/global_slug.rs new file mode 100644 index 0000000..4205273 --- /dev/null +++ b/src/db/models/global_slug.rs @@ -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, +} diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs index d71c5b8..0f36210 100644 --- a/src/db/models/mod.rs +++ b/src/db/models/mod.rs @@ -1,6 +1,8 @@ pub mod api_token; pub mod application; +pub mod application_member; pub mod audit_log; +pub mod global_slug; pub mod group; pub mod permission; pub mod refresh_token; @@ -12,7 +14,9 @@ pub mod user; pub use api_token::ApiToken; pub use application::Application; +pub use application_member::{ApplicationMember, ApplicationMembershipRole}; pub use audit_log::{AuditFilter, AuditLog, AuditSeverity}; +pub use global_slug::GlobalSlug; pub use group::Group; #[allow(unused_imports)] pub use permission::Permission; diff --git a/src/db/provider.rs b/src/db/provider.rs index 1c793c3..3b529b6 100644 --- a/src/db/provider.rs +++ b/src/db/provider.rs @@ -18,6 +18,8 @@ pub trait DatabaseProvider: Send + Sync { fn tokens(&self) -> Box; fn tenants(&self) -> Box; fn groups(&self) -> Box; + fn application_members(&self) -> Box; + fn global_slugs(&self) -> Box; } #[cfg(feature = "sqlite")] @@ -112,6 +114,20 @@ impl DatabaseProvider for SqliteProvider { }, ) } + fn application_members(&self) -> Box { + Box::new( + crate::db::repository::sqlite::application_members::SqliteApplicationMembersRepository { + pool: self.pool.clone(), + }, + ) + } + fn global_slugs(&self) -> Box { + Box::new( + crate::db::repository::sqlite::global_slugs::SqliteGlobalSlugsRepository { + pool: self.pool.clone(), + }, + ) + } } #[cfg(feature = "postgres")] @@ -206,4 +222,18 @@ impl DatabaseProvider for PostgresProvider { }, ) } + fn application_members(&self) -> Box { + Box::new( + crate::db::repository::postgres::application_members::PostgresApplicationMembersRepository { + pool: self.pool.clone(), + }, + ) + } + fn global_slugs(&self) -> Box { + Box::new( + crate::db::repository::postgres::global_slugs::PostgresGlobalSlugsRepository { + pool: self.pool.clone(), + }, + ) + } } diff --git a/src/db/repository/postgres/application_members.rs b/src/db/repository/postgres/application_members.rs new file mode 100644 index 0000000..f394d00 --- /dev/null +++ b/src/db/repository/postgres/application_members.rs @@ -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, 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, 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, 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 { + 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>, + ) -> Result { + 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>, + ) -> 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>, + ) -> 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>, + ) -> 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(()) + } +} diff --git a/src/db/repository/postgres/applications.rs b/src/db/repository/postgres/applications.rs index 3cc9a33..f0f3d53 100644 --- a/src/db/repository/postgres/applications.rs +++ b/src/db/repository/postgres/applications.rs @@ -1,9 +1,11 @@ +use crate::db::models::Application; +use crate::db::repository::postgres::global_slugs::{ + release_slug_by_name_postgres, release_slug_postgres, reserve_slug_postgres, +}; use crate::db::repository::traits::ApplicationsRepository; use async_trait::async_trait; use sqlx::PgPool; -use crate::db::models::Application; - pub struct PostgresApplicationsRepository { pub pool: PgPool, } @@ -25,6 +27,8 @@ impl ApplicationsRepository for PostgresApplicationsRepository { ) -> Result { let mut tx = self.pool.begin().await?; + reserve_slug_postgres(&mut tx, slug, "application", id, tenant_id).await?; + let app = sqlx::query_as::<_, Application>( r#" INSERT INTO applications (id, tenant_id, name, slug, client_id, client_secret_hash, description, redirect_uris, scopes) @@ -188,31 +192,74 @@ impl ApplicationsRepository for PostgresApplicationsRepository { scopes: Option<&str>, enabled: bool, ) -> Result<(), sqlx::Error> { - sqlx::query( - r#" - UPDATE applications - SET name = $1, slug = $2, description = $3, redirect_uris = $4, scopes = $5, enabled = $6, - updated_at = to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') - WHERE id = $7 - "#, - ) - .bind(name) - .bind(slug) - .bind(description) - .bind(redirect_uris) - .bind(scopes) - .bind(enabled) - .bind(id) - .execute(&self.pool) - .await?; + let existing = self.find_by_id(id).await?.ok_or(sqlx::Error::RowNotFound)?; + + let existing_slug_str = existing.slug.as_deref().unwrap_or(""); + + if slug == existing_slug_str { + // Unchanged slug: registry no-op + sqlx::query( + r#" + UPDATE applications + SET name = $1, description = $2, redirect_uris = $3, scopes = $4, enabled = $5, + updated_at = to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') + WHERE id = $6 + "#, + ) + .bind(name) + .bind(description) + .bind(redirect_uris) + .bind(scopes) + .bind(enabled) + .bind(id) + .execute(&self.pool) + .await?; + } else { + // Changed slug: single transaction reserve -> update -> release + let mut tx = self.pool.begin().await?; + + reserve_slug_postgres(&mut tx, slug, "application", id, &existing.tenant_id).await?; + + sqlx::query( + r#" + UPDATE applications + SET name = $1, slug = $2, description = $3, redirect_uris = $4, scopes = $5, enabled = $6, + updated_at = to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') + WHERE id = $7 + "#, + ) + .bind(name) + .bind(slug) + .bind(description) + .bind(redirect_uris) + .bind(scopes) + .bind(enabled) + .bind(id) + .execute(&mut *tx) + .await?; + + if !existing_slug_str.is_empty() { + release_slug_by_name_postgres(&mut tx, existing_slug_str, "application", id) + .await?; + } + + tx.commit().await?; + } + Ok(()) } async fn delete(&self, id: &str) -> Result<(), sqlx::Error> { + let mut tx = self.pool.begin().await?; + + release_slug_postgres(&mut tx, "application", id).await?; + sqlx::query("DELETE FROM applications WHERE id = $1") .bind(id) - .execute(&self.pool) + .execute(&mut *tx) .await?; + + tx.commit().await?; Ok(()) } diff --git a/src/db/repository/postgres/audit.rs b/src/db/repository/postgres/audit.rs index 057fe36..9a5adf7 100644 --- a/src/db/repository/postgres/audit.rs +++ b/src/db/repository/postgres/audit.rs @@ -64,8 +64,6 @@ impl AuditRepository for PostgresAuditRepository { } async fn list_filtered(&self, filter: &AuditFilter) -> Result, sqlx::Error> { - // Build a dynamic but simple filter using COALESCE-style optional matches. - // Empty optionals are treated as wildcards via OR IS NULL pattern with bind of None. let search_like = filter .search .as_ref() @@ -88,8 +86,13 @@ impl AuditRepository for PostgresAuditRepository { OR ip_address LIKE $7 ESCAPE '\' OR metadata_json LIKE $7 ESCAPE '\' ) + AND ( + $8::boolean IS NULL + OR ($8::boolean = TRUE AND action NOT LIKE '%fail%' AND action NOT LIKE '%denied%' AND severity != 'critical') + OR ($8::boolean = FALSE AND (action LIKE '%fail%' OR action LIKE '%denied%' OR severity = 'critical')) + ) ORDER BY created_at DESC - LIMIT $8 OFFSET $9 + LIMIT $9 OFFSET $10 "#, ) .bind(filter.actor_user_id.as_deref()) @@ -99,6 +102,7 @@ impl AuditRepository for PostgresAuditRepository { .bind(filter.since.as_deref()) .bind(filter.until.as_deref()) .bind(search_like.as_deref()) + .bind(filter.success) .bind(filter.limit) .bind(filter.offset) .fetch_all(&self.pool) @@ -128,6 +132,11 @@ impl AuditRepository for PostgresAuditRepository { OR ip_address LIKE $7 ESCAPE '\' OR metadata_json LIKE $7 ESCAPE '\' ) + AND ( + $8::boolean IS NULL + OR ($8::boolean = TRUE AND action NOT LIKE '%fail%' AND action NOT LIKE '%denied%' AND severity != 'critical') + OR ($8::boolean = FALSE AND (action LIKE '%fail%' OR action LIKE '%denied%' OR severity = 'critical')) + ) "#, ) .bind(filter.actor_user_id.as_deref()) @@ -137,6 +146,7 @@ impl AuditRepository for PostgresAuditRepository { .bind(filter.since.as_deref()) .bind(filter.until.as_deref()) .bind(search_like.as_deref()) + .bind(filter.success) .fetch_one(&self.pool) .await?; Ok(row.0) diff --git a/src/db/repository/postgres/global_slugs.rs b/src/db/repository/postgres/global_slugs.rs new file mode 100644 index 0000000..38877ea --- /dev/null +++ b/src/db/repository/postgres/global_slugs.rs @@ -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, 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 { + 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 { + 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()) +} diff --git a/src/db/repository/postgres/mod.rs b/src/db/repository/postgres/mod.rs index fb5d2f2..b2d97a7 100644 --- a/src/db/repository/postgres/mod.rs +++ b/src/db/repository/postgres/mod.rs @@ -1,5 +1,7 @@ +pub mod application_members; pub mod applications; pub mod audit; +pub mod global_slugs; pub mod groups; pub mod permissions; pub mod refresh_tokens; diff --git a/src/db/repository/postgres/tenants.rs b/src/db/repository/postgres/tenants.rs index b62b9e1..5845b9a 100644 --- a/src/db/repository/postgres/tenants.rs +++ b/src/db/repository/postgres/tenants.rs @@ -1,7 +1,11 @@ use sqlx::PgPool; use crate::db::models::Tenant; +use crate::db::repository::postgres::global_slugs::{ + release_slug_by_name_postgres, release_slug_postgres, reserve_slug_postgres, +}; use crate::db::repository::traits::TenantsRepository; +use crate::identity::slug::{slugify, validate_slug}; pub struct PostgresTenantsRepository { pub pool: PgPool, @@ -47,7 +51,17 @@ impl TenantsRepository for PostgresTenantsRepository { name: &str, slug: Option<&str>, ) -> Result { - let slug = slug.unwrap_or(id); + let final_slug = match slug { + Some(s) if !s.trim().is_empty() => { + let trimmed = s.trim(); + validate_slug(trimmed).map_err(|e| sqlx::Error::Protocol(e.to_string()))?; + trimmed.to_string() + } + _ => slugify(name).map_err(|e| sqlx::Error::Protocol(e.to_string()))?, + }; + + let mut tx = self.pool.begin().await?; + let row = sqlx::query_as::<_, Tenant>( r#" INSERT INTO tenants (id, name, slug, enabled) @@ -57,27 +71,68 @@ impl TenantsRepository for PostgresTenantsRepository { ) .bind(id) .bind(name) - .bind(slug) - .fetch_one(&self.pool) + .bind(&final_slug) + .fetch_one(&mut *tx) .await?; + reserve_slug_postgres(&mut tx, &final_slug, "tenant", id, id).await?; + + tx.commit().await?; Ok(row) } async fn update(&self, id: &str, name: &str, slug: Option<&str>) -> Result<(), sqlx::Error> { - let slug = slug.unwrap_or(name); - sqlx::query( - r#" - UPDATE tenants - SET name = $1, slug = $2, updated_at = CURRENT_TIMESTAMP - WHERE id = $3 - "#, - ) - .bind(name) - .bind(slug) - .bind(id) - .execute(&self.pool) - .await?; + let existing = self.find_by_id(id).await?.ok_or(sqlx::Error::RowNotFound)?; + + let target_slug = match slug { + Some(s) if !s.trim().is_empty() => { + let trimmed = s.trim(); + validate_slug(trimmed).map_err(|e| sqlx::Error::Protocol(e.to_string()))?; + trimmed.to_string() + } + _ => existing.slug.clone().unwrap_or_else(|| id.to_string()), + }; + + let existing_slug_str = existing.slug.as_deref().unwrap_or(""); + + if target_slug == existing_slug_str { + // Unchanged slug: registry no-op + sqlx::query( + r#" + UPDATE tenants + SET name = $1, updated_at = CURRENT_TIMESTAMP + WHERE id = $2 + "#, + ) + .bind(name) + .bind(id) + .execute(&self.pool) + .await?; + } else { + // Changed slug: single transaction reserve -> update -> release + let mut tx = self.pool.begin().await?; + + reserve_slug_postgres(&mut tx, &target_slug, "tenant", id, id).await?; + + sqlx::query( + r#" + UPDATE tenants + SET name = $1, slug = $2, updated_at = CURRENT_TIMESTAMP + WHERE id = $3 + "#, + ) + .bind(name) + .bind(&target_slug) + .bind(id) + .execute(&mut *tx) + .await?; + + if !existing_slug_str.is_empty() { + release_slug_by_name_postgres(&mut tx, existing_slug_str, "tenant", id).await?; + } + + tx.commit().await?; + } Ok(()) } @@ -99,10 +154,16 @@ impl TenantsRepository for PostgresTenantsRepository { } async fn delete(&self, id: &str) -> Result<(), sqlx::Error> { + let mut tx = self.pool.begin().await?; + + release_slug_postgres(&mut tx, "tenant", id).await?; + sqlx::query("DELETE FROM tenants WHERE id = $1") .bind(id) - .execute(&self.pool) + .execute(&mut *tx) .await?; + + tx.commit().await?; Ok(()) } } diff --git a/src/db/repository/postgres/users.rs b/src/db/repository/postgres/users.rs index f3c8b8d..9ffb26b 100644 --- a/src/db/repository/postgres/users.rs +++ b/src/db/repository/postgres/users.rs @@ -98,6 +98,125 @@ impl UsersRepository for PostgresUsersRepository { Ok(()) } + async fn update_user_tenant(&self, id: &str, tenant_id: &str) -> Result<(), sqlx::Error> { + sqlx::query( + "UPDATE users SET tenant_id = $1, updated_at = to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') WHERE id = $2", + ) + .bind(tenant_id) + .bind(id) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn reassign_user_tenant_with_audit( + &self, + user_id: &str, + destination_tenant_id: &str, + actor_id: Option<&str>, + ip_address: Option<&str>, + user_agent: Option<&str>, + ) -> Result<(), sqlx::Error> { + let mut tx = self.pool.begin().await?; + + let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1 FOR UPDATE") + .bind(user_id) + .fetch_optional(&mut *tx) + .await? + .ok_or(sqlx::Error::RowNotFound)?; + + if user.tenant_id == destination_tenant_id { + tx.commit().await?; + return Ok(()); + } + + let from_tenant_id = user.tenant_id.clone(); + + if from_tenant_id == crate::db::models::Tenant::DEFAULT_ID { + let admin_rows: Vec<(String,)> = sqlx::query_as( + "SELECT ur.user_id FROM user_roles ur JOIN roles r ON ur.role_id = r.id WHERE r.name = 'admin' FOR UPDATE", + ) + .fetch_all(&mut *tx) + .await?; + + let is_target_admin = admin_rows.iter().any(|r| r.0 == user_id); + if is_target_admin && admin_rows.len() <= 1 { + return Err(sqlx::Error::Protocol( + "cannot reassign the last system administrator away from default tenant".into(), + )); + } + } + + let dest_exists: Option<(String,)> = sqlx::query_as("SELECT id FROM tenants WHERE id = $1") + .bind(destination_tenant_id) + .fetch_optional(&mut *tx) + .await?; + if dest_exists.is_none() { + return Err(sqlx::Error::RowNotFound); + } + + let collision: Option<(i64,)> = + sqlx::query_as("SELECT 1 FROM users WHERE tenant_id = $1 AND username = $2") + .bind(destination_tenant_id) + .bind(&user.username) + .fetch_optional(&mut *tx) + .await?; + if collision.is_some() { + return Err(sqlx::Error::Protocol(format!( + "username '{}' already exists in target tenant", + user.username + ))); + } + + let result = sqlx::query( + "UPDATE users SET tenant_id = $1, updated_at = to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') WHERE id = $2 AND tenant_id = $3", + ) + .bind(destination_tenant_id) + .bind(user_id) + .bind(&from_tenant_id) + .execute(&mut *tx) + .await?; + + if result.rows_affected() != 1 { + return Err(sqlx::Error::RowNotFound); + } + + let metadata = serde_json::json!({ + "user_id": user.id, + "username": user.username, + "from_tenant_id": from_tenant_id, + "to_tenant_id": destination_tenant_id, + }) + .to_string(); + + let audit_id = uuid::Uuid::new_v4().to_string(); + sqlx::query( + r#" + INSERT INTO audit_logs ( + id, actor_user_id, target_user_id, + action, resource_type, resource_id, + severity, ip_address, user_agent, metadata_json + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + "#, + ) + .bind(&audit_id) + .bind(actor_id) + .bind(Some(&user.id)) + .bind("user.tenant_reassigned") + .bind("user") + .bind(Some(&user.id)) + .bind("info") + .bind(ip_address) + .bind(user_agent) + .bind(&metadata) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + Ok(()) + } + async fn update_password_hash(&self, id: &str, password_hash: &str) -> Result<(), sqlx::Error> { sqlx::query( "UPDATE users SET password_hash = $1, updated_at = to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') WHERE id = $2", diff --git a/src/db/repository/sqlite/application_members.rs b/src/db/repository/sqlite/application_members.rs new file mode 100644 index 0000000..9873858 --- /dev/null +++ b/src/db/repository/sqlite/application_members.rs @@ -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, 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, 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, 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 { + 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>, + ) -> Result { + 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>, + ) -> 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>, + ) -> 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>, + ) -> 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(()) + } +} diff --git a/src/db/repository/sqlite/applications.rs b/src/db/repository/sqlite/applications.rs index 9569c84..2da9884 100644 --- a/src/db/repository/sqlite/applications.rs +++ b/src/db/repository/sqlite/applications.rs @@ -1,9 +1,11 @@ +use crate::db::models::Application; +use crate::db::repository::sqlite::global_slugs::{ + release_slug_by_name_sqlite, release_slug_sqlite, reserve_slug_sqlite, +}; use crate::db::repository::traits::ApplicationsRepository; use async_trait::async_trait; use sqlx::SqlitePool; -use crate::db::models::Application; - pub struct SqliteApplicationsRepository { pub pool: SqlitePool, } @@ -25,6 +27,8 @@ impl ApplicationsRepository for SqliteApplicationsRepository { ) -> Result { let mut tx = self.pool.begin().await?; + reserve_slug_sqlite(&mut tx, slug, "application", id, tenant_id).await?; + let app = sqlx::query_as::<_, Application>( r#" INSERT INTO applications (id, tenant_id, name, slug, client_id, client_secret_hash, description, redirect_uris, scopes) @@ -188,31 +192,73 @@ impl ApplicationsRepository for SqliteApplicationsRepository { scopes: Option<&str>, enabled: bool, ) -> Result<(), sqlx::Error> { - sqlx::query( - r#" - UPDATE applications - SET name = ?, slug = ?, description = ?, redirect_uris = ?, scopes = ?, enabled = ?, - updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') - WHERE id = ? - "#, - ) - .bind(name) - .bind(slug) - .bind(description) - .bind(redirect_uris) - .bind(scopes) - .bind(enabled) - .bind(id) - .execute(&self.pool) - .await?; + let existing = self.find_by_id(id).await?.ok_or(sqlx::Error::RowNotFound)?; + + let existing_slug_str = existing.slug.as_deref().unwrap_or(""); + + if slug == existing_slug_str { + // Unchanged slug: registry no-op + sqlx::query( + r#" + UPDATE applications + SET name = ?, description = ?, redirect_uris = ?, scopes = ?, enabled = ?, + updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') + WHERE id = ? + "#, + ) + .bind(name) + .bind(description) + .bind(redirect_uris) + .bind(scopes) + .bind(enabled) + .bind(id) + .execute(&self.pool) + .await?; + } else { + // Changed slug: single transaction reserve -> update -> release + let mut tx = self.pool.begin().await?; + + reserve_slug_sqlite(&mut tx, slug, "application", id, &existing.tenant_id).await?; + + sqlx::query( + r#" + UPDATE applications + SET name = ?, slug = ?, description = ?, redirect_uris = ?, scopes = ?, enabled = ?, + updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') + WHERE id = ? + "#, + ) + .bind(name) + .bind(slug) + .bind(description) + .bind(redirect_uris) + .bind(scopes) + .bind(enabled) + .bind(id) + .execute(&mut *tx) + .await?; + + if !existing_slug_str.is_empty() { + release_slug_by_name_sqlite(&mut tx, existing_slug_str, "application", id).await?; + } + + tx.commit().await?; + } + Ok(()) } async fn delete(&self, id: &str) -> Result<(), sqlx::Error> { + let mut tx = self.pool.begin().await?; + + release_slug_sqlite(&mut tx, "application", id).await?; + sqlx::query("DELETE FROM applications WHERE id = ?") .bind(id) - .execute(&self.pool) + .execute(&mut *tx) .await?; + + tx.commit().await?; Ok(()) } diff --git a/src/db/repository/sqlite/audit.rs b/src/db/repository/sqlite/audit.rs index 22f39e1..19ae050 100644 --- a/src/db/repository/sqlite/audit.rs +++ b/src/db/repository/sqlite/audit.rs @@ -65,12 +65,11 @@ impl AuditRepository for SqliteAuditRepository { } async fn list_filtered(&self, filter: &AuditFilter) -> Result, sqlx::Error> { - // Build a dynamic but simple filter using COALESCE-style optional matches. - // Empty optionals are treated as wildcards via OR IS NULL pattern with bind of None. let search_like = filter .search .as_ref() .map(|s| format!("%{}%", s.replace('%', "\\%"))); + let success_val = filter.success.map(|b| if b { 1i32 } else { 0i32 }); sqlx::query_as::<_, AuditLog>( r#" @@ -89,8 +88,13 @@ impl AuditRepository for SqliteAuditRepository { OR ip_address LIKE ?7 ESCAPE '\' OR metadata_json LIKE ?7 ESCAPE '\' ) + AND ( + ?8 IS NULL + OR (?8 = 1 AND action NOT LIKE '%fail%' AND action NOT LIKE '%denied%' AND severity != 'critical') + OR (?8 = 0 AND (action LIKE '%fail%' OR action LIKE '%denied%' OR severity = 'critical')) + ) ORDER BY created_at DESC - LIMIT ?8 OFFSET ?9 + LIMIT ?9 OFFSET ?10 "#, ) .bind(filter.actor_user_id.as_deref()) @@ -100,6 +104,7 @@ impl AuditRepository for SqliteAuditRepository { .bind(filter.since.as_deref()) .bind(filter.until.as_deref()) .bind(search_like.as_deref()) + .bind(success_val) .bind(filter.limit) .bind(filter.offset) .fetch_all(&self.pool) @@ -111,6 +116,7 @@ impl AuditRepository for SqliteAuditRepository { .search .as_ref() .map(|s| format!("%{}%", s.replace('%', "\\%"))); + let success_val = filter.success.map(|b| if b { 1i32 } else { 0i32 }); let row: (i64,) = sqlx::query_as( r#" @@ -129,6 +135,11 @@ impl AuditRepository for SqliteAuditRepository { OR ip_address LIKE ?7 ESCAPE '\' OR metadata_json LIKE ?7 ESCAPE '\' ) + AND ( + ?8 IS NULL + OR (?8 = 1 AND action NOT LIKE '%fail%' AND action NOT LIKE '%denied%' AND severity != 'critical') + OR (?8 = 0 AND (action LIKE '%fail%' OR action LIKE '%denied%' OR severity = 'critical')) + ) "#, ) .bind(filter.actor_user_id.as_deref()) @@ -138,6 +149,7 @@ impl AuditRepository for SqliteAuditRepository { .bind(filter.since.as_deref()) .bind(filter.until.as_deref()) .bind(search_like.as_deref()) + .bind(success_val) .fetch_one(&self.pool) .await?; Ok(row.0) diff --git a/src/db/repository/sqlite/global_slugs.rs b/src/db/repository/sqlite/global_slugs.rs new file mode 100644 index 0000000..7f02f77 --- /dev/null +++ b/src/db/repository/sqlite/global_slugs.rs @@ -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, 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 { + 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 { + 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()) +} diff --git a/src/db/repository/sqlite/mod.rs b/src/db/repository/sqlite/mod.rs index fb5d2f2..b2d97a7 100644 --- a/src/db/repository/sqlite/mod.rs +++ b/src/db/repository/sqlite/mod.rs @@ -1,5 +1,7 @@ +pub mod application_members; pub mod applications; pub mod audit; +pub mod global_slugs; pub mod groups; pub mod permissions; pub mod refresh_tokens; diff --git a/src/db/repository/sqlite/tenants.rs b/src/db/repository/sqlite/tenants.rs index 16ebaa5..ecb90c8 100644 --- a/src/db/repository/sqlite/tenants.rs +++ b/src/db/repository/sqlite/tenants.rs @@ -1,7 +1,11 @@ use sqlx::SqlitePool; use crate::db::models::Tenant; +use crate::db::repository::sqlite::global_slugs::{ + release_slug_by_name_sqlite, release_slug_sqlite, reserve_slug_sqlite, +}; use crate::db::repository::traits::TenantsRepository; +use crate::identity::slug::{slugify, validate_slug}; pub struct SqliteTenantsRepository { pub pool: SqlitePool, @@ -47,7 +51,17 @@ impl TenantsRepository for SqliteTenantsRepository { name: &str, slug: Option<&str>, ) -> Result { - let slug = slug.unwrap_or(id); + let final_slug = match slug { + Some(s) if !s.trim().is_empty() => { + let trimmed = s.trim(); + validate_slug(trimmed).map_err(|e| sqlx::Error::Protocol(e.to_string()))?; + trimmed.to_string() + } + _ => slugify(name).map_err(|e| sqlx::Error::Protocol(e.to_string()))?, + }; + + let mut tx = self.pool.begin().await?; + let row = sqlx::query_as::<_, Tenant>( r#" INSERT INTO tenants (id, name, slug, enabled) @@ -57,27 +71,68 @@ impl TenantsRepository for SqliteTenantsRepository { ) .bind(id) .bind(name) - .bind(slug) - .fetch_one(&self.pool) + .bind(&final_slug) + .fetch_one(&mut *tx) .await?; + reserve_slug_sqlite(&mut tx, &final_slug, "tenant", id, id).await?; + + tx.commit().await?; Ok(row) } async fn update(&self, id: &str, name: &str, slug: Option<&str>) -> Result<(), sqlx::Error> { - let slug = slug.unwrap_or(name); - sqlx::query( - r#" - UPDATE tenants - SET name = ?, slug = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') - WHERE id = ? - "#, - ) - .bind(name) - .bind(slug) - .bind(id) - .execute(&self.pool) - .await?; + let existing = self.find_by_id(id).await?.ok_or(sqlx::Error::RowNotFound)?; + + let target_slug = match slug { + Some(s) if !s.trim().is_empty() => { + let trimmed = s.trim(); + validate_slug(trimmed).map_err(|e| sqlx::Error::Protocol(e.to_string()))?; + trimmed.to_string() + } + _ => existing.slug.clone().unwrap_or_else(|| id.to_string()), + }; + + let existing_slug_str = existing.slug.as_deref().unwrap_or(""); + + if target_slug == existing_slug_str { + // Unchanged slug: registry no-op + sqlx::query( + r#" + UPDATE tenants + SET name = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') + WHERE id = ? + "#, + ) + .bind(name) + .bind(id) + .execute(&self.pool) + .await?; + } else { + // Changed slug: single transaction reserve -> update -> release + let mut tx = self.pool.begin().await?; + + reserve_slug_sqlite(&mut tx, &target_slug, "tenant", id, id).await?; + + sqlx::query( + r#" + UPDATE tenants + SET name = ?, slug = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') + WHERE id = ? + "#, + ) + .bind(name) + .bind(&target_slug) + .bind(id) + .execute(&mut *tx) + .await?; + + if !existing_slug_str.is_empty() { + release_slug_by_name_sqlite(&mut tx, existing_slug_str, "tenant", id).await?; + } + + tx.commit().await?; + } Ok(()) } @@ -99,10 +154,16 @@ impl TenantsRepository for SqliteTenantsRepository { } async fn delete(&self, id: &str) -> Result<(), sqlx::Error> { + let mut tx = self.pool.begin().await?; + + release_slug_sqlite(&mut tx, "tenant", id).await?; + sqlx::query("DELETE FROM tenants WHERE id = ?") .bind(id) - .execute(&self.pool) + .execute(&mut *tx) .await?; + + tx.commit().await?; Ok(()) } } diff --git a/src/db/repository/sqlite/users.rs b/src/db/repository/sqlite/users.rs index b5e4a29..5b6c4a3 100644 --- a/src/db/repository/sqlite/users.rs +++ b/src/db/repository/sqlite/users.rs @@ -100,6 +100,134 @@ impl UsersRepository for SqliteUsersRepository { Ok(()) } + async fn update_user_tenant(&self, id: &str, tenant_id: &str) -> Result<(), sqlx::Error> { + sqlx::query( + "UPDATE users SET tenant_id = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?", + ) + .bind(tenant_id) + .bind(id) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn reassign_user_tenant_with_audit( + &self, + user_id: &str, + destination_tenant_id: &str, + actor_id: Option<&str>, + ip_address: Option<&str>, + user_agent: Option<&str>, + ) -> Result<(), sqlx::Error> { + let mut tx = self.pool.begin().await?; + + let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?") + .bind(user_id) + .fetch_optional(&mut *tx) + .await? + .ok_or(sqlx::Error::RowNotFound)?; + + if user.tenant_id == destination_tenant_id { + tx.commit().await?; + return Ok(()); + } + + let from_tenant_id = user.tenant_id.clone(); + + if from_tenant_id == crate::db::models::Tenant::DEFAULT_ID { + let is_admin: Option<(i64,)> = sqlx::query_as( + "SELECT 1 FROM user_roles ur JOIN roles r ON ur.role_id = r.id WHERE ur.user_id = ? AND r.name = 'admin'", + ) + .bind(user_id) + .fetch_optional(&mut *tx) + .await?; + + if is_admin.is_some() { + let admin_count: (i64,) = sqlx::query_as( + "SELECT COUNT(DISTINCT ur.user_id) FROM user_roles ur JOIN roles r ON ur.role_id = r.id WHERE r.name = 'admin'", + ) + .fetch_one(&mut *tx) + .await?; + + if admin_count.0 <= 1 { + return Err(sqlx::Error::Protocol( + "cannot reassign the last system administrator away from default tenant" + .into(), + )); + } + } + } + + let dest_exists: Option<(String,)> = sqlx::query_as("SELECT id FROM tenants WHERE id = ?") + .bind(destination_tenant_id) + .fetch_optional(&mut *tx) + .await?; + if dest_exists.is_none() { + return Err(sqlx::Error::RowNotFound); + } + + let collision: Option<(i64,)> = + sqlx::query_as("SELECT 1 FROM users WHERE tenant_id = ? AND username = ?") + .bind(destination_tenant_id) + .bind(&user.username) + .fetch_optional(&mut *tx) + .await?; + if collision.is_some() { + return Err(sqlx::Error::Protocol(format!( + "username '{}' already exists in target tenant", + user.username + ))); + } + + let result = sqlx::query( + "UPDATE users SET tenant_id = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ? AND tenant_id = ?", + ) + .bind(destination_tenant_id) + .bind(user_id) + .bind(&from_tenant_id) + .execute(&mut *tx) + .await?; + + if result.rows_affected() != 1 { + return Err(sqlx::Error::RowNotFound); + } + + let metadata = serde_json::json!({ + "user_id": user.id, + "username": user.username, + "from_tenant_id": from_tenant_id, + "to_tenant_id": destination_tenant_id, + }) + .to_string(); + + let audit_id = uuid::Uuid::new_v4().to_string(); + sqlx::query( + r#" + INSERT INTO audit_logs ( + id, actor_user_id, target_user_id, + action, resource_type, resource_id, + severity, ip_address, user_agent, metadata_json + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + "#, + ) + .bind(&audit_id) + .bind(actor_id) + .bind(Some(&user.id)) + .bind("user.tenant_reassigned") + .bind("user") + .bind(Some(&user.id)) + .bind("info") + .bind(ip_address) + .bind(user_agent) + .bind(&metadata) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + Ok(()) + } + async fn update_password_hash(&self, id: &str, password_hash: &str) -> Result<(), sqlx::Error> { sqlx::query( "UPDATE users SET password_hash = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?", diff --git a/src/db/repository/traits.rs b/src/db/repository/traits.rs index 9ef7de7..88a6708 100644 --- a/src/db/repository/traits.rs +++ b/src/db/repository/traits.rs @@ -1,8 +1,13 @@ use crate::db::models::{ - ApiToken, Application, AuditFilter, AuditLog, Group, Permission, RefreshToken, Role, - ServiceAccount, Session, Tenant, User, UserProfile, + ApiToken, Application, ApplicationMember, AuditFilter, AuditLog, GlobalSlug, Group, Permission, + RefreshToken, Role, ServiceAccount, Session, Tenant, User, UserProfile, }; +#[async_trait::async_trait] +pub trait GlobalSlugsRepository: Send + Sync { + async fn find_by_slug(&self, slug: &str) -> Result, sqlx::Error>; +} + #[async_trait::async_trait] pub trait UsersRepository: Send + Sync { async fn find_by_id(&self, id: &str) -> Result, sqlx::Error>; @@ -16,6 +21,15 @@ pub trait UsersRepository: Send + Sync { password_hash: &str, ) -> Result; async fn update_status(&self, id: &str, status: i32) -> Result<(), sqlx::Error>; + async fn update_user_tenant(&self, id: &str, tenant_id: &str) -> Result<(), sqlx::Error>; + async fn reassign_user_tenant_with_audit( + &self, + user_id: &str, + destination_tenant_id: &str, + actor_id: Option<&str>, + ip_address: Option<&str>, + user_agent: Option<&str>, + ) -> Result<(), sqlx::Error>; async fn update_password_hash(&self, id: &str, password_hash: &str) -> Result<(), sqlx::Error>; async fn set_last_login(&self, id: &str) -> Result<(), sqlx::Error>; async fn username_exists(&self, tenant_id: &str, username: &str) -> Result; @@ -271,3 +285,69 @@ pub trait GroupsRepository: Send + Sync { async fn remove_member(&self, group_id: &str, user_id: &str) -> Result<(), sqlx::Error>; async fn count(&self, tenant_id: &str) -> Result; } + +/// 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, sqlx::Error>; + async fn list_by_user(&self, user_id: &str) -> Result, sqlx::Error>; + async fn find( + &self, + application_id: &str, + user_id: &str, + ) -> Result, sqlx::Error>; + async fn add( + &self, + id: &str, + application_id: &str, + user_id: &str, + role: &str, + ) -> Result; + 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>, + ) -> Result; + async fn update_role_with_audit( + &self, + application_id: &str, + user_id: &str, + role: &str, + audit_event: Option>, + ) -> Result<(), sqlx::Error>; + async fn set_enabled_with_audit( + &self, + application_id: &str, + user_id: &str, + enabled: bool, + audit_event: Option>, + ) -> Result<(), sqlx::Error>; + async fn remove_with_audit( + &self, + application_id: &str, + user_id: &str, + audit_event: Option>, + ) -> Result<(), sqlx::Error>; +} diff --git a/src/identity/application_members.rs b/src/identity/application_members.rs new file mode 100644 index 0000000..384b87f --- /dev/null +++ b/src/identity/application_members.rs @@ -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 { + 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, + application_id: &str, +) -> Result, 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, + user_id: &str, +) -> Result, 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, + application_id: &str, + user_id: &str, +) -> Result, 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, + application_id: &str, + user_id: &str, + role: Option<&str>, + audit_actor_id: Option<&str>, + audit_ip: Option<&str>, + audit_ua: Option<&str>, +) -> Result { + 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, + application_id: &str, + user_id: &str, + role: Option<&str>, + enabled: Option, + audit_actor_id: Option<&str>, + audit_ip: Option<&str>, + audit_ua: Option<&str>, +) -> Result { + 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, + 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, + application_id: &str, +) -> Result { + provider + .applications() + .find_by_id(application_id) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound) +} diff --git a/src/identity/applications.rs b/src/identity/applications.rs index 44c6e08..1f75fa3 100644 --- a/src/identity/applications.rs +++ b/src/identity/applications.rs @@ -108,11 +108,13 @@ pub async fn create( "name and slug cannot be empty".into(), )); } + crate::identity::slug::validate_slug(slug)?; + if let Some(ref uris) = redirect_uris { validate_redirect_uris(uris)?; } if provider - .applications() + .global_slugs() .find_by_slug(slug) .await .map_err(AppError::Database)? @@ -312,16 +314,18 @@ pub async fn update( "name and slug cannot be empty".into(), )); } + crate::identity::slug::validate_slug(slug)?; + if let Some(ref uris) = redirect_uris { validate_redirect_uris(uris)?; } if let Some(other) = provider - .applications() + .global_slugs() .find_by_slug(slug) .await .map_err(AppError::Database)? { - if other.id != id { + if other.entity_id != id || other.entity_type != "application" { return Err(AppError::Conflict(format!("slug '{slug}' already exists"))); } } diff --git a/src/identity/mod.rs b/src/identity/mod.rs index b9a8266..df5a165 100644 --- a/src/identity/mod.rs +++ b/src/identity/mod.rs @@ -1,5 +1,7 @@ +pub mod application_members; pub mod applications; pub mod permissions; pub mod roles; pub mod service_accounts; +pub mod slug; pub mod users; diff --git a/src/identity/slug.rs b/src/identity/slug.rs new file mode 100644 index 0000000..ccb636f --- /dev/null +++ b/src/identity/slug.rs @@ -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 { + 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()); + } +} diff --git a/tests/application_members_test.rs b/tests/application_members_test.rs new file mode 100644 index 0000000..7a34c90 --- /dev/null +++ b/tests/application_members_test.rs @@ -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, 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 = 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 = 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; +} diff --git a/tests/audit_export_test.rs b/tests/audit_export_test.rs new file mode 100644 index 0000000..81dc3f1 --- /dev/null +++ b/tests/audit_export_test.rs @@ -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, 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\""); +} diff --git a/tests/postgres_migration_test.rs b/tests/postgres_migration_test.rs new file mode 100644 index 0000000..2dc4d00 --- /dev/null +++ b/tests/postgres_migration_test.rs @@ -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 + ); +} diff --git a/tests/slug_management_test.rs b/tests/slug_management_test.rs new file mode 100644 index 0000000..b0a2aab --- /dev/null +++ b/tests/slug_management_test.rs @@ -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, 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 = 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 = 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); +} diff --git a/tests/tenant_management_ui_test.rs b/tests/tenant_management_ui_test.rs new file mode 100644 index 0000000..7e2c9df --- /dev/null +++ b/tests/tenant_management_ui_test.rs @@ -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, 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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; +} diff --git a/ui/src/components/navigation/mod.rs b/ui/src/components/navigation/mod.rs index 00bcaff..aabd79a 100644 --- a/ui/src/components/navigation/mod.rs +++ b/ui/src/components/navigation/mod.rs @@ -12,13 +12,22 @@ pub fn Header() -> Element { let auth = state.auth; let theme = state.theme; 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 username = auth().username().to_string(); let theme_icon = theme().icon(); 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! { header { class: "app-header", button { @@ -36,66 +45,97 @@ pub fn Header() -> Element { span { "nx9-auth" } } - // Tenant Switcher - div { class: "dropdown", style: "margin-left: 1rem;", - 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 { style: "margin-left: 0.4rem; font-weight: 500;", - {(state.tenant)().map(|t| t.name).unwrap_or("Default Tenant".to_string())} - } - span { style: "margin-left: 0.25rem; opacity: 0.6;", "▾" } + // Tenant Indicator & Management Link + div { class: "tenant-badge", style: "margin-left: 1rem; display: flex; align-items: center; gap: 0.5rem;", + span { class: "icon", "🏢" } + span { style: "font-weight: 500; font-size: 13px;", + {(state.tenant)().map(|t| t.name).unwrap_or("Default Tenant".to_string())} } - if tenant_menu_open() { - div { class: "dropdown-menu", role: "menu", - button { class: "dropdown-item", r#type: "button", "Default Tenant" } - div { class: "dropdown-divider" } - Link { - class: "dropdown-item text-primary", - to: Route::TenantsPage {}, - onclick: move |_| tenant_menu_open.set(false), - "Manage tenants…" + Link { + class: "btn btn-xs btn-ghost text-primary", + to: Route::TenantsPage {}, + "Manage tenants…" + } + } + + div { style: "flex: 1;" } + + div { class: "header-actions", + if has_any_create { + div { class: "dropdown", style: "position: relative; margin-right: 0.5rem;", + button { + class: "btn btn-primary btn-sm", + r#type: "button", + title: "Quick Create", + "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" + } + } + } } } } - } - - // Global Search (Ctrl+K) - 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", - // Quick Create - button { - class: "btn btn-primary btn-sm", - style: "margin-right: 0.5rem;", - r#type: "button", - title: "Quick Create", - "➕ New" - } - - // Notifications - button { - class: "btn btn-ghost btn-icon", - r#type: "button", - title: "Notifications", - "aria-label": "Notifications", - "🔔" - } // Theme button { diff --git a/ui/src/components/tables/datatable.rs b/ui/src/components/tables/datatable.rs index 7d1576c..f3d8fa6 100644 --- a/ui/src/components/tables/datatable.rs +++ b/ui/src/components/tables/datatable.rs @@ -60,7 +60,6 @@ pub fn DataTable( input { r#type: "checkbox", checked: col.visible, - // TODO: emit event } 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", diff --git a/ui/src/models/mod.rs b/ui/src/models/mod.rs index 1fe791e..3b0f277 100644 --- a/ui/src/models/mod.rs +++ b/ui/src/models/mod.rs @@ -21,6 +21,8 @@ pub struct TenantsResponse { pub struct UserView { pub id: String, pub username: String, + #[serde(default)] + pub tenant_id: Option, pub status: String, #[serde(default)] pub last_login_at: Option, @@ -156,6 +158,60 @@ pub struct RotateSecretResponse { 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, +} + +#[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, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] pub struct ServiceAccountView { pub id: String, diff --git a/ui/src/pages/applications/mod.rs b/ui/src/pages/applications/mod.rs index c93029d..3327f7a 100644 --- a/ui/src/pages/applications/mod.rs +++ b/ui/src/pages/applications/mod.rs @@ -1,11 +1,11 @@ -//! Applications CRUD. +//! Applications CRUD and application membership management. use crate::components::feedback::{ConfirmDialog, EmptyState, ErrorState, LoadingSpinner, Modal}; -use crate::components::forms::TextInput; +use crate::components::forms::{PasswordInput, TextInput}; use crate::components::navigation::Breadcrumb; -use crate::components::tables::{DataTable, ColumnDef}; +use crate::components::tables::{ColumnDef, DataTable}; use crate::components::widgets::StatusChip; -use crate::models::ApplicationView; +use crate::models::{ApplicationMemberView, ApplicationView, AuditEntry, UserView}; use crate::routes::Route; use crate::services::api; use crate::state::{AppState, ToastKind}; @@ -52,11 +52,23 @@ pub fn ApplicationsPage() -> Element { } }); }); - use_effect(move || { reload.call(()); }); + use_effect(move || { + reload.call(()); + }); + + use_effect(move || { + if can_manage && crate::utils::check_and_clear_create_intent() { + show_create.set(true); + } + }); let mut filtered: Vec<_> = apps() .into_iter() - .filter(|a| matches_query(&a.name, &query()) || matches_query(&a.slug, &query()) || matches_query(&a.client_id, &query())) + .filter(|a| { + matches_query(&a.name, &query()) + || matches_query(&a.slug, &query()) + || matches_query(&a.client_id, &query()) + }) .collect(); let sk = sort_key(); filtered.sort_by(|a, b| match sk.as_str() { @@ -64,9 +76,13 @@ pub fn ApplicationsPage() -> Element { "created" => b.created_at.cmp(&a.created_at), _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()), }); - + let total = filtered.len(); - let page_items: Vec<_> = filtered.into_iter().skip(page() * page_size).take(page_size).collect(); + let page_items: Vec<_> = filtered + .into_iter() + .skip(page() * page_size) + .take(page_size) + .collect(); rsx! { Breadcrumb { items: vec![ @@ -100,20 +116,15 @@ pub fn ApplicationsPage() -> Element { } } else { DataTable { - columns: { - let mut cols = vec![ - ColumnDef { key: "name".into(), label: "Name".into(), sortable: true, visible: true }, - ColumnDef { key: "client_id".into(), label: "Client ID".into(), sortable: false, visible: true }, - ColumnDef { key: "redirect".into(), label: "Redirect URLs".into(), sortable: false, visible: true }, - ColumnDef { key: "scopes".into(), label: "Scopes".into(), sortable: false, visible: true }, - ColumnDef { key: "status".into(), label: "Status".into(), sortable: true, visible: true }, - ColumnDef { key: "created".into(), label: "Created".into(), sortable: true, visible: true }, - ]; - if can_manage { - cols.push(ColumnDef { key: "actions".into(), label: "Actions".into(), sortable: false, visible: true }); - } - cols - }, + columns: vec![ + ColumnDef { key: "name".into(), label: "Name".into(), sortable: true, visible: true }, + ColumnDef { key: "client_id".into(), label: "Client ID".into(), sortable: false, visible: true }, + ColumnDef { key: "redirect".into(), label: "Redirect URLs".into(), sortable: false, visible: true }, + ColumnDef { key: "scopes".into(), label: "Scopes".into(), sortable: false, visible: true }, + ColumnDef { key: "status".into(), label: "Status".into(), sortable: true, visible: true }, + ColumnDef { key: "created".into(), label: "Created".into(), sortable: true, visible: true }, + ColumnDef { key: "actions".into(), label: "Actions".into(), sortable: false, visible: true }, + ], on_search: move |v| { query.set(v); page.set(0); }, search_value: query(), search_placeholder: "Search applications…".to_string(), @@ -131,10 +142,14 @@ pub fn ApplicationsPage() -> Element { let app = a.clone(); let app_rotate = a.clone(); let app_delete = a.clone(); + let app_id = a.id.clone(); rsx! { tr { key: "{a.id}", td { - strong { "{a.name}" } + Link { + to: Route::ApplicationDetailPage { id: a.id.clone() }, + strong { "{a.name}" } + } if let Some(desc) = &a.description { div { class: "text-muted", style: "font-size: 0.8rem;", "{desc}" } } @@ -152,9 +167,14 @@ pub fn ApplicationsPage() -> Element { } } td { "{format_datetime(&a.created_at)}" } - if can_manage { - td { style: "text-align: right;", - div { class: "actions", style: "display: inline-flex; gap: 0.25rem;", + td { style: "text-align: right;", + div { class: "actions", style: "display: inline-flex; gap: 0.25rem;", + Link { + class: "btn btn-sm btn-outline", + to: Route::ApplicationDetailPage { id: app_id }, + "Manage" + } + if can_manage { button { class: "btn btn-sm btn-outline", r#type: "button", @@ -367,3 +387,1158 @@ pub fn ApplicationsPage() -> Element { } } } + +#[component] +pub fn ApplicationDetailPage(id: String) -> Element { + let state = use_context::(); + let auth = state.auth; + let can_manage = auth().has_permission("applications:manage"); + let can_view_audit = auth().has_permission("audit:view"); + + let mut app = use_signal(|| Option::::None); + let mut members = use_signal(Vec::::new); + let mut all_users = use_signal(Vec::::new); + let mut activity = use_signal(Vec::::new); + let mut error = use_signal(|| Option::::None); + let mut loading = use_signal(|| true); + let mut tab = use_signal(|| "overview".to_string()); + + // Edit configuration + let mut edit_mode = use_signal(|| false); + let mut edit_name = use_signal(String::new); + let mut edit_slug = use_signal(String::new); + let mut edit_desc = use_signal(String::new); + let mut edit_redirects = use_signal(String::new); + let mut edit_scopes = use_signal(String::new); + let mut edit_enabled = use_signal(|| true); + + // Members + let mut show_add_member = use_signal(|| false); + let mut add_mode = use_signal(|| "existing".to_string()); // "existing" | "new" + let mut add_user_id = use_signal(String::new); + let mut add_role = use_signal(|| "member".to_string()); + let mut new_username = use_signal(String::new); + let mut new_password = use_signal(String::new); + let mut add_form_error = use_signal(|| Option::::None); + let mut add_busy = use_signal(|| false); + // After user create succeeds but membership fails: recoverable assignment state. + let mut pending_assign_user_id = use_signal(|| Option::::None); + let mut pending_assign_username = use_signal(|| Option::::None); + let mut remove_target = use_signal(|| Option::::None); + let mut role_target = use_signal(|| Option::<(ApplicationMemberView, String)>::None); + let mut change_role_value = use_signal(|| "member".to_string()); + + // Credentials + let mut one_time_secret = use_signal(|| Option::::None); + let mut rotate_confirm = use_signal(|| false); + let mut delete_confirm = use_signal(|| false); + + let app_id = id.clone(); + let reload = use_callback(move |_: ()| { + let id = app_id.clone(); + loading.set(true); + // Clear prior error so a successful retry is not blocked by ErrorState. + error.set(None); + spawn(async move { + match api::get_application(&id).await { + Ok(a) => { + app.set(Some(a)); + // On failure, clear members so the UI never shows stale memberships. + match api::list_application_members(&id).await { + Ok(m) => members.set(m), + Err(_) => members.set(Vec::new()), + } + if let Ok(users) = api::list_users().await { + all_users.set(users); + } + loading.set(false); + } + Err(e) => { + error.set(Some(e.to_string())); + loading.set(false); + } + } + }); + }); + + use_effect(move || { + reload.call(()); + }); + + let app_id_activity = id.clone(); + let load_activity = use_callback(move |_: ()| { + if !can_view_audit { + return; + } + let id = app_id_activity.clone(); + spawn(async move { + let q = format!("resource_type=application&q={id}&limit=50"); + if let Ok(resp) = api::list_audit(&q).await { + let filtered: Vec<_> = resp + .entries + .into_iter() + .filter(|e| e.resource_id.as_deref() == Some(id.as_str())) + .collect(); + activity.set(filtered); + } + }); + }); + + rsx! { + Breadcrumb { items: vec![ + ("Dashboard".to_string(), Some(Route::DashboardPage {})), + ("Applications".to_string(), Some(Route::ApplicationsPage {})), + (app().map(|a| a.name.clone()).unwrap_or_else(|| id.clone()), None), + ]} + + if loading() { + LoadingSpinner {} + } else if let Some(err) = error() { + ErrorState { message: err, on_retry: move |_| reload.call(()) } + } else if let Some(a) = app() { + { + let app_for_actions = a.clone(); + let app_for_edit = a.clone(); + let app_id = a.id.clone(); + let app_id2 = a.id.clone(); + let app_id2b = a.id.clone(); + let app_id2c = a.id.clone(); + let app_id3 = a.id.clone(); + let app_id4 = a.id.clone(); + let app_id_remove = a.id.clone(); + let app_id_rotate = a.id.clone(); + let app_id_delete = a.id.clone(); + let app_name = a.name.clone(); + rsx! { + div { class: "page-header", + div { + h1 { "{a.name}" } + p { class: "desc", + code { "{a.slug}" } + " · " + StatusChip { + status: if a.enabled { "active".to_string() } else { "disabled".to_string() } + } + } + } + if can_manage { + div { class: "row", + button { + class: "btn btn-outline", + r#type: "button", + onclick: move |_| { + edit_name.set(app_for_edit.name.clone()); + edit_slug.set(app_for_edit.slug.clone()); + edit_desc.set(app_for_edit.description.clone().unwrap_or_default()); + edit_redirects.set(app_for_edit.redirect_urls.join(", ")); + edit_scopes.set(app_for_edit.scopes.join(", ")); + edit_enabled.set(app_for_edit.enabled); + edit_mode.set(true); + }, + "Edit" + } + button { + class: "btn btn-danger", + r#type: "button", + onclick: move |_| delete_confirm.set(true), + "Delete" + } + } + } + } + + 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" + } + button { + class: if tab() == "credentials" { "btn btn-sm btn-primary" } else { "btn btn-sm btn-outline" }, + r#type: "button", + onclick: move |_| tab.set("credentials".into()), + "Credentials" + } + button { + class: if tab() == "configuration" { "btn btn-sm btn-primary" } else { "btn btn-sm btn-outline" }, + r#type: "button", + onclick: move |_| tab.set("configuration".into()), + "Configuration" + } + if can_view_audit { + 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" + } + } + } + + // ── Overview ────────────────────────────────────────── + if tab() == "overview" { + div { class: "grid-2", + div { class: "card", + div { class: "card-header", h3 { "Application" } } + div { class: "card-body stack", + div { strong { "Name: " } "{a.name}" } + div { strong { "Slug: " } code { "{a.slug}" } } + div { + strong { "Status: " } + StatusChip { + status: if a.enabled { "active".to_string() } else { "disabled".to_string() } + } + } + div { + strong { "Description: " } + span { class: "text-secondary", + "{a.description.as_deref().unwrap_or(\"—\")}" + } + } + div { class: "text-secondary", "Created: {format_datetime(&a.created_at)}" } + div { class: "text-secondary", "Updated: {format_datetime(&a.updated_at)}" } + } + } + div { class: "card", + div { class: "card-header", h3 { "Registration summary" } } + div { class: "card-body stack", + div { + strong { "Client ID: " } + code { "{a.client_id}" } + } + div { + strong { "Credentials: " } + if a.credentials_configured { + span { class: "badge badge-accent", "Configured" } + } else { + span { class: "badge", "Not configured" } + } + } + div { + strong { "Members: " } + span { class: "badge", "{members().len()}" } + } + div { + strong { "Redirect URLs: " } + if a.redirect_urls.is_empty() { + span { class: "text-muted", "—" } + } else { + span { class: "text-secondary", "{a.redirect_urls.join(\", \")}" } + } + } + div { + strong { "Scopes: " } + if a.scopes.is_empty() { + span { class: "text-muted", "—" } + } else { + span { class: "text-secondary", "{a.scopes.join(\" \")}" } + } + } + } + } + } + } + + // ── Users (membership) ──────────────────────────────── + if tab() == "users" { + div { class: "card", + div { class: "card-header", style: "display:flex; justify-content:space-between; align-items:center;", + h3 { "Application users" } + if can_manage { + button { + class: "btn btn-sm btn-primary", + r#type: "button", + onclick: move |_| { + add_mode.set("existing".into()); + add_user_id.set(String::new()); + add_role.set("member".into()); + new_username.set(String::new()); + new_password.set(String::new()); + add_form_error.set(None); + add_busy.set(false); + pending_assign_user_id.set(None); + pending_assign_username.set(None); + show_add_member.set(true); + }, + "Add User" + } + } + } + div { class: "card-body", + p { class: "text-secondary", style: "margin-bottom:1rem; font-size:0.9rem;", + "Assign existing NX9-Auth users, or create a new user and assign them in one step. " + "Membership roles (owner/admin/member) are metadata only and do not grant global administrative permissions." + } + if members().is_empty() { + p { class: "text-muted", "No users assigned to this application." } + } else { + DataTable { + columns: vec![ + ColumnDef { key: "user".into(), label: "User".into(), sortable: false, visible: true }, + ColumnDef { key: "username".into(), label: "Username".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 }, + ColumnDef { key: "actions".into(), label: "Actions".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: members().len().max(1), + total: members().len(), + for m in members() { + { + let m_role = m.clone(); + let m_enable = m.clone(); + let m_remove = m.clone(); + let aid = app_id.clone(); + rsx! { + tr { key: "{m.id}", + td { + Link { + to: Route::UserDetailPage { id: m.user_id.clone() }, + "{m.username}" + } + } + td { code { "{m.username}" } } + td { span { class: "badge badge-accent", "{m.role}" } } + td { + StatusChip { + status: if m.enabled { "active".to_string() } else { "disabled".to_string() } + } + span { class: "text-muted", style: "margin-left:0.35rem; font-size:0.8rem;", + "(user: {m.user_status})" + } + } + td { "{format_datetime(&m.created_at)}" } + td { style: "text-align: right;", + if can_manage { + div { class: "actions", style: "display:inline-flex; gap:0.25rem; flex-wrap:wrap;", + button { + class: "btn btn-sm btn-outline", + r#type: "button", + onclick: move |_| { + change_role_value.set(m_role.role.clone()); + role_target.set(Some((m_role.clone(), m_role.role.clone()))); + }, + "Change Role" + } + button { + class: "btn btn-sm btn-outline", + r#type: "button", + onclick: { + let aid = aid.clone(); + let m = m_enable.clone(); + move |_| { + let aid = aid.clone(); + let uid = m.user_id.clone(); + let enabled = !m.enabled; + spawn(async move { + match api::update_application_member(&aid, &uid, None, Some(enabled)).await { + Ok(_) => { + state.toast( + ToastKind::Success, + if enabled { "Membership enabled" } else { "Membership disabled" }, + ); + reload.call(()); + } + Err(e) => state.toast(ToastKind::Error, e.to_string()), + } + }); + } + }, + if m.enabled { "Disable" } else { "Enable" } + } + button { + class: "btn btn-sm btn-danger", + r#type: "button", + onclick: move |_| remove_target.set(Some(m_remove.clone())), + "Remove" + } + } + } + } + } + } + } + } + } + } + } + } + } + + // ── Credentials ─────────────────────────────────────── + if tab() == "credentials" { + div { class: "card", + div { class: "card-header", h3 { "Client credentials" } } + div { class: "card-body stack", + div { + label { style: "font-weight:600; display:block; font-size:0.85rem;", "Client ID (immutable)" } + div { style: "display:flex; gap:0.5rem; align-items:center;", + code { style: "flex:1; padding:0.4rem; background:#f8f9fa; border:1px solid #e9ecef; border-radius:4px; word-break:break-all;", + "{a.client_id}" + } + button { + class: "btn btn-sm btn-outline", + r#type: "button", + onclick: { + let cid = a.client_id.clone(); + move |_| { + if let Some(window) = web_sys::window() { + let nav = window.navigator(); + let clipboard = nav.clipboard(); + let _ = clipboard.write_text(&cid); + } + state.toast(ToastKind::Success, "Client ID copied"); + } + }, + "Copy" + } + } + } + div { + label { style: "font-weight:600; display:block; font-size:0.85rem;", "Credentials status" } + if a.credentials_configured { + span { class: "badge badge-accent", "Configured" } + } else { + span { class: "badge", "Not configured" } + } + p { class: "text-muted", style: "font-size:0.85rem; margin-top:0.35rem;", + "Client secrets are shown only once at creation or rotation. The secret hash is never exposed." + } + } + if can_manage { + button { + class: "btn btn-outline", + r#type: "button", + onclick: move |_| rotate_confirm.set(true), + "Rotate Secret" + } + } + } + } + } + + // ── Configuration ───────────────────────────────────── + if tab() == "configuration" { + div { class: "card", + div { class: "card-header", style: "display:flex; justify-content:space-between; align-items:center;", + h3 { "Configuration" } + if can_manage { + button { + class: "btn btn-sm btn-outline", + r#type: "button", + onclick: move |_| { + edit_name.set(app_for_actions.name.clone()); + edit_slug.set(app_for_actions.slug.clone()); + edit_desc.set(app_for_actions.description.clone().unwrap_or_default()); + edit_redirects.set(app_for_actions.redirect_urls.join(", ")); + edit_scopes.set(app_for_actions.scopes.join(", ")); + edit_enabled.set(app_for_actions.enabled); + edit_mode.set(true); + }, + "Edit configuration" + } + } + } + div { class: "card-body stack", + div { strong { "Name: " } "{a.name}" } + div { strong { "Slug: " } code { "{a.slug}" } } + div { + strong { "Enabled: " } + StatusChip { + status: if a.enabled { "active".to_string() } else { "disabled".to_string() } + } + } + div { + strong { "Description: " } + "{a.description.as_deref().unwrap_or(\"—\")}" + } + div { + strong { "Redirect URIs: " } + if a.redirect_urls.is_empty() { + span { class: "text-muted", "—" } + } else { + ul { + for u in a.redirect_urls.iter() { + li { code { "{u}" } } + } + } + } + } + div { + strong { "Scopes: " } + if a.scopes.is_empty() { + span { class: "text-muted", "—" } + } else { + span { "{a.scopes.join(\" \")}" } + } + } + div { + strong { "Client ID: " } + code { "{a.client_id}" } + span { class: "text-muted", style: "margin-left:0.5rem;", "(immutable)" } + } + } + } + } + + // ── Activity ────────────────────────────────────────── + if tab() == "activity" && can_view_audit { + div { class: "card", + div { class: "card-header", style: "display:flex; justify-content:space-between; align-items:center;", + h3 { "Activity" } + button { + class: "btn btn-sm btn-outline", + r#type: "button", + onclick: move |_| load_activity.call(()), + "Refresh" + } + } + div { class: "card-body", + if activity().is_empty() { + p { class: "text-muted", "No application-related audit events found." } + } else { + DataTable { + columns: vec![ + ColumnDef { key: "time".into(), label: "Time".into(), sortable: false, visible: true }, + ColumnDef { key: "action".into(), label: "Action".into(), sortable: false, visible: true }, + ColumnDef { key: "severity".into(), label: "Severity".into(), sortable: false, visible: true }, + ColumnDef { key: "target".into(), label: "Target".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: activity().len().max(1), + total: activity().len(), + for e in activity() { + tr { key: "{e.id}", + td { "{format_datetime(&e.created_at)}" } + td { code { "{e.action}" } } + td { "{e.severity}" } + td { class: "text-muted", + "{e.target_user_id.as_deref().unwrap_or(\"—\")}" + } + } + } + } + } + } + } + } + + // ── Add member modal ────────────────────────────────── + Modal { + title: "Add user to application".to_string(), + open: show_add_member(), + on_close: move |_| { + show_add_member.set(false); + new_password.set(String::new()); + add_form_error.set(None); + add_busy.set(false); + }, + // Mode selector + div { class: "row", style: "gap:0.5rem; margin-bottom:0.75rem;", + button { + class: if add_mode() == "existing" { "btn btn-sm btn-primary" } else { "btn btn-sm btn-outline" }, + r#type: "button", + disabled: add_busy(), + onclick: move |_| { + add_mode.set("existing".into()); + add_form_error.set(None); + }, + "Existing User" + } + button { + class: if add_mode() == "new" { "btn btn-sm btn-primary" } else { "btn btn-sm btn-outline" }, + r#type: "button", + disabled: add_busy(), + onclick: move |_| { + add_mode.set("new".into()); + add_form_error.set(None); + }, + "New User" + } + } + + if let Some(err) = add_form_error() { + div { + class: "alert alert-warning", + style: "margin-bottom:0.75rem; padding:0.6rem; border-radius:4px; background:#fff3cd; color:#856404; border:1px solid #ffeeba; font-size:0.9rem;", + "{err}" + } + } + + if let Some(uname) = pending_assign_username() { + if pending_assign_user_id().is_some() { + p { class: "text-secondary", style: "margin-bottom:0.75rem; font-size:0.9rem;", + "User " + strong { "{uname}" } + " was created. Retry assignment or switch to Existing User mode." + } + } + } + + // ── Existing User mode ──────────────────────────── + if add_mode() == "existing" { + p { class: "text-secondary", style: "margin-bottom:0.75rem; font-size:0.9rem;", + "Select an existing NX9-Auth user. This does not create a new user account." + } + div { class: "stack", + label { style: "font-weight:600; font-size:0.85rem;", "User" } + select { + class: "form-control", + value: "{add_user_id()}", + disabled: add_busy(), + onchange: move |e| { + add_user_id.set(e.value()); + add_form_error.set(None); + }, + option { value: "", "Select user…" } + for u in all_users() { + if !members().iter().any(|m| m.user_id == u.id) { + option { value: "{u.id}", "{u.username} ({u.status})" } + } + } + } + label { style: "font-weight:600; font-size:0.85rem; margin-top:0.75rem;", "Membership role" } + select { + class: "form-control", + value: "{add_role()}", + disabled: add_busy(), + onchange: move |e| add_role.set(e.value()), + option { value: "member", "member" } + option { value: "admin", "admin" } + option { value: "owner", "owner" } + } + } + div { class: "modal-footer", style: "margin-top:1rem; padding:0; border:none; background:transparent;", + button { + class: "btn btn-outline", r#type: "button", + disabled: add_busy(), + onclick: move |_| { + show_add_member.set(false); + new_password.set(String::new()); + add_form_error.set(None); + add_busy.set(false); + }, + "Cancel" + } + button { + class: "btn btn-primary", r#type: "button", + disabled: add_user_id().is_empty() || add_busy(), + onclick: move |_| { + let uid = add_user_id(); + let role = add_role(); + let aid = app_id2.clone(); + add_busy.set(true); + add_form_error.set(None); + spawn(async move { + match api::add_application_member(&aid, &uid, Some(&role)).await { + Ok(_) => { + state.toast(ToastKind::Success, "User added to application"); + show_add_member.set(false); + add_user_id.set(String::new()); + add_role.set("member".into()); + pending_assign_user_id.set(None); + pending_assign_username.set(None); + add_busy.set(false); + reload.call(()); + } + Err(e) => { + add_form_error.set(Some(e.to_string())); + state.toast(ToastKind::Error, e.to_string()); + add_busy.set(false); + } + } + }); + }, + if add_busy() { "Adding…" } else { "Add User" } + } + } + } + + // ── New User mode ───────────────────────────────── + if add_mode() == "new" { + p { class: "text-secondary", style: "margin-bottom:0.75rem; font-size:0.9rem;", + "Creates a normal NX9-Auth user account, then assigns them to this application. " + "Application membership roles do not grant global administrative permissions." + } + div { class: "stack", + TextInput { + label: "Username", + value: new_username(), + oninput: move |v| { + new_username.set(v); + add_form_error.set(None); + }, + required: true, + } + PasswordInput { + label: "Password", + value: new_password(), + oninput: move |v| { + new_password.set(v); + add_form_error.set(None); + }, + required: true, + autocomplete: "new-password", + } + p { class: "form-hint text-muted", style: "font-size:0.8rem; margin:0;", + "Minimum 8 characters. Avoid common sequences like \"password\"." + } + label { style: "font-weight:600; font-size:0.85rem; margin-top:0.75rem;", "Membership role" } + select { + class: "form-control", + value: "{add_role()}", + disabled: add_busy(), + onchange: move |e| add_role.set(e.value()), + option { value: "member", "member" } + option { value: "admin", "admin" } + option { value: "owner", "owner" } + } + } + div { class: "modal-footer", style: "margin-top:1rem; padding:0; border:none; background:transparent; gap:0.5rem; flex-wrap:wrap;", + button { + class: "btn btn-outline", r#type: "button", + disabled: add_busy(), + onclick: move |_| { + show_add_member.set(false); + new_password.set(String::new()); + add_form_error.set(None); + add_busy.set(false); + }, + "Cancel" + } + if pending_assign_user_id().is_some() { + button { + class: "btn btn-outline", r#type: "button", + disabled: add_busy(), + onclick: move |_| { + if let Some(uid) = pending_assign_user_id() { + add_user_id.set(uid); + } + add_mode.set("existing".into()); + add_form_error.set(None); + }, + "Use Existing User mode" + } + button { + class: "btn btn-primary", r#type: "button", + disabled: add_busy() || pending_assign_user_id().is_none(), + onclick: move |_| { + let Some(uid) = pending_assign_user_id() else { return }; + let role = add_role(); + let aid = app_id2b.clone(); + add_busy.set(true); + add_form_error.set(None); + spawn(async move { + match api::add_application_member(&aid, &uid, Some(&role)).await { + Ok(_) => { + state.toast(ToastKind::Success, "User added to application"); + show_add_member.set(false); + new_username.set(String::new()); + new_password.set(String::new()); + add_role.set("member".into()); + pending_assign_user_id.set(None); + pending_assign_username.set(None); + add_busy.set(false); + reload.call(()); + } + Err(e) => { + add_form_error.set(Some(format!( + "User was created successfully, but could not be added to this application. {e}" + ))); + state.toast(ToastKind::Error, e.to_string()); + add_busy.set(false); + } + } + }); + }, + if add_busy() { "Retrying…" } else { "Retry assignment" } + } + } else { + button { + class: "btn btn-primary", r#type: "button", + disabled: add_busy() + || new_username().trim().is_empty() + || new_password().len() < 8, + onclick: move |_| { + let username = new_username().trim().to_string(); + let password = new_password(); + let role = add_role(); + let aid = app_id2c.clone(); + if username.is_empty() { + add_form_error.set(Some("username is required".into())); + return; + } + if password.len() < 8 { + add_form_error.set(Some( + "password must be at least 8 characters long".into(), + )); + return; + } + add_busy.set(true); + add_form_error.set(None); + spawn(async move { + // 1) Canonical user creation + let created = match api::create_user(&username, &password).await { + Ok(u) => u, + Err(e) => { + add_form_error.set(Some(e.to_string())); + state.toast(ToastKind::Error, e.to_string()); + add_busy.set(false); + return; + } + }; + // Clear password from UI after successful create. + new_password.set(String::new()); + + // 2) Application membership via existing API + match api::add_application_member( + &aid, + &created.id, + Some(&role), + ) + .await + { + Ok(_) => { + state.toast( + ToastKind::Success, + "User created and added to application", + ); + show_add_member.set(false); + new_username.set(String::new()); + add_role.set("member".into()); + pending_assign_user_id.set(None); + pending_assign_username.set(None); + add_busy.set(false); + reload.call(()); + } + Err(e) => { + pending_assign_user_id + .set(Some(created.id.clone())); + pending_assign_username + .set(Some(created.username.clone())); + add_user_id.set(created.id.clone()); + add_form_error.set(Some(format!( + "User was created successfully, but could not be added to this application. {e}" + ))); + state.toast( + ToastKind::Error, + format!( + "User created, but assignment failed: {e}" + ), + ); + add_busy.set(false); + // Refresh user list so new user is selectable. + reload.call(()); + } + } + }); + }, + if add_busy() { "Creating…" } else { "Create & Add User" } + } + } + } + } + } + + // ── Edit configuration modal ────────────────────────── + Modal { + title: "Edit application".to_string(), + open: edit_mode(), + on_close: move |_| edit_mode.set(false), + TextInput { + label: "Name", + value: edit_name(), + oninput: move |v| edit_name.set(v), + } + TextInput { + label: "Slug", + value: edit_slug(), + oninput: move |v| edit_slug.set(v), + } + TextInput { + label: "Description", + value: edit_desc(), + oninput: move |v| edit_desc.set(v), + } + TextInput { + label: "Redirect URLs (comma separated)", + value: edit_redirects(), + oninput: move |v| edit_redirects.set(v), + } + TextInput { + label: "Scopes (comma separated)", + value: edit_scopes(), + oninput: move |v| edit_scopes.set(v), + } + div { class: "row", style: "align-items:center; gap:0.5rem; margin-top:0.5rem;", + input { + r#type: "checkbox", + checked: edit_enabled(), + onchange: move |e| edit_enabled.set(e.checked()), + } + label { "Enabled" } + } + p { class: "form-hint text-muted", "Client ID cannot be changed." } + div { class: "modal-footer", style: "margin-top:1rem; padding:0; border:none; background:transparent;", + button { + class: "btn btn-outline", r#type: "button", + onclick: move |_| edit_mode.set(false), + "Cancel" + } + button { + class: "btn btn-primary", r#type: "button", + onclick: move |_| { + let aid = app_id3.clone(); + let n = edit_name(); + let s = edit_slug(); + let d = if edit_desc().trim().is_empty() { + None + } else { + Some(edit_desc().trim().to_string()) + }; + let r_urls = if edit_redirects().trim().is_empty() { + None + } else { + Some( + edit_redirects() + .split(',') + .map(|x| x.trim().to_string()) + .filter(|x| !x.is_empty()) + .collect::>(), + ) + }; + let sc = if edit_scopes().trim().is_empty() { + None + } else { + Some( + edit_scopes() + .split(',') + .map(|x| x.trim().to_string()) + .filter(|x| !x.is_empty()) + .collect::>(), + ) + }; + let en = edit_enabled(); + spawn(async move { + match api::update_application( + &aid, + &n, + &s, + d.as_deref(), + r_urls, + sc, + en, + ) + .await + { + Ok(_) => { + state.toast(ToastKind::Success, "Application updated"); + edit_mode.set(false); + reload.call(()); + } + Err(e) => state.toast(ToastKind::Error, e.to_string()), + } + }); + }, + "Save" + } + } + } + + // ── Role change modal ───────────────────────────────── + Modal { + title: "Change membership role".to_string(), + open: role_target().is_some(), + on_close: move |_| role_target.set(None), + if let Some((m, _prev)) = role_target() { + p { + "Change membership role for " + strong { "{m.username}" } + " from " + code { "{m.role}" } + " to:" + } + } + select { + class: "form-control", + value: "{change_role_value()}", + onchange: move |e| change_role_value.set(e.value()), + option { value: "member", "member" } + option { value: "admin", "admin" } + option { value: "owner", "owner" } + } + if change_role_value() == "owner" || role_target().as_ref().map(|(m, _)| m.role.as_str()) == Some("owner") { + p { class: "alert alert-warning", style: "margin-top:0.75rem; padding:0.5rem; background:#fff3cd; color:#856404; border-radius:4px; font-size:0.85rem;", + "Owner is application membership metadata only. It does not grant global NX9-Auth administrative permissions." + } + } + div { class: "modal-footer", style: "margin-top:1rem; padding:0; border:none; background:transparent;", + button { + class: "btn btn-outline", r#type: "button", + onclick: move |_| role_target.set(None), + "Cancel" + } + button { + class: "btn btn-primary", r#type: "button", + onclick: move |_| { + if let Some((m, _)) = role_target() { + let aid = app_id4.clone(); + let uid = m.user_id.clone(); + let role = change_role_value(); + spawn(async move { + match api::update_application_member(&aid, &uid, Some(&role), None).await { + Ok(_) => { + state.toast(ToastKind::Success, "Membership role updated"); + role_target.set(None); + reload.call(()); + } + Err(e) => state.toast(ToastKind::Error, e.to_string()), + } + }); + } + }, + "Change Role" + } + } + } + + // ── Remove confirmation ─────────────────────────────── + ConfirmDialog { + title: "Remove user from application".to_string(), + message: format!( + "Remove \"{}\" from this application? This revokes the user's membership in this application. It does not delete the NX9-Auth user account.", + remove_target().as_ref().map(|m| m.username.as_str()).unwrap_or("") + ), + open: remove_target().is_some(), + confirm_label: "Remove", + danger: true, + on_confirm: move |_| { + if let Some(m) = remove_target() { + let aid = app_id_remove.clone(); + let uid = m.user_id.clone(); + spawn(async move { + match api::remove_application_member(&aid, &uid).await { + Ok(_) => { + state.toast(ToastKind::Success, "User removed from application"); + remove_target.set(None); + reload.call(()); + } + Err(e) => state.toast(ToastKind::Error, e.to_string()), + } + }); + } + }, + on_cancel: move |_| remove_target.set(None), + } + + // ── Rotate secret confirmation ──────────────────────── + ConfirmDialog { + title: "Rotate Client Secret".to_string(), + message: format!( + "Are you sure you want to rotate the client secret for \"{}\"? Any existing client using the current secret will be invalidated immediately.", + app_name + ), + open: rotate_confirm(), + confirm_label: "Rotate Secret", + danger: true, + on_confirm: move |_| { + let aid = app_id_rotate.clone(); + spawn(async move { + match api::rotate_application_secret(&aid).await { + Ok(sec) => { + state.toast(ToastKind::Success, "Client secret rotated"); + rotate_confirm.set(false); + one_time_secret.set(Some(sec)); + reload.call(()); + } + Err(e) => state.toast(ToastKind::Error, e.to_string()), + } + }); + }, + on_cancel: move |_| rotate_confirm.set(false), + } + + // ── One-time secret modal ───────────────────────────── + if let Some(sec) = one_time_secret() { + Modal { + title: "Client Credentials Disclosed".to_string(), + open: true, + on_close: move |_| one_time_secret.set(None), + div { class: "alert alert-warning", style: "margin-bottom: 1rem; padding: 0.75rem; border-radius: 4px; background: #fff3cd; color: #856404; border: 1px solid #ffeeba;", + strong { "Important: " } + "Store this client secret securely. It will never be displayed again after closing this dialog." + } + div { style: "display: flex; flex-direction: column; gap: 0.75rem;", + div { + label { style: "font-weight: 600; display: block; font-size: 0.85rem;", "Client ID" } + code { style: "display:block; padding: 0.4rem; background: #f8f9fa; border: 1px solid #e9ecef; border-radius: 4px;", + "{a.client_id}" + } + } + div { + label { style: "font-weight: 600; display: block; font-size: 0.85rem;", "Client Secret" } + code { style: "display:block; padding: 0.4rem; background: #f8f9fa; border: 1px solid #e9ecef; border-radius: 4px; color: #d63384; word-break: break-all;", + "{sec}" + } + } + } + div { class: "modal-footer", style: "margin-top:1.5rem; padding:0; border:none; background:transparent; justify-content: flex-end;", + button { + class: "btn btn-primary", r#type: "button", + onclick: move |_| one_time_secret.set(None), + "I have saved my secret" + } + } + } + } + + // ── Delete confirmation ─────────────────────────────── + ConfirmDialog { + title: "Delete application".to_string(), + message: format!("Delete application \"{}\"? Memberships will be removed. This cannot be undone.", a.name), + open: delete_confirm(), + confirm_label: "Delete", + danger: true, + on_confirm: move |_| { + let aid = app_id_delete.clone(); + spawn(async move { + match api::delete_application(&aid).await { + Ok(_) => { + state.toast(ToastKind::Success, "Application deleted"); + let _ = dioxus_router::hooks::use_navigator() + .replace(Route::ApplicationsPage {}); + } + Err(e) => state.toast(ToastKind::Error, e.to_string()), + } + }); + }, + on_cancel: move |_| delete_confirm.set(false), + } + } + } + } + } +} diff --git a/ui/src/pages/audit/mod.rs b/ui/src/pages/audit/mod.rs index 432ffbf..d08ca4f 100644 --- a/ui/src/pages/audit/mod.rs +++ b/ui/src/pages/audit/mod.rs @@ -84,10 +84,38 @@ pub fn AuditPage() -> Element { } div { class: "row", button { - class: "btn btn-outline", r#type: "button", - title: "Export is a placeholder", - onclick: move |_| {}, - "Export (soon)" + class: "btn btn-outline", + r#type: "button", + title: "Export filtered audit log records as CSV", + 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::() { + html_elem.click(); + } + } + } + } + } + }, + "Export CSV" } button { class: "btn btn-outline", r#type: "button", onclick: move |_| load.call(()), "Refresh" } } @@ -231,3 +259,44 @@ fn urlencoding_lite(s: &str) -> String { }) .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::() { + html_elem.click(); + } + } + } + } + } +} diff --git a/ui/src/pages/auth/mod.rs b/ui/src/pages/auth/mod.rs index 086c511..1a5b4ad 100644 --- a/ui/src/pages/auth/mod.rs +++ b/ui/src/pages/auth/mod.rs @@ -74,6 +74,10 @@ pub fn LoginPage() -> Element { .and_then(|v| v.as_str()) .unwrap_or("") .to_string(), + tenant_id: user_val + .get("tenant_id") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), status: user_val .get("status") .and_then(|v| v.as_str()) diff --git a/ui/src/pages/dashboard/mod.rs b/ui/src/pages/dashboard/mod.rs index c59c0e3..79cf044 100644 --- a/ui/src/pages/dashboard/mod.rs +++ b/ui/src/pages/dashboard/mod.rs @@ -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" } - } - } } } } diff --git a/ui/src/pages/profile/mod.rs b/ui/src/pages/profile/mod.rs index 48fdb39..f544092 100644 --- a/ui/src/pages/profile/mod.rs +++ b/ui/src/pages/profile/mod.rs @@ -177,7 +177,7 @@ pub fn ProfilePage() -> Element { } 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: "row", style: "justify-content:space-between;", span { "Avatar upload" } diff --git a/ui/src/pages/roles/mod.rs b/ui/src/pages/roles/mod.rs index cd1e2d3..3a39b3f 100644 --- a/ui/src/pages/roles/mod.rs +++ b/ui/src/pages/roles/mod.rs @@ -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 = roles() .into_iter() diff --git a/ui/src/pages/service_accounts/mod.rs b/ui/src/pages/service_accounts/mod.rs index 58576f4..d529360 100644 --- a/ui/src/pages/service_accounts/mod.rs +++ b/ui/src/pages/service_accounts/mod.rs @@ -46,6 +46,13 @@ pub fn ServiceAccountsPage() -> Element { }); 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() .into_iter() .filter(|s| { diff --git a/ui/src/pages/tenants/mod.rs b/ui/src/pages/tenants/mod.rs index b04ff14..d39e885 100644 --- a/ui/src/pages/tenants/mod.rs +++ b/ui/src/pages/tenants/mod.rs @@ -2,7 +2,7 @@ use crate::components::feedback::{ConfirmDialog, EmptyState, ErrorState, Loading use crate::components::forms::TextInput; use crate::components::navigation::Breadcrumb; use crate::components::tables::{DataTable, ColumnDef}; -use crate::models::TenantView; +use crate::models::{ApplicationView, AuditEntry, TenantView, UserView}; use crate::routes::Route; use crate::services::api; use crate::state::{AppState, ToastKind}; @@ -38,6 +38,13 @@ pub fn TenantsPage() -> Element { 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 q = query(); let sk = sort_key(); @@ -227,21 +234,44 @@ pub fn TenantDetailPage(id: String) -> Element { let mut error = use_signal(|| Option::::None); 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_slug = use_signal(String::new); + let mut tenant_users = use_signal(Vec::::new); + let mut all_users = use_signal(Vec::::new); + let mut tenant_apps = use_signal(Vec::::new); + let mut activity = use_signal(Vec::::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::::None); let mut confirm_delete = use_signal(|| false); let tenant_id = id.clone(); let reload = use_callback(move |_: ()| { let id = tenant_id.clone(); loading.set(true); + error.set(None); spawn(async move { match api::get_tenant(&id).await { Ok(t) => { edit_name.set(t.name.clone()); edit_slug.set(t.slug.clone()); 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); } 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! { Breadcrumb { items: vec![ @@ -267,68 +310,424 @@ pub fn TenantDetailPage(id: String) -> Element { ErrorState { message: err, on_retry: move |_| reload.call(()) } } else if let Some(t) = tenant() { { - let tid = t.id.clone(); - let tid2 = t.id.clone(); - let tid3 = t.id.clone(); + let tid_save = t.id.clone(); + let tid_assign = 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 = tenant_users().iter().map(|u| u.id.clone()).collect(); + let assignable_users: Vec = all_users() + .into_iter() + .filter(|u| !current_member_ids.contains(&u.id)) + .collect(); + + let filtered_members: Vec = { + let q = user_query(); + tenant_users() + .into_iter() + .filter(|u| matches_query(&u.username, &q)) + .collect() + }; + rsx! { div { class: "page-header", div { h1 { "{t.name}" } - p { class: "desc", "Tenant configuration and overview" } + p { class: "desc", + code { "{t.slug}" } + " · Tenant ID: " + code { "{t.id}" } + } } } - div { class: "grid-2", + 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: "card", + div { class: "card-header", h3 { "Tenant Details" } } + div { class: "card-body", + TextInput { + label: "Name", + value: edit_name(), + oninput: move |v| edit_name.set(v), + } + TextInput { + label: "Slug", + value: edit_slug(), + 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 { + class: "btn btn-primary mt-2", + r#type: "button", + onclick: move |_| { + let n = edit_name(); + let s = edit_slug(); + let tid = tid_save.clone(); + spawn(async move { + match api::update_tenant(&tid, &n, Some(s.as_str()).filter(|s| !s.is_empty())).await { + Ok(_) => { + state.toast(ToastKind::Success, "Tenant updated"); + reload.call(()); + } + Err(e) => state.toast(ToastKind::Error, e.to_string()), + } + }); + }, + "Save changes" + } + } + } + + div { class: "card", + div { class: "card-header", h3 { "Danger Zone" } } + div { class: "card-body", + p { "Deleting a tenant is permanent and cannot be undone." } + button { + class: "btn btn-danger", + r#type: "button", + disabled: is_default_tenant, + onclick: move |_| confirm_delete.set(true), + "Delete Tenant" + } + } + } + } + } + + // ── Tab: Users (Tenant User Assignment) ─────────────────── + if tab() == "users" { div { class: "card", - div { class: "card-header", h3 { "Tenant Details" } } + 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", - TextInput { - label: "Name", - value: edit_name(), - oninput: move |v| edit_name.set(v), + 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" } + } + } + } + } + } + } } - TextInput { - label: "Slug", - value: edit_slug(), - oninput: move |v| edit_slug.set(v), + } + } + } + + // ── 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" + } + } + } + } + } + } } - button { - class: "btn btn-primary mt-2", - r#type: "button", - onclick: move |_| { - let n = edit_name(); - let s = edit_slug(); - let tid = tid.clone(); + } + } + } + + // ── 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| 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::update_tenant(&tid, &n, Some(s.as_str()).filter(|s| !s.is_empty())).await { + match api::assign_tenant_user(&tid, &uid).await { Ok(_) => { - state.toast(ToastKind::Success, "Tenant updated"); + state.toast(ToastKind::Success, "User reassigned to tenant"); reload.call(()); } Err(e) => state.toast(ToastKind::Error, e.to_string()), } }); }, - "Save changes" - } - } - } - - div { class: "card", - div { class: "card-header", h3 { "Danger Zone" } } - div { class: "card-body", - p { "Deleting a tenant is permanent and cannot be undone." } - button { - class: "btn btn-danger", - r#type: "button", - disabled: tid2 == "00000000-0000-0000-0000-000000000001", - onclick: move |_| confirm_delete.set(true), - "Delete Tenant" + 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 { title: "Delete tenant".to_string(), message: format!("Delete tenant \"{}\"? This cannot be undone.", t.name), @@ -336,7 +735,7 @@ pub fn TenantDetailPage(id: String) -> Element { confirm_label: "Delete", danger: true, on_confirm: move |_| { - let tid = tid3.clone(); + let tid = tid_delete.clone(); spawn(async move { match api::delete_tenant(&tid).await { Ok(_) => { diff --git a/ui/src/pages/users/mod.rs b/ui/src/pages/users/mod.rs index 388bfa5..89a3ffe 100644 --- a/ui/src/pages/users/mod.rs +++ b/ui/src/pages/users/mod.rs @@ -49,6 +49,13 @@ pub fn UsersPage() -> Element { 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 q = query(); let sf = status_filter(); @@ -305,9 +312,14 @@ pub fn UsersPage() -> Element { #[component] pub fn UserDetailPage(id: String) -> Element { let state = use_context::(); + let state_auth = state.auth; + let can_manage_apps = state_auth().has_permission("applications:manage"); + let mut user = use_signal(|| Option::::None); let mut roles = use_signal(Vec::::new); let mut all_roles = use_signal(Vec::::new); + let mut user_apps = + use_signal(Vec::::new); let mut error = use_signal(|| Option::::None); let mut loading = use_signal(|| true); 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 { 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); } 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)}" } + } + } + } + } + } + } + } } } } diff --git a/ui/src/routes/mod.rs b/ui/src/routes/mod.rs index 483718d..bd3d7bf 100644 --- a/ui/src/routes/mod.rs +++ b/ui/src/routes/mod.rs @@ -3,7 +3,7 @@ use crate::components::layout::AppLayout; use crate::pages::{ about::AboutPage, - applications::ApplicationsPage, + applications::{ApplicationDetailPage, ApplicationsPage}, audit::AuditPage, auth::{ForbiddenPage, LoginPage, UnauthorizedPage}, dashboard::DashboardPage, @@ -77,6 +77,9 @@ pub enum Route { #[route("/applications")] ApplicationsPage {}, + #[route("/applications/:id")] + ApplicationDetailPage { id: String }, + #[route("/service-accounts")] ServiceAccountsPage {}, diff --git a/ui/src/services/api.rs b/ui/src/services/api.rs index 2496750..63823b4 100644 --- a/ui/src/services/api.rs +++ b/ui/src/services/api.rs @@ -435,6 +435,58 @@ pub async fn delete_application(id: &str) -> Result<(), ApiError> { Ok(()) } +pub async fn get_application(id: &str) -> Result { + 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, 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 { + 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, +) -> Result { + 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, ApiError> { + let r: UserApplicationsResponse = get(&format!("/users/{user_id}/applications")).await?; + Ok(r.applications) +} + // ── Service accounts ────────────────────────────────────────────────────────── pub async fn list_service_accounts() -> Result, ApiError> { @@ -566,3 +618,27 @@ pub async fn delete_tenant(id: &str) -> Result<(), ApiError> { let _: Value = delete_json(&format!("/tenants/{id}")).await?; Ok(()) } + +pub async fn list_tenant_users(tenant_id: &str) -> Result, 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 { + 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, 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())) +} diff --git a/ui/src/utils/mod.rs b/ui/src/utils/mod.rs index 50b42e7..2b5f7f0 100644 --- a/ui/src/utils/mod.rs +++ b/ui/src/utils/mod.rs @@ -75,3 +75,47 @@ pub fn slugify(s: &str) -> String { .collect::>() .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 +}