implement table reader/writer/owner roles

This commit is contained in:
Brent Schroeter 2025-10-25 05:32:22 +00:00
parent fb6b0f0ed8
commit 44ccb2791c
5 changed files with 141 additions and 18 deletions

View file

@ -48,11 +48,22 @@ database should always be confirmed.
#### Accessing the `phono` schema
`GRANT USAGE ON <schema> to <role>;`
`GRANT USAGE ON <schema> TO <role>;`
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 <table owner role> TO <role>;`
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 <table> TO <role>;`
@ -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

View file

@ -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<String>,
#[serde(default)]
pub(crate) dropdown_option_values: Vec<String>,
pub(crate) text_input_mode: Option<String>,
pub(crate) timestamp_format: Option<String>,
#[serde(default)]
pub(crate) text_input_mode: String,
#[serde(default)]
pub(crate) timestamp_format: String,
}
impl TryFrom<PresentationForm> for Presentation {
@ -35,18 +43,14 @@ impl TryFrom<PresentationForm> 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 {},
})

View file

@ -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)

View file

@ -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())
}

View file

@ -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<Vec<PgAclItem>>,
required_privileges: HashSet<PgPrivilegeType>,
disallowed_privileges: HashSet<PgPrivilegeType>,
role_prefix: &str,
) -> Result<String, AppError> {
let mut roles: Vec<String> = 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<PgPrivilegeType> = 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<String, AppError> {
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<String, AppError> {
get_table_role(
rel.relacl,
[PgPrivilegeType::Delete, PgPrivilegeType::Truncate].into(),
[PgPrivilegeType::Select].into(),
TABLE_WRITER_ROLE_PREFIX,
)
}