203 lines
7.9 KiB
Rust
203 lines
7.9 KiB
Rust
|
|
use std::collections::HashSet;
|
||
|
|
|
||
|
|
use derive_builder::Builder;
|
||
|
|
use interim_pgtypes::{
|
||
|
|
client::WorkspaceClient, pg_acl::PgPrivilegeType, pg_class::PgClass, pg_role::RoleTree,
|
||
|
|
rolnames::ROLE_PREFIX_USER,
|
||
|
|
};
|
||
|
|
use sqlx::postgres::types::Oid;
|
||
|
|
use tracing::{Instrument, info, 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 {
|
||
|
|
// FIXME: Do we need to explicitly verify that the actor has
|
||
|
|
// database `CONNECT` privileges, or is that implicitly given by the
|
||
|
|
// fact that a workspace client has already been acquired?
|
||
|
|
|
||
|
|
let portal = Portal::with_id(spec.id)
|
||
|
|
.fetch_optional(spec.using_app_db)
|
||
|
|
.await?
|
||
|
|
.ok_or(AccessError::NotFound)?;
|
||
|
|
|
||
|
|
spec.verify_workspace_id
|
||
|
|
.is_none_or(|value| portal.workspace_id == value)
|
||
|
|
.ok_or_else(|| {
|
||
|
|
info!("workspace_id check failed for portal");
|
||
|
|
AccessError::NotFound
|
||
|
|
})?;
|
||
|
|
spec.verify_rel_oid
|
||
|
|
.is_none_or(|value| portal.class_oid == value)
|
||
|
|
.ok_or_else(|| {
|
||
|
|
info!("rel_oid check failed for portal");
|
||
|
|
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(|| {
|
||
|
|
info!("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 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) {
|
||
|
|
info!("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()
|
||
|
|
.any(|value| value.rolname == actor_rolname))
|
||
|
|
{
|
||
|
|
info!("actor is not relation owner");
|
||
|
|
return Err(AccessError::NotFound);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
Ok(portal)
|
||
|
|
}
|
||
|
|
.instrument(info_span!(
|
||
|
|
"PortalAccessor::fetch_optional()",
|
||
|
|
portal_id = spec.id.to_string(),
|
||
|
|
))
|
||
|
|
.await
|
||
|
|
}
|
||
|
|
}
|