implement accessor pattern for authorized portal access

This commit is contained in:
Brent Schroeter 2025-11-19 01:31:09 +00:00
parent 7f63cbb521
commit 5d5acb09bb
30 changed files with 601 additions and 190 deletions

2
Cargo.lock generated
View file

@ -1757,6 +1757,7 @@ dependencies = [
"bigdecimal", "bigdecimal",
"chrono", "chrono",
"derive_builder", "derive_builder",
"futures",
"interim-pgtypes", "interim-pgtypes",
"redact", "redact",
"regex", "regex",
@ -1765,6 +1766,7 @@ dependencies = [
"sqlx", "sqlx",
"strum", "strum",
"thiserror 2.0.12", "thiserror 2.0.12",
"tracing",
"url", "url",
"uuid", "uuid",
"validator", "validator",

View file

@ -7,6 +7,7 @@ version.workspace = true
bigdecimal = { workspace = true } bigdecimal = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
derive_builder = { workspace = true } derive_builder = { workspace = true }
futures = { workspace = true }
interim-pgtypes = { path = "../interim-pgtypes" } interim-pgtypes = { path = "../interim-pgtypes" }
redact = { workspace = true } redact = { workspace = true }
regex = { workspace = true } regex = { workspace = true }
@ -15,6 +16,7 @@ serde_json = { workspace = true }
sqlx = { workspace = true } sqlx = { workspace = true }
strum = { workspace = true } strum = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
tracing = { workspace = true }
url = { workspace = true } url = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }
validator = { workspace = true } validator = { workspace = true }

View 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>>;
}

View 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
}
}

View file

@ -1,3 +1,6 @@
#![feature(bool_to_result)] // Enable support for `ok_or()` on bools.
pub mod accessors;
pub mod client; pub mod client;
pub mod cluster; pub mod cluster;
pub mod datum; pub mod datum;

View file

@ -4,8 +4,9 @@ use crate::escape_identifier;
/// Newtype to differentiate between workspace and application database /// Newtype to differentiate between workspace and application database
/// connections. /// connections.
#[derive(Debug)]
pub struct WorkspaceClient { pub struct WorkspaceClient {
pub(crate) conn: PoolConnection<Postgres>, conn: PoolConnection<Postgres>,
} }
impl WorkspaceClient { impl WorkspaceClient {

View file

@ -5,6 +5,7 @@ pub mod pg_class;
pub mod pg_database; pub mod pg_database;
pub mod pg_namespace; pub mod pg_namespace;
pub mod pg_role; pub mod pg_role;
pub mod rolnames;
/// Given a raw identifier (such as a table name, column name, etc.), format it /// 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. /// so that it may be safely interpolated into a SQL query.

View file

@ -88,7 +88,7 @@ where attrelid = $1 and attnum > 0 and not attisdropped
"#, "#,
&self.rel_oid &self.rel_oid
) )
.fetch_all(&mut *client.conn) .fetch_all(client.get_conn())
.await .await
} }
} }
@ -131,7 +131,7 @@ where i.indrelid = $1 and i.indisprimary;
"#, "#,
&self.rel_oid &self.rel_oid
) )
.fetch_all(&mut *client.conn) .fetch_all(client.get_conn())
.await .await
} }
} }

View file

@ -52,7 +52,7 @@ impl PgClass {
&self, &self,
client: &mut WorkspaceClient, client: &mut WorkspaceClient,
) -> Result<PgNamespace, sqlx::Error> { ) -> Result<PgNamespace, sqlx::Error> {
PgNamespace::fetch_by_oid(self.relnamespace, &mut *client.conn) PgNamespace::fetch_by_oid(self.relnamespace, client.get_conn())
.await? .await?
// If client has access to the class, it would expect to have access // 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. // to the namespace that contains it. If not, that's an error.
@ -178,7 +178,7 @@ where
impl WithOidQuery { impl WithOidQuery {
pub async fn fetch_one(self, client: &mut WorkspaceClient) -> Result<PgClass, sqlx::Error> { pub async fn fetch_one(self, client: &mut WorkspaceClient) -> Result<PgClass, sqlx::Error> {
with_oid_sqlx_query!(self.oid) with_oid_sqlx_query!(self.oid)
.fetch_one(&mut *client.conn) .fetch_one(client.get_conn())
.await .await
} }
@ -187,7 +187,7 @@ impl WithOidQuery {
client: &mut WorkspaceClient, client: &mut WorkspaceClient,
) -> Result<Option<PgClass>, sqlx::Error> { ) -> Result<Option<PgClass>, sqlx::Error> {
with_oid_sqlx_query!(self.oid) with_oid_sqlx_query!(self.oid)
.fetch_optional(&mut *client.conn) .fetch_optional(client.get_conn())
.await .await
} }
} }
@ -235,7 +235,7 @@ where
"#, "#,
kinds_i8.as_slice(), kinds_i8.as_slice(),
) )
.fetch_all(&mut *client.conn) .fetch_all(client.get_conn())
.await .await
} }
} }

View file

