implement accessor pattern for authorized portal access
This commit is contained in:
parent
7f63cbb521
commit
5d5acb09bb
30 changed files with 601 additions and 190 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -1757,6 +1757,7 @@ dependencies = [
|
|||
"bigdecimal",
|
||||
"chrono",
|
||||
"derive_builder",
|
||||
"futures",
|
||||
"interim-pgtypes",
|
||||
"redact",
|
||||
"regex",
|
||||
|
|
@ -1765,6 +1766,7 @@ dependencies = [
|
|||
"sqlx",
|
||||
"strum",
|
||||
"thiserror 2.0.12",
|
||||
"tracing",
|
||||
"url",
|
||||
"uuid",
|
||||
"validator",
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ version.workspace = true
|
|||
bigdecimal = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
derive_builder = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
interim-pgtypes = { path = "../interim-pgtypes" }
|
||||
redact = { workspace = true }
|
||||
regex = { workspace = true }
|
||||
|
|
@ -15,6 +16,7 @@ serde_json = { workspace = true }
|
|||
sqlx = { workspace = true }
|
||||
strum = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
url = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
validator = { workspace = true }
|
||||
|
|
|
|||
53
interim-models/src/accessors/mod.rs
Normal file
53
interim-models/src/accessors/mod.rs
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use derive_builder::UninitializedFieldError;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub mod portal;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Actor {
|
||||
/// Bypass explicit auth checks.
|
||||
Bypass,
|
||||
|
||||
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
|
||||
/// authorization checks.
|
||||
pub trait Accessor<T>: Default + Sized {
|
||||
/// Returns a new builder struct. Alias for [`Self::default()`].
|
||||
fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Fetch an entity from the database, returning [`AccessError::NotFound`]
|
||||
/// if access is denied or the object cannot be found.
|
||||
fn fetch_one(self) -> impl Future<Output = Result<T, AccessError>>;
|
||||
}
|
||||
202
interim-models/src/accessors/portal.rs
Normal file
202
interim-models/src/accessors/portal.rs
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,6 @@
|
|||
#![feature(bool_to_result)] // Enable support for `ok_or()` on bools.
|
||||
|
||||
pub mod accessors;
|
||||
pub mod client;
|
||||
pub mod cluster;
|
||||
pub mod datum;
|
||||
|
|
|
|||
|
|
@ -4,8 +4,9 @@ use crate::escape_identifier;
|
|||
|
||||
/// Newtype to differentiate between workspace and application database
|
||||
/// connections.
|
||||
#[derive(Debug)]
|
||||
pub struct WorkspaceClient {
|
||||
pub(crate) conn: PoolConnection<Postgres>,
|
||||
conn: PoolConnection<Postgres>,
|
||||
}
|
||||
|
||||
impl WorkspaceClient {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ pub mod pg_class;
|
|||
pub mod pg_database;
|
||||
pub mod pg_namespace;
|
||||
pub mod pg_role;
|
||||
pub mod rolnames;
|
||||
|
||||
/// Given a raw identifier (such as a table name, column name, etc.), format it
|
||||
/// so that it may be safely interpolated into a SQL query.
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ where attrelid = $1 and attnum > 0 and not attisdropped
|
|||
"#,
|
||||
&self.rel_oid
|
||||
)
|
||||
.fetch_all(&mut *client.conn)
|
||||
.fetch_all(client.get_conn())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
|
@ -131,7 +131,7 @@ where i.indrelid = $1 and i.indisprimary;
|
|||
"#,
|
||||
&self.rel_oid
|
||||
)
|
||||
.fetch_all(&mut *client.conn)
|
||||
.fetch_all(client.get_conn())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ impl PgClass {
|
|||
&self,
|
||||
client: &mut WorkspaceClient,
|
||||
) -> Result<PgNamespace, sqlx::Error> {
|
||||
PgNamespace::fetch_by_oid(self.relnamespace, &mut *client.conn)
|
||||
PgNamespace::fetch_by_oid(self.relnamespace, client.get_conn())
|
||||
.await?
|
||||
// If client has access to the class, it would expect to have access
|
||||
// to the namespace that contains it. If not, that's an error.
|
||||
|
|
@ -178,7 +178,7 @@ where
|
|||
impl WithOidQuery {
|
||||
pub async fn fetch_one(self, client: &mut WorkspaceClient) -> Result<PgClass, sqlx::Error> {
|
||||
with_oid_sqlx_query!(self.oid)
|
||||
.fetch_one(&mut *client.conn)
|
||||
.fetch_one(client.get_conn())
|
||||
.await
|
||||
}
|
||||
|
||||
|
|
@ -187,7 +187,7 @@ impl WithOidQuery {
|
|||
client: &mut WorkspaceClient,
|
||||
) -> Result<Option<PgClass>, sqlx::Error> {
|
||||
with_oid_sqlx_query!(self.oid)
|
||||
.fetch_optional(&mut *client.conn)
|
||||
.fetch_optional(client.get_conn())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
|
@ -235,7 +235,7 @@ where
|
|||
"#,
|
||||
kinds_i8.as_slice(),
|
||||
)
|
||||
.fetch_all(&mut *client.conn)
|
||||
.fetch_all(client.get_conn())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ from pg_database
|
|||
where datname = current_database()
|
||||
"#,
|
||||
)
|
||||
.fetch_one(&mut *client.conn)
|
||||
.fetch_one(client.get_conn())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,24 +9,34 @@ use crate::client::WorkspaceClient;
|
|||
pub struct PgRole {
|
||||
/// ID of role
|
||||
pub oid: Oid,
|
||||
|
||||
/// Role name
|
||||
pub rolname: String,
|
||||
|
||||
/// Role has superuser privileges
|
||||
pub rolsuper: bool,
|
||||
|
||||
/// Role automatically inherits privileges of roles it is a member of
|
||||
pub rolinherit: bool,
|
||||
|
||||
/// Role can create more roles
|
||||
pub rolcreaterole: bool,
|
||||
|
||||
/// Role can create databases
|
||||
pub rolcreatedb: bool,
|
||||
|
||||
/// Role can log in. That is, this role can be given as the initial session authorization identifier
|
||||
pub rolcanlogin: bool,
|
||||
|
||||
/// Role is a replication role. A replication role can initiate replication connections and create and drop replication slots.
|
||||
pub rolreplication: bool,
|
||||
|
||||
/// For roles that can log in, this sets maximum number of concurrent connections this role can make. -1 means no limit.
|
||||
pub rolconnlimit: i32,
|
||||
|
||||
/// Password expiry time (only used for password authentication); null if no expiration
|
||||
pub rolvaliduntil: Option<DateTime<Utc>>,
|
||||
|
||||
/// Role bypasses every row-level security policy, see Section 5.9 for more information.
|
||||
pub rolbypassrls: bool,
|
||||
}
|
||||
|
|
@ -71,7 +81,7 @@ where rolname = any($1)
|
|||
"#,
|
||||
self.names.as_slice()
|
||||
)
|
||||
.fetch_all(&mut *client.conn)
|
||||
.fetch_all(client.get_conn())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
|
@ -106,11 +116,16 @@ where starts_with(rolname, $1)
|
|||
"#,
|
||||
self.prefix
|
||||
)
|
||||
.fetch_all(&mut *client.conn)
|
||||
.fetch_all(client.get_conn())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
/// A representation of role grants, starting from a single role and traversing
|
||||
/// the full set (as visible via the current DB connection) of either more
|
||||
/// specific roles which are granted to it, or more general roles to which it
|
||||
/// is granted. This is useful, for example, for enumerating permissions which
|
||||
/// are inherited rather than granted directly to a specific user.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RoleTree {
|
||||
pub role: PgRole,
|
||||
|
|
@ -187,7 +202,7 @@ impl MembersOfOidQuery {
|
|||
",
|
||||
)
|
||||
.bind(self.role)
|
||||
.fetch_all(&mut *client.conn)
|
||||
.fetch_all(client.get_conn())
|
||||
.await?;
|
||||
Ok(rows
|
||||
.iter()
|
||||
|
|
@ -231,7 +246,7 @@ impl MembersOfRolnameQuery {
|
|||
",
|
||||
)
|
||||
.bind(self.role.as_str() as &str)
|
||||
.fetch_all(&mut *client.conn)
|
||||
.fetch_all(client.get_conn())
|
||||
.await?;
|
||||
Ok(rows
|
||||
.iter()
|
||||
|
|
@ -273,7 +288,7 @@ from (
|
|||
",
|
||||
)
|
||||
.bind(self.role_oid)
|
||||
.fetch_all(&mut *client.conn)
|
||||
.fetch_all(client.get_conn())
|
||||
.await?;
|
||||
Ok(rows
|
||||
.iter()
|
||||
|
|
@ -315,7 +330,7 @@ from (
|
|||
",
|
||||
)
|
||||
.bind(self.rolname)
|
||||
.fetch_all(&mut *client.conn)
|
||||
.fetch_all(client.get_conn())
|
||||
.await?;
|
||||
Ok(rows
|
||||
.iter()
|
||||
|
|
|
|||
7
interim-pgtypes/src/rolnames.rs
Normal file
7
interim-pgtypes/src/rolnames.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
pub const ROLE_PREFIX_SERVICE_CRED: &str = "svc_";
|
||||
pub const ROLE_PREFIX_TABLE_OWNER: &str = "tbo_";
|
||||
pub const ROLE_PREFIX_TABLE_READER: &str = "tbr_";
|
||||
pub const ROLE_PREFIX_TABLE_WRITER: &str = "tbw_";
|
||||
pub const ROLE_PREFIX_USER: &str = "usr_";
|
||||
pub const SERVICE_CRED_SUFFIX_LEN: usize = 8;
|
||||
pub const SERVICE_CRED_CONN_LIMIT: usize = 4;
|
||||
|
|
@ -5,31 +5,31 @@ use axum::response::{IntoResponse, Response};
|
|||
|
||||
macro_rules! forbidden {
|
||||
($message:literal) => {
|
||||
AppError::Forbidden($message.to_owned())
|
||||
crate::errors::AppError::Forbidden($message.to_owned())
|
||||
};
|
||||
|
||||
($message:literal, $($param:expr),+) => {
|
||||
AppError::Forbidden(format!($message, $($param)+))
|
||||
crate::errors::AppError::Forbidden(format!($message, $($param)+))
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! not_found {
|
||||
($message:literal) => {
|
||||
AppError::NotFound($message.to_owned())
|
||||
crate::errors::AppError::NotFound($message.to_owned())
|
||||
};
|
||||
|
||||
($message:literal, $($param:expr),+) => {
|
||||
AppError::NotFound(format!($message, $($param)+))
|
||||
crate::errors::AppError::NotFound(format!($message, $($param)+))
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! bad_request {
|
||||
($message:literal) => {
|
||||
AppError::BadRequest(format!($message))
|
||||
crate::errors::AppError::BadRequest(format!($message))
|
||||
};
|
||||
|
||||
($message:literal, $($param:expr),+) => {
|
||||
AppError::BadRequest(format!($message, $($param)+))
|
||||
crate::errors::AppError::BadRequest(format!($message, $($param)+))
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,20 +6,13 @@ use interim_pgtypes::{
|
|||
client::WorkspaceClient,
|
||||
pg_acl::{PgAclItem, PgPrivilegeType},
|
||||
pg_class::PgClass,
|
||||
rolnames::{ROLE_PREFIX_TABLE_OWNER, ROLE_PREFIX_TABLE_READER, ROLE_PREFIX_TABLE_WRITER},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{postgres::types::Oid, prelude::FromRow, query_as};
|
||||
|
||||
use crate::errors::AppError;
|
||||
|
||||
pub(crate) const ROLE_PREFIX_SERVICE_CRED: &str = "svc_";
|
||||
pub(crate) const ROLE_PREFIX_TABLE_OWNER: &str = "tbo_";
|
||||
pub(crate) const ROLE_PREFIX_TABLE_READER: &str = "tbr_";
|
||||
pub(crate) const ROLE_PREFIX_TABLE_WRITER: &str = "tbw_";
|
||||
pub(crate) const ROLE_PREFIX_USER: &str = "usr_";
|
||||
pub(crate) const SERVICE_CRED_SUFFIX_LEN: usize = 8;
|
||||
pub(crate) const SERVICE_CRED_CONN_LIMIT: usize = 4;
|
||||
|
||||
// TODO: custom error type
|
||||
// TODO: make params and result references
|
||||
fn get_table_role(
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ use interim_models::{
|
|||
portal::Portal,
|
||||
presentation::{Presentation, TextInputMode},
|
||||
};
|
||||
use interim_pgtypes::{pg_attribute::PgAttribute, pg_class::PgClass};
|
||||
use interim_pgtypes::pg_attribute::PgAttribute;
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
|
|
@ -29,19 +29,22 @@ pub(super) struct PathParams {
|
|||
portal_id: Uuid,
|
||||
}
|
||||
|
||||
/// HTTP GET handler for public-facing survey interface. This allows form
|
||||
/// responses to be collected as rows directly into a Phonograph table.
|
||||
pub(super) async fn get(
|
||||
State(settings): State<Settings>,
|
||||
State(mut pooler): State<WorkspacePooler>,
|
||||
AppDbConn(mut app_db): AppDbConn,
|
||||
Path(PathParams { portal_id }): Path<PathParams>,
|
||||
) -> Result<Response, AppError> {
|
||||
// FIXME: Disallow access unless form has been explicitly marked as public.
|
||||
|
||||
// WARNING: Form handler bypasses standard auth checks.
|
||||
let portal = Portal::with_id(portal_id)
|
||||
.fetch_optional(&mut app_db)
|
||||
.await?
|
||||
.ok_or(not_found!("form not found"))?;
|
||||
|
||||
// FIXME: auth
|
||||
|
||||
// WARNING: This client is connected with full workspace privileges. Even
|
||||
// more so than usual, the Phonograph server is responsible for ensuring all
|
||||
// auth checks are performed properly.
|
||||
|
|
@ -52,9 +55,6 @@ pub(super) async fn get(
|
|||
.acquire_for(portal.workspace_id, RoleAssignment::Root)
|
||||
.await?;
|
||||
|
||||
let rel = PgClass::with_oid(portal.class_oid)
|
||||
.fetch_one(&mut workspace_client)
|
||||
.await?;
|
||||
let attrs: HashMap<String, PgAttribute> = PgAttribute::all_for_rel(portal.class_oid)
|
||||
.fetch_all(&mut workspace_client)
|
||||
.await?
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@ use axum::{
|
|||
// https://docs.rs/axum-extra/0.10.1/axum_extra/extract/struct.Form.html#differences-from-axumextractform
|
||||
use axum_extra::extract::Form;
|
||||
use interim_models::{
|
||||
field::Field, portal::Portal, presentation::Presentation, workspace::Workspace,
|
||||
accessors::{Accessor as _, Actor, portal::PortalAccessor},
|
||||
field::Field,
|
||||
presentation::Presentation,
|
||||
};
|
||||
use interim_pgtypes::{escape_identifier, pg_class::PgClass};
|
||||
use serde::Deserialize;
|
||||
|
|
@ -49,7 +51,7 @@ pub(super) struct FormBody {
|
|||
/// [`PathParams`].
|
||||
#[debug_handler(state = App)]
|
||||
pub(super) async fn post(
|
||||
State(mut workspace_pooler): State<WorkspacePooler>,
|
||||
State(mut pooler): State<WorkspacePooler>,
|
||||
AppDbConn(mut app_db): AppDbConn,
|
||||
CurrentUser(user): CurrentUser,
|
||||
navigator: Navigator,
|
||||
|
|
@ -60,28 +62,33 @@ pub(super) async fn post(
|
|||
}): Path<PathParams>,
|
||||
Form(form): Form<FormBody>,
|
||||
) -> Result<Response, AppError> {
|
||||
// FIXME: Check workspace authorization.
|
||||
// FIXME ensure workspace corresponds to rel/portal, and that user has
|
||||
// permission to access/alter both as needed.
|
||||
// FIXME: csrf
|
||||
|
||||
let portal = Portal::with_id(portal_id).fetch_one(&mut app_db).await?;
|
||||
let workspace = Workspace::with_id(portal.workspace_id)
|
||||
.fetch_one(&mut app_db)
|
||||
let mut workspace_client = pooler
|
||||
.acquire_for(workspace_id, RoleAssignment::User(user.id))
|
||||
.await?;
|
||||
|
||||
let mut workspace_client = workspace_pooler
|
||||
.acquire_for(workspace.id, RoleAssignment::User(user.id))
|
||||
.await?;
|
||||
|
||||
let class = PgClass::with_oid(portal.class_oid)
|
||||
let rel = PgClass::with_oid(Oid(rel_oid))
|
||||
.fetch_one(&mut workspace_client)
|
||||
.await?;
|
||||
|
||||
let portal = PortalAccessor::new()
|
||||
.id(portal_id)
|
||||
.as_actor(Actor::User(user.id))
|
||||
.verify_workspace_id(workspace_id)
|
||||
.verify_rel_oid(Oid(rel_oid))
|
||||
.verify_rel_ownership()
|
||||
.using_rel(&rel)
|
||||
.using_app_db(&mut app_db)
|
||||
.using_workspace_client(&mut workspace_client)
|
||||
.fetch_one()
|
||||
.await?;
|
||||
|
||||
let presentation = Presentation::try_from(form.presentation_form)?;
|
||||
|
||||
query(&format!(
|
||||
"alter table {ident} add column if not exists {col} {typ}",
|
||||
ident = class.get_identifier(),
|
||||
ident = rel.get_identifier(),
|
||||
col = escape_identifier(&form.name),
|
||||
typ = presentation.attr_data_type_fragment(),
|
||||
))
|
||||
|
|
@ -90,8 +97,8 @@ pub(super) async fn post(
|
|||
query(&format!(
|
||||
"grant insert ({col}), update ({col}) on table {ident} to {writer_role}",
|
||||
col = escape_identifier(&form.name),
|
||||
ident = class.get_identifier(),
|
||||
writer_role = escape_identifier(&get_writer_role(&class)?),
|
||||
ident = rel.get_identifier(),
|
||||
writer_role = escape_identifier(&get_writer_role(&rel)?),
|
||||
))
|
||||
.execute(workspace_client.get_conn())
|
||||
.await?;
|
||||
|
|
|
|||
|
|
@ -7,8 +7,13 @@ use axum::{
|
|||
response::{Html, IntoResponse},
|
||||
};
|
||||
use interim_models::{
|
||||
field::Field, field_form_prompt::FieldFormPrompt, form_transition::FormTransition,
|
||||
language::Language, portal::Portal, workspace::Workspace,
|
||||
accessors::{Accessor as _, Actor, portal::PortalAccessor},
|
||||
field::Field,
|
||||
field_form_prompt::FieldFormPrompt,
|
||||
form_transition::FormTransition,
|
||||
language::Language,
|
||||
portal::Portal,
|
||||
workspace::Workspace,
|
||||
};
|
||||
use interim_pgtypes::pg_attribute::PgAttribute;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
|
@ -49,17 +54,23 @@ pub(super) async fn get(
|
|||
navigator: Navigator,
|
||||
State(mut pooler): State<WorkspacePooler>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
// FIXME: Check workspace authorization.
|
||||
// FIXME ensure workspace corresponds to rel/portal, and that user has
|
||||
// permission to access/alter both as needed.
|
||||
|
||||
let portal = Portal::with_id(portal_id).fetch_one(&mut app_db).await?;
|
||||
let workspace = Workspace::with_id(portal.workspace_id)
|
||||
.fetch_one(&mut app_db)
|
||||
let mut workspace_client = pooler
|
||||
.acquire_for(workspace_id, RoleAssignment::User(user.id))
|
||||
.await?;
|
||||
|
||||
let mut workspace_client = pooler
|
||||
.acquire_for(workspace.id, RoleAssignment::User(user.id))
|
||||
let portal = PortalAccessor::new()
|
||||
.id(portal_id)
|
||||
.as_actor(Actor::User(user.id))
|
||||
.verify_workspace_id(workspace_id)
|
||||
.verify_rel_oid(Oid(rel_oid))
|
||||
.verify_rel_ownership()
|
||||
.using_app_db(&mut app_db)
|
||||
.using_workspace_client(&mut workspace_client)
|
||||
.fetch_one()
|
||||
.await?;
|
||||
|
||||
let workspace = Workspace::with_id(portal.workspace_id)
|
||||
.fetch_one(&mut app_db)
|
||||
.await?;
|
||||
|
||||
let attrs: HashMap<String, PgAttribute> = PgAttribute::all_for_rel(portal.class_oid)
|
||||
|
|
|
|||
|
|
@ -5,10 +5,19 @@ use axum::{
|
|||
extract::{Path, State},
|
||||
response::{IntoResponse as _, Response},
|
||||
};
|
||||
use interim_models::{datum::Datum, field::Field, portal::Portal};
|
||||
use interim_pgtypes::{escape_identifier, pg_attribute::PgAttribute, pg_class::PgClass};
|
||||
use interim_models::{
|
||||
accessors::{Accessor, Actor, portal::PortalAccessor},
|
||||
datum::Datum,
|
||||
field::Field,
|
||||
};
|
||||
use interim_pgtypes::{
|
||||
escape_identifier, pg_acl::PgPrivilegeType, pg_attribute::PgAttribute, pg_class::PgClass,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{postgres::PgRow, query};
|
||||
use sqlx::{
|
||||
postgres::{PgRow, types::Oid},
|
||||
query,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
|
|
@ -22,6 +31,8 @@ use crate::{
|
|||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub(super) struct PathParams {
|
||||
portal_id: Uuid,
|
||||
rel_oid: u32,
|
||||
workspace_id: Uuid,
|
||||
}
|
||||
|
||||
const FRONTEND_ROW_LIMIT: i64 = 1000;
|
||||
|
|
@ -33,19 +44,33 @@ const FRONTEND_ROW_LIMIT: i64 = 1000;
|
|||
pub(super) async fn get(
|
||||
State(mut workspace_pooler): State<WorkspacePooler>,
|
||||
AppDbConn(mut app_db): AppDbConn,
|
||||
CurrentUser(current_user): CurrentUser,
|
||||
Path(PathParams { portal_id }): Path<PathParams>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Path(PathParams {
|
||||
portal_id,
|
||||
rel_oid,
|
||||
workspace_id,
|
||||
}): Path<PathParams>,
|
||||
) -> Result<Response, AppError> {
|
||||
// FIXME auth
|
||||
let portal = Portal::with_id(portal_id).fetch_one(&mut app_db).await?;
|
||||
|
||||
let mut workspace_client = workspace_pooler
|
||||
.acquire_for(portal.workspace_id, RoleAssignment::User(current_user.id))
|
||||
.acquire_for(workspace_id, RoleAssignment::User(user.id))
|
||||
.await?;
|
||||
let rel = PgClass::with_oid(portal.class_oid)
|
||||
|
||||
let rel = PgClass::with_oid(Oid(rel_oid))
|
||||
.fetch_one(&mut workspace_client)
|
||||
.await?;
|
||||
|
||||
let portal = PortalAccessor::default()
|
||||
.id(portal_id)
|
||||
.as_actor(Actor::User(user.id))
|
||||
.verify_workspace_id(workspace_id)
|
||||
.verify_rel_oid(Oid(rel_oid))
|
||||
.verify_rel_permissions([PgPrivilegeType::Select])
|
||||
.using_rel(&rel)
|
||||
.using_workspace_client(&mut workspace_client)
|
||||
.using_app_db(&mut app_db)
|
||||
.fetch_one()
|
||||
.await?;
|
||||
|
||||
let attrs = PgAttribute::all_for_rel(portal.class_oid)
|
||||
.fetch_all(&mut workspace_client)
|
||||
.await?;
|
||||
|
|
|
|||
|
|
@ -8,8 +8,11 @@ use axum::{
|
|||
// [`axum_extra`]'s form extractor is required to support repeated keys:
|
||||
// https://docs.rs/axum-extra/0.10.1/axum_extra/extract/struct.Form.html#differences-from-axumextractform
|
||||
use axum_extra::extract::Form;
|
||||
use interim_models::{datum::Datum, portal::Portal, workspace::Workspace};
|
||||
use interim_pgtypes::{escape_identifier, pg_class::PgClass};
|
||||
use interim_models::{
|
||||
accessors::{Accessor as _, Actor, portal::PortalAccessor},
|
||||
datum::Datum,
|
||||
};
|
||||
use interim_pgtypes::{escape_identifier, pg_acl::PgPrivilegeType, pg_class::PgClass};
|
||||
use serde::Deserialize;
|
||||
use sqlx::{postgres::types::Oid, query};
|
||||
use uuid::Uuid;
|
||||
|
|
@ -47,24 +50,29 @@ pub(super) async fn post(
|
|||
}): Path<PathParams>,
|
||||
Form(form): Form<HashMap<String, Vec<String>>>,
|
||||
) -> Result<Response, AppError> {
|
||||
// FIXME: Check workspace authorization.
|
||||
// FIXME ensure workspace corresponds to rel/portal, and that user has
|
||||
// permission to access/alter both as needed.
|
||||
// FIXME CSRF
|
||||
|
||||
let portal = Portal::with_id(portal_id).fetch_one(&mut app_db).await?;
|
||||
let workspace = Workspace::with_id(portal.workspace_id)
|
||||
.fetch_one(&mut app_db)
|
||||
.await?;
|
||||
|
||||
let mut workspace_client = workspace_pooler
|
||||
.acquire_for(workspace.id, RoleAssignment::User(user.id))
|
||||
.acquire_for(workspace_id, RoleAssignment::User(user.id))
|
||||
.await?;
|
||||
|
||||
let rel = PgClass::with_oid(Oid(rel_oid))
|
||||
.fetch_one(&mut workspace_client)
|
||||
.await?;
|
||||
|
||||
// For authorization only.
|
||||
let _portal = PortalAccessor::new()
|
||||
.id(portal_id)
|
||||
.as_actor(Actor::User(user.id))
|
||||
.verify_workspace_id(workspace_id)
|
||||
.verify_rel_oid(Oid(rel_oid))
|
||||
.verify_rel_permissions([PgPrivilegeType::Insert])
|
||||
.using_rel(&rel)
|
||||
.using_app_db(&mut app_db)
|
||||
.using_workspace_client(&mut workspace_client)
|
||||
.fetch_one()
|
||||
.await?;
|
||||
|
||||
let col_names: Vec<String> = form.keys().cloned().collect();
|
||||
|
||||
// Prevent users from modifying Phonograph metadata columns.
|
||||
|
|
|
|||
|
|
@ -3,8 +3,12 @@ use axum::{
|
|||
extract::{Path, State},
|
||||
response::{Html, IntoResponse as _, Response},
|
||||
};
|
||||
use interim_models::{expression::PgExpressionAny, portal::Portal, workspace::Workspace};
|
||||
use interim_pgtypes::pg_attribute::PgAttribute;
|
||||
use interim_models::{
|
||||
accessors::{Accessor, Actor, portal::PortalAccessor},
|
||||
expression::PgExpressionAny,
|
||||
workspace::Workspace,
|
||||
};
|
||||
use interim_pgtypes::{pg_acl::PgPrivilegeType, pg_attribute::PgAttribute};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::postgres::types::Oid;
|
||||
use uuid::Uuid;
|
||||
|
|
@ -19,7 +23,7 @@ use crate::{
|
|||
workspace_pooler::{RoleAssignment, WorkspacePooler},
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct PathParams {
|
||||
portal_id: Uuid,
|
||||
rel_oid: u32,
|
||||
|
|
@ -32,25 +36,29 @@ pub(super) struct PathParams {
|
|||
/// module.
|
||||
pub(super) async fn get(
|
||||
State(settings): State<Settings>,
|
||||
State(mut workspace_pooler): State<WorkspacePooler>,
|
||||
State(mut pooler): State<WorkspacePooler>,
|
||||
AppDbConn(mut app_db): AppDbConn,
|
||||
CurrentUser(current_user): CurrentUser,
|
||||
CurrentUser(user): CurrentUser,
|
||||
navigator: Navigator,
|
||||
Path(PathParams {
|
||||
portal_id,
|
||||
workspace_id,
|
||||
rel_oid,
|
||||
workspace_id,
|
||||
}): Path<PathParams>,
|
||||
) -> Result<Response, AppError> {
|
||||
// FIXME auth
|
||||
|
||||
let workspace = Workspace::with_id(workspace_id)
|
||||
.fetch_one(&mut app_db)
|
||||
let mut workspace_client = pooler
|
||||
.acquire_for(workspace_id, RoleAssignment::User(user.id))
|
||||
.await?;
|
||||
let portal = Portal::with_id(portal_id).fetch_one(&mut app_db).await?;
|
||||
|
||||
let mut workspace_client = workspace_pooler
|
||||
.acquire_for(portal.workspace_id, RoleAssignment::User(current_user.id))
|
||||
let portal = PortalAccessor::new()
|
||||
.id(portal_id)
|
||||
.as_actor(Actor::User(user.id))
|
||||
.verify_workspace_id(workspace_id)
|
||||
.verify_rel_oid(Oid(rel_oid))
|
||||
.verify_rel_permissions([PgPrivilegeType::Select])
|
||||
.using_app_db(&mut app_db)
|
||||
.using_workspace_client(&mut workspace_client)
|
||||
.fetch_one()
|
||||
.await?;
|
||||
|
||||
let attrs = PgAttribute::all_for_rel(portal.class_oid)
|
||||
|
|
@ -70,6 +78,10 @@ pub(super) async fn get(
|
|||
})
|
||||
.collect();
|
||||
|
||||
let workspace = Workspace::with_id(portal.workspace_id)
|
||||
.fetch_one(&mut app_db)
|
||||
.await?;
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "portal_table.html")]
|
||||
struct ResponseTemplate {
|
||||
|
|
@ -86,11 +98,11 @@ pub(super) async fn get(
|
|||
filter: portal.table_filter.0,
|
||||
navbar: WorkspaceNav::builder()
|
||||
.navigator(navigator)
|
||||
.workspace(workspace.clone())
|
||||
.workspace(workspace)
|
||||
.populate_rels(&mut app_db, &mut workspace_client)
|
||||
.await?
|
||||
.current(NavLocation::Rel(
|
||||
Oid(rel_oid),
|
||||
portal.class_oid,
|
||||
Some(RelLocation::Portal(portal.id)),
|
||||
))
|
||||
.build()?,
|
||||
|
|
|
|||
|
|
@ -4,8 +4,11 @@ use axum::{
|
|||
extract::{Path, State},
|
||||
response::{Html, IntoResponse},
|
||||
};
|
||||
use interim_models::{portal::Portal, workspace::Workspace};
|
||||
use interim_pgtypes::pg_class::PgClass;
|
||||
use interim_models::{
|
||||
accessors::{Accessor, Actor, portal::PortalAccessor},
|
||||
portal::Portal,
|
||||
workspace::Workspace,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use sqlx::postgres::types::Oid;
|
||||
use uuid::Uuid;
|
||||
|
|
@ -41,30 +44,30 @@ pub(super) async fn get(
|
|||
navigator: Navigator,
|
||||
State(mut pooler): State<WorkspacePooler>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
// FIXME: Check workspace authorization.
|
||||
// FIXME ensure workspace corresponds to rel/portal, and that user has
|
||||
// permission to access/alter both as needed.
|
||||
let mut workspace_client = pooler
|
||||
.acquire_for(workspace_id, RoleAssignment::User(user.id))
|
||||
.await?;
|
||||
|
||||
let portal = PortalAccessor::new()
|
||||
.id(portal_id)
|
||||
.as_actor(Actor::User(user.id))
|
||||
.verify_workspace_id(workspace_id)
|
||||
.verify_rel_oid(Oid(rel_oid))
|
||||
.verify_rel_ownership()
|
||||
.using_app_db(&mut app_db)
|
||||
.using_workspace_client(&mut workspace_client)
|
||||
.fetch_one()
|
||||
.await?;
|
||||
|
||||
let workspace = Workspace::with_id(workspace_id)
|
||||
.fetch_one(&mut app_db)
|
||||
.await?;
|
||||
|
||||
let mut workspace_client = pooler
|
||||
.acquire_for(workspace.id, RoleAssignment::User(user.id))
|
||||
.await?;
|
||||
|
||||
let rel = PgClass::with_oid(Oid(rel_oid))
|
||||
.fetch_one(&mut workspace_client)
|
||||
.await?;
|
||||
|
||||
let portal = Portal::with_id(portal_id).fetch_one(&mut app_db).await?;
|
||||
|
||||
#[derive(Debug, Template)]
|
||||
#[template(path = "relations_single/portal_settings.html")]
|
||||
struct ResponseTemplate {
|
||||
navigator: Navigator,
|
||||
portal: Portal,
|
||||
rel: PgClass,
|
||||
settings: Settings,
|
||||
workspace_nav: WorkspaceNav,
|
||||
}
|
||||
|
|
@ -79,7 +82,6 @@ pub(super) async fn get(
|
|||
.build()?,
|
||||
navigator,
|
||||
portal,
|
||||
rel,
|
||||
settings,
|
||||
}
|
||||
.render()?,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,10 @@ use axum::{
|
|||
extract::{Path, State},
|
||||
response::Response,
|
||||
};
|
||||
use interim_models::{field::Field, portal::Portal};
|
||||
use interim_models::{
|
||||
accessors::{Accessor, Actor, portal::PortalAccessor},
|
||||
field::Field,
|
||||
};
|
||||
use interim_pgtypes::{escape_identifier, pg_class::PgClass};
|
||||
use serde::Deserialize;
|
||||
use sqlx::{postgres::types::Oid, query};
|
||||
|
|
@ -56,15 +59,34 @@ pub(super) async fn post(
|
|||
) -> Result<Response, AppError> {
|
||||
// FIXME CSRF
|
||||
|
||||
// FIXME ensure workspace corresponds to rel/portal, and that user has
|
||||
// permission to access/alter both as needed.
|
||||
|
||||
// Ensure field exists and belongs to portal.
|
||||
// TODO: This leaks information if it fails. Instead, return
|
||||
// `Err(AccessError::NotFound)` if not found.
|
||||
let field = Field::belonging_to_portal(portal_id)
|
||||
.with_id(field_id)
|
||||
.fetch_one(&mut app_db)
|
||||
.await?;
|
||||
|
||||
let mut workspace_client = pooler
|
||||
.acquire_for(workspace_id, RoleAssignment::User(user.id))
|
||||
.await?;
|
||||
let rel = PgClass::with_oid(Oid(rel_oid))
|
||||
.fetch_one(&mut workspace_client)
|
||||
.await?;
|
||||
|
||||
// For authorization only.
|
||||
let _portal = PortalAccessor::new()
|
||||
.id(portal_id)
|
||||
.as_actor(Actor::User(user.id))
|
||||
.verify_workspace_id(workspace_id)
|
||||
.verify_rel_oid(Oid(rel_oid))
|
||||
.verify_rel_ownership()
|
||||
.using_rel(&rel)
|
||||
.using_app_db(&mut app_db)
|
||||
.using_workspace_client(&mut workspace_client)
|
||||
.fetch_one()
|
||||
.await?;
|
||||
|
||||
if delete_data == "true" && field.name.starts_with('_') {
|
||||
return Err(bad_request!("cannot delete data for a system column"));
|
||||
}
|
||||
|
|
@ -72,13 +94,6 @@ pub(super) async fn post(
|
|||
field.delete(&mut app_db).await?;
|
||||
|
||||
if delete_data == "true" {
|
||||
let mut workspace_client = pooler
|
||||
.acquire_for(workspace_id, RoleAssignment::User(user.id))
|
||||
.await?;
|
||||
let portal = Portal::with_id(portal_id).fetch_one(&mut app_db).await?;
|
||||
let rel = PgClass::with_oid(portal.class_oid)
|
||||
.fetch_one(&mut workspace_client)
|
||||
.await?;
|
||||
query(&format!(
|
||||
"alter table {ident} drop column if exists {col_esc}",
|
||||
ident = rel.get_identifier(),
|
||||
|
|
|
|||
|
|
@ -1,8 +1,16 @@
|
|||
use axum::{debug_handler, extract::Path, response::Response};
|
||||
use axum::{
|
||||
debug_handler,
|
||||
extract::{Path, State},
|
||||
response::Response,
|
||||
};
|
||||
// [`axum_extra`]'s form extractor is preferred:
|
||||
// https://docs.rs/axum-extra/0.10.1/axum_extra/extract/struct.Form.html#differences-from-axumextractform
|
||||
use axum_extra::extract::Form;
|
||||
use interim_models::{expression::PgExpressionAny, portal::Portal};
|
||||
use interim_models::{
|
||||
accessors::{Accessor, Actor, portal::PortalAccessor},
|
||||
expression::PgExpressionAny,
|
||||
portal::Portal,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use sqlx::postgres::types::Oid;
|
||||
use uuid::Uuid;
|
||||
|
|
@ -12,6 +20,7 @@ use crate::{
|
|||
errors::AppError,
|
||||
navigator::{Navigator, NavigatorPage as _},
|
||||
user::CurrentUser,
|
||||
workspace_pooler::{RoleAssignment, WorkspacePooler},
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
|
@ -34,7 +43,8 @@ pub(super) struct FormBody {
|
|||
#[debug_handler(state = App)]
|
||||
pub(super) async fn post(
|
||||
AppDbConn(mut app_db): AppDbConn,
|
||||
CurrentUser(_user): CurrentUser,
|
||||
State(mut pooler): State<WorkspacePooler>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
navigator: Navigator,
|
||||
Path(PathParams {
|
||||
portal_id,
|
||||
|
|
@ -43,13 +53,26 @@ pub(super) async fn post(
|
|||
}): Path<PathParams>,
|
||||
Form(form): Form<FormBody>,
|
||||
) -> Result<Response, AppError> {
|
||||
// FIXME: Check workspace authorization.
|
||||
// FIXME ensure workspace corresponds to rel/portal, and that user has
|
||||
// permission to access/alter both as needed.
|
||||
// FIXME: csrf
|
||||
|
||||
let mut workspace_client = pooler
|
||||
.acquire_for(workspace_id, RoleAssignment::User(user.id))
|
||||
.await?;
|
||||
|
||||
let portal = PortalAccessor::new()
|
||||
.id(portal_id)
|
||||
.as_actor(Actor::User(user.id))
|
||||
.verify_workspace_id(workspace_id)
|
||||
.verify_rel_oid(Oid(rel_oid))
|
||||
.verify_rel_ownership()
|
||||
.using_app_db(&mut app_db)
|
||||
.using_workspace_client(&mut workspace_client)
|
||||
.fetch_one()
|
||||
.await?;
|
||||
|
||||
let portal = Portal::with_id(portal_id).fetch_one(&mut app_db).await?;
|
||||
let filter: Option<PgExpressionAny> =
|
||||
serde_json::from_str(&form.filter_expression.unwrap_or("null".to_owned()))?;
|
||||
|
||||
Portal::update()
|
||||
.id(portal.id)
|
||||
.table_filter(filter)
|
||||
|
|
@ -61,7 +84,7 @@ pub(super) async fn post(
|
|||
.portal_page()
|
||||
.workspace_id(workspace_id)
|
||||
.rel_oid(Oid(rel_oid))
|
||||
.portal_id(portal_id)
|
||||
.portal_id(portal.id)
|
||||
.build()?
|
||||
.redirect_to())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,10 @@ use axum::{
|
|||
extract::{Path, State},
|
||||
response::Response,
|
||||
};
|
||||
use interim_models::portal::{Portal, RE_PORTAL_NAME};
|
||||
use interim_pgtypes::pg_class::PgClass;
|
||||
use interim_models::{
|
||||
accessors::{Accessor, Actor, portal::PortalAccessor},
|
||||
portal::{Portal, RE_PORTAL_NAME},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use sqlx::postgres::types::Oid;
|
||||
use uuid::Uuid;
|
||||
|
|
@ -46,8 +48,6 @@ pub(super) async fn post(
|
|||
}): Path<PathParams>,
|
||||
ValidatedForm(FormBody { name }): ValidatedForm<FormBody>,
|
||||
) -> Result<Response, AppError> {
|
||||
// FIXME: Check workspace authorization.
|
||||
|
||||
let mut workspace_client = pooler
|
||||
.acquire_for(
|
||||
workspace_id,
|
||||
|
|
@ -55,15 +55,19 @@ pub(super) async fn post(
|
|||
)
|
||||
.await?;
|
||||
|
||||
let rel = PgClass::with_oid(Oid(rel_oid))
|
||||
.fetch_one(&mut workspace_client)
|
||||
let portal = PortalAccessor::new()
|
||||
.id(portal_id)
|
||||
.as_actor(Actor::User(user.id))
|
||||
.verify_workspace_id(workspace_id)
|
||||
.verify_rel_oid(Oid(rel_oid))
|
||||
.verify_rel_ownership()
|
||||
.using_app_db(&mut app_db)
|
||||
.using_workspace_client(&mut workspace_client)
|
||||
.fetch_one()
|
||||
.await?;
|
||||
// FIXME ensure that user has ownership of the table.
|
||||
|
||||
let portal = Portal::with_id(portal_id).fetch_one(&mut app_db).await?;
|
||||
|
||||
Portal::update()
|
||||
.id(portal_id)
|
||||
.id(portal.id)
|
||||
.name(name)
|
||||
.build()?
|
||||
.execute(&mut app_db)
|
||||
|
|
@ -73,7 +77,7 @@ pub(super) async fn post(
|
|||
.portal_page()
|
||||
.workspace_id(workspace_id)
|
||||
.rel_oid(Oid(rel_oid))
|
||||
.portal_id(portal_id)
|
||||
.portal_id(portal.id)
|
||||
.suffix("settings/")
|
||||
.build()?
|
||||
.redirect_to())
|
||||
|
|
|
|||
|
|
@ -9,7 +9,10 @@ use axum::{
|
|||
// https://docs.rs/axum-extra/0.10.1/axum_extra/extract/struct.Form.html#differences-from-axumextractform
|
||||
use axum_extra::extract::Form;
|
||||
use interim_models::{
|
||||
field_form_prompt::FieldFormPrompt, language::Language, portal::Portal, workspace::Workspace,
|
||||
accessors::{Accessor, Actor, portal::PortalAccessor},
|
||||
field::Field,
|
||||
field_form_prompt::FieldFormPrompt,
|
||||
language::Language,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use sqlx::postgres::types::Oid;
|
||||
|
|
@ -49,28 +52,39 @@ pub(super) async fn post(
|
|||
}): Path<PathParams>,
|
||||
Form(form): Form<HashMap<String, String>>,
|
||||
) -> Result<Response, AppError> {
|
||||
// FIXME: Check workspace authorization.
|
||||
// FIXME ensure workspace corresponds to rel/portal, and that user has
|
||||
// permission to access/alter both as needed.
|
||||
// FIXME CSRF
|
||||
|
||||
let portal = Portal::with_id(portal_id).fetch_one(&mut app_db).await?;
|
||||
let workspace = Workspace::with_id(portal.workspace_id)
|
||||
.fetch_one(&mut app_db)
|
||||
.await?;
|
||||
|
||||
let mut workspace_client = workspace_pooler
|
||||
.acquire_for(workspace.id, RoleAssignment::User(user.id))
|
||||
.acquire_for(workspace_id, RoleAssignment::User(user.id))
|
||||
.await?;
|
||||
|
||||
// FIXME assert that fields all belong to the authorized portal
|
||||
let portal = PortalAccessor::new()
|
||||
.id(portal_id)
|
||||
.as_actor(Actor::User(user.id))
|
||||
.verify_workspace_id(workspace_id)
|
||||
.verify_rel_oid(Oid(rel_oid))
|
||||
.verify_rel_ownership()
|
||||
.using_app_db(&mut app_db)
|
||||
.using_workspace_client(&mut workspace_client)
|
||||
.fetch_one()
|
||||
.await?;
|
||||
|
||||
// TODO: This can be sped up somewhat with streams, because queries using
|
||||
// `app_db` can run at the same time as others using `app_db` or
|
||||
// `workspace_client`.
|
||||
for (name, content) in form {
|
||||
let mut name_split = name.split('.');
|
||||
let field_id = name_split
|
||||
.next()
|
||||
.and_then(|value| Uuid::parse_str(value).ok())
|
||||
.ok_or(bad_request!("expected input name to start with <FIELD_ID>"))?;
|
||||
|
||||
// For authorization.
|
||||
let _field = Field::belonging_to_portal(portal.id)
|
||||
.with_id(field_id)
|
||||
.fetch_one(&mut app_db)
|
||||
.await?;
|
||||
|
||||
let language = name_split
|
||||
.next()
|
||||
.and_then(|value| Language::from_str(value).ok())
|
||||
|
|
@ -95,6 +109,7 @@ pub(super) async fn post(
|
|||
.workspace_id(workspace_id)
|
||||
.rel_oid(Oid(rel_oid))
|
||||
.portal_id(portal_id)
|
||||
.suffix("form/")
|
||||
.build()?
|
||||
.redirect_to())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,8 +5,13 @@ use axum::{
|
|||
extract::{Path, State},
|
||||
response::{IntoResponse as _, Response},
|
||||
};
|
||||
use interim_models::{datum::Datum, portal::Portal, workspace::Workspace};
|
||||
use interim_pgtypes::{escape_identifier, pg_attribute::PgAttribute, pg_class::PgClass};
|
||||
use interim_models::{
|
||||
accessors::{Accessor, Actor, portal::PortalAccessor},
|
||||
datum::Datum,
|
||||
};
|
||||
use interim_pgtypes::{
|
||||
escape_identifier, pg_acl::PgPrivilegeType, pg_attribute::PgAttribute, pg_class::PgClass,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use sqlx::{Acquire as _, postgres::types::Oid, query};
|
||||
|
|
@ -54,28 +59,32 @@ pub(super) async fn post(
|
|||
}): Path<PathParams>,
|
||||
Json(form): Json<FormBody>,
|
||||
) -> Result<Response, AppError> {
|
||||
// FIXME: Check workspace authorization.
|
||||
// FIXME ensure workspace corresponds to rel/portal, and that user has
|
||||
// permission to access/alter both as needed.
|
||||
|
||||
// Prevent users from modifying Phonograph metadata columns.
|
||||
if form.cells.iter().any(|cell| cell.column.starts_with('_')) {
|
||||
return Err(forbidden!("access denied to update system metadata column"));
|
||||
}
|
||||
|
||||
let portal = Portal::with_id(portal_id).fetch_one(&mut app_db).await?;
|
||||
let workspace = Workspace::with_id(portal.workspace_id)
|
||||
.fetch_one(&mut app_db)
|
||||
.await?;
|
||||
|
||||
let mut workspace_client = workspace_pooler
|
||||
.acquire_for(workspace.id, RoleAssignment::User(user.id))
|
||||
.acquire_for(workspace_id, RoleAssignment::User(user.id))
|
||||
.await?;
|
||||
|
||||
let rel = PgClass::with_oid(portal.class_oid)
|
||||
let rel = PgClass::with_oid(Oid(rel_oid))
|
||||
.fetch_one(&mut workspace_client)
|
||||
.await?;
|
||||
|
||||
// For authorization only.
|
||||
let _portal = PortalAccessor::new()
|
||||
.id(portal_id)
|
||||
.as_actor(Actor::User(user.id))
|
||||
.verify_workspace_id(workspace_id)
|
||||
.verify_rel_oid(Oid(rel_oid))
|
||||
.verify_rel_permissions([PgPrivilegeType::Update])
|
||||
.using_rel(&rel)
|
||||
.using_app_db(&mut app_db)
|
||||
.using_workspace_client(&mut workspace_client)
|
||||
.fetch_one()
|
||||
.await?;
|
||||
|
||||
let pkey_attrs = PgAttribute::pkeys_for_rel(Oid(rel_oid))
|
||||
.fetch_all(&mut workspace_client)
|
||||
.await?;
|
||||
|
|
|
|||
|
|
@ -3,15 +3,13 @@ use interim_models::{
|
|||
client::AppDbClient, cluster::Cluster, user::User, workspace::Workspace,
|
||||
workspace_user_perm::WorkspaceMembership,
|
||||
};
|
||||
use interim_pgtypes::{client::WorkspaceClient, escape_identifier};
|
||||
use interim_pgtypes::{client::WorkspaceClient, escape_identifier, rolnames::ROLE_PREFIX_USER};
|
||||
use sqlx::{Connection as _, PgConnection, query};
|
||||
|
||||
use crate::{
|
||||
app::AppDbConn,
|
||||
errors::AppError,
|
||||
navigator::{Navigator, NavigatorPage as _},
|
||||
roles::ROLE_PREFIX_USER,
|
||||
settings::Settings,
|
||||
user::CurrentUser,
|
||||
workspace_pooler::{RoleAssignment, WorkspacePooler},
|
||||
workspace_utils::PHONO_TABLE_NAMESPACE,
|
||||
|
|
@ -22,7 +20,6 @@ use crate::{
|
|||
/// the human-friendly name may be changed later rather than specified
|
||||
/// permenantly upon creation.
|
||||
pub(super) async fn post(
|
||||
State(settings): State<Settings>,
|
||||
State(mut pooler): State<WorkspacePooler>,
|
||||
AppDbConn(mut app_db): AppDbConn,
|
||||
navigator: Navigator,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,10 @@ use axum::{
|
|||
response::IntoResponse,
|
||||
};
|
||||
use interim_models::{service_cred::ServiceCred, workspace::Workspace};
|
||||
use interim_pgtypes::escape_identifier;
|
||||
use interim_pgtypes::{
|
||||
escape_identifier,
|
||||
rolnames::{ROLE_PREFIX_SERVICE_CRED, SERVICE_CRED_CONN_LIMIT, SERVICE_CRED_SUFFIX_LEN},
|
||||
};
|
||||
use rand::distributions::{Alphanumeric, DistString};
|
||||
use redact::Secret;
|
||||
use serde::Deserialize;
|
||||
|
|
@ -15,7 +18,6 @@ use crate::{
|
|||
app::{App, AppDbConn},
|
||||
errors::AppError,
|
||||
navigator::{Navigator, NavigatorPage},
|
||||
roles::{ROLE_PREFIX_SERVICE_CRED, SERVICE_CRED_CONN_LIMIT, SERVICE_CRED_SUFFIX_LEN},
|
||||
user::CurrentUser,
|
||||
workspace_pooler::{RoleAssignment, WorkspacePooler},
|
||||
workspace_utils::PHONO_TABLE_NAMESPACE,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,13 @@ use axum::{
|
|||
extract::{Path, State},
|
||||
response::IntoResponse,
|
||||
};
|
||||
use interim_pgtypes::escape_identifier;
|
||||
use interim_pgtypes::{
|
||||
escape_identifier,
|
||||
rolnames::{
|
||||
ROLE_PREFIX_TABLE_OWNER, ROLE_PREFIX_TABLE_READER, ROLE_PREFIX_TABLE_WRITER,
|
||||
ROLE_PREFIX_USER,
|
||||
},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use sqlx::query;
|
||||
use uuid::Uuid;
|
||||
|
|
@ -10,10 +16,6 @@ use uuid::Uuid;
|
|||
use crate::{
|
||||
errors::AppError,
|
||||
navigator::{Navigator, NavigatorPage as _},
|
||||
roles::{
|
||||
ROLE_PREFIX_TABLE_OWNER, ROLE_PREFIX_TABLE_READER, ROLE_PREFIX_TABLE_WRITER,
|
||||
ROLE_PREFIX_USER,
|
||||
},
|
||||
user::CurrentUser,
|
||||
workspace_pooler::{RoleAssignment, WorkspacePooler},
|
||||
workspace_utils::PHONO_TABLE_NAMESPACE,
|
||||
|
|
|
|||
|
|
@ -4,12 +4,12 @@ use anyhow::Result;
|
|||
use axum::extract::FromRef;
|
||||
use derive_builder::Builder;
|
||||
use interim_models::{client::AppDbClient, workspace::Workspace};
|
||||
use interim_pgtypes::client::WorkspaceClient;
|
||||
use interim_pgtypes::{client::WorkspaceClient, rolnames::ROLE_PREFIX_USER};
|
||||
use sqlx::{Executor, PgPool, postgres::PgPoolOptions, raw_sql};
|
||||
use tokio::sync::{OnceCell, RwLock};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{app::App, roles::ROLE_PREFIX_USER};
|
||||
use crate::app::App;
|
||||
|
||||
const MAX_CONNECTIONS: u32 = 4;
|
||||
const IDLE_SECONDS: u64 = 3600;
|
||||
|
|
@ -109,8 +109,9 @@ discard sequences;
|
|||
|
||||
pub async fn close_for(&mut self, base_id: Uuid) -> Result<()> {
|
||||
let pools = self.pools.read().await;
|
||||
if let Some(cell) = pools.get(&base_id) {
|
||||
if let Some(pool) = cell.get() {
|
||||
if let Some(cell) = pools.get(&base_id)
|
||||
&& let Some(pool) = cell.get()
|
||||
{
|
||||
let pool = pool.clone();
|
||||
drop(pools); // Release read lock
|
||||
let mut pools = self.pools.write().await;
|
||||
|
|
@ -118,7 +119,6 @@ discard sequences;
|
|||
drop(pools); // Release write lock
|
||||
pool.close().await;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue