apply auth checks when accessing workspace records
This commit is contained in:
parent
5a3d6eabf9
commit
6cd15e380a
16 changed files with 389 additions and 127 deletions
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
138
phono-models/src/accessors/workspace.rs
Normal file
138
phono-models/src/accessors/workspace.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
|
@ -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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>,
|
||||||
|
|
|
||||||
|
|
@ -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?;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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?;
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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}");
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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>,
|
||||||
|
|
|
||||||
|
|
@ -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)]
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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?;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue