use derive_builder::Builder; use phono_backends::{ client::WorkspaceClient, pg_acl::PgPrivilegeType, pg_database::PgDatabase, rolnames::ROLE_PREFIX_USER, }; use tracing::{Instrument as _, debug, info_span}; use uuid::Uuid; use crate::{ client::AppDbClient, errors::{AccessError, QueryResult}, workspace::Workspace, workspace_user_perm::WorkspaceMembership, }; use super::{Accessor, Actor}; /// Utility for fetching a [`Workspace`], with authorization. #[derive(Builder, Debug)] #[builder( // Build fn should only be called internally, via `fetch_optional()`. build_fn(private, error = "AccessError"), // Callers interact primarily with the generated builder struct. name = "WorkspaceAccessor", vis = "pub", // "Owned" pattern circumvents the `Clone` trait bound on fields. pattern = "owned", )] struct GeneratedWorkspaceAccessor<'a> { /// Required. ID of the workspace to be accessed. id: Uuid, /// Required. Identity against which to evaluate authorization checks. as_actor: Actor, /// Required. Client for the backing database. Providing a client authorized /// as the root Phonograph user will not compromise the integrity of the /// authorization checks. using_workspace_client: &'a mut WorkspaceClient, /// Required. Client for the application database. using_app_db: &'a mut AppDbClient, } impl<'a> Accessor for WorkspaceAccessor<'a> { async fn fetch_one(self) -> Result { let spec = self.build()?; async { debug!("accessing workspace"); let workspace = if let Some(value) = Workspace::with_id(spec.id) .fetch_optional(spec.using_app_db) .await? { value } else { debug!("workspace access denied: workspace not found"); clear_workspace_membership(spec.id, spec.as_actor, spec.using_app_db).await?; return Err(AccessError::NotFound); }; debug!("workspace found"); 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!("workspace access denied: actor lacks postgres connect privilege"); clear_workspace_membership(spec.id, spec.as_actor, spec.using_app_db).await?; return Err(AccessError::NotFound); } } debug!("workspace access approved"); cache_workspace_membership(spec.id, spec.as_actor, spec.using_app_db).await?; Ok(workspace) } .instrument(info_span!( "WorkspaceAccessor::fetch_one()", workspace_id = spec.id.to_string(), actor = spec.as_actor.to_string(), )) .await } } async fn clear_workspace_membership( workspace_id: Uuid, actor: Actor, app_db: &mut AppDbClient, ) -> QueryResult<()> { if let Actor::User(user_id) = actor { WorkspaceMembership::delete() .user_id(user_id) .workspace_id(workspace_id) .execute(app_db) .await?; debug!("cleared workspace membership cache entry if present"); } else { debug!("no action for workspace membership cache: actor is \"{actor}\""); } Ok(()) } async fn cache_workspace_membership( workspace_id: Uuid, actor: Actor, app_db: &mut AppDbClient, ) -> QueryResult<()> { if let Actor::User(user_id) = actor { WorkspaceMembership::upsert() .user_id(user_id) .workspace_id(workspace_id) .execute(app_db) .await?; debug!("cached workspace membership"); } else { debug!("no action for workspace membership cache: actor is \"{actor}\""); } Ok(()) }