ability to grant permissions to service creds
This commit is contained in:
parent
97b5ccc064
commit
b12127d220
8 changed files with 305 additions and 40 deletions
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -123,6 +124,7 @@ impl RoleDisplay {
|
|||
from pg_class
|
||||
)
|
||||
where grantee = $1::regrole::oid
|
||||
group by oid
|
||||
"#,
|
||||
)
|
||||
.bind(rolname)
|
||||
|
|
@ -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(),
|
||||
}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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?;
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue