ability to grant permissions to service creds

This commit is contained in:
Brent Schroeter 2025-11-01 23:52:48 +00:00
parent 97b5ccc064
commit b12127d220
8 changed files with 305 additions and 40 deletions

View file

@ -90,7 +90,7 @@ impl<'a> NavigatorPage for WorkspacePage<'a> {
format!( format!(
"{root_path}/w/{workspace_id}/{suffix}", "{root_path}/w/{workspace_id}/{suffix}",
root_path = self.root_path, root_path = self.root_path,
workspace_id = self.workspace_id, workspace_id = self.workspace_id.simple(),
suffix = self.suffix.unwrap_or_default(), suffix = self.suffix.unwrap_or_default(),
) )
} }
@ -168,7 +168,7 @@ impl<'a> NavigatorPage for RelPage<'a> {
root_path = self.root_path, root_path = self.root_path,
workspace_id = self.workspace_id.simple(), workspace_id = self.workspace_id.simple(),
rel_oid = self.rel_oid.0, rel_oid = self.rel_oid.0,
suffix = self.suffix.clone().unwrap_or_default(), suffix = self.suffix.unwrap_or_default(),
) )
} }
} }

View file

@ -12,11 +12,11 @@ use sqlx::{postgres::types::Oid, prelude::FromRow, query_as};
use crate::errors::AppError; 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_SERVICE_CRED: &str = "svc_";
pub(crate) const ROLE_PREFIX_TABLE_OWNER: &str = "tbo_"; pub(crate) const ROLE_PREFIX_TABLE_OWNER: &str = "tbo_";
pub(crate) const ROLE_PREFIX_TABLE_READER: &str = "tbr_"; pub(crate) const ROLE_PREFIX_TABLE_READER: &str = "tbr_";
pub(crate) const ROLE_PREFIX_TABLE_WRITER: &str = "tbw_"; 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_SUFFIX_LEN: usize = 8;
pub(crate) const SERVICE_CRED_CONN_LIMIT: usize = 4; pub(crate) const SERVICE_CRED_CONN_LIMIT: usize = 4;
@ -29,6 +29,7 @@ fn get_table_role(
role_prefix: &str, role_prefix: &str,
) -> Result<String, AppError> { ) -> Result<String, AppError> {
let mut roles: Vec<String> = vec![]; let mut roles: Vec<String> = vec![];
dbg!(&relacl);
for acl_item in relacl.ok_or(anyhow!("acl not present on class"))? { for acl_item in relacl.ok_or(anyhow!("acl not present on class"))? {
if acl_item.grantee.starts_with(role_prefix) { if acl_item.grantee.starts_with(role_prefix) {
let privileges_set: HashSet<PgPrivilegeType> = acl_item let privileges_set: HashSet<PgPrivilegeType> = acl_item
@ -56,9 +57,9 @@ fn get_table_role(
/// table permissions directly granted to it. Returns an error if no matching /// 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 /// role is found, and panics if a role is found with excess permissions
/// granted to it directly. /// granted to it directly.
pub(crate) fn get_reader_role(rel: PgClass) -> Result<String, AppError> { pub(crate) fn get_reader_role(rel: &PgClass) -> Result<String, AppError> {
get_table_role( get_table_role(
rel.relacl, rel.relacl.clone(),
[PgPrivilegeType::Select].into(), [PgPrivilegeType::Select].into(),
[ [
PgPrivilegeType::Insert, PgPrivilegeType::Insert,
@ -76,9 +77,9 @@ pub(crate) fn get_reader_role(rel: PgClass) -> Result<String, AppError> {
/// table permissions directly granted to it. Returns an error if no matching /// 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 /// role is found, and panics if a role is found with excess permissions
/// granted to it directly. /// granted to it directly.
pub(crate) fn get_writer_role(rel: PgClass) -> Result<String, AppError> { pub(crate) fn get_writer_role(rel: &PgClass) -> Result<String, AppError> {
get_table_role( get_table_role(
rel.relacl, rel.relacl.clone(),
[PgPrivilegeType::Delete, PgPrivilegeType::Truncate].into(), [PgPrivilegeType::Delete, PgPrivilegeType::Truncate].into(),
[PgPrivilegeType::Select].into(), [PgPrivilegeType::Select].into(),
ROLE_PREFIX_TABLE_WRITER, ROLE_PREFIX_TABLE_WRITER,
@ -101,7 +102,7 @@ impl RoleDisplay {
pub async fn from_rolname( pub async fn from_rolname(
rolname: &str, rolname: &str,
client: &mut WorkspaceClient, client: &mut WorkspaceClient,
) -> sqlx::Result<Option<RoleDisplay>> { ) -> sqlx::Result<Option<Self>> {
if rolname.starts_with(ROLE_PREFIX_TABLE_OWNER) if rolname.starts_with(ROLE_PREFIX_TABLE_OWNER)
|| rolname.starts_with(ROLE_PREFIX_TABLE_READER) || rolname.starts_with(ROLE_PREFIX_TABLE_READER)
|| rolname.starts_with(ROLE_PREFIX_TABLE_WRITER) || rolname.starts_with(ROLE_PREFIX_TABLE_WRITER)
@ -123,6 +124,7 @@ impl RoleDisplay {
from pg_class from pg_class
) )
where grantee = $1::regrole::oid where grantee = $1::regrole::oid
group by oid
"#, "#,
) )
.bind(rolname) .bind(rolname)
@ -131,24 +133,24 @@ impl RoleDisplay {
assert!(rels.len() <= 1); assert!(rels.len() <= 1);
Ok(rels.pop().map(|rel| { Ok(rels.pop().map(|rel| {
if rolname.starts_with(ROLE_PREFIX_TABLE_OWNER) { if rolname.starts_with(ROLE_PREFIX_TABLE_OWNER) {
RoleDisplay::TableOwner { Self::TableOwner {
oid: rel.oid, oid: rel.oid,
relname: rel.relname, relname: rel.relname,
} }
} else if rolname.starts_with(ROLE_PREFIX_TABLE_READER) { } else if rolname.starts_with(ROLE_PREFIX_TABLE_READER) {
RoleDisplay::TableReader { Self::TableReader {
oid: rel.oid, oid: rel.oid,
relname: rel.relname, relname: rel.relname,
} }
} else { } else {
RoleDisplay::TableWriter { Self::TableWriter {
oid: rel.oid, oid: rel.oid,
relname: rel.relname, relname: rel.relname,
} }
} }
})) }))
} else { } else {
Ok(Some(RoleDisplay::Unknown { Ok(Some(Self::Unknown {
rolname: rolname.to_owned(), rolname: rolname.to_owned(),
})) }))
} }

View file

@ -91,7 +91,7 @@ pub(super) async fn post(
"grant insert ({col}), update ({col}) on table {ident} to {writer_role}", "grant insert ({col}), update ({col}) on table {ident} to {writer_role}",
col = escape_identifier(&form.name), col = escape_identifier(&form.name),
ident = class.get_identifier(), 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()) .execute(workspace_client.get_conn())
.await?; .await?;

View file

@ -16,6 +16,7 @@ mod add_service_credential_handler;
mod add_table_handler; mod add_table_handler;
mod nav_handler; mod nav_handler;
mod service_credentials_handler; mod service_credentials_handler;
mod update_service_cred_permissions_handler;
#[derive(Clone, Debug, Deserialize)] #[derive(Clone, Debug, Deserialize)]
struct PathParams { struct PathParams {
@ -43,9 +44,13 @@ pub(super) fn new_router() -> Router<App> {
.route("/{workspace_id}/add-table", post(add_table_handler::post)) .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}/nav/", get(nav_handler::get))
.route_with_tsr( .route_with_tsr(
"/{workspace_id}/service-credentials", "/{workspace_id}/service-credentials/",
get(service_credentials_handler::get), get(service_credentials_handler::get),
) )
.route(
"/{workspace_id}/service-credentials/{service_cred_id}/update-permissions",
post(update_service_cred_permissions_handler::post),
)
.nest( .nest(
"/{workspace_id}/r/{rel_oid}", "/{workspace_id}/r/{rel_oid}",
relations_single::new_router(), relations_single::new_router(),

View file

@ -1,3 +1,4 @@
use anyhow::anyhow;
use askama::Template; use askama::Template;
use axum::{ use axum::{
debug_handler, debug_handler,
@ -6,7 +7,7 @@ use axum::{
}; };
use futures::{lock::Mutex, prelude::*, stream}; use futures::{lock::Mutex, prelude::*, stream};
use interim_models::{service_cred::ServiceCred, workspace::Workspace}; 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 redact::Secret;
use serde::Deserialize; use serde::Deserialize;
use url::Url; use url::Url;
@ -21,6 +22,7 @@ use crate::{
user::CurrentUser, user::CurrentUser,
workspace_nav::WorkspaceNav, workspace_nav::WorkspaceNav,
workspace_pooler::{RoleAssignment, WorkspacePooler}, workspace_pooler::{RoleAssignment, WorkspacePooler},
workspace_utils::PHONO_TABLE_NAMESPACE,
}; };
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@ -60,6 +62,50 @@ pub(super) async fn get(
conn_string_redacted: String, 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( let service_cred_info = stream::iter(
ServiceCred::belonging_to_user(user.id) ServiceCred::belonging_to_user(user.id)
.fetch_all(&mut app_db) .fetch_all(&mut app_db)
@ -67,28 +113,31 @@ pub(super) async fn get(
) )
.then(async |cred| { .then(async |cred| {
let member_of: Vec<RoleDisplay> = stream::iter({ let member_of: Vec<RoleDisplay> = stream::iter({
// Guard must be assigned to a local variable,
// lest the mutex become deadlocked.
let mut locked_client = workspace_client.lock().await; 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) .fetch_tree(&mut locked_client)
.await?; .await?
tree.unwrap().flatten_inherited() .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| { .then(async |role| {
tracing::debug!("111");
let mut locked_client = workspace_client.lock().await; let mut locked_client = workspace_client.lock().await;
RoleDisplay::from_rolname(&role.rolname, &mut locked_client).await RoleDisplay::from_rolname(&role.rolname, &mut locked_client).await
}) })
.collect::<Vec<Result<_, _>>>() .collect::<Vec<Result<_, _>>>()
.await .await
.into_iter() .into_iter()
// [`futures::stream::StreamExt::collect`] can only collect to types // [`futures::stream::StreamExt::collect`]
// that implement [`Default`], so we must do result handling with the // can only collect to types that implement [`Default`],
// sync version of `collect()`. // so we must do result handling with the sync version of `collect()`.
.collect::<Result<Vec<_>, _>>()? .collect::<Result<Vec<_>, _>>()?
.into_iter() .into_iter()
.flatten() .flatten()
.collect(); .collect();
tracing::debug!("222");
let conn_string = cluster.conn_str_for_db( let conn_string = cluster.conn_str_for_db(
&workspace.db_name, &workspace.db_name,
Some(( Some((
@ -105,26 +154,33 @@ pub(super) async fn get(
}) })
.collect::<Vec<Result<ServiceCredInfo, AppError>>>() .collect::<Vec<Result<ServiceCredInfo, AppError>>>()
.await .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() .into_iter()
.collect::<Result<Vec<ServiceCredInfo>, AppError>>()?; .collect::<Result<Vec<ServiceCredInfo>, AppError>>()?;
#[derive(Template)] #[derive(Template)]
#[template(path = "workspaces_single/service_credentials.html")] #[template(path = "workspaces_single/service_credentials.html")]
struct ResponseTemplate { struct ResponseTemplate {
all_rels: Vec<PgClass>,
service_cred_info: Vec<ServiceCredInfo>, service_cred_info: Vec<ServiceCredInfo>,
settings: Settings, settings: Settings,
workspace_nav: WorkspaceNav, workspace_nav: WorkspaceNav,
} }
let mut locked_client = workspace_client
.try_lock()
.ok_or(anyhow!("mutex should be unlocked"))?;
Ok(Html( Ok(Html(
ResponseTemplate { 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() workspace_nav: WorkspaceNav::builder()
.navigator(navigator) .navigator(navigator)
.workspace(workspace.clone()) .workspace(workspace.clone())
.populate_rels(&mut app_db, &mut *workspace_client.lock().await) .populate_rels(&mut app_db, &mut locked_client)
.await? .await?
.build()?, .build()?,
service_cred_info, service_cred_info,

View file

@ -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<WorkspacePooler>,
AppDbConn(mut app_db): AppDbConn,
CurrentUser(user): CurrentUser,
navigator: Navigator,
Path(PathParams {
workspace_id,
service_cred_id,
}): Path<PathParams>,
Form(form): Form<HashMap<String, Vec<String>>>,
) -> Result<impl IntoResponse, AppError> {
// 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::<Vec<_>>()
.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::<Vec<_>>()
.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::<Vec<_>>()
.await
.into_iter()
.collect::<Result<Vec<_>, AppError>>()?;
Ok(navigator
.workspace_page()
.workspace_id(workspace_id)
.suffix("service-credentials/")
.build()?
.redirect_to())
}

View file

@ -24,9 +24,9 @@
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>
<th>Connection String</th> <th scope="col">Connection String</th>
<th>Permissions</th> <th scope="col">Permissions</th>
<th>Actions</th> <th scope="col">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -45,6 +45,70 @@
</slot> </slot>
</copy-source> </copy-source>
</td> </td>
<td>
{% for role_display in cred.member_of %}
{{ role_display | safe }}
{% endfor %}
<button
popovertarget="permissions-editor-{{ cred.service_cred.rolname }}"
popovertargetaction="toggle"
type="button"
>
Edit
</button>
<dialog
class="dialog padded--lg"
id="permissions-editor-{{ cred.service_cred.rolname }}"
popover="auto"
>
<form action="{{ cred.service_cred.id.simple() }}/update-permissions" method="post">
<table class="table">
<thead>
<tr>
<th scope="col">Table</th>
<th scope="col">Reader</th>
<th scope="col">Writer</th>
</tr>
</thead>
<tbody>
{% for rel in all_rels %}
<tr>
<td>{{ rel.relname }}</td>
<td style="text-align: center;">
<input
type="checkbox"
name="{{ rel.oid.0 }}"
value="reader"
{%- if cred.is_reader_of(rel.relname) %}
checked="true"
{%- endif %}
>
</td>
<td style="text-align: center;">
<input
type="checkbox"
name="{{ rel.oid.0 }}"
value="writer"
{%- if cred.is_writer_of(rel.relname) %}
checked="true"
{%- endif %}
>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<button
class="button--primary"
style="margin-top: 16px;"
type="submit"
>
Save
</button>
</form>
</dialog>
</td>
<td></td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>

View file

@ -247,3 +247,14 @@ button, input[type="submit"] {
text-align: center; 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;
}