diff --git a/README.md b/README.md index 473383e..3db1972 100644 --- a/README.md +++ b/README.md @@ -48,11 +48,22 @@ database should always be confirmed. #### Accessing the `phono` schema -`GRANT USAGE ON to ;` +`GRANT USAGE ON TO ;` This permission is granted when initially creating the workspace, as well as when accepting an invitation to a table. +### Creating, updating, and deleting columns + +`GRANT TO ;` + +This permission is granted when initially creating the table, as well as when +accepting an invitation to a table, if the invitation includes "owner" +permissions. + +**This permission is only used via the web UI and must not be granted to service +credentials, lest users alter table structure in unsupported ways.** + #### Reading table data `GRANT SELECT ON
TO ;` @@ -92,7 +103,6 @@ is added; this is simplified by maintaining a single "writer" role per table. ### Actions Facilitated by Root - Creating tables -- Creating, updating, and deleting columns ### Service Credentials diff --git a/interim-server/src/presentation_form.rs b/interim-server/src/presentation_form.rs index a985f98..022a59b 100644 --- a/interim-server/src/presentation_form.rs +++ b/interim-server/src/presentation_form.rs @@ -10,10 +10,18 @@ use crate::errors::AppError; #[derive(Clone, Debug, Deserialize)] pub(crate) struct PresentationForm { pub(crate) presentation_tag: String, + + #[serde(default)] pub(crate) dropdown_option_colors: Vec, + + #[serde(default)] pub(crate) dropdown_option_values: Vec, - pub(crate) text_input_mode: Option, - pub(crate) timestamp_format: Option, + + #[serde(default)] + pub(crate) text_input_mode: String, + + #[serde(default)] + pub(crate) timestamp_format: String, } impl TryFrom for Presentation { @@ -35,18 +43,14 @@ impl TryFrom for Presentation { .collect(), }, Presentation::Text { .. } => Presentation::Text { - input_mode: form_value - .text_input_mode - .clone() - .map(|value| TextInputMode::try_from(value.as_str())) - .transpose()? - .unwrap_or_default(), + input_mode: TextInputMode::try_from(form_value.text_input_mode.as_str())?, }, Presentation::Timestamp { .. } => Presentation::Timestamp { - format: form_value - .timestamp_format - .clone() - .unwrap_or(RFC_3339_S.to_owned()), + format: if form_value.timestamp_format.is_empty() { + RFC_3339_S.to_owned() + } else { + form_value.timestamp_format + }, }, Presentation::Uuid { .. } => Presentation::Uuid {}, }) diff --git a/interim-server/src/routes/relations_single/add_field_handler.rs b/interim-server/src/routes/relations_single/add_field_handler.rs index 68c4261..37eb7fc 100644 --- a/interim-server/src/routes/relations_single/add_field_handler.rs +++ b/interim-server/src/routes/relations_single/add_field_handler.rs @@ -21,6 +21,7 @@ use crate::{ presentation_form::PresentationForm, user::CurrentUser, workspace_pooler::{RoleAssignment, WorkspacePooler}, + workspace_utils::{get_reader_role, get_writer_role}, }; #[derive(Debug, Deserialize)] @@ -86,6 +87,14 @@ pub(super) async fn post( )) .execute(workspace_client.get_conn()) .await?; + query(&format!( + "grant insert ({col}), update ({col}) on table {ident} to {writer_role}", + col = escape_identifier(&form.name), + ident = class.get_identifier(), + writer_role = escape_identifier(&get_writer_role(class.clone())?), + )) + .execute(workspace_client.get_conn()) + .await?; Field::insert() .portal_id(portal.id) diff --git a/interim-server/src/routes/workspaces_single/add_table_handler.rs b/interim-server/src/routes/workspaces_single/add_table_handler.rs index ac1ec43..3190b72 100644 --- a/interim-server/src/routes/workspaces_single/add_table_handler.rs +++ b/interim-server/src/routes/workspaces_single/add_table_handler.rs @@ -2,7 +2,7 @@ use axum::{ extract::{Path, State}, response::IntoResponse, }; -use interim_pgtypes::{escape_identifier, pg_class::PgClass}; +use interim_pgtypes::escape_identifier; use serde::Deserialize; use sqlx::query; use uuid::Uuid; @@ -14,6 +14,9 @@ use crate::{ settings::Settings, user::CurrentUser, workspace_pooler::{RoleAssignment, WorkspacePooler}, + workspace_utils::{ + TABLE_OWNER_ROLE_PREFIX, TABLE_READER_ROLE_PREFIX, TABLE_WRITER_ROLE_PREFIX, + }, }; #[derive(Debug, Deserialize)] @@ -55,9 +58,14 @@ pub(super) async fn post( user_id = user.id.simple() ); let rolname_uuid = Uuid::new_v4().simple(); - let rolname_table_reader = format!("table_reader_{rolname_uuid}"); - let rolname_table_writer = format!("table_writer_{rolname_uuid}"); - for rolname in [&rolname_table_reader, &rolname_table_writer] { + let rolname_table_owner = format!("{TABLE_OWNER_ROLE_PREFIX}{rolname_uuid}"); + let rolname_table_reader = format!("{TABLE_READER_ROLE_PREFIX}{rolname_uuid}"); + let rolname_table_writer = format!("{TABLE_WRITER_ROLE_PREFIX}{rolname_uuid}"); + for rolname in [ + &rolname_table_owner, + &rolname_table_reader, + &rolname_table_writer, + ] { query(&format!("create role {0}", escape_identifier(rolname))) .execute(root_client.get_conn()) .await?; @@ -69,6 +77,7 @@ pub(super) async fn post( .execute(root_client.get_conn()) .await?; } + query(&format!( r#" create table {0}.{1} ( @@ -82,6 +91,14 @@ create table {0}.{1} ( )) .execute(root_client.get_conn()) .await?; + query(&format!( + "alter table {nsp}.{ident} owner to {owner}", + nsp = escape_identifier(&settings.phono_table_namespace), + ident = escape_identifier(&table_name), + owner = escape_identifier(&rolname_table_owner), + )) + .execute(root_client.get_conn()) + .await?; query(&format!( "grant select on {0}.{1} to {2}", escape_identifier(&settings.phono_table_namespace), @@ -90,6 +107,14 @@ create table {0}.{1} ( )) .execute(root_client.get_conn()) .await?; + query(&format!( + "grant delete, truncate on {0}.{1} to {2}", + escape_identifier(&settings.phono_table_namespace), + escape_identifier(&table_name), + escape_identifier(&rolname_table_writer), + )) + .execute(root_client.get_conn()) + .await?; Ok(navigator.workspace_page(workspace_id).redirect_to()) } diff --git a/interim-server/src/workspace_utils.rs b/interim-server/src/workspace_utils.rs index 014e4b6..3498e49 100644 --- a/interim-server/src/workspace_utils.rs +++ b/interim-server/src/workspace_utils.rs @@ -2,13 +2,23 @@ //! the [`interim_models::workspace`] module, which is also used extensively //! across the server code. +use std::collections::HashSet; + +use anyhow::anyhow; use interim_models::{client::AppDbClient, portal::Portal}; use interim_pgtypes::{ client::WorkspaceClient, + pg_acl::{PgAclItem, PgPrivilegeType}, pg_class::{PgClass, PgRelKind}, }; use uuid::Uuid; +use crate::errors::AppError; + +pub(crate) const TABLE_OWNER_ROLE_PREFIX: &str = "table_owner_"; +pub(crate) const TABLE_READER_ROLE_PREFIX: &str = "table_reader_"; +pub(crate) const TABLE_WRITER_ROLE_PREFIX: &str = "table_writer_"; + #[derive(Clone, Debug)] pub(crate) struct RelationPortalSet { pub(crate) rel: PgClass, @@ -45,3 +55,68 @@ pub(crate) async fn fetch_all_accessible_portals( Ok(portal_sets) } + +// TODO: custom error type +// TODO: make params and result references +fn get_table_role( + relacl: Option>, + required_privileges: HashSet, + disallowed_privileges: HashSet, + role_prefix: &str, +) -> Result { + let mut roles: Vec = vec![]; + for acl_item in relacl.ok_or(anyhow!("acl not present on class"))? { + if acl_item.grantee.starts_with(role_prefix) { + let privileges_set: HashSet = acl_item + .privileges + .iter() + .map(|privilege| privilege.privilege) + .collect(); + assert!( + privileges_set.intersection(&required_privileges).count() + == required_privileges.len() + ); + assert!(privileges_set.intersection(&disallowed_privileges).count() == 0); + roles.push(acl_item.grantee) + } + } + assert!(roles.len() == 1); + Ok(roles + .first() + .expect("already asserted that `roles` has len 1") + .clone()) +} + +/// Returns the name of the "table_reader" role created by Phonograph for a +/// particular workspace table. The role is assessed based on its name and the +/// table permissions directly granted to it. Returns an error if no matching +/// role is found, and panics if a role is found with excess permissions +/// granted to it directly. +pub(crate) fn get_reader_role(rel: PgClass) -> Result { + get_table_role( + rel.relacl, + [PgPrivilegeType::Select].into(), + [ + PgPrivilegeType::Insert, + PgPrivilegeType::Update, + PgPrivilegeType::Delete, + PgPrivilegeType::Truncate, + ] + .into(), + TABLE_READER_ROLE_PREFIX, + ) +} + +/// Returns the name of the "table_writer" role created by Phonograph for a +/// particular workspace table. The role is assessed based on its name and the +/// table permissions directly granted to it. Returns an error if no matching +/// role is found, and panics if a role is found with excess permissions +/// granted to it directly. +pub(crate) fn get_writer_role(rel: PgClass) -> Result { + get_table_role( + rel.relacl, + [PgPrivilegeType::Delete, PgPrivilegeType::Truncate].into(), + [PgPrivilegeType::Select].into(), + TABLE_WRITER_ROLE_PREFIX, + ) +}