This commit is contained in:
2025-11-06 22:21:27 +01:00
parent a6f79dbc75
commit ef2ed2dc58
23 changed files with 4371 additions and 3482 deletions
+294
View File
@@ -0,0 +1,294 @@
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<ApiCapability>,
}
#[derive(Deserialize, utoipa::ToSchema)]
pub struct CreateCapabilitySetRequest {
#[serde(default)]
#[serde(rename = "slug")]
pub slug: Option<String>,
pub capabilities: Vec<ApiCapability>,
}
#[derive(Deserialize, utoipa::ToSchema)]
pub struct UpdateCapabilitySetRequest {
#[serde(default)]
#[serde(rename = "slug")]
pub slug: Option<String>,
#[serde(default)]
pub capabilities: Option<Vec<ApiCapability>>,
}
pub struct CapabilitySetService;
impl CapabilitySetService {
pub fn new() -> Self {
Self
}
pub fn list(
&self,
conn: &mut PgConnection,
tenant_id: Uuid,
) -> AppResult<JsonResponse<Vec<CapabilitySetResponse>>> {
let sets = cs_dsl::capability_sets
.filter(cs_dsl::tenant_id.eq(tenant_id))
.order(cs_dsl::slug.asc())
.load::<CapabilitySet>(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<JsonResponse<Vec<ApiCapability>>> {
let capabilities = ApiCapability::variants()
.iter()
.map(|value| value.parse::<ApiCapability>().expect("valid capability"))
.collect();
ok_json(capabilities)
}
pub fn get(
&self,
conn: &mut PgConnection,
tenant_id: Uuid,
id: Uuid,
) -> AppResult<JsonResponse<CapabilitySetResponse>> {
let set = cs_dsl::capability_sets
.filter(cs_dsl::tenant_id.eq(tenant_id))
.find(id)
.first::<CapabilitySet>(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<JsonResponse<CapabilitySetResponse>> {
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<JsonResponse<CapabilitySetResponse>> {
let set = cs_dsl::capability_sets
.filter(cs_dsl::tenant_id.eq(tenant_id))
.find(id)
.first::<CapabilitySet>(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::<CapabilitySet>(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::<CapabilitySet>(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<StatusCode> {
let set = cs_dsl::capability_sets
.filter(cs_dsl::tenant_id.eq(tenant_id))
.find(id)
.first::<CapabilitySet>(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<String> {
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<ApiCapability>) -> CapabilitySetResponse {
CapabilitySetResponse {
id: set.id,
slug: set.slug,
is_system: set.is_system,
cap_version: set.cap_version,
capabilities,
}
}