406 lines
14 KiB
Rust
406 lines
14 KiB
Rust
|
|
use std::{
|
||
|
|
collections::{HashMap, HashSet},
|
||
|
|
fmt::Display,
|
||
|
|
};
|
||
|
|
|
||
|
|
use anyhow::anyhow;
|
||
|
|
use askama::Template;
|
||
|
|
use phono_backends::{
|
||
|
|
client::WorkspaceClient,
|
||
|
|
escape_identifier,
|
||
|
|
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 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 = "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<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)]
|
||
|
|
#[template(path = "workspaces_single/permissions_editor.html")]
|
||
|
|
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,
|
||
|
|
)
|
||
|
|
}
|