apply auth checks when accessing workspace records

This commit is contained in:
Brent Schroeter 2025-12-10 22:01:47 +00:00
parent 5a3d6eabf9
commit 6cd15e380a
16 changed files with 389 additions and 127 deletions

View file

@ -1,44 +1,19 @@
use std::sync::Arc;
use derive_builder::UninitializedFieldError;
use uuid::Uuid; use uuid::Uuid;
pub mod portal; use crate::errors::AccessError;
#[derive(Clone, Copy, Debug, PartialEq)] pub mod portal;
pub mod workspace;
#[derive(Clone, Copy, Debug, PartialEq, strum::Display)]
pub enum Actor { pub enum Actor {
/// Bypass explicit auth checks. /// Bypass explicit auth checks.
Bypass, Bypass,
#[strum(to_string = "User({0})")]
User(Uuid), User(Uuid),
} }
/// Encompasses all possible (non-fatal) errors encountered while executing an
/// [`Accessor`]'s `fetch_one()` or `fetch_optional()` methods.
#[derive(Clone, Debug, thiserror::Error)]
pub enum AccessError {
#[error("database error: {0}")]
Database(Arc<sqlx::Error>),
#[error("not found or access denied")]
NotFound,
#[error("incomplete access spec: {0}")]
Incomplete(UninitializedFieldError),
}
impl From<sqlx::Error> for AccessError {
fn from(value: sqlx::Error) -> Self {
Self::Database(Arc::new(value))
}
}
impl From<UninitializedFieldError> for AccessError {
fn from(value: UninitializedFieldError) -> Self {
Self::Incomplete(value)
}
}
/// Provides methods for fetching database entities of type `T`, typically with /// Provides methods for fetching database entities of type `T`, typically with
/// authorization checks. /// authorization checks.
pub trait Accessor<T>: Default + Sized { pub trait Accessor<T>: Default + Sized {

View file

@ -6,7 +6,7 @@ use phono_backends::{
pg_role::RoleTree, rolnames::ROLE_PREFIX_USER, pg_role::RoleTree, rolnames::ROLE_PREFIX_USER,
}; };
use sqlx::postgres::types::Oid; use sqlx::postgres::types::Oid;
use tracing::{Instrument, info, info_span}; use tracing::{Instrument, debug, info_span};
use uuid::Uuid; use uuid::Uuid;
use crate::{client::AppDbClient, portal::Portal}; use crate::{client::AppDbClient, portal::Portal};
@ -84,21 +84,25 @@ impl<'a> Accessor<Portal> for PortalAccessor<'a> {
async fn fetch_one(self) -> Result<Portal, AccessError> { async fn fetch_one(self) -> Result<Portal, AccessError> {
let spec = self.build()?; let spec = self.build()?;
async { async {
debug!("accessing portal");
let portal = Portal::with_id(spec.id) let portal = Portal::with_id(spec.id)
.fetch_optional(spec.using_app_db) .fetch_optional(spec.using_app_db)
.await? .await?
.ok_or(AccessError::NotFound)?; .ok_or_else(|| {
debug!("portal not found");
AccessError::NotFound
})?;
spec.verify_workspace_id spec.verify_workspace_id
.is_none_or(|value| portal.workspace_id == value) .is_none_or(|value| portal.workspace_id == value)
.ok_or_else(|| { .ok_or_else(|| {
info!("workspace_id check failed for portal"); debug!("workspace_id check failed for portal");
AccessError::NotFound AccessError::NotFound
})?; })?;
spec.verify_rel_oid spec.verify_rel_oid
.is_none_or(|value| portal.class_oid == value) .is_none_or(|value| portal.class_oid == value)
.ok_or_else(|| { .ok_or_else(|| {
info!("rel_oid check failed for portal"); debug!("rel_oid check failed for portal");
AccessError::NotFound AccessError::NotFound
})?; })?;
@ -109,7 +113,7 @@ impl<'a> Accessor<Portal> for PortalAccessor<'a> {
.fetch_optional(spec.using_workspace_client) .fetch_optional(spec.using_workspace_client)
.await? .await?
.ok_or_else(|| { .ok_or_else(|| {
info!("unable to fetch PgClass for portal"); debug!("unable to fetch PgClass for portal");
AccessError::NotFound AccessError::NotFound
})? })?
}; };
@ -137,7 +141,7 @@ impl<'a> Accessor<Portal> for PortalAccessor<'a> {
.iter() .iter()
.any(|privilege| privilege.privilege == PgPrivilegeType::Connect) .any(|privilege| privilege.privilege == PgPrivilegeType::Connect)
}) { }) {
info!("actor lacks postgres connect privileges"); debug!("actor lacks postgres connect privileges");
return Err(AccessError::NotFound); return Err(AccessError::NotFound);
} }
@ -182,7 +186,7 @@ impl<'a> Accessor<Portal> for PortalAccessor<'a> {
} }
} }
if !actor_permissions.is_superset(&spec.verify_rel_permissions) { if !actor_permissions.is_superset(&spec.verify_rel_permissions) {
info!("actor lacks postgres privileges"); debug!("actor lacks postgres privileges");
return Err(AccessError::NotFound); return Err(AccessError::NotFound);
} }
@ -200,7 +204,7 @@ impl<'a> Accessor<Portal> for PortalAccessor<'a> {
.iter() .iter()
.any(|value| value.rolname == actor_rolname)) .any(|value| value.rolname == actor_rolname))
{ {
info!("actor is not relation owner"); debug!("actor is not relation owner");
return Err(AccessError::NotFound); return Err(AccessError::NotFound);
} }
} }
@ -208,8 +212,9 @@ impl<'a> Accessor<Portal> for PortalAccessor<'a> {
Ok(portal) Ok(portal)
} }
.instrument(info_span!( .instrument(info_span!(
"PortalAccessor::fetch_optional()", "PortalAccessor::fetch_one()",
portal_id = spec.id.to_string(), portal_id = spec.id.to_string(),
actor = spec.as_actor.to_string(),
)) ))
.await .await
} }

