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!(
"{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(),
)
}
}

View file

@ -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<String, AppError> {
let mut roles: Vec<String> = 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<PgPrivilegeType> = 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<String, AppError> {
pub(crate) fn get_reader_role(rel: &PgClass) -> Result<String, AppError> {
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<String, AppError> {
/// 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<String, AppError> {
pub(crate) fn get_writer_role(rel: &PgClass) -> Result<String, AppError> {
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<Option<RoleDisplay>> {
) -> sqlx::Result<Option<Self>> {
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<RelInfo> = query_as(
r#"
select oid, any_value(relname) as relname
from (
select oid, any_value(relname) as relname
from (
select
oid,
relname,
(aclexplode(relacl)).grantee as grantee
from pg_class
)
where grantee = $1::regrole::oid
"#,
)
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(),
}))
}

View file

@ -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?;

View file

@ -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<App> {
.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(),

View file

@ -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<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 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::<Vec<Result<_, _>>>()
.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::<Result<Vec<_>, _>>()?
.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::<Vec<Result<ServiceCredInfo, AppError>>>()
.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::<Result<Vec<ServiceCredInfo>, AppError>>()?;
#[derive(Template)]
#[template(path = "workspaces_single/service_credentials.html")]
struct ResponseTemplate {
all_rels: Vec<PgClass>,
service_cred_info: Vec<ServiceCredInfo>,
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,

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">
<thead>
<tr>
<th>Connection String</th>
<th>Permissions</th>
<th>Actions</th>
<th scope="col">Connection String</th>
<th scope="col">Permissions</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
@ -45,6 +45,70 @@
</slot>
</copy-source>
</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>
{% endfor %}
</tbody>

View file

@ -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;
}