feat: complete NX9-Auth management and integrity hardening
This commit is contained in:
1 parent
dc5417334b
commit
a969f9c571
59 files changed
+6508
-290
No files matched your search
@@ -0,0 +1,331 @@
|
||||
//! Application membership domain logic.
|
||||
//!
|
||||
//! Assigns existing NX9-Auth users to registered applications.
|
||||
//! Membership roles (owner/admin/member) are lightweight metadata only and
|
||||
//! MUST NOT grant global RBAC permissions such as `applications:manage`.
|
||||
|
||||
use crate::db::models::{Application, ApplicationMember, ApplicationMembershipRole};
|
||||
use crate::error::AppError;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn parse_role(role: Option<&str>) -> Result<ApplicationMembershipRole, AppError> {
|
||||
match role {
|
||||
None | Some("") => Ok(ApplicationMembershipRole::Member),
|
||||
Some(r) => ApplicationMembershipRole::parse(r).ok_or_else(|| {
|
||||
AppError::InvalidInput(format!(
|
||||
"invalid membership role '{r}'; allowed values are owner, admin, member"
|
||||
))
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// List members of an application.
|
||||
pub async fn list_by_application(
|
||||
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
|
||||
application_id: &str,
|
||||
) -> Result<Vec<ApplicationMember>, AppError> {
|
||||
// Ensure application exists
|
||||
let _ = provider
|
||||
.applications()
|
||||
.find_by_id(application_id)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
|
||||
provider
|
||||
.application_members()
|
||||
.list_by_application(application_id)
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
/// List application memberships for a user.
|
||||
pub async fn list_by_user(
|
||||
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
|
||||
user_id: &str,
|
||||
) -> Result<Vec<ApplicationMember>, AppError> {
|
||||
let _ = provider
|
||||
.users()
|
||||
.find_by_id(user_id)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
|
||||
provider
|
||||
.application_members()
|
||||
.list_by_user(user_id)
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
/// Find a single membership.
|
||||
pub async fn find(
|
||||
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
|
||||
application_id: &str,
|
||||
user_id: &str,
|
||||
) -> Result<Option<ApplicationMember>, AppError> {
|
||||
provider
|
||||
.application_members()
|
||||
.find(application_id, user_id)
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
/// Assign an existing same-tenant user to an application.
|
||||
pub async fn add(
|
||||
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
|
||||
application_id: &str,
|
||||
user_id: &str,
|
||||
role: Option<&str>,
|
||||
audit_actor_id: Option<&str>,
|
||||
audit_ip: Option<&str>,
|
||||
audit_ua: Option<&str>,
|
||||
) -> Result<ApplicationMember, AppError> {
|
||||
let role = parse_role(role)?;
|
||||
|
||||
let app = provider
|
||||
.applications()
|
||||
.find_by_id(application_id)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
|
||||
let user = provider
|
||||
.users()
|
||||
.find_by_id(user_id)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
|
||||
// Tenant isolation: never allow cross-tenant assignment.
|
||||
if user.tenant_id != app.tenant_id {
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
|
||||
if provider
|
||||
.application_members()
|
||||
.find(application_id, user_id)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.is_some()
|
||||
{
|
||||
return Err(AppError::Conflict(
|
||||
"user is already a member of this application".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let id = Uuid::new_v4().to_string();
|
||||
|
||||
let metadata = serde_json::json!({
|
||||
"application_id": application_id,
|
||||
"user_id": user_id,
|
||||
"role": role.as_str(),
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let audit_event = crate::audit::AuditEvent {
|
||||
actor_id: audit_actor_id,
|
||||
target_id: Some(user_id),
|
||||
action: "application.member_added",
|
||||
resource_type: "application",
|
||||
resource_id: Some(application_id),
|
||||
severity: crate::db::models::AuditSeverity::Info,
|
||||
ip: audit_ip,
|
||||
ua: audit_ua,
|
||||
metadata: Some(&metadata),
|
||||
};
|
||||
|
||||
let member = provider
|
||||
.application_members()
|
||||
.add_with_audit(
|
||||
&id,
|
||||
application_id,
|
||||
user_id,
|
||||
role.as_str(),
|
||||
Some(audit_event),
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let _ = app;
|
||||
Ok(member)
|
||||
}
|
||||
|
||||
/// Update membership role and/or enabled state.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn update(
|
||||
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
|
||||
application_id: &str,
|
||||
user_id: &str,
|
||||
role: Option<&str>,
|
||||
enabled: Option<bool>,
|
||||
audit_actor_id: Option<&str>,
|
||||
audit_ip: Option<&str>,
|
||||
audit_ua: Option<&str>,
|
||||
) -> Result<ApplicationMember, AppError> {
|
||||
if role.is_none() && enabled.is_none() {
|
||||
return Err(AppError::InvalidInput(
|
||||
"at least one of role or enabled must be provided".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Ensure application exists
|
||||
let _ = provider
|
||||
.applications()
|
||||
.find_by_id(application_id)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
|
||||
let existing = provider
|
||||
.application_members()
|
||||
.find(application_id, user_id)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
|
||||
if let Some(role_str) = role {
|
||||
let new_role = parse_role(Some(role_str))?;
|
||||
if new_role.as_str() != existing.role {
|
||||
let previous_role = existing.role.clone();
|
||||
let metadata = serde_json::json!({
|
||||
"application_id": application_id,
|
||||
"user_id": user_id,
|
||||
"previous_role": previous_role,
|
||||
"new_role": new_role.as_str(),
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let audit_event = crate::audit::AuditEvent {
|
||||
actor_id: audit_actor_id,
|
||||
target_id: Some(user_id),
|
||||
action: "application.member_role_changed",
|
||||
resource_type: "application",
|
||||
resource_id: Some(application_id),
|
||||
severity: crate::db::models::AuditSeverity::Info,
|
||||
ip: audit_ip,
|
||||
ua: audit_ua,
|
||||
metadata: Some(&metadata),
|
||||
};
|
||||
|
||||
provider
|
||||
.application_members()
|
||||
.update_role_with_audit(
|
||||
application_id,
|
||||
user_id,
|
||||
new_role.as_str(),
|
||||
Some(audit_event),
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(new_enabled) = enabled {
|
||||
if new_enabled != existing.enabled {
|
||||
let action = if new_enabled {
|
||||
"application.member_enabled"
|
||||
} else {
|
||||
"application.member_disabled"
|
||||
};
|
||||
|
||||
let metadata = serde_json::json!({
|
||||
"application_id": application_id,
|
||||
"user_id": user_id,
|
||||
"role": existing.role,
|
||||
"enabled": new_enabled,
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let audit_event = crate::audit::AuditEvent {
|
||||
actor_id: audit_actor_id,
|
||||
target_id: Some(user_id),
|
||||
action,
|
||||
resource_type: "application",
|
||||
resource_id: Some(application_id),
|
||||
severity: crate::db::models::AuditSeverity::Info,
|
||||
ip: audit_ip,
|
||||
ua: audit_ua,
|
||||
metadata: Some(&metadata),
|
||||
};
|
||||
|
||||
provider
|
||||
.application_members()
|
||||
.set_enabled_with_audit(application_id, user_id, new_enabled, Some(audit_event))
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
}
|
||||
}
|
||||
|
||||
provider
|
||||
.application_members()
|
||||
.find(application_id, user_id)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound)
|
||||
}
|
||||
|
||||
/// Remove a user from an application (does not delete the user account).
|
||||
pub async fn remove(
|
||||
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
|
||||
application_id: &str,
|
||||
user_id: &str,
|
||||
audit_actor_id: Option<&str>,
|
||||
audit_ip: Option<&str>,
|
||||
audit_ua: Option<&str>,
|
||||
) -> Result<(), AppError> {
|
||||
// Ensure application exists
|
||||
let _ = provider
|
||||
.applications()
|
||||
.find_by_id(application_id)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
|
||||
let existing = provider
|
||||
.application_members()
|
||||
.find(application_id, user_id)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
|
||||
let metadata = serde_json::json!({
|
||||
"application_id": application_id,
|
||||
"user_id": user_id,
|
||||
"role": existing.role,
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let audit_event = crate::audit::AuditEvent {
|
||||
actor_id: audit_actor_id,
|
||||
target_id: Some(user_id),
|
||||
action: "application.member_removed",
|
||||
resource_type: "application",
|
||||
resource_id: Some(application_id),
|
||||
severity: crate::db::models::AuditSeverity::Info,
|
||||
ip: audit_ip,
|
||||
ua: audit_ua,
|
||||
metadata: Some(&metadata),
|
||||
};
|
||||
|
||||
provider
|
||||
.application_members()
|
||||
.remove_with_audit(application_id, user_id, Some(audit_event))
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Helper used by API responses that need application details for a membership.
|
||||
pub async fn load_application(
|
||||
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
|
||||
application_id: &str,
|
||||
) -> Result<Application, AppError> {
|
||||
provider
|
||||
.applications()
|
||||
.find_by_id(application_id)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound)
|
||||
}
|
||||
@@ -108,11 +108,13 @@ pub async fn create(
|
||||
"name and slug cannot be empty".into(),
|
||||
));
|
||||
}
|
||||
crate::identity::slug::validate_slug(slug)?;
|
||||
|
||||
if let Some(ref uris) = redirect_uris {
|
||||
validate_redirect_uris(uris)?;
|
||||
}
|
||||
if provider
|
||||
.applications()
|
||||
.global_slugs()
|
||||
.find_by_slug(slug)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
@@ -312,16 +314,18 @@ pub async fn update(
|
||||
"name and slug cannot be empty".into(),
|
||||
));
|
||||
}
|
||||
crate::identity::slug::validate_slug(slug)?;
|
||||
|
||||
if let Some(ref uris) = redirect_uris {
|
||||
validate_redirect_uris(uris)?;
|
||||
}
|
||||
if let Some(other) = provider
|
||||
.applications()
|
||||
.global_slugs()
|
||||
.find_by_slug(slug)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
{
|
||||
if other.id != id {
|
||||
if other.entity_id != id || other.entity_type != "application" {
|
||||
return Err(AppError::Conflict(format!("slug '{slug}' already exists")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
pub mod application_members;
|
||||
pub mod applications;
|
||||
pub mod permissions;
|
||||
pub mod roles;
|
||||
pub mod service_accounts;
|
||||
pub mod slug;
|
||||
pub mod users;
|
||||
@@ -0,0 +1,133 @@
|
||||
use crate::error::AppError;
|
||||
|
||||
pub const RESERVED_SLUGS: &[&str] = &[
|
||||
"admin",
|
||||
"api",
|
||||
"system",
|
||||
"auth",
|
||||
"login",
|
||||
"logout",
|
||||
"dashboard",
|
||||
"health",
|
||||
"metrics",
|
||||
"root",
|
||||
"public",
|
||||
"private",
|
||||
"null",
|
||||
"undefined",
|
||||
"config",
|
||||
"settings",
|
||||
"account",
|
||||
"accounts",
|
||||
"role",
|
||||
"roles",
|
||||
"permission",
|
||||
"permissions",
|
||||
"group",
|
||||
"groups",
|
||||
"service-account",
|
||||
"service-accounts",
|
||||
];
|
||||
|
||||
/// Validates an explicit or derived slug string according to server-side policy:
|
||||
/// - Must be 2..=63 characters in length.
|
||||
/// - Must consist only of lowercase ASCII alphanumeric characters ('a'..='z', '0'..='9') and hyphens ('-').
|
||||
/// - Cannot start or end with a hyphen.
|
||||
/// - Cannot contain consecutive hyphens ("--").
|
||||
/// - Cannot be one of the reserved slug names (except "default" which is preserved for built-in tenant).
|
||||
pub fn validate_slug(slug: &str) -> Result<(), AppError> {
|
||||
let s = slug.trim();
|
||||
if s.is_empty() {
|
||||
return Err(AppError::InvalidInput("slug cannot be empty".into()));
|
||||
}
|
||||
if s.len() < 2 || s.len() > 63 {
|
||||
return Err(AppError::InvalidInput(format!(
|
||||
"slug length must be between 2 and 63 characters, got {}",
|
||||
s.len()
|
||||
)));
|
||||
}
|
||||
if s.starts_with('-') || s.ends_with('-') {
|
||||
return Err(AppError::InvalidInput(
|
||||
"slug cannot start or end with a hyphen".into(),
|
||||
));
|
||||
}
|
||||
if s.contains("--") {
|
||||
return Err(AppError::InvalidInput(
|
||||
"slug cannot contain consecutive hyphens".into(),
|
||||
));
|
||||
}
|
||||
for ch in s.chars() {
|
||||
if !ch.is_ascii_lowercase() && !ch.is_ascii_digit() && ch != '-' {
|
||||
return Err(AppError::InvalidInput(format!(
|
||||
"slug contains invalid character '{ch}'; only lowercase alphanumeric characters and hyphens are allowed"
|
||||
)));
|
||||
}
|
||||
}
|
||||
if RESERVED_SLUGS.contains(&s) {
|
||||
return Err(AppError::InvalidInput(format!(
|
||||
"slug '{s}' is reserved by system"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Slugifies a display name when CREATE omits an explicit slug.
|
||||
/// Converts non-alphanumeric characters to hyphens, lowercases the string,
|
||||
/// collapses repeated hyphens, and validates the result.
|
||||
pub fn slugify(input: &str) -> Result<String, AppError> {
|
||||
let mut slug = String::with_capacity(input.len());
|
||||
let mut prev_hyphen = false;
|
||||
|
||||
for ch in input.chars() {
|
||||
if ch.is_ascii_alphanumeric() {
|
||||
slug.push(ch.to_ascii_lowercase());
|
||||
prev_hyphen = false;
|
||||
} else if !prev_hyphen && !slug.is_empty() {
|
||||
slug.push('-');
|
||||
prev_hyphen = true;
|
||||
}
|
||||
}
|
||||
|
||||
let trimmed = slug.trim_matches('-');
|
||||
if trimmed.is_empty() {
|
||||
return Err(AppError::InvalidInput(
|
||||
"unable to generate valid slug from provided name".into(),
|
||||
));
|
||||
}
|
||||
|
||||
validate_slug(trimmed)?;
|
||||
Ok(trimmed.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_valid_slugs() {
|
||||
assert!(validate_slug("default").is_ok());
|
||||
assert!(validate_slug("my-app-1").is_ok());
|
||||
assert!(validate_slug("acme-corp").is_ok());
|
||||
assert!(validate_slug("xy").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_slugs() {
|
||||
assert!(validate_slug("").is_err());
|
||||
assert!(validate_slug("a").is_err());
|
||||
assert!(validate_slug("-app").is_err());
|
||||
assert!(validate_slug("app-").is_err());
|
||||
assert!(validate_slug("my--app").is_err());
|
||||
assert!(validate_slug("My-App").is_err());
|
||||
assert!(validate_slug("my_app").is_err());
|
||||
assert!(validate_slug("admin").is_err());
|
||||
assert!(validate_slug("api").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_slugify() {
|
||||
assert_eq!(slugify("Acme Corp!").unwrap(), "acme-corp");
|
||||
assert_eq!(slugify("My App 123").unwrap(), "my-app-123");
|
||||
assert!(slugify("!!!").is_err());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user