buster/apps/api/libs/handlers/src/dashboards/get_dashboard_handler.rs

516 lines
20 KiB
Rust

use std::collections::HashMap;
use anyhow::{anyhow, Result};
use chrono::{DateTime, Utc};
use diesel::{BoolExpressionMethods, ExpressionMethods, JoinOnDsl, QueryDsl, Queryable, Selectable};
use diesel_async::RunQueryDsl;
use futures::future::join_all;
use middleware::AuthenticatedUser;
use serde_json::Value;
use serde_yaml;
use tokio::task::JoinHandle;
use uuid::Uuid;
use crate::dashboards::types::{BusterShareIndividual, DashboardCollection};
use crate::metrics::{get_metric_for_dashboard_handler, get_metric_handler};
use crate::metrics::{BusterMetric, Dataset, Version};
use database::enums::{AssetPermissionRole, AssetType, IdentityType, Verification};
use database::helpers::dashboard_files::fetch_dashboard_file_with_permission;
use database::pool::get_pg_pool;
use database::schema::{
asset_permissions, collections, collections_to_assets, dashboard_files, metric_files, users,
};
use database::types::{MetricYml, VersionHistory};
use sharing::{check_permission_access, compute_effective_permission};
use super::{
BusterDashboard, BusterDashboardResponse, DashboardConfig, DashboardRow, DashboardRowItem,
};
#[derive(Queryable, Selectable)]
#[diesel(table_name = dashboard_files)]
struct QueryableDashboardFile {
id: Uuid,
name: String,
file_name: String,
content: Value,
#[allow(dead_code)]
filter: Option<String>,
#[allow(dead_code)]
organization_id: Uuid,
created_by: Uuid,
created_at: chrono::DateTime<chrono::Utc>,
updated_at: chrono::DateTime<chrono::Utc>,
publicly_accessible: bool,
publicly_enabled_by: Option<Uuid>,
public_expiry_date: Option<chrono::DateTime<chrono::Utc>>,
version_history: VersionHistory,
}
#[derive(Queryable)]
struct AssetPermissionInfo {
role: AssetPermissionRole,
email: String,
name: Option<String>,
}
/// Fetches collections that the dashboard belongs to, filtered by user permissions
async fn fetch_associated_collections_for_dashboard(
dashboard_id: Uuid,
user_id: &Uuid,
) -> Result<Vec<DashboardCollection>> {
let mut conn = get_pg_pool().get().await?;
let associated_collections = collections_to_assets::table
.inner_join(collections::table.on(collections::id.eq(collections_to_assets::collection_id)))
.inner_join(
asset_permissions::table.on(asset_permissions::asset_id
.eq(collections::id)
.and(asset_permissions::asset_type.eq(AssetType::Collection))),
)
.filter(collections_to_assets::asset_id.eq(dashboard_id))
.filter(collections_to_assets::asset_type.eq(AssetType::DashboardFile))
.filter(collections::deleted_at.is_null()) // Ensure collection isn't deleted
.filter(asset_permissions::identity_id.eq(user_id))
.filter(asset_permissions::identity_type.eq(IdentityType::User))
.filter(asset_permissions::deleted_at.is_null())
.select((collections::id, collections::name))
.load::<(Uuid, String)>(&mut conn)
.await?
.into_iter()
.map(|(id, name)| DashboardCollection {
id: id.to_string(),
name,
})
.collect();
Ok(associated_collections)
}
pub async fn get_dashboard_handler(
dashboard_id: &Uuid,
user: &AuthenticatedUser,
version_number: Option<i32>,
password: Option<String>,
) -> Result<BusterDashboardResponse> {
// First check if the user has permission to view this dashboard
let dashboard_with_permission_option =
fetch_dashboard_file_with_permission(dashboard_id, &user.id).await?;
// If dashboard not found, return error
let dashboard_with_permission = match dashboard_with_permission_option {
Some(dwp) => dwp,
None => {
tracing::warn!(dashboard_id = %dashboard_id, "Dashboard file not found during fetch");
return Err(anyhow!("Dashboard not found"));
}
};
let dashboard_file = dashboard_with_permission.dashboard_file;
let direct_permission_level = dashboard_with_permission.permission;
// Check if user has proper permission to view the dashboard
let permission: AssetPermissionRole;
tracing::debug!(dashboard_id = %dashboard_id, user_id = %user.id, "Checking permissions for dashboard");
// Check for direct/admin permission first
tracing::debug!(dashboard_id = %dashboard_id, "Checking direct/admin permissions first.");
let has_sufficient_direct_permission = check_permission_access(
direct_permission_level,
&[
AssetPermissionRole::CanView,
AssetPermissionRole::CanEdit,
AssetPermissionRole::FullAccess,
AssetPermissionRole::Owner,
AssetPermissionRole::CanFilter,
],
dashboard_file.organization_id,
&user.organizations,
dashboard_file.workspace_sharing,
);
tracing::debug!(dashboard_id = %dashboard_id, ?direct_permission_level, has_sufficient_direct_permission, "Direct permission check result");
if has_sufficient_direct_permission {
// Compute the effective permission (highest of direct and workspace sharing)
let effective_permission = compute_effective_permission(
direct_permission_level,
dashboard_file.workspace_sharing,
dashboard_file.organization_id,
&user.organizations,
);
permission = effective_permission.unwrap_or(AssetPermissionRole::CanView);
tracing::debug!(
dashboard_id = %dashboard_id,
user_id = %user.id,
?direct_permission_level,
workspace_sharing = ?dashboard_file.workspace_sharing,
?permission,
"Granting access with effective permission (max of direct and workspace sharing)."
);
} else {
// No sufficient direct/admin permission, check if user has access via a collection
tracing::debug!(dashboard_id = %dashboard_id, "Insufficient direct/admin permission. Checking collection access.");
let has_collection_access = sharing::check_dashboard_collection_access(dashboard_id, &user.id, &user.organizations)
.await
.unwrap_or(false);
if has_collection_access {
// User has access to a collection containing this dashboard, grant CanView
tracing::debug!(dashboard_id = %dashboard_id, user_id = %user.id, "User has access via collection. Granting CanView.");
permission = AssetPermissionRole::CanView;
} else {
// No collection access, check if user has access via a chat
tracing::debug!(dashboard_id = %dashboard_id, "No collection access. Checking chat access.");
let has_chat_access = sharing::check_dashboard_chat_access(dashboard_id, &user.id, &user.organizations)
.await
.unwrap_or(false);
if has_chat_access {
// User has access to a chat containing this dashboard, grant CanView
tracing::debug!(dashboard_id = %dashboard_id, user_id = %user.id, "User has access via chat. Granting CanView.");
permission = AssetPermissionRole::CanView;
} else {
// No chat access either, check public access rules
tracing::debug!(dashboard_id = %dashboard_id, "No chat access. Checking public access rules.");
if !dashboard_file.publicly_accessible {
tracing::warn!(dashboard_id = %dashboard_id, user_id = %user.id, "Permission denied (not public, no collection/chat access, insufficient direct permission).");
return Err(anyhow!("You don't have permission to view this dashboard"));
}
tracing::debug!(dashboard_id = %dashboard_id, "Dashboard is publicly accessible.");
// Check if the public access has expired
if let Some(expiry_date) = dashboard_file.public_expiry_date {
tracing::debug!(dashboard_id = %dashboard_id, ?expiry_date, "Checking expiry date");
if expiry_date < chrono::Utc::now() {
tracing::warn!(dashboard_id = %dashboard_id, "Public access expired");
return Err(anyhow!("Public access to this dashboard has expired"));
}
}
// Check if a password is required
tracing::debug!(dashboard_id = %dashboard_id, has_password = dashboard_file.public_password.is_some(), "Checking password requirement");
if let Some(required_password) = &dashboard_file.public_password {
tracing::debug!(dashboard_id = %dashboard_id, "Password required. Checking provided password.");
match password {
Some(provided_password) => {
if provided_password != *required_password {
// Incorrect password provided
tracing::warn!(dashboard_id = %dashboard_id, user_id = %user.id, "Incorrect public password provided");
return Err(anyhow!("Incorrect password for public access"));
}
// Correct password provided, grant CanView via public access
tracing::debug!(dashboard_id = %dashboard_id, user_id = %user.id, "Correct public password provided. Granting CanView.");
permission = AssetPermissionRole::CanView;
}
None => {
// Password required but none provided
tracing::warn!(dashboard_id = %dashboard_id, user_id = %user.id, "Public password required but none provided");
return Err(anyhow!("public_password required for this dashboard"));
}
}
} else {
// Publicly accessible, not expired, and no password required
tracing::debug!(dashboard_id = %dashboard_id, "Public access granted (no password required).");
permission = AssetPermissionRole::CanView;
}
}
}
}
let mut conn = match get_pg_pool().get().await {
Ok(conn) => conn,
Err(e) => return Err(anyhow!("Failed to get database connection: {}", e)),
};
// Declare variables for potentially versioned data
let resolved_name: String;
let resolved_description: Option<String>;
let resolved_content: Value;
let resolved_version_num: i32;
let resolved_updated_at: DateTime<Utc>;
// Determine which version's data to use
if let Some(version) = version_number {
// Get the specific version if it exists
if let Some(v) = dashboard_file.version_history.get_version(version) {
match &v.content {
database::types::VersionContent::DashboardYml(content) => {
resolved_content = content.to_value()?;
resolved_version_num = v.version_number;
resolved_updated_at = v.updated_at;
// Extract name and description from the version's content
resolved_name = resolved_content
.get("name")
.and_then(Value::as_str)
.map(String::from)
.unwrap_or_else(|| dashboard_file.name.clone()); // Fallback to main record name
resolved_description = resolved_content
.get("description")
.and_then(Value::as_str)
.map(String::from);
}
_ => return Err(anyhow!("Invalid version content type")),
}
} else {
return Err(anyhow!("Version {} not found", version));
}
} else {
// Use current content from the main dashboard file record
resolved_content = dashboard_file.content.to_value()?;
resolved_version_num = dashboard_file
.version_history
.get_latest_version()
.map(|v| v.version_number)
.unwrap_or(1);
resolved_updated_at = dashboard_file.updated_at; // Use main record's updated_at
resolved_name = dashboard_file.name.clone(); // Use main record's name
resolved_description = resolved_content
.get("description")
.and_then(Value::as_str)
.map(String::from);
};
// Parse the config from the resolved content
let config = parse_dashboard_config(&resolved_content)?;
// Collect all metric IDs from the rows
let metric_ids: Vec<Uuid> = config
.rows
.iter()
.flat_map(|row| {
row.items
.iter()
.filter_map(|item| Uuid::parse_str(&item.id).ok())
})
.collect();
// Fetch metrics concurrently using get_metric_for_dashboard_handler with user context
let mut metric_fetch_handles = Vec::new();
for metric_id in metric_ids {
// Clone user for each spawned task
let user_clone = user.clone();
let handle = tokio::spawn(async move {
// Pass user context and None for version_number and password
get_metric_for_dashboard_handler(&metric_id, &user_clone, None, None).await
});
metric_fetch_handles.push((metric_id, handle));
}
// Await all metric fetch tasks and collect results
let metric_results = join_all(
metric_fetch_handles
.into_iter()
.map(|(_, handle)| handle),
)
.await;
// Process results and build the metrics map
let mut metrics = HashMap::new();
for result in metric_results {
match result {
Ok(Ok(metric)) => {
// Successfully fetched metric
metrics.insert(metric.id, metric);
}
Ok(Err(e)) => {
// get_metric_handler returned an error
// Log the error, but don't fail the entire dashboard load
tracing::error!(
"Failed to fetch metric for dashboard {}: {}",
dashboard_id,
e
);
// Optionally, insert a placeholder or error metric into the map
}
Err(e) => {
// Task join error (panic)
tracing::error!(
"Task join error fetching metric for dashboard {}: {}",
dashboard_id,
e
);
}
}
}
// Query individual permissions for this dashboard
let individual_permissions_query = asset_permissions::table
.inner_join(users::table.on(users::id.eq(asset_permissions::identity_id)))
.filter(asset_permissions::asset_id.eq(dashboard_id))
.filter(asset_permissions::asset_type.eq(AssetType::DashboardFile))
.filter(asset_permissions::identity_type.eq(IdentityType::User))
.filter(asset_permissions::deleted_at.is_null())
.select((asset_permissions::role, users::email, users::name))
.load::<AssetPermissionInfo>(&mut conn)
.await;
// Get the user info for publicly_enabled_by if it exists
let public_enabled_by_user = if let Some(enabled_by_id) = dashboard_file.publicly_enabled_by {
users::table
.filter(users::id.eq(enabled_by_id))
.select(users::email)
.first::<String>(&mut conn)
.await
.ok()
} else {
None
};
// Extract versions from version history
let mut versions: Vec<Version> = dashboard_file
.version_history
.0
.values()
.map(|v| Version {
version_number: v.version_number,
updated_at: v.updated_at,
})
.collect();
// Sort versions by version_number in ascending order
versions.sort_by(|a, b| a.version_number.cmp(&b.version_number));
// Convert AssetPermissionInfo to BusterShareIndividual
let individual_permissions = match individual_permissions_query {
Ok(permissions) => {
if permissions.is_empty() {
None
} else {
Some(
permissions
.into_iter()
.map(|p| BusterShareIndividual {
email: p.email,
role: p.role,
name: p.name,
})
.collect::<Vec<BusterShareIndividual>>(),
)
}
}
Err(_) => None,
};
// Clone dashboard_id and user_id for use in spawned task
let d_id = *dashboard_id;
let u_id = user.id;
// Spawn task to fetch collections concurrently
let collections_handle: JoinHandle<Result<Vec<DashboardCollection>>> = tokio::spawn(
async move { fetch_associated_collections_for_dashboard(d_id, &u_id).await },
);
// Construct the dashboard using resolved values
let dashboard = BusterDashboard {
config,
created_at: dashboard_file.created_at,
created_by: dashboard_file.created_by,
description: resolved_description, // Use resolved description
id: dashboard_file.id,
name: resolved_name, // Use resolved name
updated_at: Some(resolved_updated_at), // Use resolved updated_at
updated_by: dashboard_file.created_by,
status: Verification::Verified,
version_number: resolved_version_num, // Use resolved version number
file: serde_yaml::to_string(&resolved_content)?, // Generate YAML from resolved content
file_name: dashboard_file.file_name,
};
// Await collections result
let collections_result = collections_handle.await;
// Handle collections result
let collections = match collections_result {
Ok(Ok(c)) => c,
Ok(Err(e)) => {
tracing::error!(
"Failed to fetch associated collections for dashboard {}: {}",
dashboard_id,
e
);
vec![]
}
Err(e) => {
// JoinError
tracing::error!(
"Task join error fetching collections for dashboard {}: {}",
dashboard_id,
e
);
vec![]
}
};
Ok(BusterDashboardResponse {
access: permission,
metrics,
dashboard,
permission,
public_password: dashboard_file.public_password,
collections, // Now populated with associated collections
// New sharing fields
individual_permissions,
publicly_accessible: dashboard_file.publicly_accessible,
public_expiry_date: dashboard_file.public_expiry_date,
public_enabled_by: public_enabled_by_user,
// Version information
versions,
})
}
fn parse_dashboard_config(content: &Value) -> Result<DashboardConfig> {
let rows = content
.get("rows")
.ok_or_else(|| anyhow!("Missing rows in dashboard content"))?
.as_array()
.ok_or_else(|| anyhow!("Rows is not an array"))?
.iter()
.enumerate()
.map(|(index, row)| {
let items = row
.get("items")
.ok_or_else(|| anyhow!("Missing items in row"))?
.as_array()
.ok_or_else(|| anyhow!("Items is not an array"))?
.iter()
.map(|item| {
Ok(DashboardRowItem {
id: item
.get("id")
.ok_or_else(|| anyhow!("Missing id in item"))?
.as_str()
.ok_or_else(|| anyhow!("Id is not a string"))?
.to_string(),
})
})
.collect::<Result<Vec<_>>>()?;
// Extract column_sizes from the row if available
let column_sizes = row.get("columnSizes").and_then(|sizes| {
sizes.as_array().map(|arr| {
arr.iter()
.filter_map(|size| size.as_u64().map(|s| s as u32))
.collect::<Vec<u32>>()
})
});
// Extract row_height from the row if available
let row_height = row
.get("rowHeight")
.and_then(|height| height.as_u64().map(|h| h as u32));
Ok(DashboardRow {
id: (index + 1).to_string(),
items,
row_height,
column_sizes,
})
})
.collect::<Result<Vec<_>>>()?;
Ok(DashboardConfig { rows })
}