mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-04 08:09:27 +00:00
feat: sso claim parser (#9156)
This commit is contained in:
@@ -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::*;
|
||||
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user