From 601a5a1034b00b5489df9597e189e2f666ab4908 Mon Sep 17 00:00:00 2001 From: Brent Schroeter Date: Wed, 22 Oct 2025 00:43:53 -0700 Subject: [PATCH] clarify auth model --- Cargo.lock | 1 + README.md | 115 +++++++++++++++- .../migrations/20250918060948_init.down.sql | 2 +- .../migrations/20250918060948_init.up.sql | 9 +- interim-models/src/rel_invitation.rs | 2 +- interim-models/src/workspace.rs | 52 +------- interim-models/src/workspace_user_perm.rs | 54 ++------ interim-pgtypes/Cargo.toml | 1 + interim-pgtypes/src/client.rs | 13 +- interim-pgtypes/src/pg_acl.rs | 104 ++++++++------- interim-server/src/main.rs | 1 - .../relations_single/add_field_handler.rs | 18 +-- .../relations_single/add_portal_handler.rs | 24 +--- .../routes/relations_single/form_handler.rs | 21 +-- .../routes/relations_single/insert_handler.rs | 17 +-- .../portal_settings_handler.rs | 18 +-- .../relations_single/set_filter_handler.rs | 20 +-- .../relations_single/settings_handler.rs | 17 +-- .../settings_invite_handler.rs | 17 +-- .../relations_single/update_field_handler.rs | 19 +-- .../update_form_transitions_handler.rs | 17 +-- .../update_portal_name_handler.rs | 17 +-- .../update_prompts_handler.rs | 18 +-- .../update_rel_name_handler.rs | 13 +- .../relations_single/update_values_handler.rs | 17 +-- .../routes/workspaces_multi/add_handlers.rs | 123 +++++++++++------- .../routes/workspaces_multi/list_handlers.rs | 6 +- .../workspaces_single/add_table_handler.rs | 84 +++++------- .../routes/workspaces_single/nav_handler.rs | 17 +-- interim-server/src/workspace_nav.rs | 7 +- interim-server/src/workspace_pooler.rs | 2 +- interim-server/src/workspace_user_perms.rs | 78 ----------- 32 files changed, 367 insertions(+), 557 deletions(-) delete mode 100644 interim-server/src/workspace_user_perms.rs diff --git a/Cargo.lock b/Cargo.lock index 9094e4c..a3b8803 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1772,6 +1772,7 @@ dependencies = [ "regex", "serde", "sqlx", + "strum", "thiserror 2.0.12", "uuid", ] diff --git a/README.md b/README.md index 76abe3f..473383e 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,114 @@ -# Interim +# Phonograph (FKA Interim) -A friendly, collaborative PostgreSQL front-end for nerds of all stripes. +A friendly, collaborative PostgreSQL derivative for nerds of all stripes. -## Auth +## The Phonograph Authorization Model -Internally, Interim controls authorization using the `SET ROLE` Postgres command to switch between users. This allows the Interim base user to impersonate roles it has created, without necessarily requiring Postgres superuser privileges itself (unlike if it were to use `SET SESSION AUTHORIZATION`). For each front-end user, Interim creates a Postgres role named (by default) `__interim_{account_uuid}`. +Postgres provides a sophisticated role based access control (RBAC) system, which +Phonograph leverages to apply permissions consistently across the web UI and +inbound PostgreSQL[^1] connections. + +In order to efficiently pool database connections from the application server, +most actions initiated via the web UI are run as the corresponding database user +role using the +[`SET ROLE` command](https://www.postgresql.org/docs/current/sql-set-role.html). +`SET ROLE` **does not** provide great insulation against privilege escalation. +**Queries which are not thoroughly validated and escaped must only be run via a +dedicated connection initiated with the user-level role's credentials.** + +Given complete freedom it is possible, in fact easy, to configure a Postgres +table into what would be considered an "invalid" state by Phonograph, so table +creation and ownership is restricted to the "root" Phonograph role, which acts +on the behalf of the user in order to facilitate schema updates via the web +interface. + +### Permissions Granted via User Roles + +#### Accessing workspace databases + +`GRANT CONNECT ON TO ;` + +This permission is granted when initially creating the workspace, as well as +when accepting an invitation to a table. + +Access to workspaces is controlled via the `CONNECT ON DATABASE` permission. +However, it is unreasonable to query every backing database to compute the set +of workspaces to which a user has access, so Phonograph caches workspace-level +"connect" permissions in its own centralized table (`workspace_memberships`). + +`workspace_memberships` rows are added whenever the `GRANT CONNECT` command is +run, and are deleted after a `REVOKE CONNECT` command is run. + +It is possible that an error occurs after `REVOKE CONNECT` but before the +membership record is deleted. Therefore for authorization purposes, membership +of a workspace is not a guarantee that the user has `CONNECT` privileges, just +that they might. In cases where the root Postgres user is fetching potentially +sensitive data on behalf of a user, the user's actual ability to connect to the +database should always be confirmed. + +#### Accessing the `phono` schema + +`GRANT USAGE ON to ;` + +This permission is granted when initially creating the workspace, as well as +when accepting an invitation to a table. + +#### Reading table data + +`GRANT SELECT ON TO ;` + +This permission is granted when initially creating the table, as well as when +accepting an invitation to the table. + +Phonograph uses `SELECT` permissions to infer whether a table should be +accessible to a user via the web UI. + +#### Inserting rows + +`GRANT INSERT () ON
TO ;` + +Write-protected columns (`_id`, etc.) are excluded. + +This permission is granted when initially creating the table, as well as when +accepting an invitation to the table, if the invitation includes "edit" +permissions. + +These permissions must be updated for each relevant user role whenever a column +is added; this is simplified by maintaining a single "writer" role per table. + +#### Updating rows + +`GRANT UPDATE () ON
TO ;` + +Write-protected columns (`_id`, etc.) are excluded. + +This permission is granted when initially creating the table, as well as when +accepting an invitation to the table, if the invitation includes "edit" +permissions. + +These permissions must be updated for each relevant user role whenever a column +is added; this is simplified by maintaining a single "writer" role per table. + +### Actions Facilitated by Root + +- Creating tables +- Creating, updating, and deleting columns + +### Service Credentials + +Direct user PostgreSQL connections are performed using secondary `LOGIN` roles +created by the user's primary workspace role (where the primary workspace role +is e.g. `phono_{user_id}`). The credentials for these secondary roles are +referred to as "service credentials" or "PostgreSQL credentials". Service +credentials are created and assigned permissions by users in the web UI, and +their permissions are revoked manually in the web UI and/or by cascading +`REVOKE` commands targeting the primary workspace role. + +## Footnotes + +[^1]: + Barring historical pedantry, "Postgres" and "PostgreSQL" are essentially + synonymous and are often used interchangeably. As a matter of convention + throughout Phonograph docs, "Postgres" is largely used to refer to the + database software, while "PostgreSQL" is typically used to refer to the + query language and/or wire protocol. diff --git a/interim-models/migrations/20250918060948_init.down.sql b/interim-models/migrations/20250918060948_init.down.sql index d4e51d6..6e52c8c 100644 --- a/interim-models/migrations/20250918060948_init.down.sql +++ b/interim-models/migrations/20250918060948_init.down.sql @@ -3,7 +3,7 @@ drop table if exists form_transitions; drop table if exists fields; drop table if exists portals; drop table if exists rel_invitations; -drop table if exists workspace_user_perms; +drop table if exists workspace_memberships; drop table if exists workspaces; drop table if exists browser_sessions; drop table if exists users; diff --git a/interim-models/migrations/20250918060948_init.up.sql b/interim-models/migrations/20250918060948_init.up.sql index 5c2ce18..eb680ef 100644 --- a/interim-models/migrations/20250918060948_init.up.sql +++ b/interim-models/migrations/20250918060948_init.up.sql @@ -28,15 +28,14 @@ create table if not exists workspaces ( ); create index on workspaces (owner_id); -create table if not exists workspace_user_perms ( +create table if not exists workpace_memberships ( id uuid not null primary key default uuidv7(), workspace_id uuid not null references workspaces(id) on delete cascade, user_id uuid not null references users(id) on delete cascade, - perm text not null, - unique (workspace_id, user_id, perm) + unique (workspace_id, user_id) ); -create index on workspace_user_perms (user_id); -create index on workspace_user_perms (workspace_id); +create index on workspace_memberships (user_id); +create index on workspace_memberships (workspace_id); -- Relation Invitations -- diff --git a/interim-models/src/rel_invitation.rs b/interim-models/src/rel_invitation.rs index 00b5f4a..dd7a180 100644 --- a/interim-models/src/rel_invitation.rs +++ b/interim-models/src/rel_invitation.rs @@ -77,7 +77,7 @@ returning * self.email, self.workspace_id, self.class_oid, - self.privilege.to_abbrev().to_string(), + self.privilege.to_string(), self.created_by, self.expires_at, ) diff --git a/interim-models/src/workspace.rs b/interim-models/src/workspace.rs index dd49cf0..a502d7d 100644 --- a/interim-models/src/workspace.rs +++ b/interim-models/src/workspace.rs @@ -25,58 +25,14 @@ pub struct Workspace { impl Workspace { /// Build an insert statement to create a new workspace. - pub fn insert() -> InsertableWorkspaceBuilder { - InsertableWorkspaceBuilder::default() + pub fn insert() -> InsertBuilder { + InsertBuilder::default() } /// Build a single-field query by workspace ID. pub fn with_id(id: Uuid) -> WithIdQuery { WithIdQuery { id } } - - /// Build a query for workspaces filtered by a user's Phono permissions. - pub fn with_permission_in>( - perms: I, - ) -> WithPermissionInQueryPartial { - let perms: Vec = perms.into_iter().map(ToOwned::to_owned).collect(); - WithPermissionInQueryPartial { perms } - } -} - -pub struct WithPermissionInQueryPartial { - perms: Vec, -} - -impl WithPermissionInQueryPartial { - pub fn for_user(self, user_id: Uuid) -> WithPermissionInQuery { - WithPermissionInQuery { - perms: self.perms, - user_id, - } - } -} - -pub struct WithPermissionInQuery { - perms: Vec, - user_id: Uuid, -} - -impl WithPermissionInQuery { - pub async fn fetch_all(self, app_db: &mut AppDbClient) -> Result, sqlx::Error> { - query_as!( - Workspace, - " -select workspaces.* -from workspaces inner join workspace_user_perms as p - on p.workspace_id = workspaces.id -where p.user_id = $1 and perm = ANY($2) -", - self.user_id, - self.perms.as_slice(), - ) - .fetch_all(&mut *app_db.conn) - .await - } } pub struct WithIdQuery { @@ -109,12 +65,12 @@ impl WithIdQuery { } #[derive(Builder)] -pub struct InsertableWorkspace { +pub struct Insert { url: Url, owner_id: Uuid, } -impl InsertableWorkspace { +impl Insert { pub async fn insert(self, app_db: &mut AppDbClient) -> Result { query_as!( Workspace, diff --git a/interim-models/src/workspace_user_perm.rs b/interim-models/src/workspace_user_perm.rs index 1bf30a2..9102312 100644 --- a/interim-models/src/workspace_user_perm.rs +++ b/interim-models/src/workspace_user_perm.rs @@ -1,9 +1,6 @@ -use std::str::FromStr; - use derive_builder::Builder; use serde::{Deserialize, Serialize}; -use sqlx::{Decode, Postgres, query_as}; -use strum::EnumString; +use sqlx::query_as; use uuid::Uuid; use crate::client::AppDbClient; @@ -11,7 +8,7 @@ use crate::client::AppDbClient; /// Assigns an access control permission on a workspace to a user. These are /// derived from the permission grants of the workspace's backing database. #[derive(Clone, Debug, Deserialize, Serialize)] -pub struct WorkspaceUserPerm { +pub struct WorkspaceMembership { /// Primary key (defaults to UUIDv7). pub id: Uuid, @@ -23,12 +20,9 @@ pub struct WorkspaceUserPerm { /// User to which the permission belongs. pub user_id: Uuid, - - /// Permission assigned to the user (currently only "connect"). - pub perm: PermissionValue, } -impl WorkspaceUserPerm { +impl WorkspaceMembership { /// Construct a single-field query to fetch workspace permissions assigned /// to a user. pub fn belonging_to_user(id: Uuid) -> BelongingToUserQuery { @@ -50,17 +44,16 @@ impl BelongingToUserQuery { pub async fn fetch_all( self, app_db: &mut AppDbClient, - ) -> Result, sqlx::Error> { + ) -> Result, sqlx::Error> { query_as!( - WorkspaceUserPerm, + WorkspaceMembership, r#" select p.id as id, p.workspace_id as workspace_id, p.user_id as user_id, - p.perm as "perm: PermissionValue", w.name as workspace_name -from workspace_user_perms as p +from workspace_memberships as p inner join workspaces as w on w.id = p.workspace_id where p.user_id = $1 @@ -76,55 +69,36 @@ where p.user_id = $1 pub struct Insert { workspace_id: Uuid, user_id: Uuid, - perm: PermissionValue, } impl Insert { - pub async fn execute(self, app_db: &mut AppDbClient) -> Result { + pub async fn execute( + self, + app_db: &mut AppDbClient, + ) -> Result { query_as!( - WorkspaceUserPerm, + WorkspaceMembership, r#" with p as ( - insert into workspace_user_perms (workspace_id, user_id, perm) values ($1, $2, $3) + insert into workspace_memberships (workspace_id, user_id) values ($1, $2) returning id, workspace_id, - user_id, - perm + user_id ) select p.id as id, p.workspace_id as workspace_id, p.user_id as user_id, - p.perm as "perm: PermissionValue", w.name as workspace_name -from workspace_user_perms as p +from workspace_memberships as p inner join workspaces as w on w.id = p.workspace_id "#, self.workspace_id, self.user_id, - self.perm.to_string(), ) .fetch_one(app_db.get_conn()) .await } } - -// TODO: The sqlx::Decode derive macro doesn't follow the strum serialization. -// Does sqlx::Encode? -#[derive(Clone, Debug, Deserialize, EnumString, PartialEq, Serialize, strum::Display)] -#[serde(rename = "snake_case")] -#[strum(serialize_all = "snake_case")] -pub enum PermissionValue { - Connect, -} - -impl Decode<'_, Postgres> for PermissionValue { - fn decode( - value: ::ValueRef<'_>, - ) -> Result { - let value = <&str as Decode>::decode(value)?; - Ok(Self::from_str(value)?) - } -} diff --git a/interim-pgtypes/Cargo.toml b/interim-pgtypes/Cargo.toml index 48e10b4..624198b 100644 --- a/interim-pgtypes/Cargo.toml +++ b/interim-pgtypes/Cargo.toml @@ -10,5 +10,6 @@ nom = "8.0.0" regex = { workspace = true } serde = { workspace = true } sqlx = { workspace = true } +strum = { workspace = true } thiserror = { workspace = true } uuid = { workspace = true } diff --git a/interim-pgtypes/src/client.rs b/interim-pgtypes/src/client.rs index 062df69..4b1fcf8 100644 --- a/interim-pgtypes/src/client.rs +++ b/interim-pgtypes/src/client.rs @@ -19,7 +19,7 @@ impl WorkspaceClient { /// Runs the Postgres `set role` command for the underlying connection. If /// the given role does not exist, it is created and granted to the - /// session_user. + /// `session_user`. Roles are created with the `createrole` option. /// /// Note that while using `set role` simulates impersonation for most data /// access and RLS purposes, it is both incomplete and easily reversible: @@ -38,11 +38,14 @@ impl WorkspaceClient { .await? .try_get(0)? { - query(&format!("create role {}", escape_identifier(rolname))) - .execute(&mut *self.conn) - .await?; query(&format!( - "grant {} to {}", + "create role {0} createrole", + escape_identifier(rolname), + )) + .execute(&mut *self.conn) + .await?; + query(&format!( + "grant {0} to {1}", escape_identifier(rolname), escape_identifier(&session_user), )) diff --git a/interim-pgtypes/src/pg_acl.rs b/interim-pgtypes/src/pg_acl.rs index 95ec473..c175aaa 100644 --- a/interim-pgtypes/src/pg_acl.rs +++ b/interim-pgtypes/src/pg_acl.rs @@ -8,11 +8,13 @@ use nom::{ multi::{many0, many1}, sequence::delimited, }; +use serde::{Deserialize, Serialize}; use sqlx::{ Decode, Postgres, error::BoxDynError, postgres::{PgHasArrayType, PgTypeInfo, PgValueRef}, }; +use strum::IntoEnumIterator as _; /// This type will automatically decode Postgres "aclitem" values, provided that /// the query is cast to a TEXT type and selected with type annotations. For @@ -22,60 +24,80 @@ use sqlx::{ /// ``` /// The TEXT cast is necessary because the aclitem type itself is incompatible /// with binary value format, which makes it incompatible with SQLx. -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] pub struct PgAclItem { pub grantee: String, pub privileges: Vec, pub grantor: String, } -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Serialize)] pub struct PgPrivilege { pub grant_option: bool, pub privilege: PgPrivilegeType, } -#[derive(Clone, Debug, Eq, Hash, PartialEq)] +#[derive( + Clone, + Copy, + Debug, + Deserialize, + Eq, + Hash, + PartialEq, + Serialize, + strum::Display, + strum::EnumIter, + strum::EnumString, +)] pub enum PgPrivilegeType { + #[serde(rename = "r")] + #[strum(to_string = "r")] Select, + #[serde(rename = "a")] + #[strum(to_string = "a")] Insert, + #[serde(rename = "w")] + #[strum(to_string = "w")] Update, + #[serde(rename = "d")] + #[strum(to_string = "d")] Delete, + #[serde(rename = "D")] + #[strum(to_string = "D")] Truncate, + #[serde(rename = "x")] + #[strum(to_string = "x")] References, + #[serde(rename = "t")] + #[strum(to_string = "t")] Trigger, + #[serde(rename = "C")] + #[strum(to_string = "C")] Create, + #[serde(rename = "c")] + #[strum(to_string = "c")] Connect, + #[serde(rename = "T")] + #[strum(to_string = "T")] Temporary, + #[serde(rename = "X")] + #[strum(to_string = "X")] Execute, + #[serde(rename = "U")] + #[strum(to_string = "U")] Usage, + #[serde(rename = "s")] + #[strum(to_string = "s")] Set, + #[serde(rename = "A")] + #[strum(to_string = "A")] AlterSystem, + #[serde(rename = "m")] + #[strum(to_string = "m")] Maintain, } -impl PgPrivilegeType { - pub fn to_abbrev(&self) -> char { - match self { - Self::Select => 'r', - Self::Insert => 'a', - Self::Update => 'w', - Self::Delete => 'd', - Self::Truncate => 'D', - Self::References => 'x', - Self::Trigger => 't', - Self::Create => 'C', - Self::Connect => 'c', - Self::Temporary => 'T', - Self::Execute => 'X', - Self::Usage => 'U', - Self::Set => 's', - Self::AlterSystem => 'A', - Self::Maintain => 'm', - } - } -} - impl<'a> Decode<'a, Postgres> for PgAclItem { fn decode(value: PgValueRef<'a>) -> Result { let acl_item_str = <&str as Decode>::decode(value)?; @@ -147,30 +169,24 @@ fn parse_privileges<'a, E: ParseError<&'a str>>( } fn parse_privilege<'a, E: ParseError<&'a str>>(input: &'a str) -> IResult<&'a str, PgPrivilege, E> { - let (remainder, priv_type) = alt(( - value(PgPrivilegeType::Select, char('r')), - value(PgPrivilegeType::Insert, char('a')), - value(PgPrivilegeType::Update, char('w')), - value(PgPrivilegeType::Delete, char('d')), - value(PgPrivilegeType::Truncate, char('D')), - value(PgPrivilegeType::References, char('x')), - value(PgPrivilegeType::Trigger, char('t')), - value(PgPrivilegeType::Create, char('C')), - value(PgPrivilegeType::Connect, char('c')), - value(PgPrivilegeType::Temporary, char('T')), - value(PgPrivilegeType::Execute, char('X')), - value(PgPrivilegeType::Usage, char('U')), - value(PgPrivilegeType::Set, char('s')), - value(PgPrivilegeType::AlterSystem, char('A')), - value(PgPrivilegeType::Maintain, char('m')), - )) - .parse(input)?; + // [`tag`] does not take owned [`String`]s, so we must first store those in + // a [`Vec`] whose items can be referenced durably throughout this function. + let branches_owned: Vec<_> = PgPrivilegeType::iter() + .map(|priv_type| (priv_type, priv_type.to_string())) + .collect(); + // [`alt`] can take a slice of branches as its `List` argument, but it must + // be mutable for some reason. + let mut branches: Vec<_> = branches_owned + .iter() + .map(|(branch_value, branch_tag_owned)| value(branch_value, tag(branch_tag_owned.as_str()))) + .collect(); + let (remainder, priv_type) = alt(branches.as_mut_slice()).parse(input)?; let (remainder, parsed_grant_option) = opt(char('*')).parse(remainder)?; Ok(( remainder, PgPrivilege { grant_option: parsed_grant_option.is_some(), - privilege: priv_type, + privilege: *priv_type, }, )) } diff --git a/interim-server/src/main.rs b/interim-server/src/main.rs index 1959811..1cf2fbe 100644 --- a/interim-server/src/main.rs +++ b/interim-server/src/main.rs @@ -27,7 +27,6 @@ mod user; mod worker; mod workspace_nav; mod workspace_pooler; -mod workspace_user_perms; mod workspace_utils; /// Run CLI diff --git a/interim-server/src/routes/relations_single/add_field_handler.rs b/interim-server/src/routes/relations_single/add_field_handler.rs index e04df07..68c4261 100644 --- a/interim-server/src/routes/relations_single/add_field_handler.rs +++ b/interim-server/src/routes/relations_single/add_field_handler.rs @@ -7,11 +7,7 @@ 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, - workspace_user_perm::{self, WorkspaceUserPerm}, + field::Field, portal::Portal, presentation::Presentation, workspace::Workspace, }; use interim_pgtypes::{escape_identifier, pg_class::PgClass}; use serde::Deserialize; @@ -20,7 +16,7 @@ use uuid::Uuid; use crate::{ app::{App, AppDbConn}, - errors::{AppError, forbidden}, + errors::AppError, navigator::{Navigator, NavigatorPage}, presentation_form::PresentationForm, user::CurrentUser, @@ -63,15 +59,7 @@ pub(super) async fn post( }): Path, Form(form): Form, ) -> Result { - // Check workspace authorization. - let workspace_perms = WorkspaceUserPerm::belonging_to_user(user.id) - .fetch_all(&mut app_db) - .await?; - if workspace_perms.iter().all(|p| { - p.workspace_id != workspace_id || p.perm != workspace_user_perm::PermissionValue::Connect - }) { - return Err(forbidden!("access denied to workspace")); - } + // FIXME: Check workspace authorization. // FIXME ensure workspace corresponds to rel/portal, and that user has // permission to access/alter both as needed. diff --git a/interim-server/src/routes/relations_single/add_portal_handler.rs b/interim-server/src/routes/relations_single/add_portal_handler.rs index 0bd03f5..878e437 100644 --- a/interim-server/src/routes/relations_single/add_portal_handler.rs +++ b/interim-server/src/routes/relations_single/add_portal_handler.rs @@ -1,18 +1,10 @@ use axum::{extract::Path, response::IntoResponse}; -use interim_models::{ - portal::Portal, - workspace_user_perm::{self, WorkspaceUserPerm}, -}; +use interim_models::portal::Portal; use serde::Deserialize; use sqlx::postgres::types::Oid; use uuid::Uuid; -use crate::{ - app::AppDbConn, - errors::{AppError, forbidden}, - navigator::Navigator, - user::CurrentUser, -}; +use crate::{app::AppDbConn, errors::AppError, navigator::Navigator, user::CurrentUser}; #[derive(Debug, Deserialize)] pub(super) struct PathParams { @@ -26,7 +18,7 @@ pub(super) struct PathParams { /// This handler expects 2 path parameters, named `workspace_id`, which should /// deserialize to a UUID, and `rel_oid`, which should deserialize to a u32. pub(super) async fn post( - CurrentUser(user): CurrentUser, + CurrentUser(_user): CurrentUser, navigator: Navigator, AppDbConn(mut app_db): AppDbConn, Path(PathParams { @@ -34,15 +26,7 @@ pub(super) async fn post( workspace_id, }): Path, ) -> Result { - // Check workspace authorization. - let workspace_perms = WorkspaceUserPerm::belonging_to_user(user.id) - .fetch_all(&mut app_db) - .await?; - if workspace_perms.iter().all(|p| { - p.workspace_id != workspace_id || p.perm != workspace_user_perm::PermissionValue::Connect - }) { - return Err(forbidden!("access denied to workspace")); - } + // FIXME: Check workspace authorization. Portal::insert() .workspace_id(workspace_id) diff --git a/interim-server/src/routes/relations_single/form_handler.rs b/interim-server/src/routes/relations_single/form_handler.rs index 4d82335..7daecca 100644 --- a/interim-server/src/routes/relations_single/form_handler.rs +++ b/interim-server/src/routes/relations_single/form_handler.rs @@ -7,13 +7,8 @@ use axum::{ response::{Html, IntoResponse}, }; use interim_models::{ - field::Field, - field_form_prompt::FieldFormPrompt, - form_transition::FormTransition, - language::Language, - portal::Portal, - workspace::Workspace, - workspace_user_perm::{self, WorkspaceUserPerm}, + 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}; @@ -23,7 +18,7 @@ use uuid::Uuid; use crate::{ app::{App, AppDbConn}, - errors::{AppError, forbidden}, + errors::AppError, field_info::FormFieldInfo, navigator::{Navigator, NavigatorPage as _}, settings::Settings, @@ -54,15 +49,7 @@ pub(super) async fn get( navigator: Navigator, State(mut pooler): State, ) -> Result { - // Check workspace authorization. - let workspace_perms = WorkspaceUserPerm::belonging_to_user(user.id) - .fetch_all(&mut app_db) - .await?; - if workspace_perms.iter().all(|p| { - p.workspace_id != workspace_id || p.perm != workspace_user_perm::PermissionValue::Connect - }) { - return Err(forbidden!("access denied to workspace")); - } + // FIXME: Check workspace authorization. // FIXME ensure workspace corresponds to rel/portal, and that user has // permission to access/alter both as needed. diff --git a/interim-server/src/routes/relations_single/insert_handler.rs b/interim-server/src/routes/relations_single/insert_handler.rs index 48c08b1..19e277b 100644 --- a/interim-server/src/routes/relations_single/insert_handler.rs +++ b/interim-server/src/routes/relations_single/insert_handler.rs @@ -8,12 +8,7 @@ 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, - workspace_user_perm::{self, WorkspaceUserPerm}, -}; +use interim_models::{datum::Datum, portal::Portal, workspace::Workspace}; use interim_pgtypes::{escape_identifier, pg_class::PgClass}; use serde::Deserialize; use sqlx::{postgres::types::Oid, query}; @@ -52,15 +47,7 @@ pub(super) async fn post( }): Path, Form(form): Form>>, ) -> Result { - // Check workspace authorization. - let workspace_perms = WorkspaceUserPerm::belonging_to_user(user.id) - .fetch_all(&mut app_db) - .await?; - if workspace_perms.iter().all(|p| { - p.workspace_id != workspace_id || p.perm != workspace_user_perm::PermissionValue::Connect - }) { - return Err(forbidden!("access denied to workspace")); - } + // FIXME: Check workspace authorization. // FIXME ensure workspace corresponds to rel/portal, and that user has // permission to access/alter both as needed. // FIXME CSRF diff --git a/interim-server/src/routes/relations_single/portal_settings_handler.rs b/interim-server/src/routes/relations_single/portal_settings_handler.rs index fa65efe..4c2e929 100644 --- a/interim-server/src/routes/relations_single/portal_settings_handler.rs +++ b/interim-server/src/routes/relations_single/portal_settings_handler.rs @@ -4,11 +4,7 @@ use axum::{ extract::{Path, State}, response::{Html, IntoResponse}, }; -use interim_models::{ - portal::Portal, - workspace::Workspace, - workspace_user_perm::{self, WorkspaceUserPerm}, -}; +use interim_models::{portal::Portal, workspace::Workspace}; use interim_pgtypes::pg_class::PgClass; use serde::Deserialize; use sqlx::postgres::types::Oid; @@ -16,7 +12,7 @@ use uuid::Uuid; use crate::{ app::{App, AppDbConn}, - errors::{AppError, forbidden}, + errors::AppError, navigator::{Navigator, NavigatorPage as _}, settings::Settings, user::CurrentUser, @@ -45,15 +41,7 @@ pub(super) async fn get( navigator: Navigator, State(mut pooler): State, ) -> Result { - // Check workspace authorization. - let workspace_perms = WorkspaceUserPerm::belonging_to_user(user.id) - .fetch_all(&mut app_db) - .await?; - if workspace_perms.iter().all(|p| { - p.workspace_id != workspace_id || p.perm != workspace_user_perm::PermissionValue::Connect - }) { - return Err(forbidden!("access denied to workspace")); - } + // FIXME: Check workspace authorization. // FIXME ensure workspace corresponds to rel/portal, and that user has // permission to access/alter both as needed. diff --git a/interim-server/src/routes/relations_single/set_filter_handler.rs b/interim-server/src/routes/relations_single/set_filter_handler.rs index aafb1a5..72506fb 100644 --- a/interim-server/src/routes/relations_single/set_filter_handler.rs +++ b/interim-server/src/routes/relations_single/set_filter_handler.rs @@ -2,18 +2,14 @@ use axum::{debug_handler, extract::Path, 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, - workspace_user_perm::{self, WorkspaceUserPerm}, -}; +use interim_models::{expression::PgExpressionAny, portal::Portal}; use serde::Deserialize; use sqlx::postgres::types::Oid; use uuid::Uuid; use crate::{ app::{App, AppDbConn}, - errors::{AppError, forbidden}, + errors::AppError, navigator::{Navigator, NavigatorPage as _}, user::CurrentUser, }; @@ -38,7 +34,7 @@ pub(super) struct FormBody { #[debug_handler(state = App)] pub(super) async fn post( AppDbConn(mut app_db): AppDbConn, - CurrentUser(user): CurrentUser, + CurrentUser(_user): CurrentUser, navigator: Navigator, Path(PathParams { portal_id, @@ -47,15 +43,7 @@ pub(super) async fn post( }): Path, Form(form): Form, ) -> Result { - // Check workspace authorization. - let workspace_perms = WorkspaceUserPerm::belonging_to_user(user.id) - .fetch_all(&mut app_db) - .await?; - if workspace_perms.iter().all(|p| { - p.workspace_id != workspace_id || p.perm != workspace_user_perm::PermissionValue::Connect - }) { - return Err(forbidden!("access denied to workspace")); - } + // FIXME: Check workspace authorization. // FIXME ensure workspace corresponds to rel/portal, and that user has // permission to access/alter both as needed. diff --git a/interim-server/src/routes/relations_single/settings_handler.rs b/interim-server/src/routes/relations_single/settings_handler.rs index 40d5b72..beaa67b 100644 --- a/interim-server/src/routes/relations_single/settings_handler.rs +++ b/interim-server/src/routes/relations_single/settings_handler.rs @@ -4,10 +4,7 @@ use axum::{ extract::{Path, State}, response::{Html, IntoResponse}, }; -use interim_models::{ - workspace::Workspace, - workspace_user_perm::{self, WorkspaceUserPerm}, -}; +use interim_models::workspace::Workspace; use interim_pgtypes::pg_class::PgClass; use serde::Deserialize; use sqlx::postgres::types::Oid; @@ -15,7 +12,7 @@ use uuid::Uuid; use crate::{ app::{App, AppDbConn}, - errors::{AppError, forbidden}, + errors::AppError, navigator::Navigator, settings::Settings, user::CurrentUser, @@ -43,15 +40,7 @@ pub(super) async fn get( navigator: Navigator, State(mut pooler): State, ) -> Result { - // Check workspace authorization. - let workspace_perms = WorkspaceUserPerm::belonging_to_user(user.id) - .fetch_all(&mut app_db) - .await?; - if workspace_perms.iter().all(|p| { - p.workspace_id != workspace_id || p.perm != workspace_user_perm::PermissionValue::Connect - }) { - return Err(forbidden!("access denied to workspace")); - } + // FIXME: Check workspace authorization. // FIXME ensure workspace corresponds to rel/portal, and that user has // permission to access/alter both as needed. diff --git a/interim-server/src/routes/relations_single/settings_invite_handler.rs b/interim-server/src/routes/relations_single/settings_invite_handler.rs index 04fc48f..5989af5 100644 --- a/interim-server/src/routes/relations_single/settings_invite_handler.rs +++ b/interim-server/src/routes/relations_single/settings_invite_handler.rs @@ -2,10 +2,7 @@ use axum::{debug_handler, extract::Path, 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::{ - rel_invitation::RelInvitation, - workspace_user_perm::{self, WorkspaceUserPerm}, -}; +use interim_models::rel_invitation::RelInvitation; use interim_pgtypes::pg_acl::PgPrivilegeType; use serde::Deserialize; use sqlx::postgres::types::Oid; @@ -13,7 +10,7 @@ use uuid::Uuid; use crate::{ app::{App, AppDbConn}, - errors::{AppError, forbidden}, + errors::AppError, navigator::{Navigator, NavigatorPage as _}, user::CurrentUser, }; @@ -42,15 +39,7 @@ pub(super) async fn post( }): Path, Form(form): Form, ) -> Result { - // Check workspace authorization. - let workspace_perms = WorkspaceUserPerm::belonging_to_user(user.id) - .fetch_all(&mut app_db) - .await?; - if workspace_perms.iter().all(|p| { - p.workspace_id != workspace_id || p.perm != workspace_user_perm::PermissionValue::Connect - }) { - return Err(forbidden!("access denied to workspace")); - } + // FIXME: Check workspace authorization. // FIXME ensure workspace corresponds to rel/portal, and that user has // permission to access/alter both as needed. // FIXME form validation diff --git a/interim-server/src/routes/relations_single/update_field_handler.rs b/interim-server/src/routes/relations_single/update_field_handler.rs index 3bae145..d8ad364 100644 --- a/interim-server/src/routes/relations_single/update_field_handler.rs +++ b/interim-server/src/routes/relations_single/update_field_handler.rs @@ -1,9 +1,5 @@ use axum::{debug_handler, extract::Path, response::Response}; -use interim_models::{ - field::Field, - presentation::Presentation, - workspace_user_perm::{self, WorkspaceUserPerm}, -}; +use interim_models::{field::Field, presentation::Presentation}; use serde::Deserialize; use sqlx::postgres::types::Oid; use uuid::Uuid; @@ -11,7 +7,7 @@ use validator::Validate; use crate::{ app::{App, AppDbConn}, - errors::{AppError, forbidden}, + errors::AppError, extractors::ValidatedForm, navigator::{Navigator, NavigatorPage}, presentation_form::PresentationForm, @@ -41,7 +37,7 @@ pub(super) struct FormBody { #[debug_handler(state = App)] pub(super) async fn post( AppDbConn(mut app_db): AppDbConn, - CurrentUser(user): CurrentUser, + CurrentUser(_user): CurrentUser, navigator: Navigator, Path(PathParams { portal_id, @@ -56,15 +52,6 @@ pub(super) async fn post( ) -> Result { // FIXME CSRF - // Check workspace authorization. - let workspace_perms = WorkspaceUserPerm::belonging_to_user(user.id) - .fetch_all(&mut app_db) - .await?; - if workspace_perms.iter().all(|p| { - p.workspace_id != workspace_id || p.perm != workspace_user_perm::PermissionValue::Connect - }) { - return Err(forbidden!("access denied to workspace")); - } // FIXME ensure workspace corresponds to rel/portal, and that user has // permission to access/alter both as needed. diff --git a/interim-server/src/routes/relations_single/update_form_transitions_handler.rs b/interim-server/src/routes/relations_single/update_form_transitions_handler.rs index db7fa4c..e77eac4 100644 --- a/interim-server/src/routes/relations_single/update_form_transitions_handler.rs +++ b/interim-server/src/routes/relations_single/update_form_transitions_handler.rs @@ -4,17 +4,14 @@ use axum::{debug_handler, extract::Path, response::Response}; // [`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::{ - form_transition::{self, FormTransition}, - workspace_user_perm::{self, WorkspaceUserPerm}, -}; +use interim_models::form_transition::{self, FormTransition}; use serde::Deserialize; use sqlx::postgres::types::Oid; use uuid::Uuid; use crate::{ app::{App, AppDbConn}, - errors::{AppError, bad_request, forbidden}, + errors::{AppError, bad_request}, navigator::{Navigator, NavigatorPage as _}, user::CurrentUser, }; @@ -51,15 +48,7 @@ pub(super) async fn post( }): Path, Form(form): Form, ) -> Result { - // Check workspace authorization. - let workspace_perms = WorkspaceUserPerm::belonging_to_user(user.id) - .fetch_all(&mut app_db) - .await?; - if workspace_perms.iter().all(|p| { - p.workspace_id != workspace_id || p.perm != workspace_user_perm::PermissionValue::Connect - }) { - return Err(forbidden!("access denied to workspace")); - } + // FIXME: Check workspace authorization. // FIXME ensure workspace corresponds to rel/portal, and that user has // permission to access/alter both as needed. // FIXME CSRF diff --git a/interim-server/src/routes/relations_single/update_portal_name_handler.rs b/interim-server/src/routes/relations_single/update_portal_name_handler.rs index f88a22d..22836bd 100644 --- a/interim-server/src/routes/relations_single/update_portal_name_handler.rs +++ b/interim-server/src/routes/relations_single/update_portal_name_handler.rs @@ -3,10 +3,7 @@ use axum::{ extract::{Path, State}, response::Response, }; -use interim_models::{ - portal::{Portal, RE_PORTAL_NAME}, - workspace_user_perm::{self, WorkspaceUserPerm}, -}; +use interim_models::portal::{Portal, RE_PORTAL_NAME}; use interim_pgtypes::pg_class::PgClass; use serde::Deserialize; use sqlx::postgres::types::Oid; @@ -15,7 +12,7 @@ use validator::Validate; use crate::{ app::{App, AppDbConn}, - errors::{AppError, forbidden}, + errors::AppError, extractors::ValidatedForm, navigator::{Navigator, NavigatorPage as _}, user::CurrentUser, @@ -49,15 +46,7 @@ pub(super) async fn post( }): Path, ValidatedForm(FormBody { name }): ValidatedForm, ) -> Result { - // Check workspace authorization. - let workspace_perms = WorkspaceUserPerm::belonging_to_user(user.id) - .fetch_all(&mut app_db) - .await?; - if workspace_perms.iter().all(|p| { - p.workspace_id != workspace_id || p.perm != workspace_user_perm::PermissionValue::Connect - }) { - return Err(forbidden!("access denied to workspace")); - } + // FIXME: Check workspace authorization. let mut workspace_client = pooler .acquire_for( diff --git a/interim-server/src/routes/relations_single/update_prompts_handler.rs b/interim-server/src/routes/relations_single/update_prompts_handler.rs index 4bb90de..dda0660 100644 --- a/interim-server/src/routes/relations_single/update_prompts_handler.rs +++ b/interim-server/src/routes/relations_single/update_prompts_handler.rs @@ -9,11 +9,7 @@ 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, - workspace_user_perm::{self, WorkspaceUserPerm}, + field_form_prompt::FieldFormPrompt, language::Language, portal::Portal, workspace::Workspace, }; use serde::Deserialize; use sqlx::postgres::types::Oid; @@ -21,7 +17,7 @@ use uuid::Uuid; use crate::{ app::{App, AppDbConn}, - errors::{AppError, bad_request, forbidden}, + errors::{AppError, bad_request}, navigator::{Navigator, NavigatorPage as _}, user::CurrentUser, workspace_pooler::{RoleAssignment, WorkspacePooler}, @@ -53,15 +49,7 @@ pub(super) async fn post( }): Path, Form(form): Form>, ) -> Result { - // Check workspace authorization. - let workspace_perms = WorkspaceUserPerm::belonging_to_user(user.id) - .fetch_all(&mut app_db) - .await?; - if workspace_perms.iter().all(|p| { - p.workspace_id != workspace_id || p.perm != workspace_user_perm::PermissionValue::Connect - }) { - return Err(forbidden!("access denied to workspace")); - } + // FIXME: Check workspace authorization. // FIXME ensure workspace corresponds to rel/portal, and that user has // permission to access/alter both as needed. // FIXME CSRF diff --git a/interim-server/src/routes/relations_single/update_rel_name_handler.rs b/interim-server/src/routes/relations_single/update_rel_name_handler.rs index d903792..300486a 100644 --- a/interim-server/src/routes/relations_single/update_rel_name_handler.rs +++ b/interim-server/src/routes/relations_single/update_rel_name_handler.rs @@ -5,7 +5,6 @@ use axum::{ extract::{Path, State}, response::Response, }; -use interim_models::workspace_user_perm::{self, WorkspaceUserPerm}; use interim_pgtypes::{escape_identifier, pg_class::PgClass}; use regex::Regex; use serde::Deserialize; @@ -15,7 +14,7 @@ use validator::Validate; use crate::{ app::{App, AppDbConn}, - errors::{AppError, forbidden}, + errors::AppError, extractors::ValidatedForm, navigator::{Navigator, NavigatorPage as _}, user::CurrentUser, @@ -52,15 +51,7 @@ pub(super) async fn post( }): Path, ValidatedForm(FormBody { name }): ValidatedForm, ) -> Result { - // Check workspace authorization. - let workspace_perms = WorkspaceUserPerm::belonging_to_user(user.id) - .fetch_all(&mut app_db) - .await?; - if workspace_perms.iter().all(|p| { - p.workspace_id != workspace_id || p.perm != workspace_user_perm::PermissionValue::Connect - }) { - return Err(forbidden!("access denied to workspace")); - } + // FIXME: Check workspace authorization. let mut workspace_client = pooler .acquire_for( diff --git a/interim-server/src/routes/relations_single/update_values_handler.rs b/interim-server/src/routes/relations_single/update_values_handler.rs index 7c0278f..656169d 100644 --- a/interim-server/src/routes/relations_single/update_values_handler.rs +++ b/interim-server/src/routes/relations_single/update_values_handler.rs @@ -5,12 +5,7 @@ use axum::{ extract::{Path, State}, response::{IntoResponse as _, Response}, }; -use interim_models::{ - datum::Datum, - portal::Portal, - workspace::Workspace, - workspace_user_perm::{self, WorkspaceUserPerm}, -}; +use interim_models::{datum::Datum, portal::Portal, workspace::Workspace}; use interim_pgtypes::{escape_identifier, pg_attribute::PgAttribute, pg_class::PgClass}; use serde::Deserialize; use serde_json::json; @@ -59,15 +54,7 @@ pub(super) async fn post( }): Path, Json(form): Json, ) -> Result { - // Check workspace authorization. - let workspace_perms = WorkspaceUserPerm::belonging_to_user(user.id) - .fetch_all(&mut app_db) - .await?; - if workspace_perms.iter().all(|p| { - p.workspace_id != workspace_id || p.perm != workspace_user_perm::PermissionValue::Connect - }) { - return Err(forbidden!("access denied to workspace")); - } + // FIXME: Check workspace authorization. // FIXME ensure workspace corresponds to rel/portal, and that user has // permission to access/alter both as needed. diff --git a/interim-server/src/routes/workspaces_multi/add_handlers.rs b/interim-server/src/routes/workspaces_multi/add_handlers.rs index fa9d285..9749f43 100644 --- a/interim-server/src/routes/workspaces_multi/add_handlers.rs +++ b/interim-server/src/routes/workspaces_multi/add_handlers.rs @@ -1,6 +1,8 @@ use axum::{extract::State, response::IntoResponse}; -use interim_models::workspace::Workspace; -use interim_pgtypes::escape_identifier; +use interim_models::{ + client::AppDbClient, user::User, workspace::Workspace, workspace_user_perm::WorkspaceMembership, +}; +use interim_pgtypes::{client::WorkspaceClient, escape_identifier}; use sqlx::{Connection as _, PgConnection, query}; use crate::{ @@ -32,17 +34,26 @@ pub(super) async fn post( let db_name = interim_namegen::default_generator() .with_separator('_') .generate_name(NAME_LEN_WORDS); - // No need to pool these connections, since we don't expect to be using them - // often. One less thing to keep track of in application state. - let mut workspace_creator_conn = - PgConnection::connect(settings.new_workspace_db_url.as_str()).await?; - query(&format!( - // `db_name` is an underscore-separated sequence of alphabetical words, - // which should be safe to inject directly into the SQL statement. - "create database {db_name}" - )) - .execute(&mut workspace_creator_conn) - .await?; + { + // No need to pool these connections, since we don't expect to be using them + // often. One less thing to keep track of in application state. + let mut workspace_creator_conn = + PgConnection::connect(settings.new_workspace_db_url.as_str()).await?; + query(&format!( + // `db_name` is an underscore-separated sequence of alphabetical words, + // which should be safe to inject directly into the SQL statement. + "create database {db_name}" + )) + .execute(&mut workspace_creator_conn) + .await?; + query(&format!("revoke connect on database {db_name} from public")) + .execute(&mut workspace_creator_conn) + .await?; + + // Close and drop `workspace_creator_conn` at end of block, then + // reconnect using a pooled connection. + workspace_creator_conn.close().await?; + } let mut workspace_url = settings.new_workspace_db_url.clone(); // Alter database name but preserve auth and any query parameters. @@ -55,52 +66,70 @@ pub(super) async fn post( .insert(&mut app_db) .await?; - pooler - .acquire_for(workspace.id, RoleAssignment::User(user.id)) - .await?; - - let rolname = format!( - "{prefix}{user_id}", - prefix = settings.db_role_prefix, - user_id = user.id.simple() - ); - - query(&format!("revoke connect on database {db_name} from public")) - .execute(&mut workspace_creator_conn) - .await?; - query(&format!( - "grant connect on database {db_name} to {db_user}", - db_user = escape_identifier(&rolname), - )) - .execute(&mut workspace_creator_conn) - .await?; - let mut workspace_root_conn = pooler .acquire_for(workspace.id, RoleAssignment::Root) .await?; + + // Initialize database user. Connection is not used and may be dropped + // immediately. + pooler + .acquire_for(workspace.id, RoleAssignment::User(user.id)) + .await?; query(&format!( "create schema {nsp}", nsp = escape_identifier(&settings.phono_table_namespace) )) .execute(workspace_root_conn.get_conn()) .await?; - query(&format!( - "grant usage, create on schema {nsp} to {rolname}", - nsp = escape_identifier(&settings.phono_table_namespace), - rolname = escape_identifier(&rolname) - )) - .execute(workspace_root_conn.get_conn()) - .await?; - - crate::workspace_user_perms::sync_for_workspace( - workspace.id, + grant_workspace_membership( + &db_name, + settings.clone(), &mut app_db, - &mut pooler - .acquire_for(workspace.id, RoleAssignment::Root) - .await?, - &settings.db_role_prefix, + &mut workspace_root_conn, + &user, + &workspace, ) .await?; Ok(navigator.workspace_page(workspace.id).redirect_to()) } + +async fn grant_workspace_membership( + db_name: &str, + settings: Settings, + app_db_client: &mut AppDbClient, + workspace_root_client: &mut WorkspaceClient, + user: &User, + workspace: &Workspace, +) -> Result<(), AppError> { + let rolname = format!( + "{prefix}{user_id}", + prefix = settings.db_role_prefix, + user_id = user.id.simple() + ); + query(&format!( + "grant connect on database {db_name} to {db_user}", + db_user = escape_identifier(&rolname), + )) + .execute(workspace_root_client.get_conn()) + .await?; + + // TODO: There may be cases in which we will want to grant granular + // workspace access which excludes privileges to create tables. + query(&format!( + "grant usage, create on schema {nsp} to {rolname}", + nsp = escape_identifier(&settings.phono_table_namespace), + rolname = escape_identifier(&rolname) + )) + .execute(workspace_root_client.get_conn()) + .await?; + + WorkspaceMembership::insert() + .workspace_id(workspace.id) + .user_id(user.id) + .build()? + .execute(app_db_client) + .await?; + + Ok(()) +} diff --git a/interim-server/src/routes/workspaces_multi/list_handlers.rs b/interim-server/src/routes/workspaces_multi/list_handlers.rs index 3fa8a04..30b6da4 100644 --- a/interim-server/src/routes/workspaces_multi/list_handlers.rs +++ b/interim-server/src/routes/workspaces_multi/list_handlers.rs @@ -3,7 +3,7 @@ use axum::{ extract::State, response::{Html, IntoResponse}, }; -use interim_models::workspace_user_perm::WorkspaceUserPerm; +use interim_models::workspace_user_perm::WorkspaceMembership; use crate::{ app::AppDbConn, errors::AppError, navigator::Navigator, settings::Settings, user::CurrentUser, @@ -15,7 +15,7 @@ pub(super) async fn get( navigator: Navigator, AppDbConn(mut app_db): AppDbConn, ) -> Result { - let workspace_perms = WorkspaceUserPerm::belonging_to_user(user.id) + let workspace_perms = WorkspaceMembership::belonging_to_user(user.id) .fetch_all(&mut app_db) .await?; @@ -24,7 +24,7 @@ pub(super) async fn get( struct ResponseTemplate { navigator: Navigator, settings: Settings, - workspace_perms: Vec, + workspace_perms: Vec, } Ok(Html( diff --git a/interim-server/src/routes/workspaces_single/add_table_handler.rs b/interim-server/src/routes/workspaces_single/add_table_handler.rs index ef7d6c0..ac1ec43 100644 --- a/interim-server/src/routes/workspaces_single/add_table_handler.rs +++ b/interim-server/src/routes/workspaces_single/add_table_handler.rs @@ -2,15 +2,14 @@ use axum::{ extract::{Path, State}, response::IntoResponse, }; -use interim_models::workspace_user_perm::{self, WorkspaceUserPerm}; -use interim_pgtypes::escape_identifier; +use interim_pgtypes::{escape_identifier, pg_class::PgClass}; use serde::Deserialize; use sqlx::query; use uuid::Uuid; use crate::{ app::AppDbConn, - errors::{AppError, forbidden}, + errors::AppError, navigator::Navigator, settings::Settings, user::CurrentUser, @@ -37,72 +36,59 @@ pub(super) async fn post( AppDbConn(mut app_db): AppDbConn, Path(PathParams { workspace_id }): Path, ) -> Result { - // Check workspace authorization. - let workspace_perms = WorkspaceUserPerm::belonging_to_user(user.id) - .fetch_all(&mut app_db) - .await?; - if workspace_perms.iter().all(|p| { - p.workspace_id != workspace_id || p.perm != workspace_user_perm::PermissionValue::Connect - }) { - return Err(forbidden!("access denied to workspace")); - } + // FIXME: CSRF, Check workspace authorization. - let mut workspace_client = pooler + const NAME_LEN_WORDS: usize = 3; + let table_name = interim_namegen::default_generator() + .with_separator('_') + .generate_name(NAME_LEN_WORDS); + + let mut root_client = pooler // FIXME: Should this be scoped down to the unprivileged role after // setting up the table owner? .acquire_for(workspace_id, RoleAssignment::Root) .await?; - let table_owner_rolname = format!("table_owner_{0}", Uuid::new_v4().simple()); - query(&format!( - "create role {0}", - escape_identifier(&table_owner_rolname), - )) - .execute(workspace_client.get_conn()) - .await?; - query(&format!( - "grant {0} to {1} with admin option", - escape_identifier(&table_owner_rolname), - escape_identifier(&format!( - "{0}{1}", - settings.db_role_prefix, - user.id.simple() + let user_rolname = format!( + "{prefix}{user_id}", + prefix = settings.db_role_prefix, + user_id = user.id.simple() + ); + let rolname_uuid = Uuid::new_v4().simple(); + let rolname_table_reader = format!("table_reader_{rolname_uuid}"); + let rolname_table_writer = format!("table_writer_{rolname_uuid}"); + for rolname in [&rolname_table_reader, &rolname_table_writer] { + query(&format!("create role {0}", escape_identifier(rolname))) + .execute(root_client.get_conn()) + .await?; + query(&format!( + "grant {0} to {1} with admin option", + escape_identifier(rolname), + escape_identifier(&user_rolname) )) - )) - .execute(workspace_client.get_conn()) - .await?; - query(&format!( - "grant create, usage on schema {0} to {1}", - escape_identifier(&settings.phono_table_namespace), - escape_identifier(&table_owner_rolname), - )) - .execute(workspace_client.get_conn()) - .await?; - const TABLE_NAME: &str = "untitled"; + .execute(root_client.get_conn()) + .await?; + } query(&format!( r#" create table {0}.{1} ( _id uuid primary key not null default uuidv7(), _created_by text default current_user, - _created_at timestamptz not null default now(), - _form_session uuid, - _form_backlink_portal uuid, - _form_backlink_row uuid, - notes text + _created_at timestamptz not null default now() ) "#, escape_identifier(&settings.phono_table_namespace), - escape_identifier(TABLE_NAME), + escape_identifier(&table_name), )) - .execute(workspace_client.get_conn()) + .execute(root_client.get_conn()) .await?; query(&format!( - "alter table {0}.{1} owner to {2}", + "grant select on {0}.{1} to {2}", escape_identifier(&settings.phono_table_namespace), - escape_identifier(TABLE_NAME), - escape_identifier(&table_owner_rolname) + escape_identifier(&table_name), + escape_identifier(&rolname_table_reader), )) - .execute(workspace_client.get_conn()) + .execute(root_client.get_conn()) .await?; Ok(navigator.workspace_page(workspace_id).redirect_to()) diff --git a/interim-server/src/routes/workspaces_single/nav_handler.rs b/interim-server/src/routes/workspaces_single/nav_handler.rs index 0dc21c6..52cce86 100644 --- a/interim-server/src/routes/workspaces_single/nav_handler.rs +++ b/interim-server/src/routes/workspaces_single/nav_handler.rs @@ -4,16 +4,13 @@ use axum::{ extract::{Path, State}, response::{Html, IntoResponse}, }; -use interim_models::{ - workspace::Workspace, - workspace_user_perm::{self, WorkspaceUserPerm}, -}; +use interim_models::workspace::Workspace; use serde::Deserialize; use uuid::Uuid; use crate::{ app::{App, AppDbConn}, - errors::{AppError, forbidden}, + errors::AppError, navigator::Navigator, settings::Settings, user::CurrentUser, @@ -38,15 +35,7 @@ pub(super) async fn get( navigator: Navigator, State(mut pooler): State, ) -> Result { - // Check workspace authorization. - let workspace_perms = WorkspaceUserPerm::belonging_to_user(user.id) - .fetch_all(&mut app_db) - .await?; - if workspace_perms.iter().all(|p| { - p.workspace_id != workspace_id || p.perm != workspace_user_perm::PermissionValue::Connect - }) { - return Err(forbidden!("access denied to workspace")); - } + // FIXME: Check workspace authorization. let workspace = Workspace::with_id(workspace_id) .fetch_one(&mut app_db) diff --git a/interim-server/src/workspace_nav.rs b/interim-server/src/workspace_nav.rs index 0b3fefe..d949646 100644 --- a/interim-server/src/workspace_nav.rs +++ b/interim-server/src/workspace_nav.rs @@ -1,11 +1,8 @@ use anyhow::Result; use askama::Template; use derive_builder::Builder; -use interim_models::{client::AppDbClient, portal::Portal, workspace::Workspace}; -use interim_pgtypes::{ - client::WorkspaceClient, - pg_class::{PgClass, PgRelKind}, -}; +use interim_models::{client::AppDbClient, workspace::Workspace}; +use interim_pgtypes::client::WorkspaceClient; use sqlx::postgres::types::Oid; use uuid::Uuid; diff --git a/interim-server/src/workspace_pooler.rs b/interim-server/src/workspace_pooler.rs index 52c3e2d..e8b7897 100644 --- a/interim-server/src/workspace_pooler.rs +++ b/interim-server/src/workspace_pooler.rs @@ -57,7 +57,7 @@ discard sequences; Ok(true) }) }) - .connect(&workspace.url.expose_secret()) + .connect(workspace.url.expose_secret()) .await?) }; diff --git a/interim-server/src/workspace_user_perms.rs b/interim-server/src/workspace_user_perms.rs deleted file mode 100644 index da5362a..0000000 --- a/interim-server/src/workspace_user_perms.rs +++ /dev/null @@ -1,78 +0,0 @@ -use std::collections::HashSet; - -use anyhow::Result; -use interim_models::{ - client::AppDbClient, - workspace_user_perm::{self, WorkspaceUserPerm}, -}; -use interim_pgtypes::{ - client::WorkspaceClient, - pg_acl::PgPrivilegeType, - pg_database::PgDatabase, - pg_role::{PgRole, RoleTree, user_id_from_rolname}, -}; -use sqlx::query; -use uuid::Uuid; - -/// Derive workspace access control permissions from the permission grants of -/// a workspace's backing database. -pub(crate) async fn sync_for_workspace( - workspace_id: Uuid, - app_db: &mut AppDbClient, - workspace_client: &mut WorkspaceClient, - db_role_prefix: &str, -) -> Result<()> { - tracing::debug!("determining current database"); - let db = PgDatabase::current().fetch_one(workspace_client).await?; - tracing::debug!("querying explicit role grants"); - let explicit_roles = PgRole::with_name_in( - db.datacl - .unwrap_or_default() - .into_iter() - .filter(|item| { - item.privileges - .iter() - .any(|privilege| privilege.privilege == PgPrivilegeType::Connect) - }) - .map(|item| item.grantee) - .collect(), - ) - .fetch_all(workspace_client) - .await?; - tracing::debug!("querying inherited role grants"); - let mut all_roles: HashSet = HashSet::new(); - for explicit_role in explicit_roles { - if let Some(role_tree) = RoleTree::members_of_oid(explicit_role.oid) - .fetch_tree(workspace_client) - .await? - { - for implicit_role in role_tree.flatten_inherited() { - all_roles.insert(implicit_role.clone()); - } - } - } - let user_ids: Vec = all_roles - .iter() - .filter_map(|role| user_id_from_rolname(&role.rolname, db_role_prefix).ok()) - .collect(); - tracing::debug!("clearing outdated workspace_user_perms"); - query!( - "delete from workspace_user_perms where workspace_id = $1 and not (user_id = any($2))", - workspace_id, - user_ids.as_slice(), - ) - .execute(app_db.get_conn()) - .await?; - tracing::debug!("inserting new workspace_user_perms"); - for user_id in user_ids { - WorkspaceUserPerm::insert() - .workspace_id(workspace_id) - .user_id(user_id) - .perm(workspace_user_perm::PermissionValue::Connect) - .build()? - .execute(app_db) - .await?; - } - tracing::debug!("finished syncing workspace_user_perms"); - Ok(()) -}