phonograph/phono-models/src/accessors/portal.rs

224 lines
8.8 KiB
Rust
Raw Normal View History

use std::collections::HashSet;
use derive_builder::Builder;
use phono_backends::{
client::WorkspaceClient, pg_acl::PgPrivilegeType, pg_class::PgClass, pg_database::PgDatabase,
pg_role::RoleTree, rolnames::ROLE_PREFIX_USER,
};
use sqlx::postgres::types::Oid;
use tracing::{Instrument, debug, info_span};
use uuid::Uuid;
use crate::{client::AppDbClient, portal::Portal};
use super::{AccessError, Accessor, Actor};
/// Utility for fetching a [`Portal`], with authorization.
#[derive(Builder, Debug)]
#[builder(
// Build fn should only be called internally, via `fetch_optional()`.
build_fn(private, error = "super::AccessError"),
// Callers interact primarily with the generated builder struct.
name = "PortalAccessor",
vis = "pub",
// "Owned" pattern circumvents the `Clone` trait bound on fields.
pattern = "owned",
)]
struct GeneratedPortalAccessor<'a> {
/// Required. ID of the portal to be accessed.
id: Uuid,
/// Required. Identity against which to evaluate authorization checks.
as_actor: Actor,
/// Required. Client for the backing database.
using_workspace_client: &'a mut WorkspaceClient,
/// Required. Client for the application database.
using_app_db: &'a mut AppDbClient,
/// Optionally verify that the portal is associated with a specific
/// workspace ID.
#[builder(default, setter(strip_option))]
verify_workspace_id: Option<Uuid>,
/// Optionally verify that the portal is associated with a specific relation
/// OID.
#[builder(default, setter(strip_option))]
verify_rel_oid: Option<Oid>,
/// Optionally verify that the actor has or inherits specific Postgres
/// permissions on the relation in the backing database.
#[builder(default, setter(into))]
// Using [`HashSet<PgPrivilegeType>`] with `#[builder(setter(into))]` is
// more straightforward for callers than asking them to provide a
// `&'a [PgPrivilege]`. Forcing a conversion to a heap-based data structure
// feels wrong when callers will often be starting with a statically
// allocated array of `PgPrivilegeType`s, but that optimization can be made
// later if the trade-off with ergonomics proves warranted.
verify_rel_permissions: HashSet<PgPrivilegeType>,
// Find doc comment on setter method.
#[builder(default, setter(custom))]
verify_rel_ownership: bool,
/// Optionally for performance, relation metadata may be provided by the
/// caller so that the accessor doesn't need to refetch it behind the
/// scenes.
#[builder(default, setter(strip_option))]
using_rel: Option<&'a PgClass>,
}
impl PortalAccessor<'_> {
/// Optionally verify that the actor has or belongs to the role that owns
/// the relation in the backing database.
pub fn verify_rel_ownership(self) -> Self {
Self {
verify_rel_ownership: Some(true),
..self
}
}
}
impl<'a> Accessor<Portal> for PortalAccessor<'a> {
async fn fetch_one(self) -> Result<Portal, AccessError> {
let spec = self.build()?;
async {
debug!("accessing portal");
let portal = Portal::with_id(spec.id)
.fetch_optional(spec.using_app_db)
.await?
.ok_or_else(|| {
debug!("portal not found");
AccessError::NotFound
})?;
2026-02-13 08:00:23 +00:00
if spec
.verify_workspace_id
.is_some_and(|value| portal.workspace_id != value)
{
debug!("workspace_id check failed for portal");
return Err(AccessError::NotFound);
}
if spec
.verify_rel_oid
.is_some_and(|value| portal.class_oid != value)
{
debug!("rel_oid check failed for portal");
return Err(AccessError::NotFound);
}
let rel = if let Some(value) = spec.using_rel {
value
} else {
&PgClass::with_oid(portal.class_oid)
.fetch_optional(spec.using_workspace_client)
.await?
.ok_or_else(|| {
debug!("unable to fetch PgClass for portal");
AccessError::NotFound
})?
};
let actor_rolname = match spec.as_actor {
Actor::Bypass => None,
Actor::User(user_id) => Some(format!(
"{ROLE_PREFIX_USER}{user_id}",
user_id = user_id.simple()
)),
};
if let Some(actor_rolname) = actor_rolname {
// Verify database CONNECT permissions.
let pg_db = PgDatabase::current()
.fetch_one(spec.using_workspace_client)
.await?;
if !pg_db.datacl.unwrap_or_default().iter().any(|acl| {
// Currently database connect permissions are always granted
// directly, though this may change in the future.
// TODO: Generalize to inherited roles
acl.grantee == actor_rolname
&& acl
.privileges
.iter()
.any(|privilege| privilege.privilege == PgPrivilegeType::Connect)
}) {
debug!("actor lacks postgres connect privileges");
return Err(AccessError::NotFound);
}
// Verify ACL permissions.
//
// No need to explicitly check whether
// `verify_rel_permissions` is empty: database queries are
// only performed based on roles which intersect with the
// permissions check.
//
// This could alternatively be implemented using streams,
// but this naive for-loop version is much more readable and
// still bottlenecks on the same serial database access.
let mut actor_permissions: HashSet<PgPrivilegeType> = HashSet::new();
for acl_item in rel
.relacl
.as_ref()
.unwrap_or(&vec![])
.iter()
.filter(|acl_item| {
acl_item
.privileges
.iter()
.any(|value| spec.verify_rel_permissions.contains(&value.privilege))
})
{
// The naive equality check is technically redundant,
// but it allows us to eliminate a database round trip
// if it evaluates to true.
if acl_item.grantee == actor_rolname
|| RoleTree::members_of_rolname(&acl_item.grantee)
.fetch_tree(spec.using_workspace_client)
.await?
.map(|tree| tree.flatten_inherited())
.unwrap_or_default()
.iter()
2025-12-16 09:59:30 -08:00
.any(|value| value.role.rolname == actor_rolname)
{
for permission in acl_item.privileges.iter() {
actor_permissions.insert(permission.privilege);
}
}
}
if !actor_permissions.is_superset(&spec.verify_rel_permissions) {
debug!("actor lacks postgres privileges");
return Err(AccessError::NotFound);
}
// Verify relation ownership.
if spec.verify_rel_ownership
// The naive equality check is technically redundant,
// but it allows us to eliminate a database round trip
// if it evaluates to true.
&& (rel.regowner == actor_rolname
|| !RoleTree::members_of_oid(rel.relowner)
.fetch_tree(spec.using_workspace_client)
.await?
.map(|tree| tree.flatten_inherited())
.unwrap_or_default()
.iter()
2025-12-16 09:59:30 -08:00
.any(|value| value.role.rolname == actor_rolname))
{
debug!("actor is not relation owner");
return Err(AccessError::NotFound);
}
}
Ok(portal)
}
.instrument(info_span!(
"PortalAccessor::fetch_one()",
portal_id = spec.id.to_string(),
actor = spec.as_actor.to_string(),
))
.await
}
}