clarify auth model

This commit is contained in:
Brent Schroeter 2025-10-22 00:43:53 -07:00
parent 15e1057a8d
commit 601a5a1034
32 changed files with 367 additions and 557 deletions

1
Cargo.lock generated
View file

@ -1772,6 +1772,7 @@ dependencies = [
"regex", "regex",
"serde", "serde",
"sqlx", "sqlx",
"strum",
"thiserror 2.0.12", "thiserror 2.0.12",
"uuid", "uuid",
] ]

115
README.md
View file

@ -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 <database> TO <role>;`
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 <schema> to <role>;`
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 <table> TO <role>;`
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 (<columns>) ON <table> TO <role>;`
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 (<columns>) ON <table> TO <role>;`
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.

View file

@ -3,7 +3,7 @@ drop table if exists form_transitions;
drop table if exists fields; drop table if exists fields;
drop table if exists portals; drop table if exists portals;
drop table if exists rel_invitations; 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 workspaces;
drop table if exists browser_sessions; drop table if exists browser_sessions;
drop table if exists users; drop table if exists users;

View file

@ -28,15 +28,14 @@ create table if not exists workspaces (
); );
create index on workspaces (owner_id); 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(), id uuid not null primary key default uuidv7(),
workspace_id uuid not null references workspaces(id) on delete cascade, workspace_id uuid not null references workspaces(id) on delete cascade,
user_id uuid not null references users(id) on delete cascade, user_id uuid not null references users(id) on delete cascade,
perm text not null, unique (workspace_id, user_id)
unique (workspace_id, user_id, perm)
); );
create index on workspace_user_perms (user_id); create index on workspace_memberships (user_id);
create index on workspace_user_perms (workspace_id); create index on workspace_memberships (workspace_id);
-- Relation Invitations -- -- Relation Invitations --

View file

@ -77,7 +77,7 @@ returning *
self.email, self.email,
self.workspace_id, self.workspace_id,
self.class_oid, self.class_oid,
self.privilege.to_abbrev().to_string(), self.privilege.to_string(),
self.created_by, self.created_by,
self.expires_at, self.expires_at,
) )

View file

@ -25,58 +25,14 @@ pub struct Workspace {
impl Workspace { impl Workspace {
/// Build an insert statement to create a new workspace. /// Build an insert statement to create a new workspace.
pub fn insert() -> InsertableWorkspaceBuilder { pub fn insert() -> InsertBuilder {
InsertableWorkspaceBuilder::default() InsertBuilder::default()
} }
/// Build a single-field query by workspace ID. /// Build a single-field query by workspace ID.
pub fn with_id(id: Uuid) -> WithIdQuery { pub fn with_id(id: Uuid) -> WithIdQuery {
WithIdQuery { id } WithIdQuery { id }
} }
/// Build a query for workspaces filtered by a user's Phono permissions.
pub fn with_permission_in<I: IntoIterator<Item = &'static str>>(
perms: I,
) -> WithPermissionInQueryPartial {
let perms: Vec<String> = perms.into_iter().map(ToOwned::to_owned).collect();
WithPermissionInQueryPartial { perms }
}
}
pub struct WithPermissionInQueryPartial {
perms: Vec<String>,
}
impl WithPermissionInQueryPartial {
pub fn for_user(self, user_id: Uuid) -> WithPermissionInQuery {
WithPermissionInQuery {
perms: self.perms,
user_id,
}
}
}
pub struct WithPermissionInQuery {
perms: Vec<String>,
user_id: Uuid,
}
impl WithPermissionInQuery {
pub async fn fetch_all(self, app_db: &mut AppDbClient) -> Result<Vec<Workspace>, 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 { pub struct WithIdQuery {
@ -109,12 +65,12 @@ impl WithIdQuery {
} }
#[derive(Builder)] #[derive(Builder)]
pub struct InsertableWorkspace { pub struct Insert {
url: Url, url: Url,
owner_id: Uuid, owner_id: Uuid,
} }
impl InsertableWorkspace { impl Insert {
pub async fn insert(self, app_db: &mut AppDbClient) -> Result<Workspace, sqlx::Error> { pub async fn insert(self, app_db: &mut AppDbClient) -> Result<Workspace, sqlx::Error> {
query_as!( query_as!(
Workspace, Workspace,

View file

@ -1,9 +1,6 @@
use std::str::FromStr;
use derive_builder::Builder; use derive_builder::Builder;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::{Decode, Postgres, query_as}; use sqlx::query_as;
use strum::EnumString;
use uuid::Uuid; use uuid::Uuid;
use crate::client::AppDbClient; 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 /// Assigns an access control permission on a workspace to a user. These are
/// derived from the permission grants of the workspace's backing database. /// derived from the permission grants of the workspace's backing database.
#[derive(Clone, Debug, Deserialize, Serialize)] #[derive(Clone, Debug, Deserialize, Serialize)]
pub struct WorkspaceUserPerm { pub struct WorkspaceMembership {
/// Primary key (defaults to UUIDv7). /// Primary key (defaults to UUIDv7).
pub id: Uuid, pub id: Uuid,
@ -23,12 +20,9 @@ pub struct WorkspaceUserPerm {
/// User to which the permission belongs. /// User to which the permission belongs.
pub user_id: Uuid, 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 /// Construct a single-field query to fetch workspace permissions assigned
/// to a user. /// to a user.
pub fn belonging_to_user(id: Uuid) -> BelongingToUserQuery { pub fn belonging_to_user(id: Uuid) -> BelongingToUserQuery {
@ -50,17 +44,16 @@ impl BelongingToUserQuery {
pub async fn fetch_all( pub async fn fetch_all(
self, self,
app_db: &mut AppDbClient, app_db: &mut AppDbClient,
) -> Result<Vec<WorkspaceUserPerm>, sqlx::Error> { ) -> Result<Vec<WorkspaceMembership>, sqlx::Error> {
query_as!( query_as!(
WorkspaceUserPerm, WorkspaceMembership,
r#" r#"
select select
p.id as id, p.id as id,
p.workspace_id as workspace_id, p.workspace_id as workspace_id,
p.user_id as user_id, p.user_id as user_id,
p.perm as "perm: PermissionValue",
w.name as workspace_name w.name as workspace_name
from workspace_user_perms as p from workspace_memberships as p
inner join workspaces as w inner join workspaces as w
on w.id = p.workspace_id on w.id = p.workspace_id
where p.user_id = $1 where p.user_id = $1
@ -76,55 +69,36 @@ where p.user_id = $1
pub struct Insert { pub struct Insert {
workspace_id: Uuid, workspace_id: Uuid,
user_id: Uuid, user_id: Uuid,
perm: PermissionValue,
} }
impl Insert { impl Insert {
pub async fn execute(self, app_db: &mut AppDbClient) -> Result<WorkspaceUserPerm, sqlx::Error> { pub async fn execute(
self,
app_db: &mut AppDbClient,
) -> Result<WorkspaceMembership, sqlx::Error> {
query_as!( query_as!(
WorkspaceUserPerm, WorkspaceMembership,
r#" r#"
with p as ( 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 returning
id, id,
workspace_id, workspace_id,
user_id, user_id
perm
) )
select select
p.id as id, p.id as id,
p.workspace_id as workspace_id, p.workspace_id as workspace_id,
p.user_id as user_id, p.user_id as user_id,
p.perm as "perm: PermissionValue",
w.name as workspace_name w.name as workspace_name
from workspace_user_perms as p from workspace_memberships as p
inner join workspaces as w inner join workspaces as w
on w.id = p.workspace_id on w.id = p.workspace_id
"#, "#,
self.workspace_id, self.workspace_id,
self.user_id, self.user_id,
self.perm.to_string(),
) )
.fetch_one(app_db.get_conn()) .fetch_one(app_db.get_conn())
.await .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: <Postgres as sqlx::Database>::ValueRef<'_>,
) -> Result<Self, sqlx::error::BoxDynError> {
let value = <&str as Decode<Postgres>>::decode(value)?;
Ok(Self::from_str(value)?)
}
}

View file

@ -10,5 +10,6 @@ nom = "8.0.0"
regex = { workspace = true } regex = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
sqlx = { workspace = true } sqlx = { workspace = true }
strum = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }

View file

@ -19,7 +19,7 @@ impl WorkspaceClient {
/// Runs the Postgres `set role` command for the underlying connection. If /// Runs the Postgres `set role` command for the underlying connection. If
/// the given role does not exist, it is created and granted to the /// 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 /// Note that while using `set role` simulates impersonation for most data
/// access and RLS purposes, it is both incomplete and easily reversible: /// access and RLS purposes, it is both incomplete and easily reversible:
@ -38,11 +38,14 @@ impl WorkspaceClient {
.await? .await?
.try_get(0)? .try_get(0)?
{ {
query(&format!("create role {}", escape_identifier(rolname)))
.execute(&mut *self.conn)
.await?;
query(&format!( 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(rolname),
escape_identifier(&session_user), escape_identifier(&session_user),
)) ))

View file

@ -8,11 +8,13 @@ use nom::{
multi::{many0, many1}, multi::{many0, many1},
sequence::delimited, sequence::delimited,
}; };
use serde::{Deserialize, Serialize};
use sqlx::{ use sqlx::{
Decode, Postgres, Decode, Postgres,
error::BoxDynError, error::BoxDynError,
postgres::{PgHasArrayType, PgTypeInfo, PgValueRef}, postgres::{PgHasArrayType, PgTypeInfo, PgValueRef},
}; };
use strum::IntoEnumIterator as _;
/// This type will automatically decode Postgres "aclitem" values, provided that /// This type will automatically decode Postgres "aclitem" values, provided that
/// the query is cast to a TEXT type and selected with type annotations. For /// 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 /// The TEXT cast is necessary because the aclitem type itself is incompatible
/// with binary value format, which makes it incompatible with SQLx. /// with binary value format, which makes it incompatible with SQLx.
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct PgAclItem { pub struct PgAclItem {
pub grantee: String, pub grantee: String,
pub privileges: Vec<PgPrivilege>, pub privileges: Vec<PgPrivilege>,
pub grantor: String, pub grantor: String,
} }
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Copy, Debug, Deserialize, PartialEq, Serialize)]
pub struct PgPrivilege { pub struct PgPrivilege {
pub grant_option: bool, pub grant_option: bool,
pub privilege: PgPrivilegeType, 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 { pub enum PgPrivilegeType {
#[serde(rename = "r")]
#[strum(to_string = "r")]
Select, Select,
#[serde(rename = "a")]
#[strum(to_string = "a")]
Insert, Insert,
#[serde(rename = "w")]
#[strum(to_string = "w")]
Update, Update,
#[serde(rename = "d")]
#[strum(to_string = "d")]
Delete, Delete,
#[serde(rename = "D")]
#[strum(to_string = "D")]
Truncate, Truncate,
#[serde(rename = "x")]
#[strum(to_string = "x")]
References, References,
#[serde(rename = "t")]
#[strum(to_string = "t")]
Trigger, Trigger,
#[serde(rename = "C")]
#[strum(to_string = "C")]
Create, Create,
#[serde(rename = "c")]
#[strum(to_string = "c")]
Connect, Connect,
#[serde(rename = "T")]
#[strum(to_string = "T")]
Temporary, Temporary,
#[serde(rename = "X")]
#[strum(to_string = "X")]
Execute, Execute,
#[serde(rename = "U")]
#[strum(to_string = "U")]
Usage, Usage,
#[serde(rename = "s")]
#[strum(to_string = "s")]
Set, Set,
#[serde(rename = "A")]
#[strum(to_string = "A")]
AlterSystem, AlterSystem,
#[serde(rename = "m")]
#[strum(to_string = "m")]
Maintain, 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 { impl<'a> Decode<'a, Postgres> for PgAclItem {
fn decode(value: PgValueRef<'a>) -> Result<Self, BoxDynError> { fn decode(value: PgValueRef<'a>) -> Result<Self, BoxDynError> {
let acl_item_str = <&str as Decode<Postgres>>::decode(value)?; let acl_item_str = <&str as Decode<Postgres>>::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> { fn parse_privilege<'a, E: ParseError<&'a str>>(input: &'a str) -> IResult<&'a str, PgPrivilege, E> {
let (remainder, priv_type) = alt(( // [`tag`] does not take owned [`String`]s, so we must first store those in
value(PgPrivilegeType::Select, char('r')), // a [`Vec`] whose items can be referenced durably throughout this function.
value(PgPrivilegeType::Insert, char('a')), let branches_owned: Vec<_> = PgPrivilegeType::iter()
value(PgPrivilegeType::Update, char('w')), .map(|priv_type| (priv_type, priv_type.to_string()))
value(PgPrivilegeType::Delete, char('d')), .collect();
value(PgPrivilegeType::Truncate, char('D')), // [`alt`] can take a slice of branches as its `List` argument, but it must
value(PgPrivilegeType::References, char('x')), // be mutable for some reason.
value(PgPrivilegeType::Trigger, char('t')), let mut branches: Vec<_> = branches_owned
value(PgPrivilegeType::Create, char('C')), .iter()
value(PgPrivilegeType::Connect, char('c')), .map(|(branch_value, branch_tag_owned)| value(branch_value, tag(branch_tag_owned.as_str())))
value(PgPrivilegeType::Temporary, char('T')), .collect();
value(PgPrivilegeType::Execute, char('X')), let (remainder, priv_type) = alt(branches.as_mut_slice()).parse(input)?;
value(PgPrivilegeType::Usage, char('U')),
value(PgPrivilegeType::Set, char('s')),
value(PgPrivilegeType::AlterSystem, char('A')),
value(PgPrivilegeType::Maintain, char('m')),
))
.parse(input)?;
let (remainder, parsed_grant_option) = opt(char('*')).parse(remainder)?; let (remainder, parsed_grant_option) = opt(char('*')).parse(remainder)?;
Ok(( Ok((
remainder, remainder,
PgPrivilege { PgPrivilege {
grant_option: parsed_grant_option.is_some(), grant_option: parsed_grant_option.is_some(),
privilege: priv_type, privilege: *priv_type,
}, },
)) ))
} }

View file

@ -27,7 +27,6 @@ mod user;
mod worker; mod worker;
mod workspace_nav; mod workspace_nav;
mod workspace_pooler; mod workspace_pooler;
mod workspace_user_perms;
mod workspace_utils; mod workspace_utils;
/// Run CLI /// Run CLI

View file

@ -7,11 +7,7 @@ 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, field::Field, portal::Portal, presentation::Presentation, workspace::Workspace,
portal::Portal,
presentation::Presentation,
workspace::Workspace,
workspace_user_perm::{self, WorkspaceUserPerm},
}; };
use interim_pgtypes::{escape_identifier, pg_class::PgClass}; use interim_pgtypes::{escape_identifier, pg_class::PgClass};
use serde::Deserialize; use serde::Deserialize;
@ -20,7 +16,7 @@ use uuid::Uuid;
use crate::{ use crate::{
app::{App, AppDbConn}, app::{App, AppDbConn},
errors::{AppError, forbidden}, errors::AppError,
navigator::{Navigator, NavigatorPage}, navigator::{Navigator, NavigatorPage},
presentation_form::PresentationForm, presentation_form::PresentationForm,
user::CurrentUser, user::CurrentUser,
@ -63,15 +59,7 @@ pub(super) async fn post(
}): Path<PathParams>, }): Path<PathParams>,
Form(form): Form<FormBody>, Form(form): Form<FormBody>,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
// Check workspace authorization. // FIXME: 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 // FIXME ensure workspace corresponds to rel/portal, and that user has
// permission to access/alter both as needed. // permission to access/alter both as needed.

View file

@ -1,18 +1,10 @@
use axum::{extract::Path, response::IntoResponse}; use axum::{extract::Path, response::IntoResponse};
use interim_models::{ use interim_models::portal::Portal;
portal::Portal,
workspace_user_perm::{self, WorkspaceUserPerm},
};
use serde::Deserialize; use serde::Deserialize;
use sqlx::postgres::types::Oid; use sqlx::postgres::types::Oid;
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{app::AppDbConn, errors::AppError, navigator::Navigator, user::CurrentUser};
app::AppDbConn,
errors::{AppError, forbidden},
navigator::Navigator,
user::CurrentUser,
};
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub(super) struct PathParams { pub(super) struct PathParams {
@ -26,7 +18,7 @@ pub(super) struct PathParams {
/// This handler expects 2 path parameters, named `workspace_id`, which should /// This handler expects 2 path parameters, named `workspace_id`, which should
/// deserialize to a UUID, and `rel_oid`, which should deserialize to a u32. /// deserialize to a UUID, and `rel_oid`, which should deserialize to a u32.
pub(super) async fn post( pub(super) async fn post(
CurrentUser(user): CurrentUser, CurrentUser(_user): CurrentUser,
navigator: Navigator, navigator: Navigator,
AppDbConn(mut app_db): AppDbConn, AppDbConn(mut app_db): AppDbConn,
Path(PathParams { Path(PathParams {
@ -34,15 +26,7 @@ pub(super) async fn post(
workspace_id, workspace_id,
}): Path<PathParams>, }): Path<PathParams>,
) -> Result<impl IntoResponse, AppError> { ) -> Result<impl IntoResponse, AppError> {
// Check workspace authorization. // FIXME: 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"));
}
Portal::insert() Portal::insert()
.workspace_id(workspace_id) .workspace_id(workspace_id)

View file

@ -7,13 +7,8 @@ use axum::{
response::{Html, IntoResponse}, response::{Html, IntoResponse},
}; };
use interim_models::{ use interim_models::{
field::Field, field::Field, field_form_prompt::FieldFormPrompt, form_transition::FormTransition,
field_form_prompt::FieldFormPrompt, language::Language, portal::Portal, workspace::Workspace,
form_transition::FormTransition,
language::Language,
portal::Portal,
workspace::Workspace,
workspace_user_perm::{self, WorkspaceUserPerm},
}; };
use interim_pgtypes::pg_attribute::PgAttribute; use interim_pgtypes::pg_attribute::PgAttribute;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -23,7 +18,7 @@ use uuid::Uuid;
use crate::{ use crate::{
app::{App, AppDbConn}, app::{App, AppDbConn},
errors::{AppError, forbidden}, errors::AppError,
field_info::FormFieldInfo, field_info::FormFieldInfo,
navigator::{Navigator, NavigatorPage as _}, navigator::{Navigator, NavigatorPage as _},
settings::Settings, settings::Settings,
@ -54,15 +49,7 @@ 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> {
// Check workspace authorization. // FIXME: 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 // FIXME ensure workspace corresponds to rel/portal, and that user has
// permission to access/alter both as needed. // permission to access/alter both as needed.

View file

@ -8,12 +8,7 @@ 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::{ use interim_models::{datum::Datum, portal::Portal, workspace::Workspace};
datum::Datum,
portal::Portal,
workspace::Workspace,
workspace_user_perm::{self, WorkspaceUserPerm},
};
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};
@ -52,15 +47,7 @@ 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> {
// Check workspace authorization. // FIXME: 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 // FIXME ensure workspace corresponds to rel/portal, and that user has
// permission to access/alter both as needed. // permission to access/alter both as needed.
// FIXME CSRF // FIXME CSRF

View file

@ -4,11 +4,7 @@ use axum::{
extract::{Path, State}, extract::{Path, State},
response::{Html, IntoResponse}, response::{Html, IntoResponse},
}; };
use interim_models::{ use interim_models::{portal::Portal, workspace::Workspace};
portal::Portal,
workspace::Workspace,
workspace_user_perm::{self, WorkspaceUserPerm},
};
use interim_pgtypes::pg_class::PgClass; use interim_pgtypes::pg_class::PgClass;
use serde::Deserialize; use serde::Deserialize;
use sqlx::postgres::types::Oid; use sqlx::postgres::types::Oid;
@ -16,7 +12,7 @@ use uuid::Uuid;
use crate::{ use crate::{
app::{App, AppDbConn}, app::{App, AppDbConn},
errors::{AppError, forbidden}, errors::AppError,
navigator::{Navigator, NavigatorPage as _}, navigator::{Navigator, NavigatorPage as _},
settings::Settings, settings::Settings,
user::CurrentUser, user::CurrentUser,
@ -45,15 +41,7 @@ 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> {
// Check workspace authorization. // FIXME: 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 // FIXME ensure workspace corresponds to rel/portal, and that user has
// permission to access/alter both as needed. // permission to access/alter both as needed.

View file

@ -2,18 +2,14 @@ use axum::{debug_handler, extract::Path, 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::{ use interim_models::{expression::PgExpressionAny, portal::Portal};
expression::PgExpressionAny,
portal::Portal,
workspace_user_perm::{self, WorkspaceUserPerm},
};
use serde::Deserialize; use serde::Deserialize;
use sqlx::postgres::types::Oid; use sqlx::postgres::types::Oid;
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
app::{App, AppDbConn}, app::{App, AppDbConn},
errors::{AppError, forbidden}, errors::AppError,
navigator::{Navigator, NavigatorPage as _}, navigator::{Navigator, NavigatorPage as _},
user::CurrentUser, user::CurrentUser,
}; };
@ -38,7 +34,7 @@ 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, CurrentUser(_user): CurrentUser,
navigator: Navigator, navigator: Navigator,
Path(PathParams { Path(PathParams {
portal_id, portal_id,
@ -47,15 +43,7 @@ pub(super) async fn post(
}): Path<PathParams>, }): Path<PathParams>,
Form(form): Form<FormBody>, Form(form): Form<FormBody>,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
// Check workspace authorization. // FIXME: 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 // FIXME ensure workspace corresponds to rel/portal, and that user has
// permission to access/alter both as needed. // permission to access/alter both as needed.

View file

@ -4,10 +4,7 @@ use axum::{
extract::{Path, State}, extract::{Path, State},
response::{Html, IntoResponse}, response::{Html, IntoResponse},
}; };
use interim_models::{ use interim_models::workspace::Workspace;
workspace::Workspace,
workspace_user_perm::{self, WorkspaceUserPerm},
};
use interim_pgtypes::pg_class::PgClass; use interim_pgtypes::pg_class::PgClass;
use serde::Deserialize; use serde::Deserialize;
use sqlx::postgres::types::Oid; use sqlx::postgres::types::Oid;
@ -15,7 +12,7 @@ use uuid::Uuid;
use crate::{ use crate::{
app::{App, AppDbConn}, app::{App, AppDbConn},
errors::{AppError, forbidden}, errors::AppError,
navigator::Navigator, navigator::Navigator,
settings::Settings, settings::Settings,
user::CurrentUser, user::CurrentUser,
@ -43,15 +40,7 @@ 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> {
// Check workspace authorization. // FIXME: 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 // FIXME ensure workspace corresponds to rel/portal, and that user has
// permission to access/alter both as needed. // permission to access/alter both as needed.

View file

@ -2,10 +2,7 @@ use axum::{debug_handler, extract::Path, 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::{ use interim_models::rel_invitation::RelInvitation;
rel_invitation::RelInvitation,
workspace_user_perm::{self, WorkspaceUserPerm},
};
use interim_pgtypes::pg_acl::PgPrivilegeType; use interim_pgtypes::pg_acl::PgPrivilegeType;
use serde::Deserialize; use serde::Deserialize;
use sqlx::postgres::types::Oid; use sqlx::postgres::types::Oid;
@ -13,7 +10,7 @@ use uuid::Uuid;
use crate::{ use crate::{
app::{App, AppDbConn}, app::{App, AppDbConn},
errors::{AppError, forbidden}, errors::AppError,
navigator::{Navigator, NavigatorPage as _}, navigator::{Navigator, NavigatorPage as _},
user::CurrentUser, user::CurrentUser,
}; };
@ -42,15 +39,7 @@ pub(super) async fn post(
}): Path<PathParams>, }): Path<PathParams>,
Form(form): Form<FormBody>, Form(form): Form<FormBody>,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
// Check workspace authorization. // FIXME: 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 // FIXME ensure workspace corresponds to rel/portal, and that user has
// permission to access/alter both as needed. // permission to access/alter both as needed.
// FIXME form validation // FIXME form validation

View file

@ -1,9 +1,5 @@
use axum::{debug_handler, extract::Path, response::Response}; use axum::{debug_handler, extract::Path, response::Response};
use interim_models::{ use interim_models::{field::Field, presentation::Presentation};
field::Field,
presentation::Presentation,
workspace_user_perm::{self, WorkspaceUserPerm},
};
use serde::Deserialize; use serde::Deserialize;
use sqlx::postgres::types::Oid; use sqlx::postgres::types::Oid;
use uuid::Uuid; use uuid::Uuid;
@ -11,7 +7,7 @@ use validator::Validate;
use crate::{ use crate::{
app::{App, AppDbConn}, app::{App, AppDbConn},
errors::{AppError, forbidden}, errors::AppError,
extractors::ValidatedForm, extractors::ValidatedForm,
navigator::{Navigator, NavigatorPage}, navigator::{Navigator, NavigatorPage},
presentation_form::PresentationForm, presentation_form::PresentationForm,
@ -41,7 +37,7 @@ 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, CurrentUser(_user): CurrentUser,
navigator: Navigator, navigator: Navigator,
Path(PathParams { Path(PathParams {
portal_id, portal_id,
@ -56,15 +52,6 @@ pub(super) async fn post(
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
// FIXME CSRF // 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 // FIXME ensure workspace corresponds to rel/portal, and that user has
// permission to access/alter both as needed. // permission to access/alter both as needed.

View file

@ -4,17 +4,14 @@ use axum::{debug_handler, extract::Path, response::Response};
// [`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::{ use interim_models::form_transition::{self, FormTransition};
form_transition::{self, FormTransition},
workspace_user_perm::{self, WorkspaceUserPerm},
};
use serde::Deserialize; use serde::Deserialize;
use sqlx::postgres::types::Oid; use sqlx::postgres::types::Oid;
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
app::{App, AppDbConn}, app::{App, AppDbConn},
errors::{AppError, bad_request, forbidden}, errors::{AppError, bad_request},
navigator::{Navigator, NavigatorPage as _}, navigator::{Navigator, NavigatorPage as _},
user::CurrentUser, user::CurrentUser,
}; };
@ -51,15 +48,7 @@ pub(super) async fn post(
}): Path<PathParams>, }): Path<PathParams>,
Form(form): Form<FormBody>, Form(form): Form<FormBody>,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
// Check workspace authorization. // FIXME: 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 // FIXME ensure workspace corresponds to rel/portal, and that user has
// permission to access/alter both as needed. // permission to access/alter both as needed.
// FIXME CSRF // FIXME CSRF

