2025-11-19 01:31:09 +00:00
|
|
|
use std::collections::HashSet;
|
|
|
|
|
|
|
|
|
|
use derive_builder::Builder;
|
2025-11-19 01:45:58 +00:00
|
|
|
use phono_backends::{
|
2025-11-22 06:30:50 +00:00
|
|
|
client::WorkspaceClient, pg_acl::PgPrivilegeType, pg_class::PgClass, pg_database::PgDatabase,
|
|
|
|
|
pg_role::RoleTree, rolnames::ROLE_PREFIX_USER,
|
2025-11-19 01:31:09 +00:00
|
|
|
};
|
|
|
|
|
use sqlx::postgres::types::Oid;
|
2025-12-10 22:01:47 +00:00
|
|
|
use tracing::{Instrument, debug, info_span};
|
2025-11-19 01:31:09 +00:00
|
|
|
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 {
|
2025-12-10 22:01:47 +00:00
|
|
|
debug!("accessing portal");
|
2025-11-19 01:31:09 +00:00
|
|
|
let portal = Portal::with_id(spec.id)
|
|
|
|
|
.fetch_optional(spec.using_app_db)
|
|
|
|
|
.await?
|
2025-12-10 22:01:47 +00:00
|
|
|
.ok_or_else(|| {
|
|
|
|
|
debug!("portal not found");
|
|
|
|
|
AccessError::NotFound
|
|
|
|
|
})?;
|
2025-11-19 01:31:09 +00:00
|
|
|
|
|
|
|
|
spec.verify_workspace_id
|
|
|
|
|
.is_none_or(|value| portal.workspace_id == value)
|
|
|
|
|
.ok_or_else(|| {
|
2025-12-10 22:01:47 +00:00
|
|
|
debug!("workspace_id check failed for portal");
|
2025-11-19 01:31:09 +00:00
|
|
|
AccessError::NotFound
|
|
|
|
|
})?;
|
|
|
|
|
spec.verify_rel_oid
|
|
|
|
|
.is_none_or(|value| portal.class_oid == value)
|
|
|
|
|
.ok_or_else(|| {
|
2025-12-10 22:01:47 +00:00
|
|
|
debug!("rel_oid check failed for portal");
|
2025-11-19 01:31:09 +00:00
|
|
|
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(|| {
|
2025-12-10 22:01:47 +00:00
|
|
|
debug!("unable to fetch PgClass for portal");
|
2025-11-19 01:31:09 +00:00
|
|
|
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 {
|
2025-11-22 06:30:50 +00:00
|
|
|
// 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)
|
|
|
|
|
}) {
|
2025-12-10 22:01:47 +00:00
|
|
|
debug!("actor lacks postgres connect privileges");
|
2025-11-22 06:30:50 +00:00
|
|
|
return Err(AccessError::NotFound);
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-19 01:31:09 +00:00
|
|
|
// 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()
|
|
|
|
|
.any(|value| value.rolname == actor_rolname)
|
|
|
|
|
{
|
|
|
|
|
for permission in acl_item.privileges.iter() {
|
|
|
|
|
actor_permissions.insert(permission.privilege);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if !actor_permissions.is_superset(&spec.verify_rel_permissions) {
|
2025-12-10 22:01:47 +00:00
|
|
|
debug!("actor lacks postgres privileges");
|
2025-11-19 01:31:09 +00:00
|
|
|
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()
|
|
|
|
|
.any(|value| value.rolname == actor_rolname))
|
|
|
|
|
{
|
2025-12-10 22:01:47 +00:00
|
|
|
debug!("actor is not relation owner");
|
2025-11-19 01:31:09 +00:00
|
|
|
return Err(AccessError::NotFound);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(portal)
|
|
|
|
|
}
|
|
|
|
|
.instrument(info_span!(
|
2025-12-10 22:01:47 +00:00
|
|
|
"PortalAccessor::fetch_one()",
|
2025-11-19 01:31:09 +00:00
|
|
|
portal_id = spec.id.to_string(),
|
2025-12-10 22:01:47 +00:00
|
|
|
actor = spec.as_actor.to_string(),
|
2025-11-19 01:31:09 +00:00
|
|
|
))
|
|
|
|
|
.await
|
|
|
|
|
}
|
|
|
|
|
}
|