From b12127d220002a272b8cbdc24cfb9dc87ddf0114 Mon Sep 17 00:00:00 2001 From: Brent Schroeter Date: Sat, 1 Nov 2025 23:52:48 +0000 Subject: [PATCH] ability to grant permissions to service creds --- interim-server/src/navigator.rs | 4 +- interim-server/src/roles.rs | 42 +++--- .../relations_single/add_field_handler.rs | 2 +- .../src/routes/workspaces_single/mod.rs | 7 +- .../service_credentials_handler.rs | 82 +++++++++-- ...update_service_cred_permissions_handler.rs | 127 ++++++++++++++++++ .../service_credentials.html | 70 +++++++++- sass/main.scss | 11 ++ 8 files changed, 305 insertions(+), 40 deletions(-) create mode 100644 interim-server/src/routes/workspaces_single/update_service_cred_permissions_handler.rs diff --git a/interim-server/src/navigator.rs b/interim-server/src/navigator.rs index 2868bc6..ed3561b 100644 --- a/interim-server/src/navigator.rs +++ b/interim-server/src/navigator.rs @@ -90,7 +90,7 @@ impl<'a> NavigatorPage for WorkspacePage<'a> { format!( "{root_path}/w/{workspace_id}/{suffix}", root_path = self.root_path, - workspace_id = self.workspace_id, + workspace_id = self.workspace_id.simple(), suffix = self.suffix.unwrap_or_default(), ) } @@ -168,7 +168,7 @@ impl<'a> NavigatorPage for RelPage<'a> { root_path = self.root_path, workspace_id = self.workspace_id.simple(), rel_oid = self.rel_oid.0, - suffix = self.suffix.clone().unwrap_or_default(), + suffix = self.suffix.unwrap_or_default(), ) } } diff --git a/interim-server/src/roles.rs b/interim-server/src/roles.rs index 5850098..3993a3f 100644 --- a/interim-server/src/roles.rs +++ b/interim-server/src/roles.rs @@ -12,11 +12,11 @@ use sqlx::{postgres::types::Oid, prelude::FromRow, query_as}; use crate::errors::AppError; -pub(crate) const ROLE_PREFIX_USER: &str = "usr_"; pub(crate) const ROLE_PREFIX_SERVICE_CRED: &str = "svc_"; pub(crate) const ROLE_PREFIX_TABLE_OWNER: &str = "tbo_"; pub(crate) const ROLE_PREFIX_TABLE_READER: &str = "tbr_"; pub(crate) const ROLE_PREFIX_TABLE_WRITER: &str = "tbw_"; +pub(crate) const ROLE_PREFIX_USER: &str = "usr_"; pub(crate) const SERVICE_CRED_SUFFIX_LEN: usize = 8; pub(crate) const SERVICE_CRED_CONN_LIMIT: usize = 4; @@ -29,6 +29,7 @@ fn get_table_role( role_prefix: &str, ) -> Result { let mut roles: Vec = vec![]; + dbg!(&relacl); 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 @@ -56,9 +57,9 @@ fn get_table_role( /// 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 { +pub(crate) fn get_reader_role(rel: &PgClass) -> Result { get_table_role( - rel.relacl, + rel.relacl.clone(), [PgPrivilegeType::Select].into(), [ PgPrivilegeType::Insert, @@ -76,9 +77,9 @@ pub(crate) fn get_reader_role(rel: PgClass) -> Result { /// 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 { +pub(crate) fn get_writer_role(rel: &PgClass) -> Result { get_table_role( - rel.relacl, + rel.relacl.clone(), [PgPrivilegeType::Delete, PgPrivilegeType::Truncate].into(), [PgPrivilegeType::Select].into(), ROLE_PREFIX_TABLE_WRITER, @@ -101,7 +102,7 @@ impl RoleDisplay { pub async fn from_rolname( rolname: &str, client: &mut WorkspaceClient, - ) -> sqlx::Result> { + ) -> sqlx::Result> { if rolname.starts_with(ROLE_PREFIX_TABLE_OWNER) || rolname.starts_with(ROLE_PREFIX_TABLE_READER) || rolname.starts_with(ROLE_PREFIX_TABLE_WRITER) @@ -114,16 +115,17 @@ impl RoleDisplay { // TODO: Consider moving this to [`interim-pgtypes`]. let mut rels: Vec = query_as( r#" - select oid, any_value(relname) as relname - from ( - select - oid, - relname, - (aclexplode(relacl)).grantee as grantee - from pg_class - ) - where grantee = $1::regrole::oid - "#, +select oid, any_value(relname) as relname +from ( + select + oid, + relname, + (aclexplode(relacl)).grantee as grantee + from pg_class +) +where grantee = $1::regrole::oid +group by oid +"#, ) .bind(rolname) .fetch_all(client.get_conn()) @@ -131,24 +133,24 @@ impl RoleDisplay { assert!(rels.len() <= 1); Ok(rels.pop().map(|rel| { if rolname.starts_with(ROLE_PREFIX_TABLE_OWNER) { - RoleDisplay::TableOwner { + Self::TableOwner { oid: rel.oid, relname: rel.relname, } } else if rolname.starts_with(ROLE_PREFIX_TABLE_READER) { - RoleDisplay::TableReader { + Self::TableReader { oid: rel.oid, relname: rel.relname, } } else { - RoleDisplay::TableWriter { + Self::TableWriter { oid: rel.oid, relname: rel.relname, } } })) } else { - Ok(Some(RoleDisplay::Unknown { + Ok(Some(Self::Unknown { rolname: rolname.to_owned(), })) } 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 00ab15c..1fcf26d 100644 --- a/interim-server/src/routes/relations_single/add_field_handler.rs +++ b/interim-server/src/routes/relations_single/add_field_handler.rs @@ -91,7 +91,7 @@ pub(super) async fn post( "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())?), + writer_role = escape_identifier(&get_writer_role(&class)?), )) .execute(workspace_client.get_conn()) .await?; diff --git a/interim-server/src/routes/workspaces_single/mod.rs b/interim-server/src/routes/workspaces_single/mod.rs index d96b3b4..4cba241 100644 --- a/interim-server/src/routes/workspaces_single/mod.rs +++ b/interim-server/src/routes/workspaces_single/mod.rs @@ -16,6 +16,7 @@ mod add_service_credential_handler; mod add_table_handler; mod nav_handler; mod service_credentials_handler; +mod update_service_cred_permissions_handler; #[derive(Clone, Debug, Deserialize)] struct PathParams { @@ -43,9 +44,13 @@ pub(super) fn new_router() -> Router { .route("/{workspace_id}/add-table", post(add_table_handler::post)) .route_with_tsr("/{workspace_id}/nav/", get(nav_handler::get)) .route_with_tsr( - "/{workspace_id}/service-credentials", + "/{workspace_id}/service-credentials/", get(service_credentials_handler::get), ) + .route( + "/{workspace_id}/service-credentials/{service_cred_id}/update-permissions", + post(update_service_cred_permissions_handler::post), + ) .nest( "/{workspace_id}/r/{rel_oid}", relations_single::new_router(), diff --git a/interim-server/src/routes/workspaces_single/service_credentials_handler.rs b/interim-server/src/routes/workspaces_single/service_credentials_handler.rs index a0378b0..b320a4a 100644 --- a/interim-server/src/routes/workspaces_single/service_credentials_handler.rs +++ b/interim-server/src/routes/workspaces_single/service_credentials_handler.rs @@ -1,3 +1,4 @@ +use anyhow::anyhow; use askama::Template; use axum::{ debug_handler, @@ -6,7 +7,7 @@ use axum::{ }; use futures::{lock::Mutex, prelude::*, stream}; use interim_models::{service_cred::ServiceCred, workspace::Workspace}; -use interim_pgtypes::pg_role::RoleTree; +use interim_pgtypes::{pg_class::PgClass, pg_role::RoleTree}; use redact::Secret; use serde::Deserialize; use url::Url; @@ -21,6 +22,7 @@ use crate::{ user::CurrentUser, workspace_nav::WorkspaceNav, workspace_pooler::{RoleAssignment, WorkspacePooler}, + workspace_utils::PHONO_TABLE_NAMESPACE, }; #[derive(Debug, Deserialize)] @@ -60,6 +62,50 @@ pub(super) async fn get( conn_string_redacted: String, } + impl ServiceCredInfo { + fn is_reader_of(&self, relname: &str) -> bool { + self.member_of.iter().any(|role_display| { + if let RoleDisplay::TableReader { + relname: role_relname, + .. + } = role_display + { + role_relname == relname + } else { + false + } + }) + } + + fn is_writer_of(&self, relname: &str) -> bool { + self.member_of.iter().any(|role_display| { + if let RoleDisplay::TableWriter { + relname: role_relname, + .. + } = role_display + { + role_relname == relname + } else { + false + } + }) + } + + fn is_owner_of(&self, relname: &str) -> bool { + self.member_of.iter().any(|role_display| { + if let RoleDisplay::TableOwner { + relname: role_relname, + .. + } = role_display + { + role_relname == relname + } else { + false + } + }) + } + } + let service_cred_info = stream::iter( ServiceCred::belonging_to_user(user.id) .fetch_all(&mut app_db) @@ -67,28 +113,31 @@ pub(super) async fn get( ) .then(async |cred| { let member_of: Vec = stream::iter({ + // Guard must be assigned to a local variable, + // lest the mutex become deadlocked. let mut locked_client = workspace_client.lock().await; - let tree = RoleTree::granted_to_rolname(&cred.rolname) + RoleTree::granted_to_rolname(&cred.rolname) .fetch_tree(&mut locked_client) - .await?; - tree.unwrap().flatten_inherited() + .await? + .ok_or(anyhow!("listing roles for service cred: role tree is None"))? + .flatten_inherited() + .into_iter() + .filter(|role| role.rolname != cred.rolname) }) .then(async |role| { - tracing::debug!("111"); let mut locked_client = workspace_client.lock().await; RoleDisplay::from_rolname(&role.rolname, &mut locked_client).await }) .collect::>>() .await .into_iter() - // [`futures::stream::StreamExt::collect`] can only collect to types - // that implement [`Default`], so we must do result handling with the - // sync version of `collect()`. + // [`futures::stream::StreamExt::collect`] + // can only collect to types that implement [`Default`], + // so we must do result handling with the sync version of `collect()`. .collect::, _>>()? .into_iter() .flatten() .collect(); - tracing::debug!("222"); let conn_string = cluster.conn_str_for_db( &workspace.db_name, Some(( @@ -105,26 +154,33 @@ pub(super) async fn get( }) .collect::>>() .await - // [`futures::stream::StreamExt::collect`] can only collect to types that - // implement [`Default`], so we must do result handling with the sync - // version of `collect()`. .into_iter() .collect::, AppError>>()?; #[derive(Template)] #[template(path = "workspaces_single/service_credentials.html")] struct ResponseTemplate { + all_rels: Vec, service_cred_info: Vec, settings: Settings, workspace_nav: WorkspaceNav, } + let mut locked_client = workspace_client + .try_lock() + .ok_or(anyhow!("mutex should be unlocked"))?; Ok(Html( ResponseTemplate { + all_rels: PgClass::belonging_to_namespace(PHONO_TABLE_NAMESPACE) + .fetch_all(&mut locked_client) + .await? + .into_iter() + .filter(|rel| rel.relkind == 'r' as i8) + .collect(), workspace_nav: WorkspaceNav::builder() .navigator(navigator) .workspace(workspace.clone()) - .populate_rels(&mut app_db, &mut *workspace_client.lock().await) + .populate_rels(&mut app_db, &mut locked_client) .await? .build()?, service_cred_info, diff --git a/interim-server/src/routes/workspaces_single/update_service_cred_permissions_handler.rs b/interim-server/src/routes/workspaces_single/update_service_cred_permissions_handler.rs new file mode 100644 index 0000000..af3aaf8 --- /dev/null +++ b/interim-server/src/routes/workspaces_single/update_service_cred_permissions_handler.rs @@ -0,0 +1,127 @@ +use std::collections::{HashMap, HashSet}; + +use axum::{ + debug_handler, + extract::{Path, State}, + response::IntoResponse, +}; +use axum_extra::extract::Form; +use futures::{lock::Mutex, prelude::*, stream}; +use interim_models::service_cred::ServiceCred; +use interim_pgtypes::{escape_identifier, pg_class::PgClass}; +use serde::Deserialize; +use sqlx::query; +use uuid::Uuid; + +use crate::{ + app::{App, AppDbConn}, + errors::AppError, + navigator::{Navigator, NavigatorPage}, + roles::{get_reader_role, get_writer_role}, + user::CurrentUser, + workspace_pooler::{RoleAssignment, WorkspacePooler}, + workspace_utils::PHONO_TABLE_NAMESPACE, +}; + +#[derive(Clone, Debug, Deserialize)] +pub(super) struct PathParams { + workspace_id: Uuid, + service_cred_id: Uuid, +} + +/// HTTP POST handler for assigning +#[debug_handler(state = App)] +pub(super) async fn post( + State(mut pooler): State, + AppDbConn(mut app_db): AppDbConn, + CurrentUser(user): CurrentUser, + navigator: Navigator, + Path(PathParams { + workspace_id, + service_cred_id, + }): Path, + Form(form): Form>>, +) -> Result { + // FIXME: csrf, auth + + let workspace_client = Mutex::new( + pooler + .acquire_for(workspace_id, RoleAssignment::User(user.id)) + .await?, + ); + + let all_rels = { + let mut locked_client = workspace_client.lock().await; + PgClass::belonging_to_namespace(PHONO_TABLE_NAMESPACE) + .fetch_all(&mut locked_client) + .await? + .into_iter() + .filter(|rel| rel.relkind == 'r' as i8) + }; + + let cred = ServiceCred::with_id(service_cred_id) + .fetch_one(&mut app_db) + .await?; + + stream::iter(all_rels) + .then(async |rel| -> Result<(), AppError> { + let rolname_reader = get_reader_role(&rel)?; + let rolname_writer = get_writer_role(&rel)?; + + let roles_to_grant: HashSet<_> = form + .get(&rel.oid.0.to_string()) + .unwrap_or(&vec![]) + .iter() + .flat_map(|value| match value.as_str() { + "reader" => Some(&rolname_reader), + "writer" => Some(&rolname_writer), + _ => None, + }) + .collect(); + let all_roles = HashSet::from([&rolname_reader, &rolname_writer]); + let roles_to_revoke: HashSet<_> = all_roles.difference(&roles_to_grant).collect(); + + if !roles_to_revoke.is_empty() { + let mut locked_client = workspace_client.lock().await; + let roles_to_revoke_snippet = roles_to_revoke + .into_iter() + .map(|value| escape_identifier(value)) + .collect::>() + .join(", "); + query(&format!( + "revoke {roles_to_revoke_snippet} from {rolname_esc}", + rolname_esc = escape_identifier(&cred.rolname), + )) + .execute(locked_client.get_conn()) + .await?; + } + + if !roles_to_grant.is_empty() { + let mut locked_client = workspace_client.lock().await; + let roles_to_grant_snippet = roles_to_grant + .into_iter() + .map(|value| escape_identifier(value)) + .collect::>() + .join(", "); + query(&format!( + "grant {roles_to_grant_snippet} to {rolname_esc}", + rolname_esc = escape_identifier(&cred.rolname), + )) + .execute(locked_client.get_conn()) + .await?; + } + + Ok(()) + }) + .collect::>() + .await + .into_iter() + .collect::, AppError>>()?; + + Ok(navigator + .workspace_page() + .workspace_id(workspace_id) + .suffix("service-credentials/") + .build()? + .redirect_to()) +} diff --git a/interim-server/templates/workspaces_single/service_credentials.html b/interim-server/templates/workspaces_single/service_credentials.html index 9ab377b..53c1dd7 100644 --- a/interim-server/templates/workspaces_single/service_credentials.html +++ b/interim-server/templates/workspaces_single/service_credentials.html @@ -24,9 +24,9 @@ - - - + + + @@ -45,6 +45,70 @@ + + {% endfor %} diff --git a/sass/main.scss b/sass/main.scss index 5b32bc4..da791e1 100644 --- a/sass/main.scss +++ b/sass/main.scss @@ -247,3 +247,14 @@ button, input[type="submit"] { text-align: center; } } + +.dialog:popover-open { + @include globals.rounded; + + background: #fff; + border: globals.$popover-border; + box-shadow: globals.$popover-shadow; + display: block; + max-height: 90vh; + overflow: auto; +}
Connection StringPermissionsActionsConnection StringPermissionsActions
+ {% for role_display in cred.member_of %} + {{ role_display | safe }} + {% endfor %} + + +
+ + + + + + + + + + {% for rel in all_rels %} + + + + + + {% endfor %} + +
TableReaderWriter
{{ rel.relname }} + + + +
+ +
+
+