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; 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)] #[template(path = "includes/rel_permission.html")] 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> { 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 = 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 for User { type Error = InvalidVariantError; fn try_from(value: Grantee) -> Result { 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)] #[template(path = "includes/permissions_editor.html")] pub(crate) struct PermissionsEditor { /// User, invite, or service credential being granted permissions. pub(crate) target: Grantee, pub(crate) current_perms: HashSet, /// Endpoint to use in the `
` 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, /// Additional form fields to be rendered as ``. pub(crate) hidden_inputs: HashMap, } /// 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, 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> = 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, 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>, required_privileges: HashSet, disallowed_privileges: HashSet, 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 = 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, ) }