View file

@ -3,10 +3,7 @@ use axum::{
extract::{Path, State}, extract::{Path, State},
response::Response, response::Response,
}; };
use interim_models::{ use interim_models::portal::{Portal, RE_PORTAL_NAME};
portal::{Portal, RE_PORTAL_NAME},
workspace_user_perm::{self, WorkspaceUserPerm},
};
use interim_pgtypes::pg_class::PgClass; use interim_pgtypes::pg_class::PgClass;
use serde::Deserialize; use serde::Deserialize;
use sqlx::postgres::types::Oid; use sqlx::postgres::types::Oid;
@ -15,7 +12,7 @@ use validator::Validate;
use crate::{ use crate::{
app::{App, AppDbConn}, app::{App, AppDbConn},
errors::{AppError, forbidden}, errors::AppError,
extractors::ValidatedForm, extractors::ValidatedForm,
navigator::{Navigator, NavigatorPage as _}, navigator::{Navigator, NavigatorPage as _},
user::CurrentUser, user::CurrentUser,
@ -49,15 +46,7 @@ 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> {
// Check workspace authorization. // FIXME: 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"));
}
let mut workspace_client = pooler let mut workspace_client = pooler
.acquire_for( .acquire_for(

View file

@ -9,11 +9,7 @@ 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, field_form_prompt::FieldFormPrompt, language::Language, portal::Portal, workspace::Workspace,
language::Language,
portal::Portal,
workspace::Workspace,
workspace_user_perm::{self, WorkspaceUserPerm},
}; };
use serde::Deserialize; use serde::Deserialize;
use sqlx::postgres::types::Oid; use sqlx::postgres::types::Oid;
@ -21,7 +17,7 @@ use uuid::Uuid;
use crate::{ use crate::{
app::{App, AppDbConn}, app::{App, AppDbConn},
errors::{AppError, bad_request, forbidden}, errors::{AppError, bad_request},
navigator::{Navigator, NavigatorPage as _}, navigator::{Navigator, NavigatorPage as _},
user::CurrentUser, user::CurrentUser,
workspace_pooler::{RoleAssignment, WorkspacePooler}, workspace_pooler::{RoleAssignment, WorkspacePooler},
@ -53,15 +49,7 @@ 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> {
// Check workspace authorization. // FIXME: 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 // FIXME ensure workspace corresponds to rel/portal, and that user has
// permission to access/alter both as needed. // permission to access/alter both as needed.
// FIXME CSRF // FIXME CSRF

View file

@ -5,7 +5,6 @@ use axum::{
extract::{Path, State}, extract::{Path, State},
response::Response, response::Response,
}; };
use interim_models::workspace_user_perm::{self, WorkspaceUserPerm};
use interim_pgtypes::{escape_identifier, pg_class::PgClass}; use interim_pgtypes::{escape_identifier, pg_class::PgClass};
use regex::Regex; use regex::Regex;
use serde::Deserialize; use serde::Deserialize;
@ -15,7 +14,7 @@ use validator::Validate;
use crate::{ use crate::{
app::{App, AppDbConn}, app::{App, AppDbConn},
errors::{AppError, forbidden}, errors::AppError,
extractors::ValidatedForm, extractors::ValidatedForm,
navigator::{Navigator, NavigatorPage as _}, navigator::{Navigator, NavigatorPage as _},
user::CurrentUser, user::CurrentUser,
@ -52,15 +51,7 @@ 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> {
// Check workspace authorization. // FIXME: 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"));
}
let mut workspace_client = pooler let mut workspace_client = pooler
.acquire_for( .acquire_for(

View file

@ -5,12 +5,7 @@ use axum::{
extract::{Path, State}, extract::{Path, State},
response::{IntoResponse as _, Response}, response::{IntoResponse as _, Response},
}; };
use interim_models::{ use interim_models::{datum::Datum, portal::Portal, workspace::Workspace};
datum::Datum,
portal::Portal,
workspace::Workspace,
workspace_user_perm::{self, WorkspaceUserPerm},
};
use interim_pgtypes::{escape_identifier, pg_attribute::PgAttribute, pg_class::PgClass}; use interim_pgtypes::{escape_identifier, pg_attribute::PgAttribute, pg_class::PgClass};
use serde::Deserialize; use serde::Deserialize;
use serde_json::json; use serde_json::json;
@ -59,15 +54,7 @@ pub(super) async fn post(
}): Path<PathParams>, }): Path<PathParams>,
Json(form): Json<FormBody>, Json(form): Json<FormBody>,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
// Check workspace authorization. // FIXME: 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 // FIXME ensure workspace corresponds to rel/portal, and that user has
// permission to access/alter both as needed. // permission to access/alter both as needed.

View file

@ -1,6 +1,8 @@
use axum::{extract::State, response::IntoResponse}; use axum::{extract::State, response::IntoResponse};
use interim_models::workspace::Workspace; use interim_models::{
use interim_pgtypes::escape_identifier; client::AppDbClient, user::User, workspace::Workspace, workspace_user_perm::WorkspaceMembership,
};
use interim_pgtypes::{client::WorkspaceClient, escape_identifier};
use sqlx::{Connection as _, PgConnection, query}; use sqlx::{Connection as _, PgConnection, query};
use crate::{ use crate::{
@ -32,17 +34,26 @@ pub(super) async fn post(
let db_name = interim_namegen::default_generator() let db_name = interim_namegen::default_generator()
.with_separator('_') .with_separator('_')
.generate_name(NAME_LEN_WORDS); .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. // No need to pool these connections, since we don't expect to be using them
let mut workspace_creator_conn = // often. One less thing to keep track of in application state.
PgConnection::connect(settings.new_workspace_db_url.as_str()).await?; let mut workspace_creator_conn =
query(&format!( PgConnection::connect(settings.new_workspace_db_url.as_str()).await?;
// `db_name` is an underscore-separated sequence of alphabetical words, query(&format!(
// which should be safe to inject directly into the SQL statement. // `db_name` is an underscore-separated sequence of alphabetical words,
"create database {db_name}" // which should be safe to inject directly into the SQL statement.
)) "create database {db_name}"
.execute(&mut workspace_creator_conn) ))
.await?; .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(); let mut workspace_url = settings.new_workspace_db_url.clone();
// Alter database name but preserve auth and any query parameters. // Alter database name but preserve auth and any query parameters.
@ -55,52 +66,70 @@ pub(super) async fn post(
.insert(&mut app_db) .insert(&mut app_db)
.await?; .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 let mut workspace_root_conn = pooler
.acquire_for(workspace.id, RoleAssignment::Root) .acquire_for(workspace.id, RoleAssignment::Root)
.await?; .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!( query(&format!(
"create schema {nsp}", "create schema {nsp}",
nsp = escape_identifier(&settings.phono_table_namespace) nsp = escape_identifier(&settings.phono_table_namespace)
)) ))
.execute(workspace_root_conn.get_conn()) .execute(workspace_root_conn.get_conn())
.await?; .await?;
query(&format!( grant_workspace_membership(
"grant usage, create on schema {nsp} to {rolname}", &db_name,
nsp = escape_identifier(&settings.phono_table_namespace), settings.clone(),
rolname = escape_identifier(&rolname)
))
.execute(workspace_root_conn.get_conn())
.await?;
crate::workspace_user_perms::sync_for_workspace(
workspace.id,
&mut app_db, &mut app_db,
&mut pooler &mut workspace_root_conn,
.acquire_for(workspace.id, RoleAssignment::Root) &user,
.await?, &workspace,
&settings.db_role_prefix,
) )
.await?; .await?;
Ok(navigator.workspace_page(workspace.id).redirect_to()) 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(())
}

View file

@ -3,7 +3,7 @@ use axum::{
extract::State, extract::State,
response::{Html, IntoResponse}, response::{Html, IntoResponse},
}; };
use interim_models::workspace_user_perm::WorkspaceUserPerm; use interim_models::workspace_user_perm::WorkspaceMembership;
use crate::{ use crate::{
app::AppDbConn, errors::AppError, navigator::Navigator, settings::Settings, user::CurrentUser, app::AppDbConn, errors::AppError, navigator::Navigator, settings::Settings, user::CurrentUser,
@ -15,7 +15,7 @@ pub(super) async fn get(
navigator: Navigator, navigator: Navigator,
AppDbConn(mut app_db): AppDbConn, AppDbConn(mut app_db): AppDbConn,
) -> Result<impl IntoResponse, AppError> { ) -> Result<impl IntoResponse, AppError> {
let workspace_perms = WorkspaceUserPerm::belonging_to_user(user.id) let workspace_perms = WorkspaceMembership::belonging_to_user(user.id)
.fetch_all(&mut app_db) .fetch_all(&mut app_db)
.await?; .await?;
@ -24,7 +24,7 @@ pub(super) async fn get(
struct ResponseTemplate { struct ResponseTemplate {
navigator: Navigator, navigator: Navigator,
settings: Settings, settings: Settings,
workspace_perms: Vec<WorkspaceUserPerm>, workspace_perms: Vec<WorkspaceMembership>,
} }
Ok(Html( Ok(Html(

View file

@ -2,15 +2,14 @@ use axum::{
extract::{Path, State}, extract::{Path, State},
response::IntoResponse, response::IntoResponse,
}; };
use interim_models::workspace_user_perm::{self, WorkspaceUserPerm}; use interim_pgtypes::{escape_identifier, pg_class::PgClass};
use interim_pgtypes::escape_identifier;
use serde::Deserialize; use serde::Deserialize;
use sqlx::query; use sqlx::query;
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
app::AppDbConn, app::AppDbConn,
errors::{AppError, forbidden}, errors::AppError,
navigator::Navigator, navigator::Navigator,
settings::Settings, settings::Settings,
user::CurrentUser, user::CurrentUser,
@ -37,72 +36,59 @@ pub(super) async fn post(
AppDbConn(mut app_db): AppDbConn, AppDbConn(mut app_db): AppDbConn,
Path(PathParams { workspace_id }): Path<PathParams>, Path(PathParams { workspace_id }): Path<PathParams>,
) -> Result<impl IntoResponse, AppError> { ) -> Result<impl IntoResponse, AppError> {
// Check workspace authorization. // 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"));
}
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 // FIXME: Should this be scoped down to the unprivileged role after
// setting up the table owner? // setting up the table owner?
.acquire_for(workspace_id, RoleAssignment::Root) .acquire_for(workspace_id, RoleAssignment::Root)
.await?; .await?;
let table_owner_rolname = format!("table_owner_{0}", Uuid::new_v4().simple()); let user_rolname = format!(
query(&format!( "{prefix}{user_id}",
"create role {0}", prefix = settings.db_role_prefix,
escape_identifier(&table_owner_rolname), user_id = user.id.simple()
)) );
.execute(workspace_client.get_conn()) let rolname_uuid = Uuid::new_v4().simple();
.await?; let rolname_table_reader = format!("table_reader_{rolname_uuid}");
query(&format!( let rolname_table_writer = format!("table_writer_{rolname_uuid}");
"grant {0} to {1} with admin option", for rolname in [&rolname_table_reader, &rolname_table_writer] {
escape_identifier(&table_owner_rolname), query(&format!("create role {0}", escape_identifier(rolname)))
escape_identifier(&format!( .execute(root_client.get_conn())
"{0}{1}", .await?;
settings.db_role_prefix, query(&format!(
user.id.simple() "grant {0} to {1} with admin option",
escape_identifier(rolname),
escape_identifier(&user_rolname)
)) ))
)) .execute(root_client.get_conn())
.execute(workspace_client.get_conn()) .await?;
.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";
query(&format!( query(&format!(
r#" r#"
create table {0}.{1} ( create table {0}.{1} (
_id uuid primary key not null default uuidv7(), _id uuid primary key not null default uuidv7(),
_created_by text default current_user, _created_by text default current_user,
_created_at timestamptz not null default now(), _created_at timestamptz not null default now()
_form_session uuid,
_form_backlink_portal uuid,
_form_backlink_row uuid,
notes text
) )
"#, "#,
escape_identifier(&settings.phono_table_namespace), 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?; .await?;
query(&format!( query(&format!(
"alter table {0}.{1} owner to {2}", "grant select on {0}.{1} to {2}",
escape_identifier(&settings.phono_table_namespace), escape_identifier(&settings.phono_table_namespace),
escape_identifier(TABLE_NAME), escape_identifier(&table_name),
escape_identifier(&table_owner_rolname) escape_identifier(&rolname_table_reader),
)) ))
.execute(workspace_client.get_conn()) .execute(root_client.get_conn())
.await?; .await?;
Ok(navigator.workspace_page(workspace_id).redirect_to()) Ok(navigator.workspace_page(workspace_id).redirect_to())

View file

@ -4,16 +4,13 @@ use axum::{
extract::{Path, State}, extract::{Path, State},
response::{Html, IntoResponse}, response::{Html, IntoResponse},
}; };
use interim_models::{ use interim_models::workspace::Workspace;
workspace::Workspace,
workspace_user_perm::{self, WorkspaceUserPerm},
};
use serde::Deserialize; use serde::Deserialize;
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
app::{App, AppDbConn}, app::{App, AppDbConn},
errors::{AppError, forbidden}, errors::AppError,
navigator::Navigator, navigator::Navigator,
settings::Settings, settings::Settings,
user::CurrentUser, user::CurrentUser,
@ -38,15 +35,7 @@ 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> {
// Check workspace authorization. // FIXME: 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"));
}
let workspace = Workspace::with_id(workspace_id) let workspace = Workspace::with_id(workspace_id)
.fetch_one(&mut app_db) .fetch_one(&mut app_db)

View file

@ -1,11 +1,8 @@
use anyhow::Result; use anyhow::Result;
use askama::Template; use askama::Template;
use derive_builder::Builder; use derive_builder::Builder;
use interim_models::{client::AppDbClient, portal::Portal, workspace::Workspace}; use interim_models::{client::AppDbClient, workspace::Workspace};
use interim_pgtypes::{ use interim_pgtypes::client::WorkspaceClient;
client::WorkspaceClient,
pg_class::{PgClass, PgRelKind},
};
use sqlx::postgres::types::Oid; use sqlx::postgres::types::Oid;
use uuid::Uuid; use uuid::Uuid;

View file

@ -57,7 +57,7 @@ discard sequences;
Ok(true) Ok(true)
}) })
}) })
.connect(&workspace.url.expose_secret()) .connect(workspace.url.expose_secret())
.await?) .await?)
}; };

View file

@ -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<PgRole> = 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<Uuid> = 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(())
}