From b602c8df5322e5d5fa148a4b9d00aaf67f903cae Mon Sep 17 00:00:00 2001 From: Ashwin Kumar Date: Fri, 27 Mar 2026 21:34:28 +0100 Subject: [PATCH] feat(runtime-config): derive enabled modules from internal role permissions --- apps/users/src/handlers/config.rs | 73 +++++++++++++++++++---- apps/users/src/handlers/employees.rs | 2 +- apps/users/src/handlers/external_roles.rs | 2 +- apps/users/src/handlers/roles.rs | 2 +- 4 files changed, 64 insertions(+), 15 deletions(-) diff --git a/apps/users/src/handlers/config.rs b/apps/users/src/handlers/config.rs index 954c52c..838485b 100644 --- a/apps/users/src/handlers/config.rs +++ b/apps/users/src/handlers/config.rs @@ -13,6 +13,7 @@ use db::models::config::{ }; use db::models::user::UserRepository; use serde::Deserialize; +use std::collections::{BTreeMap, BTreeSet}; use uuid::Uuid; pub fn onboarding_router() -> Router { @@ -217,17 +218,24 @@ async fn get_my_runtime_config( auth: contracts::auth_middleware::AuthUser, State(state): State, ) -> Result { - let role_key = auth.claims.active_role.clone(); + let role_key = auth.claims.active_role.clone().to_uppercase(); - let config = ConfigRepository::get_active_runtime_by_role_key(&state.pool, &role_key) + let role = sqlx::query!("SELECT id, key, audience FROM roles WHERE key = $1", role_key) + .fetch_optional(&state.pool) .await - .map_err(|e| match e { - sqlx::Error::RowNotFound => ( - StatusCode::NOT_FOUND, - format!("No runtime config found for role {}", role_key), - ), - e => (StatusCode::INTERNAL_SERVER_ERROR, format!("Database error: {}", e)), - })?; + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Database error: {}", e)))? + .ok_or((StatusCode::NOT_FOUND, format!("Role '{}' not found", role_key)))?; + + let config = match ConfigRepository::get_active_runtime_by_role_key(&state.pool, &role_key).await { + Ok(c) => Some(c), + Err(sqlx::Error::RowNotFound) => None, + Err(e) => { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Database error: {}", e), + )) + } + }; // Fetch live user data to merge into the response so the `user` field is always fresh. let user = UserRepository::get_by_id(&state.pool, auth.user_id) @@ -240,7 +248,12 @@ async fn get_my_runtime_config( // Merge the stored config_json with live user data. // `config_json` holds role/modules/flags/permissions; we add the `user` sub-object. - let mut response = config.config_json.clone(); + let mut response = config + .map(|c| c.config_json) + .unwrap_or_else(|| serde_json::json!({})); + if !response.is_object() { + response = serde_json::json!({}); + } if let Some(obj) = response.as_object_mut() { obj.insert( "user".to_string(), @@ -252,8 +265,44 @@ async fn get_my_runtime_config( "active_role": role_key, }), ); - // Ensure role is always set from the JWT active_role (authoritative) - obj.insert("role".to_string(), serde_json::Value::String(role_key)); + obj.insert("role".to_string(), serde_json::Value::String(role_key.clone())); + obj.insert("audience".to_string(), serde_json::Value::String(role.audience.clone())); + } + + if role.audience == "INTERNAL" { + let permission_keys: Vec = sqlx::query_scalar!( + "SELECT permission_key FROM role_permissions WHERE role_id = $1 ORDER BY permission_key", + role.id + ) + .fetch_all(&state.pool) + .await + .unwrap_or_default(); + + let mut per_module: BTreeMap> = BTreeMap::new(); + for key in permission_keys { + let key = key.trim().to_uppercase(); + let parts: Vec<&str> = key.split('_').collect(); + if parts.len() < 2 { + continue; + } + let action = parts[parts.len() - 1].to_string(); + let module = parts[..parts.len() - 1].join("_"); + if module.is_empty() { + continue; + } + per_module.entry(module).or_default().insert(action); + } + + if let Some(obj) = response.as_object_mut() { + let enabled_modules = per_module.keys().cloned().collect::>(); + obj.insert("enabled_modules".to_string(), serde_json::json!(enabled_modules)); + + let mut permissions_obj = serde_json::Map::new(); + for (module, actions) in per_module { + permissions_obj.insert(module, serde_json::json!(actions.into_iter().collect::>())); + } + obj.insert("permissions".to_string(), serde_json::Value::Object(permissions_obj)); + } } Ok((StatusCode::OK, Json(response))) diff --git a/apps/users/src/handlers/employees.rs b/apps/users/src/handlers/employees.rs index 1bd9177..3dd8475 100644 --- a/apps/users/src/handlers/employees.rs +++ b/apps/users/src/handlers/employees.rs @@ -3,7 +3,7 @@ use axum::{ extract::{Path, Query, State}, http::StatusCode, response::IntoResponse, - routing::{delete, get, post, patch}, + routing::get, Json, Router, }; use contracts::auth_middleware::{AuthUser, require_admin}; diff --git a/apps/users/src/handlers/external_roles.rs b/apps/users/src/handlers/external_roles.rs index 9d45e39..b2cea9f 100644 --- a/apps/users/src/handlers/external_roles.rs +++ b/apps/users/src/handlers/external_roles.rs @@ -3,7 +3,7 @@ use axum::{ extract::{Path, Query, State}, http::StatusCode, response::IntoResponse, - routing::{delete, get, post, put}, + routing::get, Json, Router, }; use serde::{Deserialize, Serialize}; diff --git a/apps/users/src/handlers/roles.rs b/apps/users/src/handlers/roles.rs index 53e9863..c15d156 100644 --- a/apps/users/src/handlers/roles.rs +++ b/apps/users/src/handlers/roles.rs @@ -3,7 +3,7 @@ use axum::{ extract::{Path, Query, State}, http::StatusCode, response::IntoResponse, - routing::{delete, get, patch, post}, + routing::get, Json, Router, }; use serde::{Deserialize, Serialize};