phonograph/phono-server/src/roles.rs
2025-11-19 02:14:43 +00:00

151 lines
5 KiB
Rust

use std::collections::HashSet;
use anyhow::anyhow;
use askama::Template;
use phono_backends::{
client::WorkspaceClient,
pg_acl::{PgAclItem, PgPrivilegeType},
pg_class::PgClass,
rolnames::{ROLE_PREFIX_TABLE_OWNER, ROLE_PREFIX_TABLE_READER, ROLE_PREFIX_TABLE_WRITER},
};
use serde::{Deserialize, Serialize};
use sqlx::{postgres::types::Oid, prelude::FromRow, query_as};
use crate::errors::AppError;
// 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![];
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
.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.clone(),
[PgPrivilegeType::Select].into(),
[
PgPrivilegeType::Insert,
PgPrivilegeType::Update,
PgPrivilegeType::Delete,
PgPrivilegeType::Truncate,
]
.into(),
ROLE_PREFIX_TABLE_READER,
)
}
/// 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.clone(),
[PgPrivilegeType::Delete, PgPrivilegeType::Truncate].into(),
[PgPrivilegeType::Select].into(),
ROLE_PREFIX_TABLE_WRITER,
)
}
#[derive(Clone, Debug, Deserialize, Template, Serialize)]
#[template(path = "role_display.html")]
pub(crate) enum RoleDisplay {
TableOwner { oid: Oid, relname: String },
TableReader { oid: Oid, relname: String },
TableWriter { oid: Oid, relname: String },
Unknown { rolname: String },
}
impl RoleDisplay {
/// Attempt to infer value from a role name in a specific workspace. If the
/// role corresponds specifically to a relation and that relation is not
/// present in the current workspace, the returned value is `None`.
pub async fn from_rolname(
rolname: &str,
client: &mut WorkspaceClient,
) -> 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)
{
#[derive(FromRow)]
struct RelInfo {
oid: Oid,
relname: String,
}
// TODO: Consider moving this to [`phono_backends`].
let mut rels: Vec<RelInfo> = 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
group by oid
"#,
)
.bind(rolname)
.fetch_all(client.get_conn())
.await?;
assert!(rels.len() <= 1);
Ok(rels.pop().map(|rel| {
if rolname.starts_with(ROLE_PREFIX_TABLE_OWNER) {
Self::TableOwner {
oid: rel.oid,
relname: rel.relname,
}
} else if rolname.starts_with(ROLE_PREFIX_TABLE_READER) {
Self::TableReader {
oid: rel.oid,
relname: rel.relname,
}
} else {
Self::TableWriter {
oid: rel.oid,
relname: rel.relname,
}
}
}))
} else {
Ok(Some(Self::Unknown {
rolname: rolname.to_owned(),
}))
}
}
}