View file

@ -0,0 +1,138 @@
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<Workspace> for WorkspaceAccessor<'a> {
async fn fetch_one(self) -> Result<Workspace, AccessError> {
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(())
}

View file

@ -1,21 +1,54 @@
use std::sync::Arc;
use thiserror::Error; use thiserror::Error;
#[derive(Debug, Error)] /// Any error encountered while building, validating, or executing a query
/// struct.
#[derive(Clone, Debug, Error)]
pub enum QueryError { pub enum QueryError {
#[error("query validation failed: {0}")]
ValidationErrors(validator::ValidationErrors),
#[error("sqlx error: {0}")] #[error("sqlx error: {0}")]
SqlxError(sqlx::Error), Database(Arc<sqlx::Error>),
}
impl From<validator::ValidationErrors> for QueryError { #[error("query validation failed: {0}")]
fn from(value: validator::ValidationErrors) -> Self { Validation(validator::ValidationErrors),
Self::ValidationErrors(value)
} #[error("uninitialized field: {0}")]
Incomplete(derive_builder::UninitializedFieldError),
} }
impl From<sqlx::Error> for QueryError { impl From<sqlx::Error> for QueryError {
fn from(value: sqlx::Error) -> Self { fn from(value: sqlx::Error) -> Self {
Self::SqlxError(value) Self::Database(Arc::new(value))
}
}
impl From<validator::ValidationErrors> for QueryError {
fn from(value: validator::ValidationErrors) -> Self {
Self::Validation(value)
}
}
impl From<derive_builder::UninitializedFieldError> for QueryError {
fn from(value: derive_builder::UninitializedFieldError) -> Self {
Self::Incomplete(value)
}
}
pub type QueryResult<T> = Result<T, QueryError>;
/// Encompasses all possible (non-fatal) errors encountered while executing an
/// [`Accessor`]'s `fetch_one()` or `fetch_optional()` methods.
#[derive(Clone, Debug, thiserror::Error)]
pub enum AccessError {
#[error("not found or access denied")]
NotFound,
#[error(transparent)]
Query(QueryError),
}
impl<T: Into<QueryError>> From<T> for AccessError {
fn from(value: T) -> Self {
Self::Query(value.into())
} }
} }

View file

@ -1,9 +1,12 @@
use derive_builder::Builder; use derive_builder::Builder;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::query_as; use sqlx::{query, query_as};
use uuid::Uuid; use uuid::Uuid;
use crate::client::AppDbClient; use crate::{
client::AppDbClient,
errors::{QueryError, QueryResult},
};
/// Assigns an access control permission on a workspace to a user. These are /// Assigns an access control permission on a workspace to a user. These are
/// derived from the permission grants of the workspace's backing database. /// derived from the permission grants of the workspace's backing database.
@ -29,9 +32,15 @@ impl WorkspaceMembership {
BelongingToUserQuery { id } BelongingToUserQuery { id }
} }
/// Build an insert statement to create a new object. /// Build an upsert statement to create a new object. If the new object is a
pub fn insert() -> InsertBuilder { /// duplicate, no update is performed.
InsertBuilder::default() pub fn upsert() -> UpsertBuilder {
UpsertBuilder::default()
}
/// Build a delete statement to remove the matching record if it exists.
pub fn delete() -> DeleteBuilder {
DeleteBuilder::default()
} }
} }
@ -66,38 +75,53 @@ where p.user_id = $1
} }
#[derive(Builder, Clone, Debug)] #[derive(Builder, Clone, Debug)]
pub struct Insert { #[builder(build_fn(error = "QueryError"), vis = "pub", pattern = "owned")]
struct Upsert {
workspace_id: Uuid, workspace_id: Uuid,
user_id: Uuid, user_id: Uuid,
} }
impl Insert { impl UpsertBuilder {
pub async fn execute( pub async fn execute(self, app_db: &mut AppDbClient) -> QueryResult<()> {
self, // To circumvent performance and complexity concerns, this method does not
app_db: &mut AppDbClient, // return the upserted record. Refer to:
) -> Result<WorkspaceMembership, sqlx::Error> { // https://stackoverflow.com/a/42217872
query_as!(
WorkspaceMembership, let spec = self.build()?;
query!(
r#" r#"
with p as ( insert into workspace_memberships (workspace_id, user_id) values ($1, $2)
insert into workspace_memberships (workspace_id, user_id) values ($1, $2) on conflict (workspace_id, user_id) do nothing
returning
id,
workspace_id,
user_id
)
select
p.id as id,
p.workspace_id as workspace_id,
p.user_id as user_id,
w.display_name as workspace_display_name
from p inner join workspaces as w
on w.id = p.workspace_id
"#, "#,
self.workspace_id, spec.workspace_id,
self.user_id, spec.user_id,
) )
.fetch_one(app_db.get_conn()) .execute(app_db.get_conn())
.await .await?;
Ok(())
}
}
#[derive(Builder, Clone, Debug)]
#[builder(build_fn(error = "QueryError"), vis = "pub", pattern = "owned")]
pub struct Delete {
workspace_id: Uuid,
user_id: Uuid,
}
impl DeleteBuilder {
pub async fn execute(self, app_db: &mut AppDbClient) -> QueryResult<()> {
let spec = self.build()?;
query!(
r#"
delete from workspace_memberships
where workspace_id = $1 and user_id = $2
"#,
spec.workspace_id,
spec.user_id,
)
.execute(app_db.get_conn())
.await?;
Ok(())
} }
} }

View file

@ -64,7 +64,7 @@ impl IntoResponse for AppError {
} }
Self::TooManyRequests(client_message) => { Self::TooManyRequests(client_message) => {
// Debug level so that if this is from a runaway loop, it won't // Debug level so that if this is from a runaway loop, it won't
// overwhelm server logs // overwhelm production logs.
tracing::debug!("Too many requests: {}", client_message); tracing::debug!("Too many requests: {}", client_message);
(StatusCode::TOO_MANY_REQUESTS, client_message).into_response() (StatusCode::TOO_MANY_REQUESTS, client_message).into_response()
} }
@ -76,7 +76,7 @@ impl IntoResponse for AppError {
} }
} }
// Easily convert semi-arbitrary errors to InternalServerError // Easily convert semi-arbitrary errors to InternalServerError.
impl<E> From<E> for AppError impl<E> From<E> for AppError
where where
E: Into<anyhow::Error>, E: Into<anyhow::Error>,

View file

@ -126,10 +126,9 @@ async fn grant_workspace_membership(
.execute(workspace_root_client.get_conn()) .execute(workspace_root_client.get_conn())
.await?; .await?;
WorkspaceMembership::insert() WorkspaceMembership::upsert()
.workspace_id(workspace.id) .workspace_id(workspace.id)
.user_id(user.id) .user_id(user.id)
.build()?
.execute(app_db_client) .execute(app_db_client)
.await?; .await?;

View file

@ -19,6 +19,15 @@ pub(super) async fn get(
navigator: Navigator, navigator: Navigator,
AppDbConn(mut app_db): AppDbConn, AppDbConn(mut app_db): AppDbConn,
) -> Result<impl IntoResponse, AppError> { ) -> Result<impl IntoResponse, AppError> {
// Workspace memberships may be pulled directly from the cache table without
// additional auth checks in this case, because at worst it should only
// inaccurately give the user the impression that they still have access to
// a previously visible workspace. In a specific failure mode, this may
// allow the user to continue seeing updates to the workspace name after
// access has been revoked, until the cache record is cleared, e.g. by the
// user attempting to actually access the data of the workspace. This isn't
// ideal, but it should only happen under exceedingly rare circumstances and
// in those cases is not expected to be catastrophic.
let workspace_perms = WorkspaceMembership::belonging_to_user(user.id) let workspace_perms = WorkspaceMembership::belonging_to_user(user.id)
.fetch_all(&mut app_db) .fetch_all(&mut app_db)
.await?; .await?;

View file

@ -7,7 +7,10 @@ use phono_backends::{
escape_identifier, escape_identifier,
rolnames::{ROLE_PREFIX_SERVICE_CRED, SERVICE_CRED_CONN_LIMIT, SERVICE_CRED_SUFFIX_LEN}, rolnames::{ROLE_PREFIX_SERVICE_CRED, SERVICE_CRED_CONN_LIMIT, SERVICE_CRED_SUFFIX_LEN},
}; };
use phono_models::{service_cred::ServiceCred, workspace::Workspace}; use phono_models::{
accessors::{Accessor as _, Actor, workspace::WorkspaceAccessor},
service_cred::ServiceCred,
};
use rand::distributions::{Alphanumeric, DistString}; use rand::distributions::{Alphanumeric, DistString};
use redact::Secret; use redact::Secret;
use serde::Deserialize; use serde::Deserialize;
@ -38,16 +41,20 @@ pub(super) async fn post(
navigator: Navigator, navigator: Navigator,
Path(PathParams { workspace_id }): Path<PathParams>, Path(PathParams { workspace_id }): Path<PathParams>,
) -> Result<impl IntoResponse, AppError> { ) -> Result<impl IntoResponse, AppError> {
// FIXME: auth and csrf // FIXME: CSRF
let workspace = Workspace::with_id(workspace_id)
.fetch_one(&mut app_db)
.await?;
let mut workspace_client = pooler let mut workspace_client = pooler
.acquire_for(workspace_id, RoleAssignment::User(user.id)) .acquire_for(workspace_id, RoleAssignment::User(user.id))
.await?; .await?;
let workspace = WorkspaceAccessor::new()
.id(workspace_id)
.as_actor(Actor::User(user.id))
.using_app_db(&mut app_db)
.using_workspace_client(&mut workspace_client)
.fetch_one()
.await?;
let rolname = format!( let rolname = format!(
"{ROLE_PREFIX_SERVICE_CRED}{uid}_{suffix}", "{ROLE_PREFIX_SERVICE_CRED}{uid}_{suffix}",
uid = user.id.simple(), uid = user.id.simple(),

View file

@ -9,11 +9,13 @@ use phono_backends::{
ROLE_PREFIX_USER, ROLE_PREFIX_USER,
}, },
}; };
use phono_models::accessors::{Accessor as _, Actor, workspace::WorkspaceAccessor};
use serde::Deserialize; use serde::Deserialize;
use sqlx::{Acquire as _, query}; use sqlx::{Acquire as _, query};
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
app::AppDbConn,
errors::AppError, errors::AppError,
navigator::{Navigator, NavigatorPage as _}, navigator::{Navigator, NavigatorPage as _},
user::CurrentUser, user::CurrentUser,
@ -35,23 +37,34 @@ pub(super) struct PathParams {
/// deserialize to a UUID. /// deserialize to a UUID.
pub(super) async fn post( pub(super) async fn post(
State(mut pooler): State<WorkspacePooler>, State(mut pooler): State<WorkspacePooler>,
AppDbConn(mut app_db): AppDbConn,
CurrentUser(user): CurrentUser, CurrentUser(user): CurrentUser,
navigator: Navigator, navigator: Navigator,
Path(PathParams { workspace_id }): Path<PathParams>, Path(PathParams { workspace_id }): Path<PathParams>,
) -> Result<impl IntoResponse, AppError> { ) -> Result<impl IntoResponse, AppError> {
// FIXME: CSRF, Check workspace authorization. // FIXME: CSRF
// TODO: Condition table creation on schema "CREATE" privileges, which will
// allow this client to be configured with user-level permissions rather
// than root-level.
let mut root_client = pooler
.acquire_for(workspace_id, RoleAssignment::Root)
.await?;
// For authorization only.
WorkspaceAccessor::new()
.id(workspace_id)
.as_actor(Actor::User(user.id))
.using_workspace_client(&mut root_client)
.using_app_db(&mut app_db)
.fetch_one()
.await?;
const NAME_LEN_WORDS: usize = 3; const NAME_LEN_WORDS: usize = 3;
let table_name = phono_namegen::default_generator() let table_name = phono_namegen::default_generator()
.with_separator('_') .with_separator('_')
.generate_name(NAME_LEN_WORDS); .generate_name(NAME_LEN_WORDS);
let mut root_client = pooler
// FIXME: Should this be scoped down to the unprivileged role after
// setting up the table owner?
.acquire_for(workspace_id, RoleAssignment::Root)
.await?;
let user_rolname = format!("{ROLE_PREFIX_USER}{uid}", uid = user.id.simple()); let user_rolname = format!("{ROLE_PREFIX_USER}{uid}", uid = user.id.simple());
let rolname_uuid = Uuid::new_v4().simple(); let rolname_uuid = Uuid::new_v4().simple();
let rolname_table_owner = format!("{ROLE_PREFIX_TABLE_OWNER}{rolname_uuid}"); let rolname_table_owner = format!("{ROLE_PREFIX_TABLE_OWNER}{rolname_uuid}");

View file

@ -4,7 +4,10 @@ use axum::{
extract::{Path, State}, extract::{Path, State},
response::{Html, IntoResponse}, response::{Html, IntoResponse},
}; };
use phono_models::workspace::Workspace; use phono_models::{
accessors::{Accessor as _, Actor, workspace::WorkspaceAccessor},
workspace::Workspace,
};
use serde::Deserialize; use serde::Deserialize;
use uuid::Uuid; use uuid::Uuid;
@ -35,16 +38,18 @@ pub(super) async fn get(
navigator: Navigator, navigator: Navigator,
State(mut pooler): State<WorkspacePooler>, State(mut pooler): State<WorkspacePooler>,
) -> Result<impl IntoResponse, AppError> { ) -> Result<impl IntoResponse, AppError> {
// FIXME: Check workspace authorization.
let workspace = Workspace::with_id(workspace_id)
.fetch_one(&mut app_db)
.await?;
let mut workspace_client = pooler let mut workspace_client = pooler
.acquire_for(workspace_id, RoleAssignment::User(user.id)) .acquire_for(workspace_id, RoleAssignment::User(user.id))
.await?; .await?;
let workspace = WorkspaceAccessor::new()
.id(workspace_id)
.as_actor(Actor::User(user.id))
.using_app_db(&mut app_db)
.using_workspace_client(&mut workspace_client)
.fetch_one()
.await?;
#[derive(Template)] #[derive(Template)]
#[template(path = "workspaces_single/nav.html")] #[template(path = "workspaces_single/nav.html")]
struct ResponseTemplate { struct ResponseTemplate {

View file

@ -7,7 +7,10 @@ use axum::{
}; };
use futures::{lock::Mutex, prelude::*, stream}; use futures::{lock::Mutex, prelude::*, stream};
use phono_backends::{pg_class::PgClass, pg_role::RoleTree}; use phono_backends::{pg_class::PgClass, pg_role::RoleTree};
use phono_models::{service_cred::ServiceCred, workspace::Workspace}; use phono_models::{
accessors::{Accessor as _, Actor, workspace::WorkspaceAccessor},
service_cred::ServiceCred,
};
use redact::Secret; use redact::Secret;
use serde::Deserialize; use serde::Deserialize;
use url::Url; use url::Url;
@ -35,19 +38,12 @@ pub(super) struct PathParams {
#[debug_handler(state = App)] #[debug_handler(state = App)]
pub(super) async fn get( pub(super) async fn get(
State(settings): State<Settings>, State(settings): State<Settings>,
State(mut pooler): State<WorkspacePooler>,
CurrentUser(user): CurrentUser, CurrentUser(user): CurrentUser,
AppDbConn(mut app_db): AppDbConn, AppDbConn(mut app_db): AppDbConn,
Path(PathParams { workspace_id }): Path<PathParams>, Path(PathParams { workspace_id }): Path<PathParams>,
navigator: Navigator, navigator: Navigator,
State(mut pooler): State<WorkspacePooler>,
) -> Result<impl IntoResponse, AppError> { ) -> Result<impl IntoResponse, AppError> {
// FIXME: auth
let workspace = Workspace::with_id(workspace_id)
.fetch_one(&mut app_db)
.await?;
let cluster = workspace.fetch_cluster(&mut app_db).await?;
// Mutex is required to use client in async closures. // Mutex is required to use client in async closures.
let workspace_client = Mutex::new( let workspace_client = Mutex::new(
pooler pooler
@ -55,6 +51,19 @@ pub(super) async fn get(
.await?, .await?,
); );
let workspace = {
let mut locked_client = workspace_client.lock().await;
WorkspaceAccessor::new()
.id(workspace_id)
.as_actor(Actor::User(user.id))
.using_app_db(&mut app_db)
.using_workspace_client(&mut locked_client)
.fetch_one()
.await?
};
let cluster = workspace.fetch_cluster(&mut app_db).await?;
struct ServiceCredInfo { struct ServiceCredInfo {
service_cred: ServiceCred, service_cred: ServiceCred,
member_of: Vec<RoleDisplay>, member_of: Vec<RoleDisplay>,

View file

@ -4,7 +4,10 @@ use axum::{
extract::{Path, State}, extract::{Path, State},
response::{Html, IntoResponse}, response::{Html, IntoResponse},
}; };
use phono_models::workspace::Workspace; use phono_models::{
accessors::{Accessor as _, Actor, workspace::WorkspaceAccessor},
workspace::Workspace,
};
use serde::Deserialize; use serde::Deserialize;
use uuid::Uuid; use uuid::Uuid;
@ -34,15 +37,17 @@ pub(super) async fn get(
navigator: Navigator, navigator: Navigator,
State(mut pooler): State<WorkspacePooler>, State(mut pooler): State<WorkspacePooler>,
) -> Result<impl IntoResponse, AppError> { ) -> Result<impl IntoResponse, AppError> {
// FIXME: Check workspace authorization. let mut workspace_client = pooler
// permission to access/alter both as needed. .acquire_for(workspace_id, RoleAssignment::User(user.id))
let workspace = Workspace::with_id(workspace_id)
.fetch_one(&mut app_db)
.await?; .await?;
let mut workspace_client = pooler // TODO: Limit access to workspace "owners" or equivalent.
.acquire_for(workspace.id, RoleAssignment::User(user.id)) let workspace = WorkspaceAccessor::new()
.id(workspace_id)
.as_actor(Actor::User(user.id))
.using_app_db(&mut app_db)
.using_workspace_client(&mut workspace_client)
.fetch_one()
.await?; .await?;
#[derive(Debug, Template)] #[derive(Debug, Template)]

View file

@ -1,4 +1,9 @@
use axum::{debug_handler, extract::Path, response::Response}; use axum::{
debug_handler,
extract::{Path, State},
response::Response,
};
use phono_models::accessors::{Accessor as _, Actor, workspace::WorkspaceAccessor};
use serde::Deserialize; use serde::Deserialize;
use sqlx::query; use sqlx::query;
use uuid::Uuid; use uuid::Uuid;
@ -10,6 +15,7 @@ use crate::{
extractors::ValidatedForm, extractors::ValidatedForm,
navigator::{Navigator, NavigatorPage as _}, navigator::{Navigator, NavigatorPage as _},
user::CurrentUser, user::CurrentUser,
workspace_pooler::{RoleAssignment, WorkspacePooler},
}; };
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@ -25,13 +31,26 @@ pub(super) struct FormBody {
/// HTTP POST handler for updating a workspace's name. /// HTTP POST handler for updating a workspace's name.
#[debug_handler(state = App)] #[debug_handler(state = App)]
pub(super) async fn post( pub(super) async fn post(
State(mut pooler): State<WorkspacePooler>,
AppDbConn(mut app_db): AppDbConn, AppDbConn(mut app_db): AppDbConn,
CurrentUser(_user): CurrentUser, CurrentUser(user): CurrentUser,
navigator: Navigator, navigator: Navigator,
Path(PathParams { workspace_id }): Path<PathParams>, Path(PathParams { workspace_id }): Path<PathParams>,
ValidatedForm(FormBody { name }): ValidatedForm<FormBody>, ValidatedForm(FormBody { name }): ValidatedForm<FormBody>,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
// FIXME: Check workspace authorization. let mut workspace_client = pooler
.acquire_for(workspace_id, RoleAssignment::User(user.id))
.await?;
// For auth only.
// TODO: Limit access to workspace "owners" or equivalent.
WorkspaceAccessor::new()
.id(workspace_id)
.as_actor(Actor::User(user.id))
.using_app_db(&mut app_db)
.using_workspace_client(&mut workspace_client)
.fetch_one()
.await?;
query!( query!(
"update workspaces set display_name = $1 where id = $2", "update workspaces set display_name = $1 where id = $2",

View file

@ -8,7 +8,10 @@ use axum::{
use axum_extra::extract::Form; use axum_extra::extract::Form;
use futures::{lock::Mutex, prelude::*, stream}; use futures::{lock::Mutex, prelude::*, stream};
use phono_backends::{escape_identifier, pg_class::PgClass}; use phono_backends::{escape_identifier, pg_class::PgClass};
use phono_models::service_cred::ServiceCred; use phono_models::{
accessors::{Accessor as _, Actor, workspace::WorkspaceAccessor},
service_cred::ServiceCred,
};
use serde::Deserialize; use serde::Deserialize;
use sqlx::query; use sqlx::query;
use uuid::Uuid; use uuid::Uuid;
@ -50,6 +53,19 @@ pub(super) async fn post(
.await?, .await?,
); );
{
// Ensure lock is dropped as soon as we're finished with it, or else
// `workspace_client` mutex will deadlock later on.
let mut locked_client = workspace_client.lock().await;
WorkspaceAccessor::new()
.id(workspace_id)
.as_actor(Actor::User(user.id))
.using_app_db(&mut app_db)
.using_workspace_client(&mut locked_client)
.fetch_one()
.await?;
}
let all_rels = { let all_rels = {
let mut locked_client = workspace_client.lock().await; let mut locked_client = workspace_client.lock().await;
PgClass::belonging_to_namespace(PHONO_TABLE_NAMESPACE) PgClass::belonging_to_namespace(PHONO_TABLE_NAMESPACE)

View file

@ -84,7 +84,7 @@ discard sequences;
.clone()) .clone())
} }
/// Note that while using `set role` simulates impersonation for most data /// Note that while using `SET ROLE` simulates impersonation for most data
/// access and RLS purposes, it is both incomplete and easily reversible: /// access and RLS purposes, it is both incomplete and easily reversible:
/// some commands and system tables will still behave according to the /// some commands and system tables will still behave according to the
/// privileges of the session user, and clients relying on this abstraction /// privileges of the session user, and clients relying on this abstraction
@ -98,6 +98,11 @@ discard sequences;
let mut client = WorkspaceClient::from_pool_conn(pool.acquire().await?); let mut client = WorkspaceClient::from_pool_conn(pool.acquire().await?);
match set_role { match set_role {
RoleAssignment::User(uid) => { RoleAssignment::User(uid) => {
// TODO: Return error if user does not have "CONNECT" privileges
// on backing database. Note that this change will entail a
// fairly broad refactor of [`phono-server`] code for
// initializing user roles and for performing workspace auth
// checks.
client client
.init_role(&format!("{ROLE_PREFIX_USER}{uid}", uid = uid.simple())) .init_role(&format!("{ROLE_PREFIX_USER}{uid}", uid = uid.simple()))
.await?; .await?;