@ -73,7 +73,7 @@ from pg_database
where datname = current_database() where datname = current_database()
"#, "#,
) )
.fetch_one(&mut *client.conn) .fetch_one(client.get_conn())
.await .await
} }
} }

View file

@ -9,24 +9,34 @@ use crate::client::WorkspaceClient;
pub struct PgRole { pub struct PgRole {
/// ID of role /// ID of role
pub oid: Oid, pub oid: Oid,
/// Role name /// Role name
pub rolname: String, pub rolname: String,
/// Role has superuser privileges /// Role has superuser privileges
pub rolsuper: bool, pub rolsuper: bool,
/// Role automatically inherits privileges of roles it is a member of /// Role automatically inherits privileges of roles it is a member of
pub rolinherit: bool, pub rolinherit: bool,
/// Role can create more roles /// Role can create more roles
pub rolcreaterole: bool, pub rolcreaterole: bool,
/// Role can create databases /// Role can create databases
pub rolcreatedb: bool, pub rolcreatedb: bool,
/// Role can log in. That is, this role can be given as the initial session authorization identifier /// Role can log in. That is, this role can be given as the initial session authorization identifier
pub rolcanlogin: bool, pub rolcanlogin: bool,
/// Role is a replication role. A replication role can initiate replication connections and create and drop replication slots. /// Role is a replication role. A replication role can initiate replication connections and create and drop replication slots.
pub rolreplication: bool, pub rolreplication: bool,
/// For roles that can log in, this sets maximum number of concurrent connections this role can make. -1 means no limit. /// For roles that can log in, this sets maximum number of concurrent connections this role can make. -1 means no limit.
pub rolconnlimit: i32, pub rolconnlimit: i32,
/// Password expiry time (only used for password authentication); null if no expiration /// Password expiry time (only used for password authentication); null if no expiration
pub rolvaliduntil: Option<DateTime<Utc>>, pub rolvaliduntil: Option<DateTime<Utc>>,
/// Role bypasses every row-level security policy, see Section 5.9 for more information. /// Role bypasses every row-level security policy, see Section 5.9 for more information.
pub rolbypassrls: bool, pub rolbypassrls: bool,
} }
@ -71,7 +81,7 @@ where rolname = any($1)
"#, "#,
self.names.as_slice() self.names.as_slice()
) )
.fetch_all(&mut *client.conn) .fetch_all(client.get_conn())
.await .await
} }
} }
@ -106,11 +116,16 @@ where starts_with(rolname, $1)
"#, "#,
self.prefix self.prefix
) )
.fetch_all(&mut *client.conn) .fetch_all(client.get_conn())
.await .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)] #[derive(Clone, Debug)]
pub struct RoleTree { pub struct RoleTree {
pub role: PgRole, pub role: PgRole,
@ -187,7 +202,7 @@ impl MembersOfOidQuery {
", ",
) )
.bind(self.role) .bind(self.role)
.fetch_all(&mut *client.conn) .fetch_all(client.get_conn())
.await?; .await?;
Ok(rows Ok(rows
.iter() .iter()
@ -231,7 +246,7 @@ impl MembersOfRolnameQuery {
", ",
) )
.bind(self.role.as_str() as &str) .bind(self.role.as_str() as &str)
.fetch_all(&mut *client.conn) .fetch_all(client.get_conn())
.await?; .await?;
Ok(rows Ok(rows
.iter() .iter()
@ -273,7 +288,7 @@ from (
", ",
) )
.bind(self.role_oid) .bind(self.role_oid)
.fetch_all(&mut *client.conn) .fetch_all(client.get_conn())
.await?; .await?;
Ok(rows Ok(rows
.iter() .iter()
@ -315,7 +330,7 @@ from (
", ",
) )
.bind(self.rolname) .bind(self.rolname)
.fetch_all(&mut *client.conn) .fetch_all(client.get_conn())
.await?; .await?;
Ok(rows Ok(rows
.iter() .iter()

View 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;

View file

@ -5,31 +5,31 @@ use axum::response::{IntoResponse, Response};
macro_rules! forbidden { macro_rules! forbidden {
($message:literal) => { ($message:literal) => {
AppError::Forbidden($message.to_owned()) crate::errors::AppError::Forbidden($message.to_owned())
}; };
($message:literal, $($param:expr),+) => { ($message:literal, $($param:expr),+) => {
AppError::Forbidden(format!($message, $($param)+)) crate::errors::AppError::Forbidden(format!($message, $($param)+))
}; };
} }
macro_rules! not_found { macro_rules! not_found {
($message:literal) => { ($message:literal) => {
AppError::NotFound($message.to_owned()) crate::errors::AppError::NotFound($message.to_owned())
}; };
($message:literal, $($param:expr),+) => { ($message:literal, $($param:expr),+) => {
AppError::NotFound(format!($message, $($param)+)) crate::errors::AppError::NotFound(format!($message, $($param)+))
}; };
} }
macro_rules! bad_request { macro_rules! bad_request {
($message:literal) => { ($message:literal) => {
AppError::BadRequest(format!($message)) crate::errors::AppError::BadRequest(format!($message))
}; };
($message:literal, $($param:expr),+) => { ($message:literal, $($param:expr),+) => {
AppError::BadRequest(format!($message, $($param)+)) crate::errors::AppError::BadRequest(format!($message, $($param)+))
}; };
} }

View file

@ -6,20 +6,13 @@ use interim_pgtypes::{
client::WorkspaceClient, client::WorkspaceClient,
pg_acl::{PgAclItem, PgPrivilegeType}, pg_acl::{PgAclItem, PgPrivilegeType},
pg_class::PgClass, pg_class::PgClass,
rolnames::{ROLE_PREFIX_TABLE_OWNER, ROLE_PREFIX_TABLE_READER, ROLE_PREFIX_TABLE_WRITER},
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::{postgres::types::Oid, prelude::FromRow, query_as}; use sqlx::{postgres::types::Oid, prelude::FromRow, query_as};
use crate::errors::AppError; 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: custom error type
// TODO: make params and result references // TODO: make params and result references
fn get_table_role( fn get_table_role(

View file

@ -12,7 +12,7 @@ use interim_models::{
portal::Portal, portal::Portal,
presentation::{Presentation, TextInputMode}, presentation::{Presentation, TextInputMode},
}; };
use interim_pgtypes::{pg_attribute::PgAttribute, pg_class::PgClass}; use interim_pgtypes::pg_attribute::PgAttribute;
use serde::Deserialize; use serde::Deserialize;
use uuid::Uuid; use uuid::Uuid;
@ -29,19 +29,22 @@ pub(super) struct PathParams {
portal_id: Uuid, 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( pub(super) async fn get(
State(settings): State<Settings>, State(settings): State<Settings>,
State(mut pooler): State<WorkspacePooler>, State(mut pooler): State<WorkspacePooler>,
AppDbConn(mut app_db): AppDbConn, AppDbConn(mut app_db): AppDbConn,
Path(PathParams { portal_id }): Path<PathParams>, Path(PathParams { portal_id }): Path<PathParams>,
) -> Result<Response, AppError> { ) -> 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) let portal = Portal::with_id(portal_id)
.fetch_optional(&mut app_db) .fetch_optional(&mut app_db)
.await? .await?
.ok_or(not_found!("form not found"))?; .ok_or(not_found!("form not found"))?;
// FIXME: auth
// WARNING: This client is connected with full workspace privileges. Even // WARNING: This client is connected with full workspace privileges. Even
// more so than usual, the Phonograph server is responsible for ensuring all // more so than usual, the Phonograph server is responsible for ensuring all
// auth checks are performed properly. // auth checks are performed properly.
@ -52,9 +55,6 @@ pub(super) async fn get(
.acquire_for(portal.workspace_id, RoleAssignment::Root) .acquire_for(portal.workspace_id, RoleAssignment::Root)
.await?; .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) let attrs: HashMap<String, PgAttribute> = PgAttribute::all_for_rel(portal.class_oid)
.fetch_all(&mut workspace_client) .fetch_all(&mut workspace_client)
.await? .await?

View file

@ -7,7 +7,9 @@ use axum::{
// https://docs.rs/axum-extra/0.10.1/axum_extra/extract/struct.Form.html#differences-from-axumextractform // https://docs.rs/axum-extra/0.10.1/axum_extra/extract/struct.Form.html#differences-from-axumextractform
use axum_extra::extract::Form; use axum_extra::extract::Form;
use interim_models::{ 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 interim_pgtypes::{escape_identifier, pg_class::PgClass};
use serde::Deserialize; use serde::Deserialize;
@ -49,7 +51,7 @@ pub(super) struct FormBody {
/// [`PathParams`]. /// [`PathParams`].
#[debug_handler(state = App)] #[debug_handler(state = App)]
pub(super) async fn post( pub(super) async fn post(
State(mut workspace_pooler): State<WorkspacePooler>, 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,
@ -60,28 +62,33 @@ pub(super) async fn post(
}): Path<PathParams>, }): Path<PathParams>,
Form(form): Form<FormBody>, Form(form): Form<FormBody>,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
// FIXME: Check workspace authorization. // FIXME: csrf
// 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 mut workspace_client = pooler
let workspace = Workspace::with_id(portal.workspace_id) .acquire_for(workspace_id, RoleAssignment::User(user.id))
.fetch_one(&mut app_db)
.await?; .await?;
let mut workspace_client = workspace_pooler let rel = PgClass::with_oid(Oid(rel_oid))
.acquire_for(workspace.id, RoleAssignment::User(user.id))
.await?;
let class = PgClass::with_oid(portal.class_oid)
.fetch_one(&mut workspace_client) .fetch_one(&mut workspace_client)
.await?; .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)?; let presentation = Presentation::try_from(form.presentation_form)?;
query(&format!( query(&format!(
"alter table {ident} add column if not exists {col} {typ}", "alter table {ident} add column if not exists {col} {typ}",
ident = class.get_identifier(), ident = rel.get_identifier(),
col = escape_identifier(&form.name), col = escape_identifier(&form.name),
typ = presentation.attr_data_type_fragment(), typ = presentation.attr_data_type_fragment(),
)) ))
@ -90,8 +97,8 @@ pub(super) async fn post(
query(&format!( query(&format!(
"grant insert ({col}), update ({col}) on table {ident} to {writer_role}", "grant insert ({col}), update ({col}) on table {ident} to {writer_role}",
col = escape_identifier(&form.name), col = escape_identifier(&form.name),
ident = class.get_identifier(), ident = rel.get_identifier(),
writer_role = escape_identifier(&get_writer_role(&class)?), writer_role = escape_identifier(&get_writer_role(&rel)?),
)) ))
.execute(workspace_client.get_conn()) .execute(workspace_client.get_conn())
.await?; .await?;

View file

@ -7,8 +7,13 @@ use axum::{
response::{Html, IntoResponse}, response::{Html, IntoResponse},
}; };
use interim_models::{ use interim_models::{
field::Field, field_form_prompt::FieldFormPrompt, form_transition::FormTransition, accessors::{Accessor as _, Actor, portal::PortalAccessor},
language::Language, portal::Portal, workspace::Workspace, field::Field,
field_form_prompt::FieldFormPrompt,
form_transition::FormTransition,
language::Language,
portal::Portal,
workspace::Workspace,
}; };
use interim_pgtypes::pg_attribute::PgAttribute; use interim_pgtypes::pg_attribute::PgAttribute;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -49,17 +54,23 @@ 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
// FIXME ensure workspace corresponds to rel/portal, and that user has .acquire_for(workspace_id, RoleAssignment::User(user.id))
// 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)
.await?; .await?;
let mut workspace_client = pooler let portal = PortalAccessor::new()
.acquire_for(workspace.id, RoleAssignment::User(user.id)) .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?; .await?;
let attrs: HashMap<String, PgAttribute> = PgAttribute::all_for_rel(portal.class_oid) let attrs: HashMap<String, PgAttribute> = PgAttribute::all_for_rel(portal.class_oid)

View file

@ -5,10 +5,19 @@ use axum::{
extract::{Path, State}, extract::{Path, State},
response::{IntoResponse as _, Response}, response::{IntoResponse as _, Response},
}; };
use interim_models::{datum::Datum, field::Field, portal::Portal}; use interim_models::{
use interim_pgtypes::{escape_identifier, pg_attribute::PgAttribute, pg_class::PgClass}; 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 serde::{Deserialize, Serialize};
use sqlx::{postgres::PgRow, query}; use sqlx::{
postgres::{PgRow, types::Oid},
query,
};
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
@ -22,6 +31,8 @@ use crate::{
#[derive(Clone, Debug, Deserialize)] #[derive(Clone, Debug, Deserialize)]
pub(super) struct PathParams { pub(super) struct PathParams {
portal_id: Uuid, portal_id: Uuid,
rel_oid: u32,
workspace_id: Uuid,
} }
const FRONTEND_ROW_LIMIT: i64 = 1000; const FRONTEND_ROW_LIMIT: i64 = 1000;
@ -33,19 +44,33 @@ const FRONTEND_ROW_LIMIT: i64 = 1000;
pub(super) async fn get( pub(super) async fn get(
State(mut workspace_pooler): State<WorkspacePooler>, State(mut workspace_pooler): State<WorkspacePooler>,
AppDbConn(mut app_db): AppDbConn, AppDbConn(mut app_db): AppDbConn,
CurrentUser(current_user): CurrentUser, CurrentUser(user): CurrentUser,
Path(PathParams { portal_id }): Path<PathParams>, Path(PathParams {
portal_id,
rel_oid,
workspace_id,
}): Path<PathParams>,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
// FIXME auth
let portal = Portal::with_id(portal_id).fetch_one(&mut app_db).await?;
let mut workspace_client = workspace_pooler 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?; .await?;
let rel = PgClass::with_oid(portal.class_oid)
let rel = PgClass::with_oid(Oid(rel_oid))
.fetch_one(&mut workspace_client) .fetch_one(&mut workspace_client)
.await?; .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) let attrs = PgAttribute::all_for_rel(portal.class_oid)
.fetch_all(&mut workspace_client) .fetch_all(&mut workspace_client)
.await?; .await?;

View file

@ -8,8 +8,11 @@ use axum::{
// [`axum_extra`]'s form extractor is required to support repeated keys: // [`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 // https://docs.rs/axum-extra/0.10.1/axum_extra/extract/struct.Form.html#differences-from-axumextractform
use axum_extra::extract::Form; use axum_extra::extract::Form;
use interim_models::{datum::Datum, portal::Portal, workspace::Workspace}; use interim_models::{
use interim_pgtypes::{escape_identifier, pg_class::PgClass}; accessors::{Accessor as _, Actor, portal::PortalAccessor},
datum::Datum,
};
use interim_pgtypes::{escape_identifier, pg_acl::PgPrivilegeType, pg_class::PgClass};
use serde::Deserialize; use serde::Deserialize;
use sqlx::{postgres::types::Oid, query}; use sqlx::{postgres::types::Oid, query};
use uuid::Uuid; use uuid::Uuid;
@ -47,24 +50,29 @@ pub(super) async fn post(
}): Path<PathParams>, }): Path<PathParams>,
Form(form): Form<HashMap<String, Vec<String>>>, Form(form): Form<HashMap<String, Vec<String>>>,
) -> Result<Response, AppError> { ) -> 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 // 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 let mut workspace_client = workspace_pooler
.acquire_for(workspace.id, RoleAssignment::User(user.id)) .acquire_for(workspace_id, RoleAssignment::User(user.id))
.await?; .await?;
let rel = PgClass::with_oid(Oid(rel_oid)) let rel = PgClass::with_oid(Oid(rel_oid))
.fetch_one(&mut workspace_client) .fetch_one(&mut workspace_client)
.await?; .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(); let col_names: Vec<String> = form.keys().cloned().collect();
// Prevent users from modifying Phonograph metadata columns. // Prevent users from modifying Phonograph metadata columns.

View file

@ -3,8 +3,12 @@ use axum::{
extract::{Path, State}, extract::{Path, State},
response::{Html, IntoResponse as _, Response}, response::{Html, IntoResponse as _, Response},
}; };
use interim_models::{expression::PgExpressionAny, portal::Portal, workspace::Workspace}; use interim_models::{
use interim_pgtypes::pg_attribute::PgAttribute; accessors::{Accessor, Actor, portal::PortalAccessor},
expression::PgExpressionAny,
workspace::Workspace,
};
use interim_pgtypes::{pg_acl::PgPrivilegeType, pg_attribute::PgAttribute};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::postgres::types::Oid; use sqlx::postgres::types::Oid;
use uuid::Uuid; use uuid::Uuid;
@ -19,7 +23,7 @@ use crate::{
workspace_pooler::{RoleAssignment, WorkspacePooler}, workspace_pooler::{RoleAssignment, WorkspacePooler},
}; };
#[derive(Clone, Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub(super) struct PathParams { pub(super) struct PathParams {
portal_id: Uuid, portal_id: Uuid,
rel_oid: u32, rel_oid: u32,
@ -32,25 +36,29 @@ pub(super) struct PathParams {
/// module. /// module.
pub(super) async fn get( pub(super) async fn get(
State(settings): State<Settings>, State(settings): State<Settings>,
State(mut workspace_pooler): State<WorkspacePooler>, State(mut pooler): State<WorkspacePooler>,
AppDbConn(mut app_db): AppDbConn, AppDbConn(mut app_db): AppDbConn,
CurrentUser(current_user): CurrentUser, CurrentUser(user): CurrentUser,
navigator: Navigator, navigator: Navigator,
Path(PathParams { Path(PathParams {
portal_id, portal_id,
workspace_id,
rel_oid, rel_oid,
workspace_id,
}): Path<PathParams>, }): Path<PathParams>,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
// FIXME auth let mut workspace_client = pooler
.acquire_for(workspace_id, RoleAssignment::User(user.id))
let workspace = Workspace::with_id(workspace_id)
.fetch_one(&mut app_db)
.await?; .await?;
let portal = Portal::with_id(portal_id).fetch_one(&mut app_db).await?;
let mut workspace_client = workspace_pooler let portal = PortalAccessor::new()
.acquire_for(portal.workspace_id, RoleAssignment::User(current_user.id)) .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?; .await?;
let attrs = PgAttribute::all_for_rel(portal.class_oid) let attrs = PgAttribute::all_for_rel(portal.class_oid)
@ -70,6 +78,10 @@ pub(super) async fn get(
}) })
.collect(); .collect();
let workspace = Workspace::with_id(portal.workspace_id)
.fetch_one(&mut app_db)
.await?;
#[derive(Template)] #[derive(Template)]
#[template(path = "portal_table.html")] #[template(path = "portal_table.html")]
struct ResponseTemplate { struct ResponseTemplate {
@ -86,11 +98,11 @@ pub(super) async fn get(
filter: portal.table_filter.0, filter: portal.table_filter.0,
navbar: WorkspaceNav::builder() navbar: WorkspaceNav::builder()
.navigator(navigator) .navigator(navigator)
.workspace(workspace.clone()) .workspace(workspace)
.populate_rels(&mut app_db, &mut workspace_client) .populate_rels(&mut app_db, &mut workspace_client)
.await? .await?
.current(NavLocation::Rel( .current(NavLocation::Rel(
Oid(rel_oid), portal.class_oid,
Some(RelLocation::Portal(portal.id)), Some(RelLocation::Portal(portal.id)),
)) ))
.build()?, .build()?,

View file

@ -4,8 +4,11 @@ use axum::{
extract::{Path, State}, extract::{Path, State},
response::{Html, IntoResponse}, response::{Html, IntoResponse},
}; };
use interim_models::{portal::Portal, workspace::Workspace}; use interim_models::{
use interim_pgtypes::pg_class::PgClass; accessors::{Accessor, Actor, portal::PortalAccessor},
portal::Portal,
workspace::Workspace,
};
use serde::Deserialize; use serde::Deserialize;
use sqlx::postgres::types::Oid; use sqlx::postgres::types::Oid;
use uuid::Uuid; use uuid::Uuid;
@ -41,30 +44,30 @@ 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
// FIXME ensure workspace corresponds to rel/portal, and that user has .acquire_for(workspace_id, RoleAssignment::User(user.id))
// permission to access/alter both as needed. .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) let workspace = Workspace::with_id(workspace_id)
.fetch_one(&mut app_db) .fetch_one(&mut app_db)
.await?; .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)] #[derive(Debug, Template)]
#[template(path = "relations_single/portal_settings.html")] #[template(path = "relations_single/portal_settings.html")]
struct ResponseTemplate { struct ResponseTemplate {
navigator: Navigator, navigator: Navigator,
portal: Portal, portal: Portal,
rel: PgClass,
settings: Settings, settings: Settings,
workspace_nav: WorkspaceNav, workspace_nav: WorkspaceNav,
} }
@ -79,7 +82,6 @@ pub(super) async fn get(
.build()?, .build()?,
navigator, navigator,
portal, portal,
rel,
settings, settings,
} }
.render()?, .render()?,

View file

@ -3,7 +3,10 @@ use axum::{
extract::{Path, State}, extract::{Path, State},
response::Response, 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 interim_pgtypes::{escape_identifier, pg_class::PgClass};
use serde::Deserialize; use serde::Deserialize;
use sqlx::{postgres::types::Oid, query}; use sqlx::{postgres::types::Oid, query};
@ -56,15 +59,34 @@ pub(super) async fn post(
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
// FIXME CSRF // 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. // 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) let field = Field::belonging_to_portal(portal_id)
.with_id(field_id) .with_id(field_id)
.fetch_one(&mut app_db) .fetch_one(&mut app_db)
.await?; .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('_') { if delete_data == "true" && field.name.starts_with('_') {
return Err(bad_request!("cannot delete data for a system column")); 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?; field.delete(&mut app_db).await?;
if delete_data == "true" { 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!( query(&format!(
"alter table {ident} drop column if exists {col_esc}", "alter table {ident} drop column if exists {col_esc}",
ident = rel.get_identifier(), ident = rel.get_identifier(),

View file

@ -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: // [`axum_extra`]'s form extractor is preferred:
// https://docs.rs/axum-extra/0.10.1/axum_extra/extract/struct.Form.html#differences-from-axumextractform // https://docs.rs/axum-extra/0.10.1/axum_extra/extract/struct.Form.html#differences-from-axumextractform
use axum_extra::extract::Form; 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 serde::Deserialize;
use sqlx::postgres::types::Oid; use sqlx::postgres::types::Oid;
use uuid::Uuid; use uuid::Uuid;
@ -12,6 +20,7 @@ use crate::{
errors::AppError, errors::AppError,
navigator::{Navigator, NavigatorPage as _}, navigator::{Navigator, NavigatorPage as _},
user::CurrentUser, user::CurrentUser,
workspace_pooler::{RoleAssignment, WorkspacePooler},
}; };
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@ -34,7 +43,8 @@ pub(super) struct FormBody {
#[debug_handler(state = App)] #[debug_handler(state = App)]
pub(super) async fn post( pub(super) async fn post(
AppDbConn(mut app_db): AppDbConn, AppDbConn(mut app_db): AppDbConn,
CurrentUser(_user): CurrentUser, State(mut pooler): State<WorkspacePooler>,
CurrentUser(user): CurrentUser,
navigator: Navigator, navigator: Navigator,
Path(PathParams { Path(PathParams {
portal_id, portal_id,
@ -43,13 +53,26 @@ pub(super) async fn post(
}): Path<PathParams>, }): Path<PathParams>,
Form(form): Form<FormBody>, Form(form): Form<FormBody>,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
// FIXME: Check workspace authorization. // FIXME: csrf
// 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 portal = Portal::with_id(portal_id).fetch_one(&mut app_db).await?;
let filter: Option<PgExpressionAny> = let filter: Option<PgExpressionAny> =
serde_json::from_str(&form.filter_expression.unwrap_or("null".to_owned()))?; serde_json::from_str(&form.filter_expression.unwrap_or("null".to_owned()))?;
Portal::update() Portal::update()
.id(portal.id) .id(portal.id)
.table_filter(filter) .table_filter(filter)
@ -61,7 +84,7 @@ pub(super) async fn post(
.portal_page() .portal_page()
.workspace_id(workspace_id) .workspace_id(workspace_id)
.rel_oid(Oid(rel_oid)) .rel_oid(Oid(rel_oid))
.portal_id(portal_id) .portal_id(portal.id)
.build()? .build()?
.redirect_to()) .redirect_to())
} }

View file

@ -3,8 +3,10 @@ use axum::{
extract::{Path, State}, extract::{Path, State},
response::Response, response::Response,
}; };
use interim_models::portal::{Portal, RE_PORTAL_NAME}; use interim_models::{
use interim_pgtypes::pg_class::PgClass; accessors::{Accessor, Actor, portal::PortalAccessor},
portal::{Portal, RE_PORTAL_NAME},
};
use serde::Deserialize; use serde::Deserialize;
use sqlx::postgres::types::Oid; use sqlx::postgres::types::Oid;
use uuid::Uuid; use uuid::Uuid;
@ -46,8 +48,6 @@ pub(super) async fn post(
}): Path<PathParams>, }): 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 let mut workspace_client = pooler
.acquire_for( .acquire_for(
workspace_id, workspace_id,
@ -55,15 +55,19 @@ pub(super) async fn post(
) )
.await?; .await?;
let rel = PgClass::with_oid(Oid(rel_oid)) let portal = PortalAccessor::new()
.fetch_one(&mut workspace_client) .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?; .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() Portal::update()
.id(portal_id) .id(portal.id)
.name(name) .name(name)
.build()? .build()?
.execute(&mut app_db) .execute(&mut app_db)
@ -73,7 +77,7 @@ pub(super) async fn post(
.portal_page() .portal_page()
.workspace_id(workspace_id) .workspace_id(workspace_id)
.rel_oid(Oid(rel_oid)) .rel_oid(Oid(rel_oid))
.portal_id(portal_id) .portal_id(portal.id)
.suffix("settings/") .suffix("settings/")
.build()? .build()?
.redirect_to()) .redirect_to())

View file

@ -9,7 +9,10 @@ use axum::{
// https://docs.rs/axum-extra/0.10.1/axum_extra/extract/struct.Form.html#differences-from-axumextractform // https://docs.rs/axum-extra/0.10.1/axum_extra/extract/struct.Form.html#differences-from-axumextractform
use axum_extra::extract::Form; use axum_extra::extract::Form;
use interim_models::{ 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 serde::Deserialize;
use sqlx::postgres::types::Oid; use sqlx::postgres::types::Oid;
@ -49,28 +52,39 @@ pub(super) async fn post(
}): Path<PathParams>, }): Path<PathParams>,
Form(form): Form<HashMap<String, String>>, Form(form): Form<HashMap<String, String>>,
) -> Result<Response, AppError> { ) -> 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 // 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 let mut workspace_client = workspace_pooler
.acquire_for(workspace.id, RoleAssignment::User(user.id)) .acquire_for(workspace_id, RoleAssignment::User(user.id))
.await?; .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 { for (name, content) in form {
let mut name_split = name.split('.'); let mut name_split = name.split('.');
let field_id = name_split let field_id = name_split
.next() .next()
.and_then(|value| Uuid::parse_str(value).ok()) .and_then(|value| Uuid::parse_str(value).ok())
.ok_or(bad_request!("expected input name to start with <FIELD_ID>"))?; .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 let language = name_split
.next() .next()
.and_then(|value| Language::from_str(value).ok()) .and_then(|value| Language::from_str(value).ok())
@ -95,6 +109,7 @@ pub(super) async fn post(
.workspace_id(workspace_id) .workspace_id(workspace_id)
.rel_oid(Oid(rel_oid)) .rel_oid(Oid(rel_oid))
.portal_id(portal_id) .portal_id(portal_id)
.suffix("form/")
.build()? .build()?
.redirect_to()) .redirect_to())
} }

View file

@ -5,8 +5,13 @@ use axum::{
extract::{Path, State}, extract::{Path, State},
response::{IntoResponse as _, Response}, response::{IntoResponse as _, Response},
}; };
use interim_models::{datum::Datum, portal::Portal, workspace::Workspace}; use interim_models::{
use interim_pgtypes::{escape_identifier, pg_attribute::PgAttribute, pg_class::PgClass}; 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::Deserialize;
use serde_json::json; use serde_json::json;
use sqlx::{Acquire as _, postgres::types::Oid, query}; use sqlx::{Acquire as _, postgres::types::Oid, query};
@ -54,28 +59,32 @@ pub(super) async fn post(
}): Path<PathParams>, }): Path<PathParams>,
Json(form): Json<FormBody>, Json(form): Json<FormBody>,
) -> Result<Response, AppError> { ) -> 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. // Prevent users from modifying Phonograph metadata columns.
if form.cells.iter().any(|cell| cell.column.starts_with('_')) { if form.cells.iter().any(|cell| cell.column.starts_with('_')) {
return Err(forbidden!("access denied to update system metadata column")); 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 let mut workspace_client = workspace_pooler
.acquire_for(workspace.id, RoleAssignment::User(user.id)) .acquire_for(workspace_id, RoleAssignment::User(user.id))
.await?; .await?;
let rel = PgClass::with_oid(portal.class_oid) let rel = PgClass::with_oid(Oid(rel_oid))
.fetch_one(&mut workspace_client) .fetch_one(&mut workspace_client)
.await?; .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)) let pkey_attrs = PgAttribute::pkeys_for_rel(Oid(rel_oid))
.fetch_all(&mut workspace_client) .fetch_all(&mut workspace_client)
.await?; .await?;

View file

@ -3,15 +3,13 @@ use interim_models::{
client::AppDbClient, cluster::Cluster, user::User, workspace::Workspace, client::AppDbClient, cluster::Cluster, user::User, workspace::Workspace,
workspace_user_perm::WorkspaceMembership, 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 sqlx::{Connection as _, PgConnection, query};
use crate::{ use crate::{
app::AppDbConn, app::AppDbConn,
errors::AppError, errors::AppError,
navigator::{Navigator, NavigatorPage as _}, navigator::{Navigator, NavigatorPage as _},
roles::ROLE_PREFIX_USER,
settings::Settings,
user::CurrentUser, user::CurrentUser,
workspace_pooler::{RoleAssignment, WorkspacePooler}, workspace_pooler::{RoleAssignment, WorkspacePooler},
workspace_utils::PHONO_TABLE_NAMESPACE, workspace_utils::PHONO_TABLE_NAMESPACE,
@ -22,7 +20,6 @@ use crate::{
/// the human-friendly name may be changed later rather than specified /// the human-friendly name may be changed later rather than specified
/// permenantly upon creation. /// permenantly upon creation.
pub(super) async fn post( pub(super) async fn post(
State(settings): State<Settings>,
State(mut pooler): State<WorkspacePooler>, State(mut pooler): State<WorkspacePooler>,
AppDbConn(mut app_db): AppDbConn, AppDbConn(mut app_db): AppDbConn,
navigator: Navigator, navigator: Navigator,

View file

@ -4,7 +4,10 @@ use axum::{
response::IntoResponse, response::IntoResponse,
}; };
use interim_models::{service_cred::ServiceCred, workspace::Workspace}; 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 rand::distributions::{Alphanumeric, DistString};
use redact::Secret; use redact::Secret;
use serde::Deserialize; use serde::Deserialize;
@ -15,7 +18,6 @@ use crate::{
app::{App, AppDbConn}, app::{App, AppDbConn},
errors::AppError, errors::AppError,
navigator::{Navigator, NavigatorPage}, navigator::{Navigator, NavigatorPage},
roles::{ROLE_PREFIX_SERVICE_CRED, SERVICE_CRED_CONN_LIMIT, SERVICE_CRED_SUFFIX_LEN},
user::CurrentUser, user::CurrentUser,
workspace_pooler::{RoleAssignment, WorkspacePooler}, workspace_pooler::{RoleAssignment, WorkspacePooler},
workspace_utils::PHONO_TABLE_NAMESPACE, workspace_utils::PHONO_TABLE_NAMESPACE,

View file

@ -2,7 +2,13 @@ use axum::{
extract::{Path, State}, extract::{Path, State},
response::IntoResponse, 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 serde::Deserialize;
use sqlx::query; use sqlx::query;
use uuid::Uuid; use uuid::Uuid;
@ -10,10 +16,6 @@ use uuid::Uuid;
use crate::{ use crate::{
errors::AppError, errors::AppError,
navigator::{Navigator, NavigatorPage as _}, navigator::{Navigator, NavigatorPage as _},
roles::{
ROLE_PREFIX_TABLE_OWNER, ROLE_PREFIX_TABLE_READER, ROLE_PREFIX_TABLE_WRITER,
ROLE_PREFIX_USER,
},
user::CurrentUser, user::CurrentUser,
workspace_pooler::{RoleAssignment, WorkspacePooler}, workspace_pooler::{RoleAssignment, WorkspacePooler},
workspace_utils::PHONO_TABLE_NAMESPACE, workspace_utils::PHONO_TABLE_NAMESPACE,

View file

@ -4,12 +4,12 @@ use anyhow::Result;
use axum::extract::FromRef; use axum::extract::FromRef;
use derive_builder::Builder; use derive_builder::Builder;
use interim_models::{client::AppDbClient, workspace::Workspace}; 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 sqlx::{Executor, PgPool, postgres::PgPoolOptions, raw_sql};
use tokio::sync::{OnceCell, RwLock}; use tokio::sync::{OnceCell, RwLock};
use uuid::Uuid; use uuid::Uuid;
use crate::{app::App, roles::ROLE_PREFIX_USER}; use crate::app::App;
const MAX_CONNECTIONS: u32 = 4; const MAX_CONNECTIONS: u32 = 4;
const IDLE_SECONDS: u64 = 3600; const IDLE_SECONDS: u64 = 3600;
@ -109,15 +109,15 @@ discard sequences;
pub async fn close_for(&mut self, base_id: Uuid) -> Result<()> { pub async fn close_for(&mut self, base_id: Uuid) -> Result<()> {
let pools = self.pools.read().await; let pools = self.pools.read().await;
if let Some(cell) = pools.get(&base_id) { if let Some(cell) = pools.get(&base_id)
if let Some(pool) = cell.get() { && let Some(pool) = cell.get()
let pool = pool.clone(); {
drop(pools); // Release read lock let pool = pool.clone();
let mut pools = self.pools.write().await; drop(pools); // Release read lock
pools.remove(&base_id); let mut pools = self.pools.write().await;
drop(pools); // Release write lock pools.remove(&base_id);
pool.close().await; drop(pools); // Release write lock
} pool.close().await;
} }
Ok(()) Ok(())
} }