feat: sso claim parser (#9156)

This commit is contained in:
Ashish Kolhe
2025-11-18 13:30:44 +05:30
committed by GitHub
parent 8ee637baed
commit 9059afdb2b
12 changed files with 1122 additions and 99 deletions
+13
View File
@@ -275,6 +275,11 @@ fn default_enable_streaming_search() -> bool {
false
}
#[cfg(feature = "enterprise")]
fn default_claim_parser_function() -> String {
"".to_string()
}
#[derive(Serialize, ToSchema, Deserialize, Debug, Clone)]
pub struct OrganizationSettingPayload {
/// Ideally this should be the same as prometheus-scrape-interval (in
@@ -297,6 +302,9 @@ pub struct OrganizationSettingPayload {
pub light_mode_theme_color: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dark_mode_theme_color: Option<String>,
#[cfg(feature = "enterprise")]
#[serde(skip_serializing_if = "Option::is_none")]
pub claim_parser_function: Option<String>,
}
#[derive(Serialize, ToSchema, Deserialize, Debug, Clone)]
@@ -325,6 +333,9 @@ pub struct OrganizationSetting {
pub light_mode_theme_color: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dark_mode_theme_color: Option<String>,
#[cfg(feature = "enterprise")]
#[serde(default = "default_claim_parser_function")]
pub claim_parser_function: String,
}
impl Default for OrganizationSetting {
@@ -340,6 +351,8 @@ impl Default for OrganizationSetting {
free_trial_expiry: None,
light_mode_theme_color: None,
dark_mode_theme_color: None,
#[cfg(feature = "enterprise")]
claim_parser_function: default_claim_parser_function(),
}
}
}
@@ -37,6 +37,7 @@ pub enum ErrorSource {
Dashboard,
Ingestion,
Pipeline(PipelineError),
SsoClaimParser(SsoClaimParserError),
Search,
Other,
}
@@ -70,6 +71,18 @@ impl Serialize for ErrorSource {
.serialize_field("error", &error.truncate_utf8(PIPELINE_ERROR_MAX_SIZE))?;
}
}
ErrorSource::SsoClaimParser(ce) => {
state.serialize_field("error_source", &ce.function_name)?;
state.serialize_field("pipeline_name", &"sso_claim_parser")?;
state.serialize_field("error_type", &ce.error_type)?;
state.serialize_field("error", &ce.error.truncate_utf8(PIPELINE_ERROR_MAX_SIZE))?;
if let Some(claims) = &ce.claims_json {
state.serialize_field(
"claims",
&claims.truncate_utf8(PIPELINE_ERROR_MAX_SIZE),
)?;
}
}
}
state.end()
}
@@ -135,6 +148,38 @@ impl NodeErrors {
}
}
/// SSO Claim Parser Error
///
/// Captures errors from VRL-based custom claim parsing for SSO authentication.
/// This error type is reported to _meta/errors stream for monitoring and debugging.
#[derive(Clone, Debug, PartialEq)]
pub struct SsoClaimParserError {
/// Name of the VRL function that failed
pub function_name: String,
/// Error category: compile_error, exec_error, timeout, parse_error, validation_error
pub error_type: String,
/// Detailed error message from VRL execution
pub error: String,
/// JSON string of input claims for debugging (truncated to 1KB in serialization)
pub claims_json: Option<String>,
}
impl SsoClaimParserError {
pub fn new(function_name: String, error_type: String, error: String) -> Self {
Self {
function_name,
error_type,
error,
claims_json: None,
}
}
pub fn with_claims(mut self, claims_json: String) -> Self {
self.claims_json = Some(claims_json);
self
}
}
#[cfg(test)]
mod tests {
use super::*;
+9
View File
@@ -121,6 +121,15 @@ impl UserRole {
UserRole::ServiceAccount => "Service Account".to_string(),
}
}
/// Check if a string is a valid standard role name
/// Returns true if the string matches one of the UserRole enum variants
pub fn is_valid_role(role: &str) -> bool {
matches!(
role,
"admin" | "root" | "viewer" | "editor" | "user" | "service_account"
)
}
}
// Implementing FromStr for UserRole
+448 -67
View File
@@ -97,7 +97,19 @@ pub async fn process_token(
let mut source_orgs: Vec<UserOrg> = vec![];
let mut custom_roles: Vec<String> = vec![];
let mut tuples_to_add = HashMap::new();
if groups.is_empty() {
// Try custom claim parsing first
if let Some((parsed_orgs, parsed_roles)) =
process_custom_claim_parsing(&dec_token.claims).await
{
log::info!(
"Using custom claim parsing results: {} orgs, {} custom roles",
parsed_orgs.len(),
parsed_roles.len()
);
source_orgs = parsed_orgs;
custom_roles = parsed_roles;
} else if groups.is_empty() {
let role = if let Some(role) = &res.0.user_role {
role.clone()
} else {
@@ -128,18 +140,36 @@ pub async fn process_token(
}
}
// Deduplicate source_orgs by org name to avoid duplicate key errors
// when user has multiple LDAP groups mapping to same org
if !source_orgs.is_empty() {
let mut seen_orgs = std::collections::HashSet::new();
source_orgs.retain(|org| seen_orgs.insert(org.name.clone()));
}
// Assign users custom roles in RBAC
if openfga_cfg.map_group_to_role {
// Only use custom role mapping if we have custom roles to assign
log::info!(
"Config check: map_group_to_role={}, custom_roles.len()={}, source_orgs.len()={}",
openfga_cfg.map_group_to_role,
custom_roles.len(),
source_orgs.len()
);
if openfga_cfg.map_group_to_role && !custom_roles.is_empty() {
log::info!("Calling map_group_to_custom_role");
map_group_to_custom_role(&user_email, &name, custom_roles, res.0.user_role).await;
return Ok(None);
}
log::info!("Using standard user creation flow");
// Check if the user exists in the database
let db_user = db::user::get_user_by_email(&user_email).await;
if let Some(db_user) = db_user {
// check if user is service account and skip the role update ,
// assumption is always a service account irrespective of the orgs it belongs to
println!("{:?}", db_user.organizations);
if res
.0
.user_role
@@ -373,9 +403,12 @@ fn parse_dn(dn: &str) -> Option<RoleOrg> {
custom_role = Some(dn.to_owned());
org = &dex_cfg.default_org;
} else {
// Try to parse as LDAP DN format first
let mut found_ldap_format = false;
for part in dn.split(',') {
let parts: Vec<&str> = part.split('=').collect();
if parts.len() == 2 {
found_ldap_format = true;
if parts[0].eq(&dex_cfg.group_attribute) && org.is_empty() {
org = parts[1];
}
@@ -384,6 +417,11 @@ fn parse_dn(dn: &str) -> Option<RoleOrg> {
}
}
}
// If not LDAP format (e.g., GitHub/OAuth simple strings), use the whole string as org name
if !found_ldap_format && org.is_empty() {
org = dn;
}
}
let role = if role.is_empty() {
UserRole::from_str(&dex_cfg.default_role).unwrap()
@@ -391,6 +429,7 @@ fn parse_dn(dn: &str) -> Option<RoleOrg> {
UserRole::from_str(role).unwrap()
};
// Only fall back to default org if org is still empty (shouldn't happen with the above logic)
if org.is_empty() {
org = &dex_cfg.default_org;
}
@@ -424,30 +463,131 @@ async fn map_group_to_custom_role(
UserRole::from_str(&dex_cfg.default_role).unwrap()
};
// Extract unique orgs from custom roles BEFORE moving custom_roles (format: "org/role")
// Check if custom claim parsing was used by looking for roles with custom orgs
// If custom claim parsing is disabled, custom_roles come from LDAP groups and should use
// default org
let use_custom_orgs = openfga_cfg.custom_claim_parsing_enabled;
let custom_orgs: std::collections::HashMap<String, String> = if use_custom_orgs {
custom_roles
.iter()
.filter_map(|r| {
let mut parts = r.split('/');
let org = parts.next()?;
let role = parts.next()?;
Some((org.to_string(), role.to_string()))
})
.collect()
} else {
// LDAP groups: use default org with all roles
std::collections::HashMap::new()
};
// Always create default org; additionally create custom orgs if needed
let _ = organization::check_and_create_org(&dex_cfg.default_org).await;
if use_custom_orgs && !custom_orgs.is_empty() {
for org_name in custom_orgs.keys() {
if org_name != &dex_cfg.default_org {
let _ = organization::check_and_create_org(org_name).await;
}
}
}
if openfga_cfg.enabled {
let _ = organization::check_and_create_org(&dex_cfg.default_org).await;
// Always add user to default org
get_add_user_to_org_tuples(
&dex_cfg.default_org,
user_email,
&role.to_string(),
&mut tuples,
);
// If custom claim parsing enabled, also add to custom orgs
if use_custom_orgs && !custom_orgs.is_empty() {
for org_name in custom_orgs.keys() {
if org_name != &dex_cfg.default_org {
get_add_user_to_org_tuples(
org_name,
user_email,
&role.to_string(),
&mut tuples,
);
}
}
}
// this check added to avoid service accounts from logging in
if !role.eq(&UserRole::ServiceAccount) {
get_new_user_creation_tuple(user_email, &mut tuples);
}
let start = std::time::Instant::now();
check_and_get_crole_tuple_for_new_user(
user_email,
&dex_cfg.default_org,
custom_roles,
&mut tuples,
)
.await;
log::info!(
"group_to_custom_role: Time taken to get crole tuple: {:?}",
start.elapsed()
);
// Write user-to-org tuples FIRST so the orgs exist in OpenFGA
// before we query for roles
if let Err(e) = update_tuples(tuples.clone(), vec![]).await {
log::error!("Error writing user-to-org tuples: {e}");
}
// Clear the tuples vector since we've already written them
// Now we'll collect only the role assignment tuples
tuples.clear();
// Group custom roles by organization
if use_custom_orgs && !custom_orgs.is_empty() {
// Custom claim parsing: roles are assigned to their respective orgs
let mut roles_by_org: std::collections::HashMap<String, Vec<String>> =
std::collections::HashMap::new();
for (org_name, role_name) in &custom_orgs {
roles_by_org
.entry(org_name.clone())
.or_default()
.push(format!("{org_name}/{role_name}"));
}
for (org_name, org_roles) in roles_by_org {
check_and_get_crole_tuple_for_new_user(
user_email,
&org_name,
org_roles,
&mut tuples,
)
.await;
}
} else {
// LDAP groups: all roles belong to default org
check_and_get_crole_tuple_for_new_user(
user_email,
&dex_cfg.default_org,
custom_roles.clone(),
&mut tuples,
)
.await;
}
}
// Build organizations list for DB
// - If custom claim parsing enabled and custom orgs exist: add to those orgs
// - Otherwise: add to default org only
let mut organizations = Vec::new();
if use_custom_orgs && !custom_orgs.is_empty() {
// Custom claim parsing: add user to custom orgs
for org_name in custom_orgs.keys() {
organizations.push(UserOrg {
role: role.clone(),
name: org_name.clone(),
token: Default::default(),
rum_token: Default::default(),
});
}
} else {
// LDAP groups or no custom orgs: add to default org
organizations.push(UserOrg {
role: role.clone(),
name: dex_cfg.default_org.clone(),
token: Default::default(),
rum_token: Default::default(),
});
}
let updated_db_user = DBUser {
@@ -456,54 +596,103 @@ async fn map_group_to_custom_role(
last_name: "".to_owned(),
password: "".to_owned(),
salt: "".to_owned(),
organizations: vec![UserOrg {
role,
name: dex_cfg.default_org.clone(),
token: Default::default(),
rum_token: Default::default(),
}],
organizations,
is_external: true,
password_ext: Some("".to_owned()),
};
match users::create_new_user(updated_db_user).await {
Ok(_) => {
log::info!("group_to_custom_role: User added to the database");
if openfga_cfg.enabled {
let start = std::time::Instant::now();
match update_tuples(tuples, vec![]).await {
Ok(_) => {
log::info!("group_to_custom_role: User updated to the openfga");
}
Err(e) => {
log::error!(
"group_to_custom_role: Error updating user to the openfga: {e}"
);
}
}
log::info!(
"group_to_custom_role: Time taken to update roles to db for new user: {:?}",
start.elapsed()
);
if openfga_cfg.enabled
&& let Err(e) = update_tuples(tuples, vec![]).await
{
log::error!("Error writing role tuples to OpenFGA: {e}");
}
}
Err(e) => {
log::error!("group_to_custom_role: Error adding user to the database: {e}");
log::error!("Error adding user to the database: {e}");
}
}
} else {
} else if let Some(mut existing_user) = db_user {
// check if user is service account and skip the role update
if default_role.is_some_and(|r| r.eq(&UserRole::ServiceAccount)) {
if default_role
.as_ref()
.is_some_and(|r| r.eq(&UserRole::ServiceAccount))
{
log::info!(
"group_to_custom_role: User is external service account and skipping the role update"
);
return;
}
log::info!("group_to_custom_role: User exists in the database");
// Extract unique orgs from custom roles (format: "org/role")
let custom_orgs: std::collections::HashMap<String, String> = custom_roles
.iter()
.filter_map(|r| {
let mut parts = r.split('/');
let org = parts.next()?;
let role = parts.next()?;
Some((org.to_string(), role.to_string()))
})
.collect();
// Create any new organizations
for org_name in custom_orgs.keys() {
let _ = organization::check_and_create_org(org_name).await;
}
// Get existing user and update their organizations list
let existing_org_names: std::collections::HashSet<String> = existing_user
.organizations
.iter()
.map(|org| org.name.clone())
.collect();
let role = if let Some(role) = default_role {
role
} else {
UserRole::from_str(&dex_cfg.default_role).unwrap()
};
let mut add_tuples = vec![];
let mut remove_tuples = vec![];
// user exists in the db with default org hence skip org creation tuples
// Add new orgs to user's organizations list with default role
for org_name in custom_orgs.keys() {
if !existing_org_names.contains(org_name) {
let new_org = UserOrg {
role: role.clone(),
name: org_name.clone(),
token: Default::default(),
rum_token: Default::default(),
};
existing_user.organizations.push(new_org.clone());
// Add user to org in the database
if let Err(e) = db::org_users::add(
org_name,
user_email,
role.clone(),
&new_org.token,
new_org.rum_token.clone(),
)
.await
{
log::error!("Error adding user to org {org_name} in DB: {e}");
}
// Add user to org tuples for OpenFGA
if openfga_cfg.enabled {
get_add_user_to_org_tuples(
org_name,
user_email,
&role.to_string(),
&mut add_tuples,
);
}
}
}
let existing_roles = get_roles_for_user(user_email).await;
// Find roles to delete: present in existing_role but not in custom_role
@@ -515,36 +704,40 @@ async fn map_group_to_custom_role(
}
// Find new roles to add: present in custom_role but not in existing_role
let new_roles = custom_roles
let new_roles: Vec<String> = custom_roles
.iter()
.filter(|&role| !existing_roles.contains(role))
.cloned()
.collect();
log::debug!("new roles: {new_roles:#?}");
check_and_get_crole_tuple_for_new_user(
user_email,
&dex_cfg.default_org,
new_roles,
&mut add_tuples,
)
.await;
log::debug!("add_tuples: {add_tuples:#?}\nremove_tuples: {remove_tuples:#?}");
if openfga_cfg.enabled {
let start = std::time::Instant::now();
match update_tuples(add_tuples, remove_tuples).await {
Ok(_) => {
log::info!("group_to_custom_role: User updated to the openfga");
}
Err(e) => {
log::error!("group_to_custom_role: Error updating user to the openfga: {e}");
}
// Group new roles by organization
let mut new_roles_by_org: std::collections::HashMap<String, Vec<String>> =
std::collections::HashMap::new();
for role_str in &new_roles {
let mut parts = role_str.split('/');
if let (Some(org_name), Some(_role_name)) = (parts.next(), parts.next()) {
new_roles_by_org
.entry(org_name.to_string())
.or_default()
.push(role_str.clone());
}
log::info!(
"group_to_custom_role: Time taken to update roles to db for existing user: {:?}",
start.elapsed()
);
}
// Add tuples for each org
for (org_name, org_roles) in new_roles_by_org {
check_and_get_crole_tuple_for_new_user(
user_email,
&org_name,
org_roles,
&mut add_tuples,
)
.await;
}
if openfga_cfg.enabled
&& let Err(e) = update_tuples(add_tuples, remove_tuples).await
{
log::error!("Error updating user role tuples to OpenFGA: {e}");
}
}
}
@@ -733,6 +926,194 @@ pub async fn check_and_add_to_org(
Ok((is_new_user, pending_invites))
}
/// Process custom claim parsing and populate source_orgs and custom_roles
///
/// Returns Some((source_orgs, custom_roles)) if custom parsing succeeded
/// Returns None if custom parsing is disabled or not available
#[cfg(all(feature = "enterprise", not(feature = "cloud")))]
async fn process_custom_claim_parsing(
claims: &HashMap<String, Value>,
) -> Option<(Vec<UserOrg>, Vec<String>)> {
use o2_openfga::config::get_config as get_openfga_config;
let openfga_cfg = get_openfga_config();
let dex_cfg = get_dex_config();
// Check if custom claim parsing is enabled
if !openfga_cfg.custom_claim_parsing_enabled {
return None;
}
log::info!("Custom claim parsing is enabled, attempting to parse claims");
// Prepare callback functions for claim parser
let iam_settings_fn = || {
Box::pin(async {
match db::organization::get_org_setting("_meta").await {
Ok(settings) => settings.claim_parser_function,
Err(_) => "claim_parser".to_string(),
}
}) as std::pin::Pin<Box<dyn std::future::Future<Output = String> + Send>>
};
let org_exists_fn = |org_name: &str| {
let org_name = org_name.to_string();
Box::pin(async move {
match db::organization::get_org(&org_name).await {
Ok(_) => Ok(true),
Err(_) => Ok(false),
}
})
as std::pin::Pin<
Box<dyn std::future::Future<Output = Result<bool, anyhow::Error>> + Send>,
>
};
let function_loader_fn = |function_name: &str| {
let function_name = function_name.to_string();
Box::pin(async move {
match db::functions::get("_meta", &function_name).await {
Ok(func) => Ok(func.function),
Err(e) => Err(anyhow::anyhow!(
"Failed to load function '{}' from _meta org: {}",
function_name,
e
)),
}
})
as std::pin::Pin<
Box<dyn std::future::Future<Output = Result<String, anyhow::Error>> + Send>,
>
};
// Use String as compiled program type for Send compatibility
let vrl_compile_fn = |function_content: &str| -> Result<String, anyhow::Error> {
// Just validate that it compiles, but return the source for execution
crate::service::ingestion::compile_vrl_function(function_content, "_meta")
.map_err(|e| anyhow::anyhow!("VRL compilation failed: {}", e))?;
Ok(function_content.to_string())
};
let vrl_execute_fn = |function_content: &String,
claims: Value|
-> Result<Value, anyhow::Error> {
use vrl::compiler::{TargetValue, runtime::Runtime};
// Compile the VRL function
let vrl_config = crate::service::ingestion::compile_vrl_function(function_content, "_meta")
.map_err(|e| anyhow::anyhow!("VRL compilation failed: {}", e))?;
let mut runtime = Runtime::default();
let timezone = vrl::compiler::TimeZone::Local;
let mut target = TargetValue {
value: claims.into(),
metadata: vrl::value::Value::Object(Default::default()),
secrets: vrl::value::Secrets::new(),
};
let result = match vrl::compiler::VrlRuntime::default() {
vrl::compiler::VrlRuntime::Ast => {
runtime.resolve(&mut target, &vrl_config.program, &timezone)
}
};
match result {
Ok(res) => {
let output: Value = res
.try_into()
.map_err(|e| anyhow::anyhow!("Failed to convert VRL result: {:?}", e))?;
Ok(output)
}
Err(e) => Err(anyhow::anyhow!("VRL execution failed: {}", e)),
}
};
let error_publish_fn =
|error: o2_enterprise::enterprise::auth::claim_parser::ClaimParserError| {
Box::pin(async move {
use chrono::Utc;
use config::meta::{
self_reporting::error::{ErrorData, ErrorSource, SsoClaimParserError},
stream::StreamParams,
};
let claims_json = error
.claims
.as_ref()
.map(|c| serde_json::to_string(c).unwrap_or_default());
let error_data = ErrorData {
_timestamp: Utc::now().timestamp_micros(),
stream_params: StreamParams::default(),
error_source: ErrorSource::SsoClaimParser(SsoClaimParserError {
function_name: error.function_name,
error_type: error.error_type,
error: error.error,
claims_json,
}),
};
crate::service::self_reporting::publish_error(error_data).await;
}) as std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>>
};
match o2_enterprise::enterprise::auth::claim_parser::parse_claims(
claims,
iam_settings_fn,
org_exists_fn,
function_loader_fn,
vrl_compile_fn,
vrl_execute_fn,
error_publish_fn,
)
.await
{
Ok(Some(assignments)) => {
log::info!(
"Custom claim parsing successful, processing {} assignments",
assignments.len()
);
let mut source_orgs = Vec::new();
let mut custom_roles = Vec::new();
// Process assignments based on map_group_to_role setting
// All roles go through custom role flow
for assignment in assignments {
custom_roles.push(format_role_name(&assignment.org, &assignment.role));
log::info!(
"Custom claim parser: added custom role '{}/{}'",
assignment.org,
assignment.role
);
source_orgs.push(UserOrg {
role: UserRole::from_str(&dex_cfg.default_role).unwrap(),
name: assignment.org.clone(),
token: Default::default(),
rum_token: Default::default(),
});
}
log::info!(
"Custom claim parsing result: {} source_orgs, {} custom_roles",
source_orgs.len(),
custom_roles.len()
);
Some((source_orgs, custom_roles))
}
Ok(None) => {
log::info!("Custom claim parsing returned no assignments");
None
}
Err(e) => {
log::error!("Custom claim parsing failed: {}", e);
None
}
}
}
#[cfg(feature = "enterprise")]
#[cfg(test)]
mod tests {
@@ -128,6 +128,18 @@ async fn create(
data.dark_mode_theme_color = Some(dark_mode_theme_color);
}
#[cfg(feature = "enterprise")]
if let Some(claim_parser_function) = settings.claim_parser_function {
field_found = true;
data.claim_parser_function = claim_parser_function;
}
#[cfg(feature = "enterprise")]
if let Some(claim_parser_function) = settings.claim_parser_function {
field_found = true;
data.claim_parser_function = claim_parser_function;
}
if !field_found {
return Ok(MetaHttpResponse::bad_request("No valid field found"));
}
@@ -46,9 +46,37 @@ vi.mock("@/services/domainManagement", () => ({
},
}));
// Mock jstransform service
vi.mock("@/services/jstransform", () => ({
default: {
list: vi.fn(),
},
}));
// Mock organizations service
vi.mock("@/services/organizations", () => ({
default: {
post_organization_settings: vi.fn(),
},
}));
// Mock search service
vi.mock("@/services/search", () => ({
default: {
search: vi.fn(),
},
}));
// Import after mocking
import domainManagement from "@/services/domainManagement";
import jstransform from "@/services/jstransform";
import organizations from "@/services/organizations";
import searchService from "@/services/search";
const mockDomainManagement = domainManagement as any;
const mockJstransform = jstransform as any;
const mockOrganizations = organizations as any;
const mockSearchService = searchService as any;
// Mock Vuex store with isolated scope
const createIsolatedMockStore = () => ({
@@ -59,7 +87,13 @@ const createIsolatedMockStore = () => ({
selectedOrganization: {
identifier: "test-meta-org",
},
organizationData: {
organizationSettings: {
claim_parser_function: "",
},
},
},
dispatch: vi.fn(),
});
const mockStore = createIsolatedMockStore();
@@ -86,7 +120,7 @@ describe("DomainManagement Integration Tests", () => {
// Clear all mocks and reset state
vi.clearAllMocks();
vi.resetAllMocks();
// Reset mock implementations
mockDomainManagement.getDomainRestrictions.mockResolvedValue({
data: mockDomainData.complex,
@@ -94,6 +128,25 @@ describe("DomainManagement Integration Tests", () => {
mockDomainManagement.updateDomainRestrictions.mockResolvedValue({
success: true,
});
// Mock jstransform service
mockJstransform.list.mockResolvedValue({
data: {
list: [],
},
});
// Mock organizations service
mockOrganizations.post_organization_settings.mockResolvedValue({
success: true,
});
// Mock search service
mockSearchService.search.mockResolvedValue({
data: {
hits: [],
},
});
});
afterEach(() => {
@@ -130,6 +183,12 @@ describe("DomainManagement Integration Tests", () => {
props,
global: {
plugins: [i18n],
stubs: {
QDrawer: {
template: '<div class="q-drawer-stub"><slot /></div>',
props: ['modelValue', 'side', 'bordered', 'width', 'overlay', 'elevated'],
},
},
},
});
};
@@ -39,9 +39,37 @@ vi.mock("@/services/domainManagement", () => ({
},
}));
// Import the mocked service
// Mock jstransform service
vi.mock("@/services/jstransform", () => ({
default: {
list: vi.fn(),
},
}));
// Mock organizations service
vi.mock("@/services/organizations", () => ({
default: {
post_organization_settings: vi.fn(),
},
}));
// Mock search service
vi.mock("@/services/search", () => ({
default: {
search: vi.fn(),
},
}));
// Import the mocked services
import domainManagement from "@/services/domainManagement";
import jstransform from "@/services/jstransform";
import organizations from "@/services/organizations";
import searchService from "@/services/search";
const mockDomainManagement = domainManagement as any;
const mockJstransform = jstransform as any;
const mockOrganizations = organizations as any;
const mockSearchService = searchService as any;
// Mock Vuex store
const mockStore = {
@@ -52,7 +80,13 @@ const mockStore = {
selectedOrganization: {
identifier: "test-meta-org",
},
organizationData: {
organizationSettings: {
claim_parser_function: "",
},
},
},
dispatch: vi.fn(),
};
vi.mock("vuex", () => ({
@@ -74,7 +108,7 @@ describe("DomainManagement Component", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetAllMocks();
// Mock successful API response by default
mockDomainManagement.getDomainRestrictions.mockResolvedValue({
data: {
@@ -96,6 +130,25 @@ describe("DomainManagement Component", () => {
mockDomainManagement.updateDomainRestrictions.mockResolvedValue({
success: true,
});
// Mock jstransform service
mockJstransform.list.mockResolvedValue({
data: {
list: [],
},
});
// Mock organizations service
mockOrganizations.post_organization_settings.mockResolvedValue({
success: true,
});
// Mock search service
mockSearchService.search.mockResolvedValue({
data: {
hits: [],
},
});
});
afterEach(() => {
@@ -131,6 +184,12 @@ describe("DomainManagement Component", () => {
props,
global: {
plugins: [i18n],
stubs: {
QDrawer: {
template: '<div class="q-drawer-stub"><slot /></div>',
props: ['modelValue', 'side', 'bordered', 'width', 'overlay', 'elevated'],
},
},
},
});
};
@@ -145,12 +204,22 @@ describe("DomainManagement Component", () => {
it("should display correct title and description", async () => {
wrapper = createWrapper();
const title = wrapper.find(".text-h6");
const description = wrapper.find(".text-body2");
expect(title.text()).toContain("SSO Domain Restrictions");
expect(description.text()).toContain("Control which domains");
// Check that the main container exists
const mainContainer = wrapper.find(".domain_management");
expect(mainContainer.exists()).toBeTruthy();
// Check for Claim Parser section title (should contain "Claim Parser")
const claimParserTitle = mainContainer.findAll(".text-h6").find((el: any) =>
el.text().includes("Claim Parser")
);
expect(claimParserTitle).toBeTruthy();
// Check for Domain Restrictions section title
const domainRestrictionsTitle = mainContainer.findAll(".text-h6").find((el: any) =>
el.text().includes("Domain") || el.text().includes("Restriction")
);
expect(domainRestrictionsTitle).toBeTruthy();
});
it("should load domain settings on mount", async () => {
@@ -16,11 +16,179 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<template>
<div class="q-px-md q-py-md domain_management">
<!-- Claim Parser Function Selection -->
<div class="q-mb-xl">
<div class="text-h6 text-bold q-mb-xs">
{{ t("settings.claimParserFunction") }}
</div>
<div class="text-body2 text-grey-7 q-mb-md">
{{ t("settings.claimParserFunctionDescription") }}
</div>
<div class="row q-gutter-md items-end">
<div class="col-auto claim-parser-select">
<q-select
v-model="claimParserFunction"
:options="functionOptions"
:label="t('settings.claimParserFunctionLabel')"
color="input-border"
bg-color="input-bg"
class="showLabelOnTop"
stack-label
outlined
dense
:loading="loadingFunctions"
@filter="filterFunctions"
use-input
fill-input
hide-selected
input-debounce="300"
clearable
>
<template v-slot:hint>
{{ t("settings.claimParserFunctionHint") }}
</template>
<template v-slot:no-option>
<q-item>
<q-item-section class="text-grey">
{{ t("settings.noVrlFunctionsFound") }}
</q-item-section>
</q-item>
</template>
</q-select>
</div>
<div class="col-auto">
<q-btn
:label="t('common.save')"
color="primary"
class="text-bold text-capitalize no-border"
unelevated
@click="saveClaimParserFunction"
:loading="savingClaimParser"
/>
</div>
<div class="col-auto">
<q-btn
flat
round
dense
icon="help_outline"
@click="showVrlInfo = true"
>
<q-tooltip>{{ t("settings.claimParserFunctionInfoTitle") }}</q-tooltip>
</q-btn>
</div>
</div>
<!-- Right Drawer for VRL Information -->
<q-drawer
v-model="showVrlInfo"
side="right"
bordered
:width="450"
overlay
elevated
>
<div class="q-pa-md">
<div class="row items-center q-mb-md">
<div class="col text-h6 text-bold">
{{ t("settings.claimParserFunctionInfoTitle") }}
</div>
<div class="col-auto">
<q-btn
flat
round
dense
icon="close"
@click="showVrlInfo = false"
/>
</div>
</div>
<div class="text-body2">
<div class="q-mb-md q-pa-md info-box">
<div class="text-weight-medium q-mb-sm">{{ t("settings.claimParserFunctionInputTitle") }}</div>
<div>{{ t("settings.claimParserFunctionInputDescription") }}</div>
</div>
<div class="q-mb-md q-pa-md info-box">
<div class="text-weight-medium q-mb-sm">{{ t("settings.claimParserFunctionOutputTitle") }}</div>
<div class="q-mb-sm">{{ t("settings.claimParserFunctionOutputDescription") }}</div>
<div class="q-ml-md">
<div class="q-mb-xs">{{ t("settings.claimParserFunctionOutputExample1") }}</div>
<div>{{ t("settings.claimParserFunctionOutputExample2") }}</div>
</div>
</div>
<!-- Recent Errors Section -->
<div v-if="claimParserFunction" class="q-pa-md info-box error-section">
<div class="row items-center q-mb-sm">
<div class="col text-weight-medium">{{ t("settings.claimParserRecentErrors") }}</div>
<div class="col-auto">
<q-btn
flat
dense
size="sm"
icon="refresh"
@click="loadRecentErrors"
:loading="loadingErrors"
>
<q-tooltip>{{ t("common.refresh") }}</q-tooltip>
</q-btn>
</div>
</div>
<div v-if="loadingErrors" class="text-center q-py-md">
<q-spinner color="primary" size="sm" />
</div>
<div v-else-if="recentErrors.length === 0" class="text-grey-7 text-center q-py-sm">
{{ t("settings.noRecentErrors") }}
</div>
<div v-else class="error-list">
<div
v-for="(error, index) in recentErrors.slice(0, 3)"
:key="index"
class="error-item q-pa-sm q-mb-xs"
>
<div class="row items-start q-mb-xs">
<q-icon name="error" color="negative" size="xs" class="q-mr-xs q-mt-xs" />
<div class="col">
<div class="text-caption text-weight-medium">{{ error.error_type }}</div>
<div class="text-caption text-grey-7">{{ formatTimestamp(error._timestamp) }}</div>
</div>
</div>
<div class="text-caption error-message">{{ error.error }}</div>
</div>
<!-- Show More Button -->
<div class="q-mt-sm text-center">
<q-btn
flat
dense
color="primary"
:label="t('common.showMore')"
icon-right="open_in_new"
size="sm"
@click="viewAllErrors"
/>
</div>
</div>
</div>
</div>
</div>
</q-drawer>
</div>
<!-- Divider -->
<q-separator class="q-mb-xl" />
<div class="text-h6 text-bold q-mb-xs">
{{ t("settings.ssoDomainRestrictions") }}
{{ t("settings.domainRestrictionsSubsection") }}
</div>
<div class="text-body2 text-grey-7 q-mb-lg">
{{ t("settings.ssoDomainRestrictionsDescription") }}
{{ t("settings.domainRestrictionsSubsectionDescription") }}
</div>
<!-- Domain Input Section -->
@@ -189,13 +357,16 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, onActivated } from "vue";
import { ref, reactive, onMounted, onActivated, watch } from "vue";
import { useI18n } from "vue-i18n";
import { useQuasar } from "quasar";
import { useStore } from "vuex";
import domainManagement from "@/services/domainManagement";
import { useRouter } from "vue-router";
import { add } from "date-fns";
import { add, formatDistanceToNow } from "date-fns";
import jstransform from "@/services/jstransform";
import organizations from "@/services/organizations";
import searchService from "@/services/search";
interface Domain {
name: string;
@@ -213,11 +384,22 @@ const newDomain = ref("");
const domains = reactive<Domain[]>([]);
const saving = ref(false);
// Claim parser function state
const claimParserFunction = ref("");
const functionOptions = ref<string[]>([]);
const allFunctions = ref<string[]>([]);
const loadingFunctions = ref(false);
const savingClaimParser = ref(false);
const showVrlInfo = ref(false);
const recentErrors = ref<any[]>([]);
const loadingErrors = ref(false);
const emit = defineEmits(["cancel", "saved"]);
onMounted(() => {
if(store.state.zoConfig.meta_org == store.state.selectedOrganization.identifier) {
loadDomainSettings();
loadFunctions();
} else {
router.replace({
name: "general",
@@ -231,6 +413,7 @@ onMounted(() => {
onActivated(() => {
if(store.state.zoConfig.meta_org == store.state.selectedOrganization.identifier) {
loadDomainSettings();
loadFunctions();
} else {
router.replace({
name: "general",
@@ -244,7 +427,7 @@ onActivated(() => {
const loadDomainSettings = async () => {
try {
const response = await domainManagement.getDomainRestrictions(store.state.zoConfig.meta_org);
if (response.data && response.data.domains) {
const loadedDomains = response.data.domains
.filter((domain: any) => domain && typeof domain === 'object' && domain.domain) // Filter out invalid entries
@@ -255,12 +438,18 @@ const loadDomainSettings = async () => {
}));
domains.splice(0, domains.length, ...loadedDomains);
}
// Load claim parser function from organization settings
const storedFunction = store.state?.organizationData?.organizationSettings?.claim_parser_function;
if (storedFunction) {
claimParserFunction.value = storedFunction;
}
} catch (error: any) {
// If the API doesn't exist yet or returns an error, use example data
console.warn("Domain restrictions API not available, using example data:", error);
const existingDomains = [];
domains.splice(0, domains.length, ...existingDomains);
}
};
@@ -422,6 +611,176 @@ const removeEmail = (domain: Domain, emailIndex: number) => {
});
};
// Load VRL functions from _meta org
const loadFunctions = async () => {
try {
loadingFunctions.value = true;
const response = await jstransform.list(1, 10000, "name", false, "", store.state.zoConfig.meta_org);
allFunctions.value = response.data.list.map((fn: any) => fn.name);
functionOptions.value = allFunctions.value;
// Set the current value from store if it exists
const storedFunction = store.state?.organizationData?.organizationSettings?.claim_parser_function;
if (storedFunction) {
claimParserFunction.value = storedFunction;
}
} catch (e: any) {
console.error("Error loading functions:", e);
} finally {
loadingFunctions.value = false;
}
};
// Filter functions for autocomplete
const filterFunctions = (val: string, update: Function) => {
update(() => {
if (val === "") {
functionOptions.value = allFunctions.value;
} else {
const needle = val.toLowerCase();
functionOptions.value = allFunctions.value.filter(
(v) => v.toLowerCase().indexOf(needle) > -1
);
}
});
};
// Save claim parser function separately
const saveClaimParserFunction = async () => {
savingClaimParser.value = true;
try {
const orgSettingsPayload: any = {
claim_parser_function: claimParserFunction.value || "",
};
await organizations.post_organization_settings(
store.state.zoConfig.meta_org,
orgSettingsPayload,
);
// Update store with new settings
const updatedSettings: any = {
...store.state?.organizationData?.organizationSettings,
claim_parser_function: claimParserFunction.value || "",
};
store.dispatch("setOrganizationSettings", updatedSettings);
q.notify({
type: "positive",
message: t("settings.claimParserFunctionSaved"),
timeout: 3000,
});
} catch (error: any) {
q.notify({
type: "negative",
message: error?.message || t("settings.errorSavingClaimParserFunction"),
timeout: 3000,
});
} finally {
savingClaimParser.value = false;
}
};
// Build SQL query for claim parser errors
const buildErrorsQuery = (functionName: string, limit?: number): string => {
const limitClause = limit ? ` LIMIT ${limit}` : '';
return `SELECT * FROM "errors" WHERE error_source='${functionName}' ORDER BY _timestamp DESC${limitClause}`;
};
// Load recent errors for the claim parser function
const loadRecentErrors = async () => {
if (!claimParserFunction.value) {
recentErrors.value = [];
return;
}
loadingErrors.value = true;
try {
const now = new Date();
const last24Hours = new Date(now.getTime() - 24 * 60 * 60 * 1000);
const query = {
query: {
sql: buildErrorsQuery(claimParserFunction.value, 10),
start_time: last24Hours.getTime() * 1000, // microseconds
end_time: now.getTime() * 1000,
from: 0,
size: 10,
},
};
const response = await searchService.search(
{
org_identifier: store.state.zoConfig.meta_org,
query: query,
page_type: "logs",
},
"ui"
);
if (response.data && response.data.hits) {
recentErrors.value = response.data.hits.map((hit: any) => hit);
} else {
recentErrors.value = [];
}
} catch (error: any) {
console.error("Error loading recent errors:", error);
recentErrors.value = [];
} finally {
loadingErrors.value = false;
}
};
// Format timestamp for display
const formatTimestamp = (timestamp: number) => {
try {
const date = new Date(timestamp / 1000); // Convert microseconds to milliseconds
return formatDistanceToNow(date, { addSuffix: true });
} catch (e) {
return "Unknown time";
}
};
// Watch for drawer opening and load errors
watch(showVrlInfo, (newVal) => {
if (newVal && claimParserFunction.value) {
loadRecentErrors();
}
});
// Navigate to logs page with error filters
const viewAllErrors = () => {
const now = new Date();
const last24Hours = new Date(now.getTime() - 24 * 60 * 60 * 1000);
// Build the SQL query for the logs page using the reusable function
const sqlQuery = buildErrorsQuery(claimParserFunction.value);
// Base64 encode the query
const encodedQuery = btoa(sqlQuery);
// Navigate to logs page with pre-filled parameters
router.push({
path: "/logs",
query: {
org_identifier: store.state.zoConfig.meta_org,
stream: "errors",
stream_type: "logs",
from: last24Hours.getTime() * 1000, // microseconds
to: now.getTime() * 1000,
refresh: "0",
sql_mode: "true",
query: encodedQuery,
type: "logs",
},
});
// Close the drawer
showVrlInfo.value = false;
};
const saveChanges = async () => {
saving.value = true;
@@ -476,6 +835,15 @@ const resetForm = () => {
</script>
<style scoped lang="scss">
.claim-parser-select {
min-width: 400px;
}
.info-box {
background-color: #f5f5f5;
border-radius: 4px;
}
.domain-input {
width: 300px;
}
@@ -484,6 +852,10 @@ const resetForm = () => {
min-width: 250px;
}
.function-select {
max-width: 500px;
}
.domain-card {
border: 1px solid #e0e0e0;
border-radius: 8px;
@@ -506,7 +878,31 @@ const resetForm = () => {
border: 1px solid #e0e0e0;
}
.error-section {
border-left: 3px solid #c10015;
}
.error-list {
max-height: 400px;
overflow-y: auto;
}
.error-item {
background: #fff9f9;
border-radius: 4px;
border-left: 2px solid #ff9e9e;
}
.error-message {
color: #666;
word-break: break-word;
}
.body--dark {
.info-box {
background-color: #2a2a2a;
}
.domain-card {
border-color: #444;
background: #1e1e1e;
@@ -521,6 +917,19 @@ const resetForm = () => {
background: #2a2a2a;
border-color: #444;
}
.error-section {
border-left-color: #ff6b6b;
}
.error-item {
background: #2a1f1f;
border-left-color: #ff6b6b;
}
.error-message {
color: #ccc;
}
}
</style>
<style lang="scss">
@@ -1,11 +1,12 @@
<template>
<div class="q-px-md q-pt-md q-pb-md">
<div class="text-body1 text-bold">
{{ t("settings.logDetails") }}
<div>
<div class="q-px-md q-pt-md q-pb-md">
<div class="text-body1 text-bold">
{{ t("settings.logDetails") }}
</div>
</div>
</div>
<div class="q-mx-md q-mb-md">
<div class="q-mx-md q-mb-md">
<div
data-test="add-role-rolename-input-btn"
class="trace-id-field-name o2-input q-mb-sm"
@@ -93,11 +94,12 @@
@click="saveOrgSettings"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from "vue";
import { computed, ref, onMounted } from "vue";
import { useI18n } from "vue-i18n";
import organizations from "@/services/organizations";
import { useStore } from "vuex";
@@ -146,21 +148,25 @@ const updateFieldName = (fieldName: string) => {
const saveOrgSettings = async () => {
try {
const payload: any = {
trace_id_field_name: traceIdFieldName.value,
span_id_field_name: spanIdFieldName.value,
toggle_ingestion_logs: toggleIngestionLogs.value,
};
await organizations.post_organization_settings(
store.state.selectedOrganization.identifier,
{
trace_id_field_name: traceIdFieldName.value,
span_id_field_name: spanIdFieldName.value,
toggle_ingestion_logs: toggleIngestionLogs.value,
},
payload,
);
store.dispatch("setOrganizationSettings", {
const updatedSettings: any = {
...store.state?.organizationData?.organizationSettings,
trace_id_field_name: traceIdFieldName.value,
span_id_field_name: spanIdFieldName.value,
toggle_ingestion_logs: toggleIngestionLogs.value,
});
};
store.dispatch("setOrganizationSettings", updatedSettings);
q.notify({
message: "Organization settings updated successfully",
@@ -85,7 +85,7 @@ const mockI18n = createI18n({
queryManagement: 'Query Management',
cipherKeys: 'Cipher Keys',
nodes: 'Nodes',
ssoDomainRestrictions: 'SSO Domain Restrictions',
ssoDomainRestrictions: 'SSO Management',
organizationManagement: 'Organization Management',
},
alert_destinations: {
+1
View File
@@ -1315,6 +1315,7 @@ export default defineComponent({
free_trial_expiry: orgSettings?.data?.data?.free_trial_expiry ?? "",
light_mode_theme_color: orgSettings?.data?.data?.light_mode_theme_color,
dark_mode_theme_color: orgSettings?.data?.data?.dark_mode_theme_color,
claim_parser_function: orgSettings?.data?.data?.claim_parser_function ?? "",
});
if (
+21 -2
View File
@@ -83,6 +83,7 @@
"update": "Update",
"favourite": "Favorite",
"refresh": "Refresh",
"showMore": "Show More",
"contactUs": "Contact Us",
"addFolder": "Add Folder",
"status": "Status"
@@ -1273,6 +1274,22 @@
"traceIdFieldName": "Trace ID Field Name",
"spanIdFieldName": "Span ID Field Name",
"toggleIngestionLogsLabel": "Toggle Ingestion Logs",
"claimParserFunction": "SSO Claim Parser",
"claimParserFunctionDescription": "Select a VRL function to parse and transform SSO/LDAP claims into organization and role assignments",
"claimParserFunctionLabel": "Select VRL Function",
"claimParserFunctionHint": "Choose a VRL function from _meta org to parse SSO/LDAP claims",
"claimParserFunctionSaved": "Claim parser function saved successfully",
"errorSavingClaimParserFunction": "Error saving claim parser function",
"noVrlFunctionsFound": "No VRL functions found in _meta org",
"claimParserFunctionInfoTitle": "VRL Function Input and Output Format",
"claimParserFunctionInputTitle": "Input Format:",
"claimParserFunctionInputDescription": "The VRL function receives a JSON object containing claims mapped in Dex from your SSO/LDAP provider (e.g., email, name, groups, roles).",
"claimParserFunctionOutputTitle": "Output Format:",
"claimParserFunctionOutputDescription": "The VRL function must return one of these formats:",
"claimParserFunctionOutputExample1": "• Success: Return an array of objects, each with 'org' (string) and 'role' (string) fields",
"claimParserFunctionOutputExample2": "• Deny: Return an object with 'deny' (boolean true) and 'reason' (string) fields",
"claimParserRecentErrors": "Recent Errors (Last 24 Hours)",
"noRecentErrors": "No errors found in the last 24 hours",
"cipherKeys": "Cipher Keys",
"nodes": "Nodes",
"enableStreamingSearch": "Enable Streaming Search",
@@ -1287,8 +1304,10 @@
"created_on": "Created on",
"trial_expiry": "Trial Expiry",
"actions": "Actions",
"ssoDomainRestrictions": "SSO Domain Restrictions",
"ssoDomainRestrictionsDescription": "Control which domains and specific users are allowed to login using SSO providers",
"ssoDomainRestrictions": "SSO Management",
"ssoDomainRestrictionsDescription": "Manage SSO claim parsing functions and control which domains and specific users are allowed to login using SSO providers",
"domainRestrictionsSubsection": "Domain Restrictions",
"domainRestrictionsSubsectionDescription": "Control which domains and specific users are allowed to login using SSO providers",
"domainAndAllowedUsers": "Domain and allowed users",
"domainPlaceholder": "e.g. example.com",
"domainHint": "Enter domain names without the {at_sign} symbol",