phonograph/phono-server/src/permissions.rs

406 lines
14 KiB
Rust
Raw Normal View History

2025-12-16 09:59:30 -08:00
use std::{
collections::{HashMap, HashSet},
fmt::Display,
};
use anyhow::anyhow;
use askama::Template;
use phono_backends::{
client::WorkspaceClient,
pg_acl::{PgAclItem, PgPrivilegeType},
pg_class::PgClass,
pg_role::RoleTree,
rolnames::{
ROLE_PREFIX_TABLE_OWNER, ROLE_PREFIX_TABLE_READER, ROLE_PREFIX_TABLE_WRITER,
ROLE_PREFIX_USER,
},
};
use phono_models::{accessors::Actor, service_cred::ServiceCred, user::User};
use phono_pestgros::escape_identifier;
2025-12-16 09:59:30 -08:00
use serde::{Deserialize, Serialize};
use sqlx::{postgres::types::Oid, prelude::FromRow, query, query_as};
use tracing::{Instrument, info_span};
use crate::errors::AppError;
#[derive(
Clone,
Copy,
Debug,
Deserialize,
Eq,
Hash,
PartialEq,
Serialize,
strum::Display,
strum::EnumIs,
strum::EnumIter,
strum::EnumString,
)]
#[strum(serialize_all = "snake_case")]
#[serde(rename_all = "snake_case")]
pub enum RelPermissionKind {
Owner,
Reader,
Writer,
}
#[derive(Clone, Debug, Eq, Hash, PartialEq, Template)]
2026-01-19 18:48:14 +00:00
#[template(path = "includes/rel_permission.html")]
2025-12-16 09:59:30 -08:00
pub(crate) struct RelPermission {
pub(crate) kind: RelPermissionKind,
pub(crate) rel_oid: Oid,
pub(crate) relname: String,
}
impl RelPermission {
/// 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, or if the role does not align with a
/// [`RelPermissionKind`], the returned value is `None`.
pub async fn from_rolname(
rolname: &str,
client: &mut WorkspaceClient,
) -> sqlx::Result<Option<Self>> {
let kind = if rolname.starts_with(ROLE_PREFIX_TABLE_OWNER) {
RelPermissionKind::Owner
} else if rolname.starts_with(ROLE_PREFIX_TABLE_READER) {
RelPermissionKind::Reader
} else if rolname.starts_with(ROLE_PREFIX_TABLE_WRITER) {
RelPermissionKind::Writer
} else {
return Ok(None);
};
#[derive(FromRow)]
struct RelInfo {
oid: Oid,
relname: String,
}
// TODO: Consider moving this to [`phono_backends`].
let mut rels: Vec<RelInfo> = query_as(
"
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| Self {
kind,
rel_oid: rel.oid,
relname: rel.relname,
}))
}
}
// This is implemented so that `RelPermission` can be constructed inline in
// Askama templates via `(kind, relname, oid).into()`.
impl From<(RelPermissionKind, String, Oid)> for RelPermission {
fn from((kind, relname, rel_oid): (RelPermissionKind, String, Oid)) -> Self {
Self {
kind,
relname,
rel_oid,
}
}
}
#[derive(Clone, Debug, strum::EnumIs)]
pub(crate) enum Grantee {
User(User),
ServiceCred(ServiceCred),
}
impl Display for Grantee {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::User(User { email, .. }) => write!(f, "{email}"),
Self::ServiceCred(cred) => write!(f, "svc_{0}", cred.id.simple()),
}
}
}
#[derive(Debug, thiserror::Error)]
#[error("invalid variant")]
pub(crate) struct InvalidVariantError;
impl TryFrom<Grantee> for User {
type Error = InvalidVariantError;
fn try_from(value: Grantee) -> Result<Self, Self::Error> {
if let Grantee::User(user) = value {
Ok(user)
} else {
Err(InvalidVariantError)
}
}
}
/// Askama template which renders a collection of [`RelPermission`] badges and
/// an "Edit" button which opens a dialog with a form allowing permissions to be
/// assigned across multiple tables within a workspace.
#[derive(Clone, Debug, Template)]
2026-01-19 18:48:14 +00:00
#[template(path = "includes/permissions_editor.html")]
2025-12-16 09:59:30 -08:00
pub(crate) struct PermissionsEditor {
/// User, invite, or service credential being granted permissions.
pub(crate) target: Grantee,
pub(crate) current_perms: HashSet<RelPermission>,
/// Endpoint to use in the `<form action="">` attribute.
pub(crate) update_endpoint: String,
/// Set to true to show the "table owner" column or false to hide it.
pub(crate) include_owner: bool,
pub(crate) all_rels: Vec<PgClass>,
/// Additional form fields to be rendered as `<input type="hidden">`.
pub(crate) hidden_inputs: HashMap<String, String>,
}
/// Attempt to grant permissions on a relation. Permissions for which `actor`
/// does not hold the `ADMIN OPTION` in the backing database will be skipped
/// with a warning.
///
/// The role with which `workspace_client` is authenticated is important.
/// Postgres considers all granted privileges "dependent" on the admin
/// privileges of the role in use when granting them. Revoking the admin
/// privileges of the grantor will cascade if no other roles have granted the
/// same privileges independently to the same grantee. In practice, this means
/// that service credentials should be granted permissions via a client
/// authenticated as their owner, and other users should be granted permissions
/// via a client authenticated as the Phonograph root user. This behavior is
/// checked by debug assertions, unless `actor` is set to `Actor::Bypass`.
pub(crate) async fn grant_rel_permissions(
target: Grantee,
actor: Actor,
perms: HashSet<RelPermission>,
workspace_client: &mut WorkspaceClient,
) -> Result<(), AppError> {
async {
let target_rolname = match target.clone() {
Grantee::ServiceCred(cred) => cred.rolname.clone(),
Grantee::User(User { id, .. }) => format!("{ROLE_PREFIX_USER}{id}", id = id.simple()),
};
// Assert that function is called with `workspace_client` authentication
// configured correctly. The check incurs an extra round trip to the backing
// database, so it is skipped in production builds.
#[cfg(debug_assertions)]
{
if let Actor::User(actor_id) = actor {
let actor_rolname = format!("{ROLE_PREFIX_USER}{id}", id = actor_id.simple());
#[derive(FromRow)]
struct CurrentUserRow {
current_user: String,
}
let CurrentUserRow {
current_user: workspace_current_user,
} = query_as("select current_user")
.fetch_one(workspace_client.get_conn())
.await?;
match target {
Grantee::ServiceCred(_) => {
debug_assert!(workspace_current_user == actor_rolname)
}
Grantee::User(_) => debug_assert!(workspace_current_user != actor_rolname),
}
}
}
// `None` means that we're using `Actor::Bypass` and all roles are on the table.
let permitted_rolnames: Option<HashSet<String>> = if let Actor::User(actor_id) = actor {
let actor_rolname = format!("{ROLE_PREFIX_USER}{id}", id = actor_id.simple());
Some(
RoleTree::granted_to_rolname(&actor_rolname)
.fetch_tree(workspace_client)
.await?
.ok_or(anyhow!("failed to fetch role tree"))?
.flatten_inherited()
.into_iter()
.filter(|value| value.admin)
.map(|value| value.role.rolname)
.collect(),
)
} else {
None
};
let target_rolname_esc = escape_identifier(&target_rolname);
for perm in perms {
let rel = PgClass::with_oid(perm.rel_oid)
.fetch_one(workspace_client)
.await?;
let perm_rolname = match perm.kind {
RelPermissionKind::Owner => get_owner_role(&rel),
RelPermissionKind::Reader => get_reader_role(&rel),
RelPermissionKind::Writer => get_writer_role(&rel),
}?;
if permitted_rolnames
.as_ref()
.is_none_or(|permitted_rolnames| {
permitted_rolnames.contains(perm_rolname)
})
{
let perm_rolname_esc = escape_identifier(perm_rolname);
query(&format!(
"grant {perm_rolname_esc} to {target_rolname_esc} with admin option"
))
.execute(workspace_client.get_conn())
.await?;
} else {
tracing::warn!("{actor} attempted to grant {perm_rolname} for which they do not have the admin option");
}
}
Ok(())
}
.instrument(info_span!("grant_rel_permissions()"))
.await
}
/// Attempt to revoke permissions on a relation.
///
/// Similarly to [`grant_rel_permissions`], the role with which
/// `workspace_client` is authenticated is important, in this case because
/// Postgres only allows privileges to be revoked by the role that granted them.
/// Revoking privileges using a client authenticated as a different role will
/// have no effect.
///
/// Note that if the same privilege has been granted multiple times by different
/// grantor roles, that privilege will remain in effect until all grants have
/// been undone. In practice, Phonograph should always be granting privileges to
/// user-specific Postgres roles via the Phonograph root role and to service
/// credential roles via the role associated with the credential's owner, so
/// redundant grants should be encountered rarely if ever.
pub(crate) async fn revoke_rel_permissions(
target: Grantee,
perms: HashSet<RelPermission>,
workspace_client: &mut WorkspaceClient,
) -> Result<(), AppError> {
let target_rolname = match target {
Grantee::ServiceCred(cred) => cred.rolname.clone(),
Grantee::User(User { id, .. }) => format!("{ROLE_PREFIX_USER}{id}", id = id.simple()),
};
for perm in perms {
let rel = PgClass::with_oid(perm.rel_oid)
.fetch_one(workspace_client)
.await?;
let target_rolname_esc = escape_identifier(&target_rolname);
let perm_rolname = match perm.kind {
RelPermissionKind::Owner => get_owner_role(&rel),
RelPermissionKind::Reader => get_reader_role(&rel),
RelPermissionKind::Writer => get_writer_role(&rel),
}?;
let perm_rolname_esc = escape_identifier(perm_rolname);
query(&format!(
"revoke {perm_rolname_esc} from {target_rolname_esc} cascade"
))
.execute(workspace_client.get_conn())
.await?;
}
Ok(())
}
/// Error type returned by functions which find certain Phonograph-generated
/// roles within relation ACLs.
#[derive(Clone, Copy, Debug, thiserror::Error)]
pub(crate) enum GetTableRoleError {
#[error("relacl field not present on pg_class record")]
MissingAcl,
#[error("no role found with prefix {role_prefix}")]
MissingRole { role_prefix: &'static str },
}
fn get_table_role<'a>(
relacl: Option<&'a Vec<PgAclItem>>,
required_privileges: HashSet<PgPrivilegeType>,
disallowed_privileges: HashSet<PgPrivilegeType>,
role_prefix: &'static str,
) -> Result<&'a str, GetTableRoleError> {
relacl
.ok_or(GetTableRoleError::MissingAcl)?
.iter()
.find_map(|acl_item| {
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);
Some(acl_item.grantee.as_str())
} else {
None
}
})
.ok_or(GetTableRoleError::MissingRole { role_prefix })
}
/// Returns the name of the "table_owner" 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_owner_role(rel: &PgClass) -> Result<&str, GetTableRoleError> {
get_table_role(
rel.relacl.as_ref(),
[
PgPrivilegeType::Insert,
PgPrivilegeType::Update,
PgPrivilegeType::Delete,
PgPrivilegeType::Truncate,
]
.into(),
[].into(),
ROLE_PREFIX_TABLE_OWNER,
)
}
/// 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<&str, GetTableRoleError> {
get_table_role(
rel.relacl.as_ref(),
[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<&str, GetTableRoleError> {
get_table_role(
rel.relacl.as_ref(),
[PgPrivilegeType::Delete, PgPrivilegeType::Truncate].into(),
[PgPrivilegeType::Select].into(),
ROLE_PREFIX_TABLE_WRITER,
)
}