//! Reads everything the workspace shows. //! //! Four calls, in this order: the column-type catalog is the vocabulary the //! append panel offers, the profile tree names the profiles and their tables, //! the profile details describe the selected table's columns and scripts, and //! the rename history explains how those columns got their names. //! Nothing here trusts the posted selection — a profile or table that is gone //! is dropped from the selection rather than reported as an error, because the //! commonest way to get here with a stale one is having just deleted it. use axum::http::HeaderMap; use tonic::transport::Channel; use crate::{ AppState, auth::{GetAuthorizationRequest, ListGrantableObjectsRequest, ListRolePermissionsRequest, ListRolesRequest}, definitions::{ common::Empty, table_definition::{ GetColumnAliasRenameHistoryRequest, GetProfileDetailsRequest, MoneyRounding, table_definition_client::TableDefinitionClient, }, }, schema::{ColumnCatalog, column_catalog}, services::authenticated_request, }; use super::state::{ DetailColumn, GLOBAL_SCOPE, LoadError, PageInputs, RenameEntry, ScriptView, TableDefinitionPageState, TableDetailView, TablePermissionAction, TableRolePermissions, TableSummary, }; /// Reads the column-type vocabulary on its own. /// /// The panel's handlers stage and validate a column before the rest of the /// workspace is read, and they cannot do either without the vocabulary, so /// they fetch it with this and hand it to [`load_page`] on the draft. pub(crate) async fn load_column_catalog( definitions: &mut TableDefinitionClient, headers: &HeaderMap, ) -> Result { let request = authenticated_request(headers, Empty {}).map_err(|_| LoadError::Unauthenticated)?; let response = definitions .list_column_types(request) .await .map_err(|error| match error.code() { tonic::Code::Unauthenticated => LoadError::Unauthenticated, _ => LoadError::Backend(error.message().to_string()), })?; Ok(column_catalog(response.into_inner().column_types)) } pub(crate) async fn load_page( state: AppState, headers: &HeaderMap, mut inputs: PageInputs, ) -> Result { let authorization_request = authenticated_request(headers, GetAuthorizationRequest {}) .map_err(|_| LoadError::Unauthenticated)?; let mut auth = state.auth; let authorization = auth .get_authorization(authorization_request) .await .map_err(|error| match error.code() { tonic::Code::Unauthenticated => LoadError::Unauthenticated, _ => LoadError::Backend(error.message().to_string()), })? .into_inner(); if !crate::authz::can_manage(&authorization, crate::authz::STRUCT_TABLE) { return Err(LoadError::Forbidden); } let mut definitions = state.definitions; // A handler that had to stage or validate a column before it got here has // already read the vocabulary; anything else reads it now. if !inputs.columns.catalog.is_loaded() { inputs.columns.catalog = load_column_catalog(&mut definitions, headers).await?; } // Also what the definition below is read through: the catalog describes // the server-generated companion types as well as the declarable ones. let catalog = inputs.columns.catalog.clone(); let tree = definitions .get_profile_tree( authenticated_request(headers, Empty {}).map_err(|_| LoadError::Unauthenticated)?, ) .await .map_err(|error| LoadError::Backend(error.message().to_string()))? .into_inner(); let profiles = tree .profiles .iter() .map(|profile| profile.name.clone()) .collect::>(); // A profile that no longer exists takes the table selection with it. if inputs.selection.profile != GLOBAL_SCOPE && !profiles.contains(&inputs.selection.profile) { inputs.selection.profile.clear(); inputs.selection.table.clear(); } let selected_tables = if inputs.selection.is_global() { tree.profiles.first().map(|profile| profile.tables.as_slice()) } else { tree.profiles .iter() .find(|profile| profile.name == inputs.selection.profile) .map(|profile| profile.tables.as_slice()) }; let tables = selected_tables .map(|tables| { tables .iter() .filter(|table| table.global == inputs.selection.is_global()) .map(|table| TableSummary { name: table.name.clone(), table_kind: table.table_kind.clone(), global: table.global, // One entry per link, named by the column carrying it, so a // table pointing at one target twice reads as two links. depends_on: table .depends_on .iter() .map(|dependency| { format!("{} ({})", dependency.table_name, dependency.column_name) }) .collect(), row_display_columns: table.row_display_columns.clone(), }) .collect::>() }) .unwrap_or_default(); if !tables .iter() .any(|table| table.name == inputs.selection.table) { inputs.selection.table.clear(); } let detail = match inputs.selection.has_table() { true => { let request = GetProfileDetailsRequest { profile_name: inputs.selection.profile.clone(), }; let details = definitions .get_profile_details( authenticated_request(headers, request) .map_err(|_| LoadError::Unauthenticated)?, ) .await .map_err(|error| LoadError::Backend(error.message().to_string()))? .into_inner(); details .tables .into_iter() .find(|table| table.name == inputs.selection.table) .map(|table| TableDetailView { id: table.id, columns: table .columns .iter() .map(|column| { let behavior = table.column_behaviors.get(&column.name); DetailColumn { name: column.name.clone(), sql_type: catalog.sql_type(&column.field_type), field_type: column.field_type.clone(), currency: column.currency.clone(), quantity_ledger: column.quantity_ledger, rounded: column.rounding == i32::from(MoneyRounding::HalfUp), generated: behavior.is_some_and(|behavior| behavior.generated), read_only: behavior.is_some_and(|behavior| behavior.read_only), generated_from: behavior .map(|behavior| behavior.generated_from.clone()) .unwrap_or_default(), // Renaming changes shared table metadata, so an // incomplete server capability response does // not grant permission by omission. renameable: behavior.is_some_and(|behavior| behavior.renameable), } }) .collect(), scripts: table .scripts .into_iter() .map(|script| ScriptView { target_column: script.target_column, target_column_type: script.target_column_type, description: script.description, script: script.script, }) .collect(), row_display_columns: table.row_display_columns, table_kind: table.table_kind, name: table.name, }) } false => None, }; // The history is per profile; a selected table narrows it to that table. let history = match inputs.selection.has_profile() { true => { let request = GetColumnAliasRenameHistoryRequest { profile_name: inputs.selection.profile.clone(), table_definition_id: detail.as_ref().map(|detail| detail.id), }; definitions .get_column_alias_rename_history( authenticated_request(headers, request) .map_err(|_| LoadError::Unauthenticated)?, ) .await .map_err(|error| LoadError::Backend(error.message().to_string()))? .into_inner() .entries .into_iter() .map(|entry| RenameEntry { table_name: entry.table_name, old_column_name: entry.old_column_name, new_column_name: entry.new_column_name, created_at: entry.created_at, }) .collect() } false => Vec::new(), }; let mut permission_object = String::new(); let mut role_permissions = Vec::new(); if inputs.selection.has_table() && crate::authz::can_manage(&authorization, crate::authz::STRUCT_ROLE) { let expected_object = crate::authz::table_object( &inputs.selection.profile, &inputs.selection.table, ); let roles = auth .list_roles( authenticated_request(headers, ListRolesRequest {}) .map_err(|_| LoadError::Unauthenticated)?, ) .await .map_err(|error| LoadError::Backend(error.message().to_string()))? .into_inner() .roles; for role in roles.into_iter().filter(|role| role.kind == "data") { let grantable = auth .list_grantable_objects( authenticated_request( headers, ListGrantableObjectsRequest { target_role: role.name.clone(), }, ) .map_err(|_| LoadError::Unauthenticated)?, ) .await .map_err(|error| LoadError::Backend(error.message().to_string()))? .into_inner() .objects .into_iter() .find(|object| object.object == expected_object); let Some(grantable) = grantable else { continue; }; permission_object = expected_object.clone(); let permissions = auth .list_role_permissions( authenticated_request( headers, ListRolePermissionsRequest { role: role.name.clone(), }, ) .map_err(|_| LoadError::Unauthenticated)?, ) .await .map_err(|error| LoadError::Backend(error.message().to_string()))? .into_inner(); role_permissions.push(TableRolePermissions { role: role.name, actions: grantable .allowed_actions .into_iter() .map(|action| TablePermissionAction { direct: crate::authz::is_direct_permission( &permissions.permissions, &expected_object, &action, ), effective: crate::authz::permissions_permit( &permissions.effective_permissions, &expected_object, &action, ), action, }) .collect(), }); } } Ok(TableDefinitionPageState { nav: crate::ui::Nav::new(headers, "admin").with_authorization(&authorization), profiles, tables, detail, history, selection: inputs.selection, columns: inputs.columns, rename: inputs.rename, copy: inputs.copy, invoice: inputs.invoice, status: inputs.status, error: inputs.error, sql: inputs.sql, generated: inputs.generated, permission_object, role_permissions, }) }