use axum::http::StatusCode; use chrono::Utc; use diesel::{pg::PgConnection, prelude::*, OptionalExtension}; use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::{ auth::capability_sets::{ compute_slug, create_capability_set as create_capability_set_record, is_system_slug, load_capabilities_for_set, normalize_capabilities, refresh_capability_set, }, error::{AppError, AppResult}, http::responders::{ created_json, no_content, ok_json, IntoAppResult, JsonResponse, RowsAffectedExt, }, models::{ApiCapability, CapabilitySet}, schema::{ api_tokens, capability_sets::{self, dsl as cs_dsl}, user_memberships, }, services::TransactionExt, utils::text::normalize_identifier, }; #[derive(Serialize, utoipa::ToSchema)] pub struct CapabilitySetResponse { pub id: Uuid, pub slug: String, pub is_system: bool, pub cap_version: i32, pub capabilities: Vec, } #[derive(Deserialize, utoipa::ToSchema)] pub struct CreateCapabilitySetRequest { #[serde(default)] #[serde(rename = "slug")] pub slug: Option, pub capabilities: Vec, } #[derive(Deserialize, utoipa::ToSchema)] pub struct UpdateCapabilitySetRequest { #[serde(default)] #[serde(rename = "slug")] pub slug: Option, #[serde(default)] pub capabilities: Option>, } pub struct CapabilitySetService; impl CapabilitySetService { pub fn new() -> Self { Self } pub fn list( &self, conn: &mut PgConnection, tenant_id: Uuid, ) -> AppResult>> { let sets = cs_dsl::capability_sets .filter(cs_dsl::tenant_id.eq(tenant_id)) .order(cs_dsl::slug.asc()) .load::(conn)?; let mut responses = Vec::with_capacity(sets.len()); for set in sets { let capabilities = load_capabilities_for_set(conn, set.id)?; responses.push(to_response(set, capabilities)); } ok_json(responses) } pub fn list_capabilities(&self) -> AppResult>> { let capabilities = ApiCapability::variants() .iter() .map(|value| value.parse::().expect("valid capability")) .collect(); ok_json(capabilities) } pub fn get( &self, conn: &mut PgConnection, tenant_id: Uuid, id: Uuid, ) -> AppResult> { let set = cs_dsl::capability_sets .filter(cs_dsl::tenant_id.eq(tenant_id)) .find(id) .first::(conn) .into_app_result()?; let capabilities = load_capabilities_for_set(conn, set.id)?; ok_json(to_response(set, capabilities)) } pub fn create( &self, conn: &mut PgConnection, tenant_id: Uuid, payload: CreateCapabilitySetRequest, ) -> AppResult> { let original_caps = payload.capabilities; let normalized_caps = normalize_capabilities(original_caps.clone())?; if normalized_caps.is_empty() { return Err(AppError::bad_request("at least one capability is required")); } let slug = if let Some(raw) = payload.slug { let normalized = normalize_slug(&raw)?; if is_system_slug(&normalized) { return Err(AppError::conflict("slug is reserved")); } normalized } else { let generated = compute_slug(&normalized_caps); if is_system_slug(&generated) { return Err(AppError::conflict( "capabilities match a reserved system capability set", )); } generated }; let set = create_capability_set_record(conn, tenant_id, &slug, original_caps)?; let response = to_response(set, normalized_caps); created_json(response) } pub fn update( &self, conn: &mut PgConnection, tenant_id: Uuid, id: Uuid, payload: UpdateCapabilitySetRequest, ) -> AppResult> { let set = cs_dsl::capability_sets .filter(cs_dsl::tenant_id.eq(tenant_id)) .find(id) .first::(conn) .into_app_result()?; if set.is_system { if payload.slug.is_some() || payload.capabilities.is_some() { return Err(AppError::conflict( "system capability sets cannot be modified", )); } let capabilities = load_capabilities_for_set(conn, set.id)?; return ok_json(to_response(set, capabilities)); } let set = conn.app_transaction(|conn| { let mut working = set.clone(); if let Some(slug) = &payload.slug { let normalized = normalize_slug(slug)?; if is_system_slug(&normalized) { return Err(AppError::conflict("slug is reserved")); } if cs_dsl::capability_sets .filter(cs_dsl::tenant_id.eq(tenant_id)) .filter(cs_dsl::slug.eq(&normalized)) .filter(cs_dsl::id.ne(working.id)) .first::(conn) .optional() .into_app_result()? .is_some() { return Err(AppError::conflict("slug already exists")); } diesel::update(cs_dsl::capability_sets.find(working.id)) .set(( cs_dsl::slug.eq(&normalized), cs_dsl::updated_at.eq(Utc::now().naive_utc()), )) .execute(conn) .into_app_result()?; working.slug = normalized; } if let Some(capabilities) = &payload.capabilities { let normalized = normalize_capabilities(capabilities.clone())?; if normalized.is_empty() { return Err(AppError::bad_request("at least one capability is required")); } let updated = refresh_capability_set(conn, &working, &normalized)?; working = updated; } capability_sets::table .find(working.id) .first::(conn) .into_app_result() })?; let capabilities = load_capabilities_for_set(conn, set.id)?; ok_json(to_response(set, capabilities)) } pub fn delete( &self, conn: &mut PgConnection, tenant_id: Uuid, id: Uuid, ) -> AppResult { let set = cs_dsl::capability_sets .filter(cs_dsl::tenant_id.eq(tenant_id)) .find(id) .first::(conn) .into_app_result()?; if set.is_system { return Err(AppError::conflict( "system capability sets cannot be deleted", )); } let in_use_memberships: i64 = user_memberships::table .filter(user_memberships::capability_set_id.eq(Some(set.id))) .count() .get_result(conn)?; if in_use_memberships > 0 { return Err(AppError::conflict( "capability set is assigned to user memberships", )); } let in_use_tokens: i64 = api_tokens::table .filter(api_tokens::capability_set_id.eq(set.id)) .count() .get_result(conn)?; if in_use_tokens > 0 { return Err(AppError::conflict( "capability set is assigned to API tokens", )); } diesel::delete(cs_dsl::capability_sets.find(set.id)) .execute(conn) .into_app_result()? .or_not_found()?; no_content() } } fn normalize_slug(value: &str) -> AppResult { let base = normalize_identifier( value, 64, "slug must not be empty", "slug must not exceed 64 characters", Some("slug may only contain alphanumeric characters, hyphen, underscore, or whitespace"), |ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch.is_whitespace(), )?; let mut normalized = String::with_capacity(base.len()); for ch in base.chars() { if ch.is_whitespace() { normalized.push('-'); } else { normalized.push(ch.to_ascii_lowercase()); } } if normalized.is_empty() { return Err(AppError::bad_request("slug must not be empty")); } Ok(normalized) } fn to_response(set: CapabilitySet, capabilities: Vec) -> CapabilitySetResponse { CapabilitySetResponse { id: set.id, slug: set.slug, is_system: set.is_system, cap_version: set.cap_version, capabilities, } }