clarify auth model
This commit is contained in:
parent
15e1057a8d
commit
601a5a1034
32 changed files with 367 additions and 557 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -1772,6 +1772,7 @@ dependencies = [
|
|||
"regex",
|
||||
"serde",
|
||||
"sqlx",
|
||||
"strum",
|
||||
"thiserror 2.0.12",
|
||||
"uuid",
|
||||
]
|
||||
|
|
|
|||
115
README.md
115
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 <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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 --
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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<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 {
|
||||
|
|
@ -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<Workspace, sqlx::Error> {
|
||||
query_as!(
|
||||
Workspace,
|
||||
|
|
|
|||
|
|
@ -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<Vec<WorkspaceUserPerm>, sqlx::Error> {
|
||||
) -> Result<Vec<WorkspaceMembership>, 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<WorkspaceUserPerm, sqlx::Error> {
|
||||
pub async fn execute(
|
||||
self,
|
||||
app_db: &mut AppDbClient,
|
||||
) -> Result<WorkspaceMembership, sqlx::Error> {
|
||||
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: <Postgres as sqlx::Database>::ValueRef<'_>,
|
||||
) -> Result<Self, sqlx::error::BoxDynError> {
|
||||
let value = <&str as Decode<Postgres>>::decode(value)?;
|
||||
Ok(Self::from_str(value)?)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
))
|
||||
|
|
|
|||
|
|
@ -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<PgPrivilege>,
|
||||
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<Self, BoxDynError> {
|
||||
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> {
|
||||
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,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ mod user;
|
|||
mod worker;
|
||||
mod workspace_nav;
|
||||
mod workspace_pooler;
|
||||
mod workspace_user_perms;
|
||||
mod workspace_utils;
|
||||
|
||||
/// Run CLI
|
||||
|
|
|
|||
|
|
@ -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<PathParams>,
|
||||
Form(form): Form<FormBody>,
|
||||
) -> Result<Response, AppError> {
|
||||
// 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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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<PathParams>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
// 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)
|
||||
|
|
|
|||
|
|
@ -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<WorkspacePooler>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
// 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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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<PathParams>,
|
||||
Form(form): Form<HashMap<String, Vec<String>>>,
|
||||
) -> Result<Response, AppError> {
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -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<WorkspacePooler>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
// 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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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<PathParams>,
|
||||
Form(form): Form<FormBody>,
|
||||
) -> Result<Response, AppError> {
|
||||
// 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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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<WorkspacePooler>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
// 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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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<PathParams>,
|
||||
Form(form): Form<FormBody>,
|
||||
) -> Result<Response, AppError> {
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -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<Response, AppError> {
|
||||
// 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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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<PathParams>,
|
||||
Form(form): Form<FormBody>,
|
||||
) -> Result<Response, AppError> {
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -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<PathParams>,
|
||||
ValidatedForm(FormBody { name }): ValidatedForm<FormBody>,
|
||||
) -> Result<Response, AppError> {
|
||||
// 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(
|
||||
|
|
|
|||
|
|
@ -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<PathParams>,
|
||||
Form(form): Form<HashMap<String, String>>,
|
||||
) -> Result<Response, AppError> {
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -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<PathParams>,
|
||||
ValidatedForm(FormBody { name }): ValidatedForm<FormBody>,
|
||||
) -> Result<Response, AppError> {
|
||||
// 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(
|
||||
|
|
|
|||
|
|
@ -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<PathParams>,
|
||||
Json(form): Json<FormBody>,
|
||||
) -> Result<Response, AppError> {
|
||||
// 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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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)
|
||||
.await?;
|
||||
|
||||
|
|
@ -24,7 +24,7 @@ pub(super) async fn get(
|
|||
struct ResponseTemplate {
|
||||
navigator: Navigator,
|
||||
settings: Settings,
|
||||
workspace_perms: Vec<WorkspaceUserPerm>,
|
||||
workspace_perms: Vec<WorkspaceMembership>,
|
||||
}
|
||||
|
||||
Ok(Html(
|
||||
|
|
|
|||
|
|
@ -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<PathParams>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
// 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())
|
||||
|
|
|
|||
|
|
@ -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<WorkspacePooler>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
// 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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ discard sequences;
|
|||
Ok(true)
|
||||
})
|
||||
})
|
||||
.connect(&workspace.url.expose_secret())
|
||||
.connect(workspace.url.expose_secret())
|
||||
.await?)
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue