implement invites for collaboration
This commit is contained in:
parent
8c284ecc05
commit
afba6497af
36 changed files with 1983 additions and 668 deletions
|
|
@ -18,16 +18,9 @@ impl WorkspaceClient {
|
||||||
&mut self.conn
|
&mut self.conn
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Runs the Postgres `set role` command for the underlying connection. If
|
/// If the given role does not exist, create it and grant it to the
|
||||||
/// the given role does not exist, it is created and granted to the
|
|
||||||
/// `session_user`. Roles are created with the `createrole` option.
|
/// `session_user`. Roles are created with the `createrole` option.
|
||||||
///
|
pub async fn ensure_role(&mut self, rolname: &str) -> Result<(), sqlx::Error> {
|
||||||
/// Note that while using `set role` simulates impersonation for most data
|
|
||||||
/// access and RLS purposes, it is both incomplete and easily reversible:
|
|
||||||
/// some commands and system tables will still behave according to the
|
|
||||||
/// privileges of the session user, and clients relying on this abstraction
|
|
||||||
/// should **NEVER** execute untrusted SQL.
|
|
||||||
pub async fn init_role(&mut self, rolname: &str) -> Result<(), sqlx::Error> {
|
|
||||||
let session_user = query!("select session_user;")
|
let session_user = query!("select session_user;")
|
||||||
.fetch_one(&mut *self.conn)
|
.fetch_one(&mut *self.conn)
|
||||||
.await?
|
.await?
|
||||||
|
|
@ -53,6 +46,20 @@ impl WorkspaceClient {
|
||||||
.execute(&mut *self.conn)
|
.execute(&mut *self.conn)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs the Postgres `SET ROLE` command for the underlying connection.
|
||||||
|
/// Returns an error if the given role does not exist. Use
|
||||||
|
/// [`WorkspaceClient::ensure_role`] to ensure the role exists on the
|
||||||
|
/// cluster, if needed.
|
||||||
|
///
|
||||||
|
/// Note that while using `set role` simulates impersonation for most data
|
||||||
|
/// access and RLS purposes, it is both incomplete and easily reversible:
|
||||||
|
/// some commands and system tables will still behave according to the
|
||||||
|
/// privileges of the session user, and clients relying on this abstraction
|
||||||
|
/// should **NEVER** execute untrusted SQL.
|
||||||
|
pub async fn set_role(&mut self, rolname: &str) -> Result<(), sqlx::Error> {
|
||||||
query(&format!("set role {}", escape_identifier(rolname)))
|
query(&format!("set role {}", escape_identifier(rolname)))
|
||||||
.execute(&mut *self.conn)
|
.execute(&mut *self.conn)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
|
||||||
|
|
@ -131,14 +131,16 @@ pub struct RoleTree {
|
||||||
pub role: PgRole,
|
pub role: PgRole,
|
||||||
pub branches: Vec<RoleTree>,
|
pub branches: Vec<RoleTree>,
|
||||||
pub inherit: bool,
|
pub inherit: bool,
|
||||||
|
pub admin: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, FromRow)]
|
#[derive(Clone, Debug, FromRow)]
|
||||||
struct RoleTreeRow {
|
struct RoleTreeRow {
|
||||||
#[sqlx(flatten)]
|
#[sqlx(flatten)]
|
||||||
role: PgRole,
|
role: PgRole,
|
||||||
branch: Option<Oid>,
|
parent: Option<Oid>,
|
||||||
inherit: bool,
|
inherit: bool,
|
||||||
|
admin: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RoleTree {
|
impl RoleTree {
|
||||||
|
|
@ -160,9 +162,15 @@ impl RoleTree {
|
||||||
GrantedToRolnameQuery { rolname }
|
GrantedToRolnameQuery { rolname }
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn flatten_inherited(self) -> Vec<PgRole> {
|
/// Flattens tree structure, omitting any nodes which are not implicitly
|
||||||
|
/// inherited by the root role. Each of the resulting item's `branches`
|
||||||
|
/// fields is emptied.
|
||||||
|
pub fn flatten_inherited(self) -> Vec<Self> {
|
||||||
[
|
[
|
||||||
vec![self.role],
|
vec![Self {
|
||||||
|
branches: vec![],
|
||||||
|
..self
|
||||||
|
}],
|
||||||
self.branches
|
self.branches
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|member| member.inherit)
|
.filter(|member| member.inherit)
|
||||||
|
|
@ -174,6 +182,83 @@ impl RoleTree {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Constructs a SQL string to query a tree of Postgres role grants.
|
||||||
|
///
|
||||||
|
/// `param_cast` is the SQL required to cast the Postgres input paramter (`$1`)
|
||||||
|
/// to type OID, for example: `"::regrole::oid"` if `$1` is a rolname or `""` if
|
||||||
|
/// `$1` is already an OID.
|
||||||
|
///
|
||||||
|
/// The query consists of 3 stages:
|
||||||
|
/// - First, the recursive CTE `memberships_all` constructs the root node and
|
||||||
|
/// then recurses across Postgres role grants directly or indirectly connected
|
||||||
|
/// to it. This produces a temporary table with columns:
|
||||||
|
/// - `roleid`: OID of the role.
|
||||||
|
/// - `parent`: Reference to the role grant traversed to get to the current
|
||||||
|
/// node. (OID of the role this node is a member of or is granted to,
|
||||||
|
/// depending on the traversal direction.)
|
||||||
|
/// - `admin`: Whether the role grant includes `WITH ADMIN OPTION`. This is
|
||||||
|
/// set to `false` for the root node, because in Postgres roles are not
|
||||||
|
/// permitted to grant themselves to others.
|
||||||
|
/// - `inherit`: Whether there is a contiguous path of `WITH INHERIT OPTION`
|
||||||
|
/// between this role grant and the starting node (inclusive of both ends).
|
||||||
|
/// This is set to `true` for the root node, because inheritance of
|
||||||
|
/// permissions from a role to itself is self-evident.
|
||||||
|
/// - Next, `memberships_agg` aggregates over `memberships_all` to consolidate
|
||||||
|
/// role grants which have been performed multiple times by distinct grantors.
|
||||||
|
/// If *any* of the paths from the root node to a role grant confer the
|
||||||
|
/// `admin` and/or `inherit` characteristics, then the corresponding property
|
||||||
|
/// is set to `true`.
|
||||||
|
/// - Finally, `memberships_agg` is joined to the `pg_roles` view for
|
||||||
|
/// convenience, to return detailed information on each row beyond its OID.
|
||||||
|
fn construct_role_tree_query(param_cast: &str, direction: RoleTreeDirection) -> String {
|
||||||
|
format!(
|
||||||
|
"
|
||||||
|
with recursive memberships_all as (
|
||||||
|
select
|
||||||
|
$1{param_cast} as roleid,
|
||||||
|
null::oid as parent,
|
||||||
|
false as admin,
|
||||||
|
true as inherit
|
||||||
|
union all
|
||||||
|
select
|
||||||
|
{roleid_col} as roleid,
|
||||||
|
prev.roleid as parent,
|
||||||
|
membership.admin_option as admin,
|
||||||
|
prev.inherit and membership.inherit_option as inherit
|
||||||
|
from memberships_all as prev join pg_auth_members as membership
|
||||||
|
on {cte_join_cond}
|
||||||
|
)
|
||||||
|
select
|
||||||
|
pg_roles.*,
|
||||||
|
memberships_agg.parent as parent,
|
||||||
|
memberships_agg.admin as admin,
|
||||||
|
memberships_agg.inherit as inherit
|
||||||
|
from (
|
||||||
|
select
|
||||||
|
roleid,
|
||||||
|
parent,
|
||||||
|
bool_or(admin) as admin,
|
||||||
|
bool_or(inherit) as inherit
|
||||||
|
from memberships_all
|
||||||
|
group by roleid, parent
|
||||||
|
) as memberships_agg join pg_roles on pg_roles.oid = memberships_agg.roleid
|
||||||
|
",
|
||||||
|
cte_join_cond = match direction {
|
||||||
|
RoleTreeDirection::MembersOf => "membership.roleid = prev.roleid",
|
||||||
|
RoleTreeDirection::GrantedTo => "membership.member = prev.roleid",
|
||||||
|
},
|
||||||
|
roleid_col = match direction {
|
||||||
|
RoleTreeDirection::MembersOf => "membership.member",
|
||||||
|
RoleTreeDirection::GrantedTo => "membership.roleid",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RoleTreeDirection {
|
||||||
|
MembersOf,
|
||||||
|
GrantedTo,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct MembersOfOidQuery {
|
pub struct MembersOfOidQuery {
|
||||||
role: Oid,
|
role: Oid,
|
||||||
|
|
@ -183,33 +268,18 @@ impl MembersOfOidQuery {
|
||||||
self,
|
self,
|
||||||
client: &mut WorkspaceClient,
|
client: &mut WorkspaceClient,
|
||||||
) -> Result<Option<RoleTree>, sqlx::Error> {
|
) -> Result<Option<RoleTree>, sqlx::Error> {
|
||||||
let rows: Vec<RoleTreeRow> = query_as(
|
let rows: Vec<RoleTreeRow> =
|
||||||
"
|
query_as(&construct_role_tree_query("", RoleTreeDirection::MembersOf))
|
||||||
with recursive cte as (
|
.bind(self.role)
|
||||||
select $1::regrole::oid as roleid, null::oid as branch, true as inherit
|
.fetch_all(client.get_conn())
|
||||||
union all
|
.await?;
|
||||||
select m.member, m.roleid, c.inherit and m.inherit_option
|
|
||||||
from cte as c
|
|
||||||
join pg_auth_members m on m.roleid = c.roleid
|
|
||||||
)
|
|
||||||
select pg_roles.*, branch, inherit
|
|
||||||
from (
|
|
||||||
select roleid, branch, bool_or(inherit) as inherit
|
|
||||||
from cte
|
|
||||||
group by roleid, branch
|
|
||||||
) as subquery
|
|
||||||
join pg_roles on pg_roles.oid = subquery.roleid
|
|
||||||
",
|
|
||||||
)
|
|
||||||
.bind(self.role)
|
|
||||||
.fetch_all(client.get_conn())
|
|
||||||
.await?;
|
|
||||||
Ok(rows
|
Ok(rows
|
||||||
.iter()
|
.iter()
|
||||||
.find(|row| row.branch.is_none())
|
.find(|row| row.parent.is_none())
|
||||||
.map(|root_row| RoleTree {
|
.map(|root_row| RoleTree {
|
||||||
role: root_row.role.clone(),
|
role: root_row.role.clone(),
|
||||||
branches: compute_members(&rows, root_row.role.oid),
|
branches: compute_members(&rows, root_row.role.oid),
|
||||||
|
admin: root_row.admin,
|
||||||
inherit: root_row.inherit,
|
inherit: root_row.inherit,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
@ -225,35 +295,20 @@ impl MembersOfRolnameQuery {
|
||||||
self,
|
self,
|
||||||
client: &mut WorkspaceClient,
|
client: &mut WorkspaceClient,
|
||||||
) -> Result<Option<RoleTree>, sqlx::Error> {
|
) -> Result<Option<RoleTree>, sqlx::Error> {
|
||||||
// This could almost be a macro to DRY with MembersOfOidQuery, except
|
let rows: Vec<RoleTreeRow> = query_as(&construct_role_tree_query(
|
||||||
// for the extra ::text:: cast required on the parameter in this query.
|
"::regrole::oid",
|
||||||
let rows: Vec<RoleTreeRow> = query_as(
|
RoleTreeDirection::MembersOf,
|
||||||
"
|
))
|
||||||
with recursive cte as (
|
|
||||||
select $1::text::regrole::oid as roleid, null::oid as branch, true as inherit
|
|
||||||
union all
|
|
||||||
select m.member, m.roleid, c.inherit and m.inherit_option
|
|
||||||
from cte as c
|
|
||||||
join pg_auth_members m on m.roleid = c.roleid
|
|
||||||
)
|
|
||||||
select pg_roles.*, branch, inherit
|
|
||||||
from (
|
|
||||||
select roleid, branch, bool_or(inherit) as inherit
|
|
||||||
from cte
|
|
||||||
group by roleid, branch
|
|
||||||
) as subquery
|
|
||||||
join pg_roles on pg_roles.oid = subquery.roleid
|
|
||||||
",
|
|
||||||
)
|
|
||||||
.bind(self.role.as_str() as &str)
|
.bind(self.role.as_str() as &str)
|
||||||
.fetch_all(client.get_conn())
|
.fetch_all(client.get_conn())
|
||||||
.await?;
|
.await?;
|
||||||
Ok(rows
|
Ok(rows
|
||||||
.iter()
|
.iter()
|
||||||
.find(|row| row.branch.is_none())
|
.find(|row| row.parent.is_none())
|
||||||
.map(|root_row| RoleTree {
|
.map(|root_row| RoleTree {
|
||||||
role: root_row.role.clone(),
|
role: root_row.role.clone(),
|
||||||
branches: compute_members(&rows, root_row.role.oid),
|
branches: compute_members(&rows, root_row.role.oid),
|
||||||
|
admin: root_row.admin,
|
||||||
inherit: root_row.inherit,
|
inherit: root_row.inherit,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
@ -269,33 +324,18 @@ impl GrantedToQuery {
|
||||||
self,
|
self,
|
||||||
client: &mut WorkspaceClient,
|
client: &mut WorkspaceClient,
|
||||||
) -> Result<Option<RoleTree>, sqlx::Error> {
|
) -> Result<Option<RoleTree>, sqlx::Error> {
|
||||||
let rows: Vec<RoleTreeRow> = query_as(
|
let rows: Vec<RoleTreeRow> =
|
||||||
"
|
query_as(&construct_role_tree_query("", RoleTreeDirection::GrantedTo))
|
||||||
with recursive cte as (
|
.bind(self.role_oid)
|
||||||
select $1 as roleid, null::oid as branch, true as inherit
|
.fetch_all(client.get_conn())
|
||||||
union all
|
.await?;
|
||||||
select m.roleid, m.member as branch, c.inherit and m.inherit_option
|
|
||||||
from cte as c
|
|
||||||
join pg_auth_members m on m.member = c.roleid
|
|
||||||
)
|
|
||||||
select pg_roles.*, branch, inherit
|
|
||||||
from (
|
|
||||||
select roleid, branch, bool_or(inherit) as inherit
|
|
||||||
from cte
|
|
||||||
group by roleid, branch
|
|
||||||
) as subquery
|
|
||||||
join pg_roles on pg_roles.oid = subquery.roleid
|
|
||||||
",
|
|
||||||
)
|
|
||||||
.bind(self.role_oid)
|
|
||||||
.fetch_all(client.get_conn())
|
|
||||||
.await?;
|
|
||||||
Ok(rows
|
Ok(rows
|
||||||
.iter()
|
.iter()
|
||||||
.find(|row| row.branch.is_none())
|
.find(|row| row.parent.is_none())
|
||||||
.map(|root_row| RoleTree {
|
.map(|root_row| RoleTree {
|
||||||
role: root_row.role.clone(),
|
role: root_row.role.clone(),
|
||||||
branches: compute_members(&rows, root_row.role.oid),
|
branches: compute_members(&rows, root_row.role.oid),
|
||||||
|
admin: root_row.admin,
|
||||||
inherit: root_row.inherit,
|
inherit: root_row.inherit,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
@ -311,33 +351,20 @@ impl<'a> GrantedToRolnameQuery<'a> {
|
||||||
self,
|
self,
|
||||||
client: &mut WorkspaceClient,
|
client: &mut WorkspaceClient,
|
||||||
) -> Result<Option<RoleTree>, sqlx::Error> {
|
) -> Result<Option<RoleTree>, sqlx::Error> {
|
||||||
let rows: Vec<RoleTreeRow> = query_as(
|
let rows: Vec<RoleTreeRow> = query_as(&construct_role_tree_query(
|
||||||
"
|
"::regrole::oid",
|
||||||
with recursive cte as (
|
RoleTreeDirection::GrantedTo,
|
||||||
select $1::regrole::oid as roleid, null::oid as branch, true as inherit
|
))
|
||||||
union all
|
|
||||||
select m.roleid, m.member as branch, c.inherit and m.inherit_option
|
|
||||||
from cte as c
|
|
||||||
join pg_auth_members m on m.member = c.roleid
|
|
||||||
)
|
|
||||||
select pg_roles.*, branch, inherit
|
|
||||||
from (
|
|
||||||
select roleid, branch, bool_or(inherit) as inherit
|
|
||||||
from cte
|
|
||||||
group by roleid, branch
|
|
||||||
) as subquery
|
|
||||||
join pg_roles on pg_roles.oid = subquery.roleid
|
|
||||||
",
|
|
||||||
)
|
|
||||||
.bind(self.rolname)
|
.bind(self.rolname)
|
||||||
.fetch_all(client.get_conn())
|
.fetch_all(client.get_conn())
|
||||||
.await?;
|
.await?;
|
||||||
Ok(rows
|
Ok(rows
|
||||||
.iter()
|
.iter()
|
||||||
.find(|row| row.branch.is_none())
|
.find(|row| row.parent.is_none())
|
||||||
.map(|root_row| RoleTree {
|
.map(|root_row| RoleTree {
|
||||||
role: root_row.role.clone(),
|
role: root_row.role.clone(),
|
||||||
branches: compute_members(&rows, root_row.role.oid),
|
branches: compute_members(&rows, root_row.role.oid),
|
||||||
|
admin: root_row.admin,
|
||||||
inherit: root_row.inherit,
|
inherit: root_row.inherit,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
@ -345,10 +372,11 @@ from (
|
||||||
|
|
||||||
fn compute_members(rows: &Vec<RoleTreeRow>, root: Oid) -> Vec<RoleTree> {
|
fn compute_members(rows: &Vec<RoleTreeRow>, root: Oid) -> Vec<RoleTree> {
|
||||||
rows.iter()
|
rows.iter()
|
||||||
.filter(|row| row.branch == Some(root))
|
.filter(|row| row.parent == Some(root))
|
||||||
.map(|row| RoleTree {
|
.map(|row| RoleTree {
|
||||||
role: row.role.clone(),
|
role: row.role.clone(),
|
||||||
branches: compute_members(rows, row.role.oid),
|
branches: compute_members(rows, row.role.oid),
|
||||||
|
admin: row.admin,
|
||||||
inherit: row.inherit,
|
inherit: row.inherit,
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
drop index if exists users_email_idx;
|
||||||
|
alter table users drop constraint if exists email_lower;
|
||||||
|
alter table users alter column uid set not null;
|
||||||
|
|
||||||
|
-- There's no need nor use for restoring the rel_invitations table, as it is
|
||||||
|
-- never used.
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
-- The rel_invitations table has never been used and may be assumed to be empty.
|
||||||
|
drop table if exists rel_invitations;
|
||||||
|
|
||||||
|
alter table users add constraint email_lower check (email = lower(email));
|
||||||
|
alter table users alter column uid drop not null;
|
||||||
|
create index on users (email);
|
||||||
|
|
@ -5,7 +5,7 @@ use crate::errors::AccessError;
|
||||||
pub mod portal;
|
pub mod portal;
|
||||||
pub mod workspace;
|
pub mod workspace;
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, strum::Display)]
|
#[derive(Clone, Copy, Debug, PartialEq, strum::Display, strum::EnumIs)]
|
||||||
pub enum Actor {
|
pub enum Actor {
|
||||||
/// Bypass explicit auth checks.
|
/// Bypass explicit auth checks.
|
||||||
Bypass,
|
Bypass,
|
||||||
|
|
|
||||||
|
|
@ -178,7 +178,7 @@ impl<'a> Accessor<Portal> for PortalAccessor<'a> {
|
||||||
.map(|tree| tree.flatten_inherited())
|
.map(|tree| tree.flatten_inherited())
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.iter()
|
.iter()
|
||||||
.any(|value| value.rolname == actor_rolname)
|
.any(|value| value.role.rolname == actor_rolname)
|
||||||
{
|
{
|
||||||
for permission in acl_item.privileges.iter() {
|
for permission in acl_item.privileges.iter() {
|
||||||
actor_permissions.insert(permission.privilege);
|
actor_permissions.insert(permission.privilege);
|
||||||
|
|
@ -202,7 +202,7 @@ impl<'a> Accessor<Portal> for PortalAccessor<'a> {
|
||||||
.map(|tree| tree.flatten_inherited())
|
.map(|tree| tree.flatten_inherited())
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.iter()
|
.iter()
|
||||||
.any(|value| value.rolname == actor_rolname))
|
.any(|value| value.role.rolname == actor_rolname))
|
||||||
{
|
{
|
||||||
debug!("actor is not relation owner");
|
debug!("actor is not relation owner");
|
||||||
return Err(AccessError::NotFound);
|
return Err(AccessError::NotFound);
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,22 @@
|
||||||
use std::str::FromStr;
|
use std::str::FromStr as _;
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::{Decode, Postgres};
|
use sqlx::{Decode, Postgres};
|
||||||
use strum::{EnumIter, EnumString};
|
|
||||||
|
|
||||||
/// Languages represented as
|
/// Languages represented as
|
||||||
/// [ISO 639-3 codes](https://en.wikipedia.org/wiki/List_of_ISO_639-3_codes).
|
/// [ISO 639-3 codes](https://en.wikipedia.org/wiki/List_of_ISO_639-3_codes).
|
||||||
#[derive(
|
#[derive(
|
||||||
Clone, Debug, Deserialize, strum::Display, Eq, Hash, PartialEq, Serialize, EnumIter, EnumString,
|
Clone,
|
||||||
|
Debug,
|
||||||
|
Deserialize,
|
||||||
|
Eq,
|
||||||
|
Hash,
|
||||||
|
PartialEq,
|
||||||
|
Serialize,
|
||||||
|
strum::Display,
|
||||||
|
strum::EnumIs,
|
||||||
|
strum::EnumIter,
|
||||||
|
strum::EnumString,
|
||||||
)]
|
)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
#[strum(serialize_all = "lowercase")]
|
#[strum(serialize_all = "lowercase")]
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,6 @@ pub mod language;
|
||||||
mod macros;
|
mod macros;
|
||||||
pub mod portal;
|
pub mod portal;
|
||||||
pub mod presentation;
|
pub mod presentation;
|
||||||
pub mod rel_invitation;
|
|
||||||
pub mod service_cred;
|
pub mod service_cred;
|
||||||
pub mod user;
|
pub mod user;
|
||||||
pub mod workspace;
|
pub mod workspace;
|
||||||
|
|
|
||||||
|
|
@ -1,87 +0,0 @@
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use derive_builder::Builder;
|
|
||||||
use phono_backends::pg_acl::PgPrivilegeType;
|
|
||||||
use sqlx::{postgres::types::Oid, query_as};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::client::AppDbClient;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct RelInvitation {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub email: String,
|
|
||||||
pub workspace_id: Uuid,
|
|
||||||
pub class_oid: Oid,
|
|
||||||
pub created_by: Uuid,
|
|
||||||
pub privilege: String,
|
|
||||||
pub expires_at: Option<DateTime<Utc>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RelInvitation {
|
|
||||||
pub fn belonging_to_rel(rel_oid: Oid) -> BelongingToRelQuery {
|
|
||||||
BelongingToRelQuery { rel_oid }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn upsertable() -> UpsertableRelInvitationBuilder {
|
|
||||||
UpsertableRelInvitationBuilder::default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct BelongingToRelQuery {
|
|
||||||
rel_oid: Oid,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl BelongingToRelQuery {
|
|
||||||
pub async fn fetch_all(
|
|
||||||
self,
|
|
||||||
app_db: &mut AppDbClient,
|
|
||||||
) -> Result<Vec<RelInvitation>, sqlx::Error> {
|
|
||||||
query_as!(
|
|
||||||
RelInvitation,
|
|
||||||
"
|
|
||||||
select * from rel_invitations
|
|
||||||
where class_oid = $1
|
|
||||||
",
|
|
||||||
self.rel_oid
|
|
||||||
)
|
|
||||||
.fetch_all(&mut *app_db.conn)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Builder, Clone, Debug)]
|
|
||||||
pub struct UpsertableRelInvitation {
|
|
||||||
email: String,
|
|
||||||
workspace_id: Uuid,
|
|
||||||
class_oid: Oid,
|
|
||||||
created_by: Uuid,
|
|
||||||
privilege: PgPrivilegeType,
|
|
||||||
#[builder(default, setter(strip_option))]
|
|
||||||
expires_at: Option<DateTime<Utc>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl UpsertableRelInvitation {
|
|
||||||
pub async fn upsert(self, app_db: &mut AppDbClient) -> Result<RelInvitation, sqlx::Error> {
|
|
||||||
query_as!(
|
|
||||||
RelInvitation,
|
|
||||||
"
|
|
||||||
insert into rel_invitations
|
|
||||||
(email, workspace_id, class_oid, privilege, created_by, expires_at)
|
|
||||||
values ($1, $2, $3, $4, $5, $6)
|
|
||||||
on conflict (email, workspace_id, class_oid, privilege) do update set
|
|
||||||
created_by = excluded.created_by,
|
|
||||||
expires_at = excluded.expires_at
|
|
||||||
returning *
|
|
||||||
",
|
|
||||||
self.email,
|
|
||||||
self.workspace_id,
|
|
||||||
self.class_oid,
|
|
||||||
self.privilege.to_string(),
|
|
||||||
self.created_by,
|
|
||||||
self.expires_at,
|
|
||||||
)
|
|
||||||
.fetch_one(&mut *app_db.conn)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
296
phono-models/src/rel_invite.rs
Normal file
296
phono-models/src/rel_invite.rs
Normal file
|
|
@ -0,0 +1,296 @@
|
||||||
|
use std::str::FromStr as _;
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use derive_builder::Builder;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::{Decode, Postgres, postgres::types::Oid, query, query_as};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
client::AppDbClient,
|
||||||
|
errors::{QueryError, QueryResult},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Represents a pending role grant for an email address for which a user
|
||||||
|
/// account has not yet been created.
|
||||||
|
///
|
||||||
|
/// Immediately after a new user account is created, all [`WorkspaceInvite`] and
|
||||||
|
/// [`RelInvite`] records pertaining to the relevant email address are evaluated
|
||||||
|
/// and removed.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct RelInvite {
|
||||||
|
/// Unique identifier.
|
||||||
|
pub id: Uuid,
|
||||||
|
|
||||||
|
/// Email address of the target user (or future user).
|
||||||
|
pub email: String,
|
||||||
|
|
||||||
|
/// Workspace on which permissions are to be granted.
|
||||||
|
pub workspace_id: Uuid,
|
||||||
|
|
||||||
|
/// OID of the relation on which permissions are to be granted.
|
||||||
|
pub rel_oid: Oid,
|
||||||
|
|
||||||
|
/// Type of permission to be granted.
|
||||||
|
pub privilege: RelInvitePrivilege,
|
||||||
|
|
||||||
|
/// ID of user who created the invitation. The user must have authority to
|
||||||
|
/// grant the relevant permissions at the time that the invite is accepted,
|
||||||
|
/// or else the invite will be discarded.
|
||||||
|
pub granted_by: Uuid,
|
||||||
|
|
||||||
|
/// Optional timestamp after which this invite will be discarded. This
|
||||||
|
/// should typically be set to an appropriate value each time the invitation
|
||||||
|
/// is updated (for example, when `granted_by` is changed).
|
||||||
|
pub expires_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RelInvite {
|
||||||
|
pub fn belonging_to_workspace(workspace_id: Uuid) -> BelongingToWorkspaceQuery {
|
||||||
|
BelongingToWorkspaceQuery { workspace_id }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Construct a query to fetch an invite by email address. Note that because
|
||||||
|
/// [destination email servers have great latitude in how they normalize
|
||||||
|
/// addresses (or don't)](
|
||||||
|
/// https://www.rfc-editor.org/rfc/rfc5321#section-2.3.11), email addresses
|
||||||
|
/// as identifiers are inherently somewhat ambiguous. Effectively all modern
|
||||||
|
/// email hosts treat addresses as case-insensitive, consistent with the
|
||||||
|
/// standard [treatment of the domain part](
|
||||||
|
/// https://www.rfc-editor.org/rfc/rfc1035#section-3.1), and this is the
|
||||||
|
/// only form of normalization reflected in the evaluation of this query.
|
||||||
|
pub fn belonging_to_email<'a>(email: &'a str) -> BelongingToEmailQuery<'a> {
|
||||||
|
BelongingToEmailQuery { email }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn upsert() -> UpsertBuilder {
|
||||||
|
UpsertBuilder::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete() -> DeleteBuilder {
|
||||||
|
DeleteBuilder::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(
|
||||||
|
Clone,
|
||||||
|
Debug,
|
||||||
|
Deserialize,
|
||||||
|
Eq,
|
||||||
|
Hash,
|
||||||
|
PartialEq,
|
||||||
|
Serialize,
|
||||||
|
strum::Display,
|
||||||
|
strum::EnumIs,
|
||||||
|
strum::EnumIter,
|
||||||
|
strum::EnumString,
|
||||||
|
)]
|
||||||
|
#[strum(serialize_all = "snake_case")]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum RelInvitePrivilege {
|
||||||
|
Owner,
|
||||||
|
Reader,
|
||||||
|
Writer,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Decode<'_, Postgres> for RelInvitePrivilege {
|
||||||
|
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)?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
|
pub struct BelongingToWorkspaceQuery {
|
||||||
|
workspace_id: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BelongingToWorkspaceQuery {
|
||||||
|
/// Specify email address in addition to workspace ID. Note that because
|
||||||
|
/// [destination email servers have great latitude in how they normalize
|
||||||
|
/// addresses (or don't)](
|
||||||
|
/// https://www.rfc-editor.org/rfc/rfc5321#section-2.3.11), email addresses
|
||||||
|
/// as identifiers are inherently somewhat ambiguous. Effectively all modern
|
||||||
|
/// email hosts treat addresses as case-insensitive, consistent with the
|
||||||
|
/// standard [treatment of the domain part](
|
||||||
|
/// https://www.rfc-editor.org/rfc/rfc1035#section-3.1), and this is the
|
||||||
|
/// only form of normalization reflected in the evaluation of this query.
|
||||||
|
pub fn belonging_to_email<'a>(self, email: &'a str) -> BelongingToWorkspaceAndEmailQuery<'a> {
|
||||||
|
BelongingToWorkspaceAndEmailQuery {
|
||||||
|
workspace_id: self.workspace_id,
|
||||||
|
email,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fetch_all(self, app_db: &mut AppDbClient) -> QueryResult<Vec<RelInvite>> {
|
||||||
|
Ok(query_as!(
|
||||||
|
RelInvite,
|
||||||
|
r#"
|
||||||
|
select
|
||||||
|
id,
|
||||||
|
email,
|
||||||
|
workspace_id,
|
||||||
|
rel_oid,
|
||||||
|
privilege as "privilege: RelInvitePrivilege",
|
||||||
|
granted_by,
|
||||||
|
expires_at
|
||||||
|
from rel_invites
|
||||||
|
where workspace_id = $1
|
||||||
|
"#,
|
||||||
|
self.workspace_id,
|
||||||
|
)
|
||||||
|
.fetch_all(app_db.get_conn())
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct BelongingToWorkspaceAndEmailQuery<'a> {
|
||||||
|
workspace_id: Uuid,
|
||||||
|
email: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> BelongingToWorkspaceAndEmailQuery<'a> {
|
||||||
|
pub async fn fetch_all(self, app_db: &mut AppDbClient) -> QueryResult<Vec<RelInvite>> {
|
||||||
|
Ok(query_as!(
|
||||||
|
RelInvite,
|
||||||
|
r#"
|
||||||
|
select
|
||||||
|
id,
|
||||||
|
email,
|
||||||
|
workspace_id,
|
||||||
|
rel_oid,
|
||||||
|
privilege as "privilege: RelInvitePrivilege",
|
||||||
|
granted_by,
|
||||||
|
expires_at
|
||||||
|
from rel_invites
|
||||||
|
where workspace_id = $1 and lower(email) = lower($2)
|
||||||
|
"#,
|
||||||
|
self.workspace_id,
|
||||||
|
self.email,
|
||||||
|
)
|
||||||
|
.fetch_all(app_db.get_conn())
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct BelongingToEmailQuery<'a> {
|
||||||
|
email: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> BelongingToEmailQuery<'a> {
|
||||||
|
pub async fn fetch_all(self, app_db: &mut AppDbClient) -> QueryResult<Vec<RelInvite>> {
|
||||||
|
Ok(query_as!(
|
||||||
|
RelInvite,
|
||||||
|
r#"
|
||||||
|
select
|
||||||
|
id,
|
||||||
|
email,
|
||||||
|
workspace_id,
|
||||||
|
rel_oid,
|
||||||
|
privilege as "privilege: RelInvitePrivilege",
|
||||||
|
granted_by,
|
||||||
|
expires_at
|
||||||
|
from rel_invites
|
||||||
|
where lower(email) = lower($1)
|
||||||
|
"#,
|
||||||
|
self.email,
|
||||||
|
)
|
||||||
|
.fetch_all(app_db.get_conn())
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Builder, Clone, Debug)]
|
||||||
|
#[builder(
|
||||||
|
build_fn(error = "QueryError", vis = ""),
|
||||||
|
pattern = "owned",
|
||||||
|
vis = "pub"
|
||||||
|
)]
|
||||||
|
struct Upsert {
|
||||||
|
email: String,
|
||||||
|
|
||||||
|
workspace_id: Uuid,
|
||||||
|
|
||||||
|
rel_oid: Oid,
|
||||||
|
|
||||||
|
privilege: RelInvitePrivilege,
|
||||||
|
|
||||||
|
granted_by: Uuid,
|
||||||
|
|
||||||
|
#[builder(default)]
|
||||||
|
expires_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UpsertBuilder {
|
||||||
|
pub async fn execute(self, app_db: &mut AppDbClient) -> QueryResult<RelInvite> {
|
||||||
|
let spec = self.build()?;
|
||||||
|
Ok(query_as!(
|
||||||
|
RelInvite,
|
||||||
|
r#"
|
||||||
|
insert into rel_invites (
|
||||||
|
email,
|
||||||
|
workspace_id,
|
||||||
|
rel_oid,
|
||||||
|
privilege,
|
||||||
|
granted_by,
|
||||||
|
expires_at
|
||||||
|
) values (
|
||||||
|
$1,
|
||||||
|
$2,
|
||||||
|
$3,
|
||||||
|
$4,
|
||||||
|
$5,
|
||||||
|
$6
|
||||||
|
)
|
||||||
|
on conflict (email, workspace_id, rel_oid, privilege)
|
||||||
|
do update set
|
||||||
|
granted_by = excluded.granted_by,
|
||||||
|
expires_at = excluded.expires_at
|
||||||
|
returning
|
||||||
|
id,
|
||||||
|
email,
|
||||||
|
workspace_id,
|
||||||
|
rel_oid,
|
||||||
|
privilege as "privilege: RelInvitePrivilege",
|
||||||
|
granted_by,
|
||||||
|
expires_at
|
||||||
|
"#,
|
||||||
|
spec.email,
|
||||||
|
spec.workspace_id,
|
||||||
|
spec.rel_oid,
|
||||||
|
spec.privilege.to_string(),
|
||||||
|
spec.granted_by,
|
||||||
|
spec.expires_at,
|
||||||
|
)
|
||||||
|
.fetch_one(app_db.get_conn())
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Builder, Clone, Debug)]
|
||||||
|
#[builder(
|
||||||
|
build_fn(error = "QueryError", vis = ""),
|
||||||
|
pattern = "owned",
|
||||||
|
vis = "pub"
|
||||||
|
)]
|
||||||
|
struct Delete {
|
||||||
|
#[builder(setter(into))]
|
||||||
|
ids: Vec<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeleteBuilder {
|
||||||
|
pub async fn execute(self, app_db: &mut AppDbClient) -> QueryResult<()> {
|
||||||
|
let spec = self.build()?;
|
||||||
|
query!(
|
||||||
|
"delete from rel_invites where id = any($1)",
|
||||||
|
spec.ids.as_slice(),
|
||||||
|
)
|
||||||
|
.execute(app_db.get_conn())
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,20 +1,158 @@
|
||||||
|
use derive_builder::Builder;
|
||||||
use sqlx::query_as;
|
use sqlx::query_as;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::client::AppDbClient;
|
use crate::{
|
||||||
|
client::AppDbClient,
|
||||||
|
errors::{QueryError, QueryResult},
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct User {
|
pub struct User {
|
||||||
|
/// Phonograph ID of the user. This is the unique identifier which should be
|
||||||
|
/// used in most cases.
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub uid: String,
|
|
||||||
|
/// External ID assigned by the OAuth provider via the OIDC `sub` claim.
|
||||||
|
/// This should only be used in connection with authentication. To reference
|
||||||
|
/// the user in other contexts, use the `id` field.
|
||||||
|
///
|
||||||
|
/// If this field is `None`, it indicates that the user has never signed in.
|
||||||
|
/// This may be the case only if the user has been created as a result of
|
||||||
|
/// another user inviting an email address to collaborate.
|
||||||
|
pub uid: Option<String>,
|
||||||
|
|
||||||
|
/// Email address as provided to Phonograph by the OAuth provider on first
|
||||||
|
/// login, lowercased.
|
||||||
|
///
|
||||||
|
/// Note that the OAuth provider is expected to require email confirmation
|
||||||
|
/// as a prerequisite to authenticating.
|
||||||
pub email: String,
|
pub email: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl User {
|
impl User {
|
||||||
|
/// Construct an upsert statement. Note that [`User`] follows certain rules
|
||||||
|
/// when updating:
|
||||||
|
/// - `email` may not be updated after creation.
|
||||||
|
/// - `uid` may not be updated from `Some` to `None`.
|
||||||
|
///
|
||||||
|
/// These rules are enforced implicitly and will be reflected in the
|
||||||
|
/// returned [`User`] object.
|
||||||
|
///
|
||||||
|
/// This operation is intended to be called during session authentication or
|
||||||
|
/// permissions updates, after performing a query to tentatively check that
|
||||||
|
/// a matching record does not already exist. It should not be used for
|
||||||
|
/// general updates in other contexts, as there is no other relevant updates
|
||||||
|
/// possible.
|
||||||
|
pub fn upsert<'a>() -> UpsertBuilder<'a> {
|
||||||
|
UpsertBuilder::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Construct a query by ID.
|
||||||
|
pub fn with_id(id: Uuid) -> WithIdQuery {
|
||||||
|
WithIdQuery { id }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Construct a query for users matching any of the given IDs.
|
||||||
pub fn with_id_in<I: IntoIterator<Item = Uuid>>(ids: I) -> WithIdInQuery {
|
pub fn with_id_in<I: IntoIterator<Item = Uuid>>(ids: I) -> WithIdInQuery {
|
||||||
let ids: Vec<Uuid> = ids.into_iter().collect();
|
let ids: Vec<Uuid> = ids.into_iter().collect();
|
||||||
WithIdInQuery { ids }
|
WithIdInQuery { ids }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Construct a query by the `uid` field. This should only be used in
|
||||||
|
/// connection with authentication.
|
||||||
|
pub fn with_uid<'a>(uid: &'a str) -> WithUidQuery<'a> {
|
||||||
|
WithUidQuery { uid }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Construct a query to fetch a user by email address. Note that because
|
||||||
|
/// [destination email servers have great latitude in how they normalize
|
||||||
|
/// addresses (or don't)](
|
||||||
|
/// https://www.rfc-editor.org/rfc/rfc5321#section-2.3.11), email addresses
|
||||||
|
/// as identifiers are inherently somewhat ambiguous. Effectively all modern
|
||||||
|
/// email hosts treat addresses as case-insensitive, consistent with the
|
||||||
|
/// standard [treatment of the domain part](
|
||||||
|
/// https://www.rfc-editor.org/rfc/rfc1035#section-3.1), and this is the
|
||||||
|
/// only form of normalization reflected in the evaluation of this query.
|
||||||
|
pub fn with_email<'a>(email: &'a str) -> WithEmailQuery<'a> {
|
||||||
|
WithEmailQuery { email }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Builder, Clone, Debug)]
|
||||||
|
#[builder(
|
||||||
|
build_fn(error = "QueryError", vis = ""),
|
||||||
|
pattern = "owned",
|
||||||
|
vis = "pub"
|
||||||
|
)]
|
||||||
|
struct Upsert<'a> {
|
||||||
|
#[builder(default, setter(strip_option))]
|
||||||
|
uid: Option<&'a str>,
|
||||||
|
email: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> UpsertBuilder<'a> {
|
||||||
|
pub async fn execute(self, app_db: &mut AppDbClient) -> QueryResult<User> {
|
||||||
|
let Upsert { uid, email } = self.build()?;
|
||||||
|
Ok(
|
||||||
|
if let Some(user) = query_as!(
|
||||||
|
User,
|
||||||
|
"
|
||||||
|
insert into users (uid, email)
|
||||||
|
values ($1, lower($2))
|
||||||
|
on conflict do nothing
|
||||||
|
returning id, uid, email
|
||||||
|
",
|
||||||
|
uid,
|
||||||
|
email,
|
||||||
|
)
|
||||||
|
.fetch_optional(app_db.get_conn())
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
user
|
||||||
|
} else if uid.is_some() {
|
||||||
|
// Conflict should have been on at least `uid`, meaning that the
|
||||||
|
// user already exists and its fields are fully populated.
|
||||||
|
query_as!(User, "select id, uid, email from users where uid = $1", uid)
|
||||||
|
.fetch_one(app_db.get_conn())
|
||||||
|
.await?
|
||||||
|
} else {
|
||||||
|
// Conflict must have been on `email`, meaning that the user
|
||||||
|
// already exists, though its `uid` may not be up to date. Use
|
||||||
|
// `COALESCE()` on the `uid` value to ensure that it is not
|
||||||
|
// inadvertently removed if present.
|
||||||
|
query_as!(
|
||||||
|
User,
|
||||||
|
"
|
||||||
|
update users
|
||||||
|
set uid = coalesce($1, uid)
|
||||||
|
where email = lower($1)
|
||||||
|
returning id, uid, email
|
||||||
|
",
|
||||||
|
email,
|
||||||
|
)
|
||||||
|
.fetch_one(app_db.get_conn())
|
||||||
|
.await?
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub struct WithIdQuery {
|
||||||
|
id: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WithIdQuery {
|
||||||
|
pub async fn fetch_optional(self, app_db: &mut AppDbClient) -> sqlx::Result<Option<User>> {
|
||||||
|
query_as!(
|
||||||
|
User,
|
||||||
|
"select id, uid, email from users where id = $1",
|
||||||
|
self.id
|
||||||
|
)
|
||||||
|
.fetch_optional(app_db.get_conn())
|
||||||
|
.await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
|
|
@ -26,13 +164,44 @@ impl WithIdInQuery {
|
||||||
pub async fn fetch_all(self, app_db: &mut AppDbClient) -> Result<Vec<User>, sqlx::Error> {
|
pub async fn fetch_all(self, app_db: &mut AppDbClient) -> Result<Vec<User>, sqlx::Error> {
|
||||||
query_as!(
|
query_as!(
|
||||||
User,
|
User,
|
||||||
"
|
"select id, uid, email from users where id = any($1)",
|
||||||
select * from users
|
|
||||||
where id = any($1)
|
|
||||||
",
|
|
||||||
self.ids.as_slice()
|
self.ids.as_slice()
|
||||||
)
|
)
|
||||||
.fetch_all(&mut *app_db.conn)
|
.fetch_all(&mut *app_db.conn)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct WithUidQuery<'a> {
|
||||||
|
uid: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WithUidQuery<'_> {
|
||||||
|
pub async fn fetch_optional(self, app_db: &mut AppDbClient) -> QueryResult<Option<User>> {
|
||||||
|
Ok(query_as!(
|
||||||
|
User,
|
||||||
|
"select id, uid, email from users where uid = $1",
|
||||||
|
self.uid,
|
||||||
|
)
|
||||||
|
.fetch_optional(app_db.get_conn())
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct WithEmailQuery<'a> {
|
||||||
|
email: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WithEmailQuery<'_> {
|
||||||
|
pub async fn fetch_optional(self, app_db: &mut AppDbClient) -> QueryResult<Option<User>> {
|
||||||
|
Ok(query_as!(
|
||||||
|
User,
|
||||||
|
"select id, uid, email from users where email = lower($1)",
|
||||||
|
self.email,
|
||||||
|
)
|
||||||
|
.fetch_optional(app_db.get_conn())
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
207
phono-server/src/invites.rs
Normal file
207
phono-server/src/invites.rs
Normal file
|
|
@ -0,0 +1,207 @@
|
||||||
|
use std::fmt::Display;
|
||||||
|
|
||||||
|
use derive_builder::Builder;
|
||||||
|
use phono_backends::client::WorkspaceClient;
|
||||||
|
use phono_models::{
|
||||||
|
invite::Invite, invite_rel_permission::RelPermissionKind, service_cred::ServiceCred, user::User,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Permissions are not always granted to users, but may also be applied to
|
||||||
|
/// future users (via invites) or service credentials. This enum spans all
|
||||||
|
/// possible targets to provide a unified representation for
|
||||||
|
/// [`PermissionsEditor`].
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub(crate) enum Grantee {
|
||||||
|
User(User),
|
||||||
|
Invite(Invite),
|
||||||
|
ServiceCred(ServiceCred),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for Grantee {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::User(user) => write!(f, "usr_{0}", user.id.simple()),
|
||||||
|
Self::Invite(invite) => write!(f, "invite_{0}", invite.id.simple()),
|
||||||
|
Self::ServiceCred(cred) => write!(f, "svc_{0}", cred.id.simple()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, PartialEq, Eq, thiserror::Error)]
|
||||||
|
#[error("mismatched PermissionsTarget variant")]
|
||||||
|
pub(crate) struct InvalidVariantError;
|
||||||
|
|
||||||
|
impl TryFrom<Grantee> for User {
|
||||||
|
type Error = InvalidVariantError;
|
||||||
|
|
||||||
|
fn try_from(value: Grantee) -> Result<Self, Self::Error> {
|
||||||
|
if let Grantee::User(user) = value {
|
||||||
|
Ok(user)
|
||||||
|
} else {
|
||||||
|
Err(InvalidVariantError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<Grantee> for Invite {
|
||||||
|
type Error = InvalidVariantError;
|
||||||
|
|
||||||
|
fn try_from(value: Grantee) -> Result<Self, Self::Error> {
|
||||||
|
if let Grantee::Invite(invite) = value {
|
||||||
|
Ok(invite)
|
||||||
|
} else {
|
||||||
|
Err(InvalidVariantError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<Grantee> for ServiceCred {
|
||||||
|
type Error = InvalidVariantError;
|
||||||
|
|
||||||
|
fn try_from(value: Grantee) -> Result<Self, Self::Error> {
|
||||||
|
if let Grantee::ServiceCred(cred) = value {
|
||||||
|
Ok(cred)
|
||||||
|
} else {
|
||||||
|
Err(InvalidVariantError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Builder, Debug)]
|
||||||
|
#[builder(
|
||||||
|
build_fn(error = "QueryError", vis = ""),
|
||||||
|
pattern = "owned",
|
||||||
|
vis = "pub(crate)"
|
||||||
|
)]
|
||||||
|
#[builder_struct_attr(doc = r"
|
||||||
|
A generalized mechanism for assigning relation permissions to a [`Grantee`].
|
||||||
|
It will make a best effort to resolve the delta between
|
||||||
|
`target.current_perms` and `desired_perms`.
|
||||||
|
|
||||||
|
Note that when grantee is not an invite, the permissions held by the
|
||||||
|
provided [`WorkspaceClient`] determine which actual Postgres roles may be
|
||||||
|
granted or revoked. For this reason, the requested permissions update may
|
||||||
|
only be partially applied, even if the [`PermissionsUpdateBuilder::execute`]
|
||||||
|
call succeeds.
|
||||||
|
|
||||||
|
The update operation does not double-check the authenticity of
|
||||||
|
`target.current_perms`, so it is technically valid to include only the
|
||||||
|
relevant subset of current relation permissions, so long as the difference
|
||||||
|
between `target.current_perms` and `desired_perms` remains as intended. For
|
||||||
|
example, if permissions are being granted only, the caller may leave
|
||||||
|
`target.current_perms` empty. This caller behavior is not recommended, but
|
||||||
|
it is permitted in the spirit of laziness.
|
||||||
|
")]
|
||||||
|
struct PermissionsUpdate<'a> {
|
||||||
|
target: Grantee,
|
||||||
|
desired_perms: HashSet<RelPermission>,
|
||||||
|
grantor_id: Uuid,
|
||||||
|
using_app_db: &'a mut AppDbClient,
|
||||||
|
using_workspace_client: &'a mut WorkspaceClient,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> PermissionsUpdateBuilder<'a> {
|
||||||
|
pub(crate) async fn execute(self) -> Result<(), AppError> {
|
||||||
|
let PermissionsUpdate {
|
||||||
|
target,
|
||||||
|
desired_perms: roles,
|
||||||
|
grantor_id,
|
||||||
|
using_app_db: app_db,
|
||||||
|
using_workspace_client: workspace_client,
|
||||||
|
} = self.build()?;
|
||||||
|
|
||||||
|
let perms_to_revoke: HashSet<_> = target.current_perms.difference(&roles).collect();
|
||||||
|
debug!(
|
||||||
|
"revoking {roles} from {target}",
|
||||||
|
roles = perms_to_revoke
|
||||||
|
.iter()
|
||||||
|
.map(|role| role.to_string())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", "),
|
||||||
|
);
|
||||||
|
let perms_to_grant: HashSet<_> = roles.difference(&target.current_perms).collect();
|
||||||
|
debug!(
|
||||||
|
"granting {roles} to {target}",
|
||||||
|
roles = perms_to_grant
|
||||||
|
.iter()
|
||||||
|
.map(|role| role.to_string())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", "),
|
||||||
|
);
|
||||||
|
|
||||||
|
match target.kind {
|
||||||
|
GranteeKind::Invite(invite) => {
|
||||||
|
let ids_to_delete: Vec<Uuid> = InviteRelPermission::belonging_to_invite(invite.id)
|
||||||
|
.fetch_all(app_db)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.filter(|invite_perm| {
|
||||||
|
perms_to_revoke.iter().any(|perm| {
|
||||||
|
perm.rel_oid == invite_perm.rel_oid
|
||||||
|
&& perm.kind == invite_perm.permission
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.map(|rel_invite| rel_invite.id)
|
||||||
|
.collect();
|
||||||
|
debug!("deleting {0} invite rel permissions", ids_to_delete.len());
|
||||||
|
InviteRelPermission::delete_by_ids(ids_to_delete)
|
||||||
|
.execute(app_db)
|
||||||
|
.await?;
|
||||||
|
for perm in perms_to_grant {
|
||||||
|
debug!("granting {perm}");
|
||||||
|
InviteRelPermission::upsert()
|
||||||
|
.invite_id(invite.id)
|
||||||
|
.rel_oid(perm.rel_oid)
|
||||||
|
.permission(perm.kind)
|
||||||
|
.granted_by(grantor_id)
|
||||||
|
.execute(app_db)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
GranteeKind::ServiceCred(ServiceCred { id, .. })
|
||||||
|
| GranteeKind::User(User { id, .. }) => {
|
||||||
|
let target_rolname = match target.kind {
|
||||||
|
GranteeKind::ServiceCred(cred) => cred.rolname.clone(),
|
||||||
|
GranteeKind::User(_) => format!("{ROLE_PREFIX_USER}{id}", id = id.simple()),
|
||||||
|
GranteeKind::Invite(_) => unreachable!("outer match arm excludes variant"),
|
||||||
|
};
|
||||||
|
for perm in perms_to_revoke {
|
||||||
|
let rel = PgClass::with_oid(perm.rel_oid)
|
||||||
|
.fetch_one(workspace_client)
|
||||||
|
.await?;
|
||||||
|
let target_rolname_esc = escape_identifier(&target_rolname);
|
||||||
|
let perm_rolname = match perm.kind {
|
||||||
|
RelPermissionKind::Owner => get_owner_role(&rel),
|
||||||
|
RelPermissionKind::Reader => get_reader_role(&rel),
|
||||||
|
RelPermissionKind::Writer => get_writer_role(&rel),
|
||||||
|
}?;
|
||||||
|
let perm_rolname_esc = escape_identifier(&perm_rolname);
|
||||||
|
query(&format!(
|
||||||
|
"revoke {perm_rolname_esc} from {target_rolname_esc}"
|
||||||
|
))
|
||||||
|
.execute(workspace_client.get_conn())
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
for perm in perms_to_grant {
|
||||||
|
let rel = PgClass::with_oid(perm.rel_oid)
|
||||||
|
.fetch_one(workspace_client)
|
||||||
|
.await?;
|
||||||
|
let target_rolname_esc = escape_identifier(&target_rolname);
|
||||||
|
let perm_rolname = match perm.kind {
|
||||||
|
RelPermissionKind::Owner => get_owner_role(&rel),
|
||||||
|
RelPermissionKind::Reader => get_reader_role(&rel),
|
||||||
|
RelPermissionKind::Writer => get_writer_role(&rel),
|
||||||
|
}?;
|
||||||
|
let perm_rolname_esc = escape_identifier(&perm_rolname);
|
||||||
|
query(&format!(
|
||||||
|
"grant {perm_rolname_esc} to {target_rolname_esc} with admin option"
|
||||||
|
))
|
||||||
|
.execute(workspace_client.get_conn())
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -32,8 +32,8 @@ mod errors;
|
||||||
mod extractors;
|
mod extractors;
|
||||||
mod field_info;
|
mod field_info;
|
||||||
mod navigator;
|
mod navigator;
|
||||||
|
mod permissions;
|
||||||
mod presentation_form;
|
mod presentation_form;
|
||||||
mod roles;
|
|
||||||
mod routes;
|
mod routes;
|
||||||
mod sessions;
|
mod sessions;
|
||||||
mod settings;
|
mod settings;
|
||||||
|
|
@ -41,7 +41,7 @@ mod user;
|
||||||
mod worker;
|
mod worker;
|
||||||
mod workspace_nav;
|
mod workspace_nav;
|
||||||
mod workspace_pooler;
|
mod workspace_pooler;
|
||||||
mod workspace_utils;
|
mod workspaces;
|
||||||
|
|
||||||
/// Run CLI
|
/// Run CLI
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
|
|
|
||||||
405
phono-server/src/permissions.rs
Normal file
405
phono-server/src/permissions.rs
Normal file
|
|
@ -0,0 +1,405 @@
|
||||||
|
use std::{
|
||||||
|
collections::{HashMap, HashSet},
|
||||||
|
fmt::Display,
|
||||||
|
};
|
||||||
|
|
||||||
|
use anyhow::anyhow;
|
||||||
|
use askama::Template;
|
||||||
|
use phono_backends::{
|
||||||
|
client::WorkspaceClient,
|
||||||
|
escape_identifier,
|
||||||
|
pg_acl::{PgAclItem, PgPrivilegeType},
|
||||||
|
pg_class::PgClass,
|
||||||
|
pg_role::RoleTree,
|
||||||
|
rolnames::{
|
||||||
|
ROLE_PREFIX_TABLE_OWNER, ROLE_PREFIX_TABLE_READER, ROLE_PREFIX_TABLE_WRITER,
|
||||||
|
ROLE_PREFIX_USER,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use phono_models::{accessors::Actor, service_cred::ServiceCred, user::User};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::{postgres::types::Oid, prelude::FromRow, query, query_as};
|
||||||
|
use tracing::{Instrument, info_span};
|
||||||
|
|
||||||
|
use crate::errors::AppError;
|
||||||
|
|
||||||
|
#[derive(
|
||||||
|
Clone,
|
||||||
|
Copy,
|
||||||
|
Debug,
|
||||||
|
Deserialize,
|
||||||
|
Eq,
|
||||||
|
Hash,
|
||||||
|
PartialEq,
|
||||||
|
Serialize,
|
||||||
|
strum::Display,
|
||||||
|
strum::EnumIs,
|
||||||
|
strum::EnumIter,
|
||||||
|
strum::EnumString,
|
||||||
|
)]
|
||||||
|
#[strum(serialize_all = "snake_case")]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum RelPermissionKind {
|
||||||
|
Owner,
|
||||||
|
Reader,
|
||||||
|
Writer,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, Hash, PartialEq, Template)]
|
||||||
|
#[template(path = "rel_permission.html")]
|
||||||
|
pub(crate) struct RelPermission {
|
||||||
|
pub(crate) kind: RelPermissionKind,
|
||||||
|
pub(crate) rel_oid: Oid,
|
||||||
|
pub(crate) relname: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RelPermission {
|
||||||
|
/// Attempt to infer value from a role name in a specific workspace. If the
|
||||||
|
/// role corresponds specifically to a relation and that relation is not
|
||||||
|
/// present in the current workspace, or if the role does not align with a
|
||||||
|
/// [`RelPermissionKind`], the returned value is `None`.
|
||||||
|
pub async fn from_rolname(
|
||||||
|
rolname: &str,
|
||||||
|
client: &mut WorkspaceClient,
|
||||||
|
) -> sqlx::Result<Option<Self>> {
|
||||||
|
let kind = if rolname.starts_with(ROLE_PREFIX_TABLE_OWNER) {
|
||||||
|
RelPermissionKind::Owner
|
||||||
|
} else if rolname.starts_with(ROLE_PREFIX_TABLE_READER) {
|
||||||
|
RelPermissionKind::Reader
|
||||||
|
} else if rolname.starts_with(ROLE_PREFIX_TABLE_WRITER) {
|
||||||
|
RelPermissionKind::Writer
|
||||||
|
} else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
#[derive(FromRow)]
|
||||||
|
struct RelInfo {
|
||||||
|
oid: Oid,
|
||||||
|
relname: String,
|
||||||
|
}
|
||||||
|
// TODO: Consider moving this to [`phono_backends`].
|
||||||
|
let mut rels: Vec<RelInfo> = query_as(
|
||||||
|
"
|
||||||
|
select oid, any_value(relname) as relname
|
||||||
|
from (
|
||||||
|
select
|
||||||
|
oid,
|
||||||
|
relname,
|
||||||
|
(aclexplode(relacl)).grantee as grantee
|
||||||
|
from pg_class
|
||||||
|
)
|
||||||
|
where grantee = $1::regrole::oid
|
||||||
|
group by oid
|
||||||
|
",
|
||||||
|
)
|
||||||
|
.bind(rolname)
|
||||||
|
.fetch_all(client.get_conn())
|
||||||
|
.await?;
|
||||||
|
assert!(rels.len() <= 1);
|
||||||
|
Ok(rels.pop().map(|rel| Self {
|
||||||
|
kind,
|
||||||
|
rel_oid: rel.oid,
|
||||||
|
relname: rel.relname,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is implemented so that `RelPermission` can be constructed inline in
|
||||||
|
// Askama templates via `(kind, relname, oid).into()`.
|
||||||
|
impl From<(RelPermissionKind, String, Oid)> for RelPermission {
|
||||||
|
fn from((kind, relname, rel_oid): (RelPermissionKind, String, Oid)) -> Self {
|
||||||
|
Self {
|
||||||
|
kind,
|
||||||
|
relname,
|
||||||
|
rel_oid,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, strum::EnumIs)]
|
||||||
|
pub(crate) enum Grantee {
|
||||||
|
User(User),
|
||||||
|
ServiceCred(ServiceCred),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for Grantee {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::User(User { email, .. }) => write!(f, "{email}"),
|
||||||
|
Self::ServiceCred(cred) => write!(f, "svc_{0}", cred.id.simple()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
#[error("invalid variant")]
|
||||||
|
pub(crate) struct InvalidVariantError;
|
||||||
|
|
||||||
|
impl TryFrom<Grantee> for User {
|
||||||
|
type Error = InvalidVariantError;
|
||||||
|
|
||||||
|
fn try_from(value: Grantee) -> Result<Self, Self::Error> {
|
||||||
|
if let Grantee::User(user) = value {
|
||||||
|
Ok(user)
|
||||||
|
} else {
|
||||||
|
Err(InvalidVariantError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Askama template which renders a collection of [`RelPermission`] badges and
|
||||||
|
/// an "Edit" button which opens a dialog with a form allowing permissions to be
|
||||||
|
/// assigned across multiple tables within a workspace.
|
||||||
|
#[derive(Clone, Debug, Template)]
|
||||||
|
#[template(path = "workspaces_single/permissions_editor.html")]
|
||||||
|
pub(crate) struct PermissionsEditor {
|
||||||
|
/// User, invite, or service credential being granted permissions.
|
||||||
|
pub(crate) target: Grantee,
|
||||||
|
|
||||||
|
pub(crate) current_perms: HashSet<RelPermission>,
|
||||||
|
|
||||||
|
/// Endpoint to use in the `<form action="">` attribute.
|
||||||
|
pub(crate) update_endpoint: String,
|
||||||
|
|
||||||
|
/// Set to true to show the "table owner" column or false to hide it.
|
||||||
|
pub(crate) include_owner: bool,
|
||||||
|
|
||||||
|
pub(crate) all_rels: Vec<PgClass>,
|
||||||
|
|
||||||
|
/// Additional form fields to be rendered as `<input type="hidden">`.
|
||||||
|
pub(crate) hidden_inputs: HashMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attempt to grant permissions on a relation. Permissions for which `actor`
|
||||||
|
/// does not hold the `ADMIN OPTION` in the backing database will be skipped
|
||||||
|
/// with a warning.
|
||||||
|
///
|
||||||
|
/// The role with which `workspace_client` is authenticated is important.
|
||||||
|
/// Postgres considers all granted privileges "dependent" on the admin
|
||||||
|
/// privileges of the role in use when granting them. Revoking the admin
|
||||||
|
/// privileges of the grantor will cascade if no other roles have granted the
|
||||||
|
/// same privileges independently to the same grantee. In practice, this means
|
||||||
|
/// that service credentials should be granted permissions via a client
|
||||||
|
/// authenticated as their owner, and other users should be granted permissions
|
||||||
|
/// via a client authenticated as the Phonograph root user. This behavior is
|
||||||
|
/// checked by debug assertions, unless `actor` is set to `Actor::Bypass`.
|
||||||
|
pub(crate) async fn grant_rel_permissions(
|
||||||
|
target: Grantee,
|
||||||
|
actor: Actor,
|
||||||
|
perms: HashSet<RelPermission>,
|
||||||
|
workspace_client: &mut WorkspaceClient,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
async {
|
||||||
|
let target_rolname = match target.clone() {
|
||||||
|
Grantee::ServiceCred(cred) => cred.rolname.clone(),
|
||||||
|
Grantee::User(User { id, .. }) => format!("{ROLE_PREFIX_USER}{id}", id = id.simple()),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Assert that function is called with `workspace_client` authentication
|
||||||
|
// configured correctly. The check incurs an extra round trip to the backing
|
||||||
|
// database, so it is skipped in production builds.
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
{
|
||||||
|
if let Actor::User(actor_id) = actor {
|
||||||
|
let actor_rolname = format!("{ROLE_PREFIX_USER}{id}", id = actor_id.simple());
|
||||||
|
#[derive(FromRow)]
|
||||||
|
struct CurrentUserRow {
|
||||||
|
current_user: String,
|
||||||
|
}
|
||||||
|
let CurrentUserRow {
|
||||||
|
current_user: workspace_current_user,
|
||||||
|
} = query_as("select current_user")
|
||||||
|
.fetch_one(workspace_client.get_conn())
|
||||||
|
.await?;
|
||||||
|
match target {
|
||||||
|
Grantee::ServiceCred(_) => {
|
||||||
|
debug_assert!(workspace_current_user == actor_rolname)
|
||||||
|
}
|
||||||
|
Grantee::User(_) => debug_assert!(workspace_current_user != actor_rolname),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// `None` means that we're using `Actor::Bypass` and all roles are on the table.
|
||||||
|
let permitted_rolnames: Option<HashSet<String>> = if let Actor::User(actor_id) = actor {
|
||||||
|
let actor_rolname = format!("{ROLE_PREFIX_USER}{id}", id = actor_id.simple());
|
||||||
|
Some(
|
||||||
|
RoleTree::granted_to_rolname(&actor_rolname)
|
||||||
|
.fetch_tree(workspace_client)
|
||||||
|
.await?
|
||||||
|
.ok_or(anyhow!("failed to fetch role tree"))?
|
||||||
|
.flatten_inherited()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|value| value.admin)
|
||||||
|
.map(|value| value.role.rolname)
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let target_rolname_esc = escape_identifier(&target_rolname);
|
||||||
|
for perm in perms {
|
||||||
|
let rel = PgClass::with_oid(perm.rel_oid)
|
||||||
|
.fetch_one(workspace_client)
|
||||||
|
.await?;
|
||||||
|
let perm_rolname = match perm.kind {
|
||||||
|
RelPermissionKind::Owner => get_owner_role(&rel),
|
||||||
|
RelPermissionKind::Reader => get_reader_role(&rel),
|
||||||
|
RelPermissionKind::Writer => get_writer_role(&rel),
|
||||||
|
}?;
|
||||||
|
if permitted_rolnames
|
||||||
|
.as_ref()
|
||||||
|
.is_none_or(|permitted_rolnames| {
|
||||||
|
permitted_rolnames.contains(perm_rolname)
|
||||||
|
})
|
||||||
|
{
|
||||||
|
let perm_rolname_esc = escape_identifier(perm_rolname);
|
||||||
|
query(&format!(
|
||||||
|
"grant {perm_rolname_esc} to {target_rolname_esc} with admin option"
|
||||||
|
))
|
||||||
|
.execute(workspace_client.get_conn())
|
||||||
|
.await?;
|
||||||
|
} else {
|
||||||
|
tracing::warn!("{actor} attempted to grant {perm_rolname} for which they do not have the admin option");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
.instrument(info_span!("grant_rel_permissions()"))
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attempt to revoke permissions on a relation.
|
||||||
|
///
|
||||||
|
/// Similarly to [`grant_rel_permissions`], the role with which
|
||||||
|
/// `workspace_client` is authenticated is important, in this case because
|
||||||
|
/// Postgres only allows privileges to be revoked by the role that granted them.
|
||||||
|
/// Revoking privileges using a client authenticated as a different role will
|
||||||
|
/// have no effect.
|
||||||
|
///
|
||||||
|
/// Note that if the same privilege has been granted multiple times by different
|
||||||
|
/// grantor roles, that privilege will remain in effect until all grants have
|
||||||
|
/// been undone. In practice, Phonograph should always be granting privileges to
|
||||||
|
/// user-specific Postgres roles via the Phonograph root role and to service
|
||||||
|
/// credential roles via the role associated with the credential's owner, so
|
||||||
|
/// redundant grants should be encountered rarely if ever.
|
||||||
|
pub(crate) async fn revoke_rel_permissions(
|
||||||
|
target: Grantee,
|
||||||
|
perms: HashSet<RelPermission>,
|
||||||
|
workspace_client: &mut WorkspaceClient,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let target_rolname = match target {
|
||||||
|
Grantee::ServiceCred(cred) => cred.rolname.clone(),
|
||||||
|
Grantee::User(User { id, .. }) => format!("{ROLE_PREFIX_USER}{id}", id = id.simple()),
|
||||||
|
};
|
||||||
|
for perm in perms {
|
||||||
|
let rel = PgClass::with_oid(perm.rel_oid)
|
||||||
|
.fetch_one(workspace_client)
|
||||||
|
.await?;
|
||||||
|
let target_rolname_esc = escape_identifier(&target_rolname);
|
||||||
|
let perm_rolname = match perm.kind {
|
||||||
|
RelPermissionKind::Owner => get_owner_role(&rel),
|
||||||
|
RelPermissionKind::Reader => get_reader_role(&rel),
|
||||||
|
RelPermissionKind::Writer => get_writer_role(&rel),
|
||||||
|
}?;
|
||||||
|
let perm_rolname_esc = escape_identifier(perm_rolname);
|
||||||
|
query(&format!(
|
||||||
|
"revoke {perm_rolname_esc} from {target_rolname_esc} cascade"
|
||||||
|
))
|
||||||
|
.execute(workspace_client.get_conn())
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Error type returned by functions which find certain Phonograph-generated
|
||||||
|
/// roles within relation ACLs.
|
||||||
|
#[derive(Clone, Copy, Debug, thiserror::Error)]
|
||||||
|
pub(crate) enum GetTableRoleError {
|
||||||
|
#[error("relacl field not present on pg_class record")]
|
||||||
|
MissingAcl,
|
||||||
|
#[error("no role found with prefix {role_prefix}")]
|
||||||
|
MissingRole { role_prefix: &'static str },
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_table_role<'a>(
|
||||||
|
relacl: Option<&'a Vec<PgAclItem>>,
|
||||||
|
required_privileges: HashSet<PgPrivilegeType>,
|
||||||
|
disallowed_privileges: HashSet<PgPrivilegeType>,
|
||||||
|
role_prefix: &'static str,
|
||||||
|
) -> Result<&'a str, GetTableRoleError> {
|
||||||
|
relacl
|
||||||
|
.ok_or(GetTableRoleError::MissingAcl)?
|
||||||
|
.iter()
|
||||||
|
.find_map(|acl_item| {
|
||||||
|
if acl_item.grantee.starts_with(role_prefix) {
|
||||||
|
let privileges_set: HashSet<PgPrivilegeType> = acl_item
|
||||||
|
.privileges
|
||||||
|
.iter()
|
||||||
|
.map(|privilege| privilege.privilege)
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
privileges_set.intersection(&required_privileges).count()
|
||||||
|
== required_privileges.len()
|
||||||
|
);
|
||||||
|
assert!(privileges_set.intersection(&disallowed_privileges).count() == 0);
|
||||||
|
Some(acl_item.grantee.as_str())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.ok_or(GetTableRoleError::MissingRole { role_prefix })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the name of the "table_owner" role created by Phonograph for a
|
||||||
|
/// particular workspace table. The role is assessed based on its name and the
|
||||||
|
/// table permissions directly granted to it. Returns an error if no matching
|
||||||
|
/// role is found, and panics if a role is found with excess permissions
|
||||||
|
/// granted to it directly.
|
||||||
|
pub(crate) fn get_owner_role(rel: &PgClass) -> Result<&str, GetTableRoleError> {
|
||||||
|
get_table_role(
|
||||||
|
rel.relacl.as_ref(),
|
||||||
|
[
|
||||||
|
PgPrivilegeType::Insert,
|
||||||
|
PgPrivilegeType::Update,
|
||||||
|
PgPrivilegeType::Delete,
|
||||||
|
PgPrivilegeType::Truncate,
|
||||||
|
]
|
||||||
|
.into(),
|
||||||
|
[].into(),
|
||||||
|
ROLE_PREFIX_TABLE_OWNER,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the name of the "table_reader" role created by Phonograph for a
|
||||||
|
/// particular workspace table. The role is assessed based on its name and the
|
||||||
|
/// table permissions directly granted to it. Returns an error if no matching
|
||||||
|
/// role is found, and panics if a role is found with excess permissions
|
||||||
|
/// granted to it directly.
|
||||||
|
pub(crate) fn get_reader_role(rel: &PgClass) -> Result<&str, GetTableRoleError> {
|
||||||
|
get_table_role(
|
||||||
|
rel.relacl.as_ref(),
|
||||||
|
[PgPrivilegeType::Select].into(),
|
||||||
|
[
|
||||||
|
PgPrivilegeType::Insert,
|
||||||
|
PgPrivilegeType::Update,
|
||||||
|
PgPrivilegeType::Delete,
|
||||||
|
PgPrivilegeType::Truncate,
|
||||||
|
]
|
||||||
|
.into(),
|
||||||
|
ROLE_PREFIX_TABLE_READER,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the name of the "table_writer" role created by Phonograph for a
|
||||||
|
/// particular workspace table. The role is assessed based on its name and the
|
||||||
|
/// table permissions directly granted to it. Returns an error if no matching
|
||||||
|
/// role is found, and panics if a role is found with excess permissions
|
||||||
|
/// granted to it directly.
|
||||||
|
pub(crate) fn get_writer_role(rel: &PgClass) -> Result<&str, GetTableRoleError> {
|
||||||
|
get_table_role(
|
||||||
|
rel.relacl.as_ref(),
|
||||||
|
[PgPrivilegeType::Delete, PgPrivilegeType::Truncate].into(),
|
||||||
|
[PgPrivilegeType::Select].into(),
|
||||||
|
ROLE_PREFIX_TABLE_WRITER,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,151 +0,0 @@
|
||||||
use std::collections::HashSet;
|
|
||||||
|
|
||||||
use anyhow::anyhow;
|
|
||||||
use askama::Template;
|
|
||||||
use phono_backends::{
|
|
||||||
client::WorkspaceClient,
|
|
||||||
pg_acl::{PgAclItem, PgPrivilegeType},
|
|
||||||
pg_class::PgClass,
|
|
||||||
rolnames::{ROLE_PREFIX_TABLE_OWNER, ROLE_PREFIX_TABLE_READER, ROLE_PREFIX_TABLE_WRITER},
|
|
||||||
};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use sqlx::{postgres::types::Oid, prelude::FromRow, query_as};
|
|
||||||
|
|
||||||
use crate::errors::AppError;
|
|
||||||
|
|
||||||
// TODO: custom error type
|
|
||||||
// TODO: make params and result references
|
|
||||||
fn get_table_role(
|
|
||||||
relacl: Option<Vec<PgAclItem>>,
|
|
||||||
required_privileges: HashSet<PgPrivilegeType>,
|
|
||||||
disallowed_privileges: HashSet<PgPrivilegeType>,
|
|
||||||
role_prefix: &str,
|
|
||||||
) -> Result<String, AppError> {
|
|
||||||
let mut roles: Vec<String> = vec![];
|
|
||||||
dbg!(&relacl);
|
|
||||||
for acl_item in relacl.ok_or(anyhow!("acl not present on class"))? {
|
|
||||||
if acl_item.grantee.starts_with(role_prefix) {
|
|
||||||
let privileges_set: HashSet<PgPrivilegeType> = acl_item
|
|
||||||
.privileges
|
|
||||||
.iter()
|
|
||||||
.map(|privilege| privilege.privilege)
|
|
||||||
.collect();
|
|
||||||
assert!(
|
|
||||||
privileges_set.intersection(&required_privileges).count()
|
|
||||||
== required_privileges.len()
|
|
||||||
);
|
|
||||||
assert!(privileges_set.intersection(&disallowed_privileges).count() == 0);
|
|
||||||
roles.push(acl_item.grantee)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assert!(roles.len() == 1);
|
|
||||||
Ok(roles
|
|
||||||
.first()
|
|
||||||
.expect("already asserted that `roles` has len 1")
|
|
||||||
.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the name of the "table_reader" role created by Phonograph for a
|
|
||||||
/// particular workspace table. The role is assessed based on its name and the
|
|
||||||
/// table permissions directly granted to it. Returns an error if no matching
|
|
||||||
/// role is found, and panics if a role is found with excess permissions
|
|
||||||
/// granted to it directly.
|
|
||||||
pub(crate) fn get_reader_role(rel: &PgClass) -> Result<String, AppError> {
|
|
||||||
get_table_role(
|
|
||||||
rel.relacl.clone(),
|
|
||||||
[PgPrivilegeType::Select].into(),
|
|
||||||
[
|
|
||||||
PgPrivilegeType::Insert,
|
|
||||||
PgPrivilegeType::Update,
|
|
||||||
PgPrivilegeType::Delete,
|
|
||||||
PgPrivilegeType::Truncate,
|
|
||||||
]
|
|
||||||
.into(),
|
|
||||||
ROLE_PREFIX_TABLE_READER,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the name of the "table_writer" role created by Phonograph for a
|
|
||||||
/// particular workspace table. The role is assessed based on its name and the
|
|
||||||
/// table permissions directly granted to it. Returns an error if no matching
|
|
||||||
/// role is found, and panics if a role is found with excess permissions
|
|
||||||
/// granted to it directly.
|
|
||||||
pub(crate) fn get_writer_role(rel: &PgClass) -> Result<String, AppError> {
|
|
||||||
get_table_role(
|
|
||||||
rel.relacl.clone(),
|
|
||||||
[PgPrivilegeType::Delete, PgPrivilegeType::Truncate].into(),
|
|
||||||
[PgPrivilegeType::Select].into(),
|
|
||||||
ROLE_PREFIX_TABLE_WRITER,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Template, Serialize)]
|
|
||||||
#[template(path = "role_display.html")]
|
|
||||||
pub(crate) enum RoleDisplay {
|
|
||||||
TableOwner { oid: Oid, relname: String },
|
|
||||||
TableReader { oid: Oid, relname: String },
|
|
||||||
TableWriter { oid: Oid, relname: String },
|
|
||||||
Unknown { rolname: String },
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RoleDisplay {
|
|
||||||
/// Attempt to infer value from a role name in a specific workspace. If the
|
|
||||||
/// role corresponds specifically to a relation and that relation is not
|
|
||||||
/// present in the current workspace, the returned value is `None`.
|
|
||||||
pub async fn from_rolname(
|
|
||||||
rolname: &str,
|
|
||||||
client: &mut WorkspaceClient,
|
|
||||||
) -> sqlx::Result<Option<Self>> {
|
|
||||||
if rolname.starts_with(ROLE_PREFIX_TABLE_OWNER)
|
|
||||||
|| rolname.starts_with(ROLE_PREFIX_TABLE_READER)
|
|
||||||
|| rolname.starts_with(ROLE_PREFIX_TABLE_WRITER)
|
|
||||||
{
|
|
||||||
#[derive(FromRow)]
|
|
||||||
struct RelInfo {
|
|
||||||
oid: Oid,
|
|
||||||
relname: String,
|
|
||||||
}
|
|
||||||
// TODO: Consider moving this to [`phono_backends`].
|
|
||||||
let mut rels: Vec<RelInfo> = query_as(
|
|
||||||
r#"
|
|
||||||
select oid, any_value(relname) as relname
|
|
||||||
from (
|
|
||||||
select
|
|
||||||
oid,
|
|
||||||
relname,
|
|
||||||
(aclexplode(relacl)).grantee as grantee
|
|
||||||
from pg_class
|
|
||||||
)
|
|
||||||
where grantee = $1::regrole::oid
|
|
||||||
group by oid
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(rolname)
|
|
||||||
.fetch_all(client.get_conn())
|
|
||||||
.await?;
|
|
||||||
assert!(rels.len() <= 1);
|
|
||||||
Ok(rels.pop().map(|rel| {
|
|
||||||
if rolname.starts_with(ROLE_PREFIX_TABLE_OWNER) {
|
|
||||||
Self::TableOwner {
|
|
||||||
oid: rel.oid,
|
|
||||||
relname: rel.relname,
|
|
||||||
}
|
|
||||||
} else if rolname.starts_with(ROLE_PREFIX_TABLE_READER) {
|
|
||||||
Self::TableReader {
|
|
||||||
oid: rel.oid,
|
|
||||||
relname: rel.relname,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Self::TableWriter {
|
|
||||||
oid: rel.oid,
|
|
||||||
relname: rel.relname,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
} else {
|
|
||||||
Ok(Some(Self::Unknown {
|
|
||||||
rolname: rolname.to_owned(),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -20,8 +20,8 @@ use crate::{
|
||||||
app::AppDbConn,
|
app::AppDbConn,
|
||||||
errors::AppError,
|
errors::AppError,
|
||||||
navigator::{Navigator, NavigatorPage},
|
navigator::{Navigator, NavigatorPage},
|
||||||
|
permissions::get_writer_role,
|
||||||
presentation_form::PresentationForm,
|
presentation_form::PresentationForm,
|
||||||
roles::get_writer_role,
|
|
||||||
user::CurrentUser,
|
user::CurrentUser,
|
||||||
workspace_pooler::{RoleAssignment, WorkspacePooler},
|
workspace_pooler::{RoleAssignment, WorkspacePooler},
|
||||||
};
|
};
|
||||||
|
|
@ -122,7 +122,7 @@ pub(super) async fn post(
|
||||||
"grant insert ({col}), update ({col}) on table {ident} to {writer_role}",
|
"grant insert ({col}), update ({col}) on table {ident} to {writer_role}",
|
||||||
col = escape_identifier(&form.name),
|
col = escape_identifier(&form.name),
|
||||||
ident = rel.get_identifier(),
|
ident = rel.get_identifier(),
|
||||||
writer_role = escape_identifier(&get_writer_role(&rel)?),
|
writer_role = escape_identifier(get_writer_role(&rel)?),
|
||||||
))
|
))
|
||||||
.execute(workspace_client.get_conn())
|
.execute(workspace_client.get_conn())
|
||||||
.await?;
|
.await?;
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ use crate::{
|
||||||
user::CurrentUser,
|
user::CurrentUser,
|
||||||
workspace_nav::{NavLocation, RelLocation, WorkspaceNav},
|
workspace_nav::{NavLocation, RelLocation, WorkspaceNav},
|
||||||
workspace_pooler::{RoleAssignment, WorkspacePooler},
|
workspace_pooler::{RoleAssignment, WorkspacePooler},
|
||||||
workspace_utils::{RelationPortalSet, fetch_all_accessible_portals},
|
workspaces::{RelationPortalSet, fetch_all_accessible_portals},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ mod portal_settings_handler;
|
||||||
mod remove_field_handler;
|
mod remove_field_handler;
|
||||||
mod set_filter_handler;
|
mod set_filter_handler;
|
||||||
mod settings_handler;
|
mod settings_handler;
|
||||||
mod settings_invite_handler;
|
|
||||||
mod update_field_handler;
|
mod update_field_handler;
|
||||||
mod update_field_ordinality_handler;
|
mod update_field_ordinality_handler;
|
||||||
mod update_form_transitions_handler;
|
mod update_form_transitions_handler;
|
||||||
|
|
@ -28,7 +27,6 @@ mod update_values_handler;
|
||||||
pub(super) fn new_router() -> Router<App> {
|
pub(super) fn new_router() -> Router<App> {
|
||||||
Router::<App>::new()
|
Router::<App>::new()
|
||||||
.route_with_tsr("/settings/", get(settings_handler::get))
|
.route_with_tsr("/settings/", get(settings_handler::get))
|
||||||
.route("/settings/invite", post(settings_invite_handler::post))
|
|
||||||
.route("/settings/update-name", post(update_rel_name_handler::post))
|
.route("/settings/update-name", post(update_rel_name_handler::post))
|
||||||
.route("/add-portal", post(add_portal_handler::post))
|
.route("/add-portal", post(add_portal_handler::post))
|
||||||
.route_with_tsr("/p/{portal_id}/", get(portal_handler::get))
|
.route_with_tsr("/p/{portal_id}/", get(portal_handler::get))
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ use crate::{
|
||||||
navigator::{Navigator, NavigatorPage as _},
|
navigator::{Navigator, NavigatorPage as _},
|
||||||
user::CurrentUser,
|
user::CurrentUser,
|
||||||
workspace_pooler::{RoleAssignment, WorkspacePooler},
|
workspace_pooler::{RoleAssignment, WorkspacePooler},
|
||||||
workspace_utils::PHONO_TABLE_NAMESPACE,
|
workspaces::PHONO_TABLE_NAMESPACE,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// HTTP POST handler for creating a new workspace. This handler does not expect
|
/// HTTP POST handler for creating a new workspace. This handler does not expect
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ use crate::{
|
||||||
navigator::{Navigator, NavigatorPage},
|
navigator::{Navigator, NavigatorPage},
|
||||||
user::CurrentUser,
|
user::CurrentUser,
|
||||||
workspace_pooler::{RoleAssignment, WorkspacePooler},
|
workspace_pooler::{RoleAssignment, WorkspacePooler},
|
||||||
workspace_utils::PHONO_TABLE_NAMESPACE,
|
workspaces::PHONO_TABLE_NAMESPACE,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ use crate::{
|
||||||
navigator::{Navigator, NavigatorPage as _},
|
navigator::{Navigator, NavigatorPage as _},
|
||||||
user::CurrentUser,
|
user::CurrentUser,
|
||||||
workspace_pooler::{RoleAssignment, WorkspacePooler},
|
workspace_pooler::{RoleAssignment, WorkspacePooler},
|
||||||
workspace_utils::PHONO_TABLE_NAMESPACE,
|
workspaces::PHONO_TABLE_NAMESPACE,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,103 @@
|
||||||
|
use axum::{
|
||||||
|
debug_handler,
|
||||||
|
extract::{Path, State},
|
||||||
|
response::IntoResponse,
|
||||||
|
};
|
||||||
|
use phono_backends::{escape_identifier, pg_database::PgDatabase, rolnames::ROLE_PREFIX_USER};
|
||||||
|
use phono_models::{
|
||||||
|
accessors::{Accessor as _, Actor, workspace::WorkspaceAccessor},
|
||||||
|
user::User,
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use sqlx::query;
|
||||||
|
use uuid::Uuid;
|
||||||
|
use validator::Validate;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
app::AppDbConn,
|
||||||
|
errors::AppError,
|
||||||
|
extractors::ValidatedForm,
|
||||||
|
navigator::{Navigator, NavigatorPage as _},
|
||||||
|
user::CurrentUser,
|
||||||
|
workspace_pooler::{RoleAssignment, WorkspacePooler},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub(super) struct PathParams {
|
||||||
|
workspace_id: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Validate)]
|
||||||
|
pub(super) struct FormBody {
|
||||||
|
email: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HTTP POST handler for granting another user (or future user with matching
|
||||||
|
/// email) access to a workspace.
|
||||||
|
#[debug_handler(state = crate::app::App)]
|
||||||
|
pub(super) async fn post(
|
||||||
|
State(mut pooler): State<WorkspacePooler>,
|
||||||
|
AppDbConn(mut app_db): AppDbConn,
|
||||||
|
CurrentUser(user): CurrentUser,
|
||||||
|
navigator: Navigator,
|
||||||
|
Path(PathParams { workspace_id }): Path<PathParams>,
|
||||||
|
ValidatedForm(form): ValidatedForm<FormBody>,
|
||||||
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
|
// FIXME: CSRF
|
||||||
|
|
||||||
|
let mut workspace_client = pooler
|
||||||
|
.acquire_for(workspace_id, RoleAssignment::User(user.id))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
WorkspaceAccessor::new()
|
||||||
|
.id(workspace_id)
|
||||||
|
.as_actor(Actor::User(user.id))
|
||||||
|
.using_app_db(&mut app_db)
|
||||||
|
// This could in theory be done with `root_client` instead to save some
|
||||||
|
// energy. It has been left as-is merely for consistency/convenience.
|
||||||
|
.using_workspace_client(&mut workspace_client)
|
||||||
|
.fetch_one()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let target_user = if let Some(target_user) = User::with_email(&form.email)
|
||||||
|
.fetch_optional(&mut app_db)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
target_user
|
||||||
|
} else {
|
||||||
|
User::upsert()
|
||||||
|
.email(&form.email)
|
||||||
|
.execute(&mut app_db)
|
||||||
|
.await?
|
||||||
|
};
|
||||||
|
let mut root_client = pooler
|
||||||
|
.acquire_for(workspace_id, RoleAssignment::Root)
|
||||||
|
.await?;
|
||||||
|
root_client
|
||||||
|
.ensure_role(&format!(
|
||||||
|
"{ROLE_PREFIX_USER}{id}",
|
||||||
|
id = target_user.id.simple()
|
||||||
|
))
|
||||||
|
.await?;
|
||||||
|
let db_name = PgDatabase::current()
|
||||||
|
.fetch_one(&mut root_client)
|
||||||
|
.await?
|
||||||
|
.datname;
|
||||||
|
query(&format!(
|
||||||
|
"grant connect on database {db_name_esc} to {rolname}",
|
||||||
|
db_name_esc = escape_identifier(&db_name),
|
||||||
|
rolname = escape_identifier(&format!(
|
||||||
|
"{ROLE_PREFIX_USER}{user_id}",
|
||||||
|
user_id = target_user.id.simple()
|
||||||
|
))
|
||||||
|
))
|
||||||
|
.execute(root_client.get_conn())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(navigator
|
||||||
|
.workspace_page()
|
||||||
|
.workspace_id(workspace_id)
|
||||||
|
.suffix("settings/")
|
||||||
|
.build()?
|
||||||
|
.redirect_to())
|
||||||
|
}
|
||||||
|
|
@ -14,10 +14,12 @@ use super::relations_single;
|
||||||
|
|
||||||
mod add_service_credential_handler;
|
mod add_service_credential_handler;
|
||||||
mod add_table_handler;
|
mod add_table_handler;
|
||||||
|
mod grant_workspace_privilege_handler;
|
||||||
mod nav_handler;
|
mod nav_handler;
|
||||||
mod service_credentials_handler;
|
mod service_credentials_handler;
|
||||||
mod settings_handler;
|
mod settings_handler;
|
||||||
mod update_name_handler;
|
mod update_name_handler;
|
||||||
|
mod update_rel_privileges_handler;
|
||||||
mod update_service_cred_permissions_handler;
|
mod update_service_cred_permissions_handler;
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
|
@ -44,6 +46,15 @@ pub(super) fn new_router() -> Router<App> {
|
||||||
"/{workspace_id}/settings/update-name",
|
"/{workspace_id}/settings/update-name",
|
||||||
post(update_name_handler::post),
|
post(update_name_handler::post),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/{workspace_id}/settings/grant-workspace-privilege",
|
||||||
|
post(grant_workspace_privilege_handler::post),
|
||||||
|
)
|
||||||
|
// TODO: Standardize naming of "permissions" vs. "privileges".
|
||||||
|
.route(
|
||||||
|
"/{workspace_id}/settings/update-rel-privileges",
|
||||||
|
post(update_rel_privileges_handler::post),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/{workspace_id}/service-credentials/add-service-credential",
|
"/{workspace_id}/service-credentials/add-service-credential",
|
||||||
post(add_service_credential_handler::post),
|
post(add_service_credential_handler::post),
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
use anyhow::anyhow;
|
use anyhow::anyhow;
|
||||||
use askama::Template;
|
use askama::Template;
|
||||||
use axum::{
|
use axum::{
|
||||||
|
|
@ -20,12 +22,12 @@ use crate::{
|
||||||
app::AppDbConn,
|
app::AppDbConn,
|
||||||
errors::AppError,
|
errors::AppError,
|
||||||
navigator::Navigator,
|
navigator::Navigator,
|
||||||
roles::RoleDisplay,
|
permissions::{Grantee, PermissionsEditor, RelPermission},
|
||||||
settings::Settings,
|
settings::Settings,
|
||||||
user::CurrentUser,
|
user::CurrentUser,
|
||||||
workspace_nav::WorkspaceNav,
|
workspace_nav::WorkspaceNav,
|
||||||
workspace_pooler::{RoleAssignment, WorkspacePooler},
|
workspace_pooler::{RoleAssignment, WorkspacePooler},
|
||||||
workspace_utils::PHONO_TABLE_NAMESPACE,
|
workspaces::PHONO_TABLE_NAMESPACE,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|
@ -64,55 +66,20 @@ pub(super) async fn get(
|
||||||
|
|
||||||
let cluster = workspace.fetch_cluster(&mut app_db).await?;
|
let cluster = workspace.fetch_cluster(&mut app_db).await?;
|
||||||
|
|
||||||
|
let all_rels: Vec<_> = {
|
||||||
|
let mut locked_client = workspace_client.lock().await;
|
||||||
|
PgClass::belonging_to_namespace(PHONO_TABLE_NAMESPACE)
|
||||||
|
.fetch_all(&mut locked_client)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.filter(|rel| rel.relkind == 'r' as i8)
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
struct ServiceCredInfo {
|
struct ServiceCredInfo {
|
||||||
service_cred: ServiceCred,
|
|
||||||
member_of: Vec<RoleDisplay>,
|
|
||||||
conn_string: Secret<Url>,
|
conn_string: Secret<Url>,
|
||||||
conn_string_redacted: String,
|
conn_string_redacted: String,
|
||||||
}
|
permissions_editor: PermissionsEditor,
|
||||||
|
|
||||||
impl ServiceCredInfo {
|
|
||||||
fn is_reader_of(&self, relname: &str) -> bool {
|
|
||||||
self.member_of.iter().any(|role_display| {
|
|
||||||
if let RoleDisplay::TableReader {
|
|
||||||
relname: role_relname,
|
|
||||||
..
|
|
||||||
} = role_display
|
|
||||||
{
|
|
||||||
role_relname == relname
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_writer_of(&self, relname: &str) -> bool {
|
|
||||||
self.member_of.iter().any(|role_display| {
|
|
||||||
if let RoleDisplay::TableWriter {
|
|
||||||
relname: role_relname,
|
|
||||||
..
|
|
||||||
} = role_display
|
|
||||||
{
|
|
||||||
role_relname == relname
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_owner_of(&self, relname: &str) -> bool {
|
|
||||||
self.member_of.iter().any(|role_display| {
|
|
||||||
if let RoleDisplay::TableOwner {
|
|
||||||
relname: role_relname,
|
|
||||||
..
|
|
||||||
} = role_display
|
|
||||||
{
|
|
||||||
role_relname == relname
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let service_cred_info = stream::iter(
|
let service_cred_info = stream::iter(
|
||||||
|
|
@ -121,7 +88,7 @@ pub(super) async fn get(
|
||||||
.await?,
|
.await?,
|
||||||
)
|
)
|
||||||
.then(async |cred| {
|
.then(async |cred| {
|
||||||
let member_of: Vec<RoleDisplay> = stream::iter({
|
let current_perms: HashSet<RelPermission> = stream::iter({
|
||||||
// Guard must be assigned to a local variable,
|
// Guard must be assigned to a local variable,
|
||||||
// lest the mutex become deadlocked.
|
// lest the mutex become deadlocked.
|
||||||
let mut locked_client = workspace_client.lock().await;
|
let mut locked_client = workspace_client.lock().await;
|
||||||
|
|
@ -131,11 +98,11 @@ pub(super) async fn get(
|
||||||
.ok_or(anyhow!("listing roles for service cred: role tree is None"))?
|
.ok_or(anyhow!("listing roles for service cred: role tree is None"))?
|
||||||
.flatten_inherited()
|
.flatten_inherited()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|role| role.rolname != cred.rolname)
|
.filter(|role| role.role.rolname != cred.rolname)
|
||||||
})
|
})
|
||||||
.then(async |role| {
|
.then(async |role| {
|
||||||
let mut locked_client = workspace_client.lock().await;
|
let mut locked_client = workspace_client.lock().await;
|
||||||
RoleDisplay::from_rolname(&role.rolname, &mut locked_client).await
|
RelPermission::from_rolname(&role.role.rolname, &mut locked_client).await
|
||||||
})
|
})
|
||||||
.collect::<Vec<Result<_, _>>>()
|
.collect::<Vec<Result<_, _>>>()
|
||||||
.await
|
.await
|
||||||
|
|
@ -157,8 +124,17 @@ pub(super) async fn get(
|
||||||
Ok(ServiceCredInfo {
|
Ok(ServiceCredInfo {
|
||||||
conn_string,
|
conn_string,
|
||||||
conn_string_redacted: "postgresql://********".to_owned(),
|
conn_string_redacted: "postgresql://********".to_owned(),
|
||||||
member_of,
|
permissions_editor: PermissionsEditor {
|
||||||
service_cred: cred,
|
update_endpoint: format!(
|
||||||
|
"{cred_id}/update-permissions",
|
||||||
|
cred_id = cred.id.simple()
|
||||||
|
),
|
||||||
|
current_perms,
|
||||||
|
target: Grantee::ServiceCred(cred),
|
||||||
|
include_owner: false,
|
||||||
|
all_rels: all_rels.clone(),
|
||||||
|
hidden_inputs: [].into(),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect::<Vec<Result<ServiceCredInfo, AppError>>>()
|
.collect::<Vec<Result<ServiceCredInfo, AppError>>>()
|
||||||
|
|
@ -169,7 +145,6 @@ pub(super) async fn get(
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
#[template(path = "workspaces_single/service_credentials.html")]
|
#[template(path = "workspaces_single/service_credentials.html")]
|
||||||
struct ResponseTemplate {
|
struct ResponseTemplate {
|
||||||
all_rels: Vec<PgClass>,
|
|
||||||
service_cred_info: Vec<ServiceCredInfo>,
|
service_cred_info: Vec<ServiceCredInfo>,
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
workspace_nav: WorkspaceNav,
|
workspace_nav: WorkspaceNav,
|
||||||
|
|
@ -180,12 +155,6 @@ pub(super) async fn get(
|
||||||
.ok_or(anyhow!("mutex should be unlocked"))?;
|
.ok_or(anyhow!("mutex should be unlocked"))?;
|
||||||
Ok(Html(
|
Ok(Html(
|
||||||
ResponseTemplate {
|
ResponseTemplate {
|
||||||
all_rels: PgClass::belonging_to_namespace(PHONO_TABLE_NAMESPACE)
|
|
||||||
.fetch_all(&mut locked_client)
|
|
||||||
.await?
|
|
||||||
.into_iter()
|
|
||||||
.filter(|rel| rel.relkind == 'r' as i8)
|
|
||||||
.collect(),
|
|
||||||
workspace_nav: WorkspaceNav::builder()
|
workspace_nav: WorkspaceNav::builder()
|
||||||
.navigator(navigator)
|
.navigator(navigator)
|
||||||
.workspace(workspace.clone())
|
.workspace(workspace.clone())
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,22 @@
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use anyhow::anyhow;
|
||||||
use askama::Template;
|
use askama::Template;
|
||||||
use axum::{
|
use axum::{
|
||||||
debug_handler,
|
debug_handler,
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
response::{Html, IntoResponse},
|
response::{Html, IntoResponse},
|
||||||
};
|
};
|
||||||
|
use futures::{lock::Mutex, prelude::*, stream};
|
||||||
|
use phono_backends::{
|
||||||
|
pg_class::PgClass,
|
||||||
|
pg_database::PgDatabase,
|
||||||
|
pg_role::{RoleTree, user_id_from_rolname},
|
||||||
|
rolnames::ROLE_PREFIX_USER,
|
||||||
|
};
|
||||||
use phono_models::{
|
use phono_models::{
|
||||||
accessors::{Accessor as _, Actor, workspace::WorkspaceAccessor},
|
accessors::{Accessor as _, Actor, workspace::WorkspaceAccessor},
|
||||||
|
user::User,
|
||||||
workspace::Workspace,
|
workspace::Workspace,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
@ -15,10 +26,12 @@ use crate::{
|
||||||
app::AppDbConn,
|
app::AppDbConn,
|
||||||
errors::AppError,
|
errors::AppError,
|
||||||
navigator::Navigator,
|
navigator::Navigator,
|
||||||
|
permissions::{Grantee, PermissionsEditor, RelPermission},
|
||||||
settings::Settings,
|
settings::Settings,
|
||||||
user::CurrentUser,
|
user::CurrentUser,
|
||||||
workspace_nav::WorkspaceNav,
|
workspace_nav::WorkspaceNav,
|
||||||
workspace_pooler::{RoleAssignment, WorkspacePooler},
|
workspace_pooler::{RoleAssignment, WorkspacePooler},
|
||||||
|
workspaces::PHONO_TABLE_NAMESPACE,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|
@ -50,21 +63,99 @@ pub(super) async fn get(
|
||||||
.fetch_one()
|
.fetch_one()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
let database = PgDatabase::current()
|
||||||
|
.fetch_one(&mut workspace_client)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// WARNING: For performance and simplicity, this does not resolve inherited
|
||||||
|
// `CONNECT` permissions. This is acceptable so long as Phonograph does not
|
||||||
|
// begin granting `CONNECT` permissions to roles which are inherited from.
|
||||||
|
// TODO: Resolve inherited permissions anyways, for the sake of defensive
|
||||||
|
// programming.
|
||||||
|
let collaborator_users = User::with_id_in(
|
||||||
|
database
|
||||||
|
.datacl
|
||||||
|
.unwrap_or(vec![])
|
||||||
|
.iter()
|
||||||
|
.flat_map(|acl_item| user_id_from_rolname(&acl_item.grantee).ok()),
|
||||||
|
)
|
||||||
|
.fetch_all(&mut app_db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let all_rels: Vec<_> = PgClass::belonging_to_namespace(PHONO_TABLE_NAMESPACE)
|
||||||
|
.fetch_all(&mut workspace_client)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.filter(|rel| rel.relkind == 'r' as i8)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let app_db_mutex = Mutex::new(app_db);
|
||||||
|
let workspace_client_mutex = Mutex::new(workspace_client);
|
||||||
|
let collaborators: Vec<PermissionsEditor> = stream::iter(collaborator_users)
|
||||||
|
.then(async |target_user| -> Result<PermissionsEditor, AppError> {
|
||||||
|
let target_rolname = format!("{ROLE_PREFIX_USER}{id}", id = target_user.id.simple());
|
||||||
|
let current_perms: HashSet<RelPermission> = stream::iter({
|
||||||
|
// Guard must be assigned to a local variable,
|
||||||
|
// lest the mutex become deadlocked.
|
||||||
|
let mut workspace_client = workspace_client_mutex.lock().await;
|
||||||
|
RoleTree::granted_to_rolname(&target_rolname)
|
||||||
|
.fetch_tree(&mut workspace_client)
|
||||||
|
.await?
|
||||||
|
.ok_or(anyhow!("listing roles for user: role tree is None"))?
|
||||||
|
.flatten_inherited()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|role| role.role.rolname != target_rolname)
|
||||||
|
})
|
||||||
|
.then(async |role| {
|
||||||
|
let mut workspace_client = workspace_client_mutex.lock().await;
|
||||||
|
RelPermission::from_rolname(&role.role.rolname, &mut workspace_client).await
|
||||||
|
})
|
||||||
|
.collect::<Vec<Result<_, _>>>()
|
||||||
|
.await
|
||||||
|
.into_iter()
|
||||||
|
// [`futures::stream::StreamExt::collect`]
|
||||||
|
// can only collect to types that implement [`Default`],
|
||||||
|
// so we must do result handling with the sync version of `collect()`.
|
||||||
|
.collect::<Result<Vec<_>, _>>()?
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.collect();
|
||||||
|
Ok(PermissionsEditor {
|
||||||
|
current_perms,
|
||||||
|
update_endpoint: "update-rel-privileges".to_owned(),
|
||||||
|
include_owner: true,
|
||||||
|
all_rels: all_rels.clone(),
|
||||||
|
hidden_inputs: [("user_id".to_owned(), target_user.id.simple().to_string())].into(),
|
||||||
|
target: Grantee::User(target_user),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.await
|
||||||
|
.into_iter()
|
||||||
|
.collect::<Result<Vec<_>, AppError>>()?;
|
||||||
|
|
||||||
|
let mut app_db = app_db_mutex.into_inner();
|
||||||
|
let mut workspace_client = workspace_client_mutex.into_inner();
|
||||||
|
|
||||||
|
let workspace_nav = WorkspaceNav::builder()
|
||||||
|
.navigator(navigator)
|
||||||
|
.workspace(workspace.clone())
|
||||||
|
.populate_rels(&mut app_db, &mut workspace_client)
|
||||||
|
.await?
|
||||||
|
.build()?;
|
||||||
|
|
||||||
#[derive(Debug, Template)]
|
#[derive(Debug, Template)]
|
||||||
#[template(path = "workspaces_single/settings.html")]
|
#[template(path = "workspaces_single/settings.html")]
|
||||||
struct ResponseTemplate {
|
struct ResponseTemplate {
|
||||||
|
collaborators: Vec<PermissionsEditor>,
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
workspace: Workspace,
|
workspace: Workspace,
|
||||||
workspace_nav: WorkspaceNav,
|
workspace_nav: WorkspaceNav,
|
||||||
}
|
}
|
||||||
Ok(Html(
|
Ok(Html(
|
||||||
ResponseTemplate {
|
ResponseTemplate {
|
||||||
workspace_nav: WorkspaceNav::builder()
|
collaborators,
|
||||||
.navigator(navigator)
|
workspace_nav,
|
||||||
.workspace(workspace.clone())
|
|
||||||
.populate_rels(&mut app_db, &mut workspace_client)
|
|
||||||
.await?
|
|
||||||
.build()?,
|
|
||||||
workspace,
|
workspace,
|
||||||
settings,
|
settings,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,167 @@
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
|
use anyhow::anyhow;
|
||||||
|
use axum::{
|
||||||
|
debug_handler,
|
||||||
|
extract::{Path, State},
|
||||||
|
response::IntoResponse,
|
||||||
|
};
|
||||||
|
use axum_extra::extract::Form;
|
||||||
|
use futures::{lock::Mutex, prelude::*, stream};
|
||||||
|
use phono_backends::{pg_class::PgClass, pg_role::RoleTree, rolnames::ROLE_PREFIX_USER};
|
||||||
|
use phono_models::{
|
||||||
|
accessors::{Accessor as _, Actor, workspace::WorkspaceAccessor},
|
||||||
|
user::User,
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use sqlx::postgres::types::Oid;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
app::AppDbConn,
|
||||||
|
errors::{AppError, bad_request},
|
||||||
|
navigator::{Navigator, NavigatorPage},
|
||||||
|
permissions::{Grantee, RelPermission, grant_rel_permissions, revoke_rel_permissions},
|
||||||
|
user::CurrentUser,
|
||||||
|
workspace_pooler::{RoleAssignment, WorkspacePooler},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
pub(super) struct PathParams {
|
||||||
|
workspace_id: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HTTP POST handler for assigning relation permissions to an invite.
|
||||||
|
///
|
||||||
|
/// The form body is expected to encode a one-to-many mapping of relation OID to
|
||||||
|
/// [`RelPermissionKind`], as well as exactly one `user_id` field.
|
||||||
|
/// For example:
|
||||||
|
/// ```text
|
||||||
|
/// user_id=5c2eec2fc05e415888a9c2f79216ad56&
|
||||||
|
/// 123=reader&
|
||||||
|
/// 123=writer&
|
||||||
|
/// 123=owner&
|
||||||
|
/// 234=reader&
|
||||||
|
/// 345=writer
|
||||||
|
/// ```
|
||||||
|
#[debug_handler(state = crate::app::App)]
|
||||||
|
pub(super) async fn post(
|
||||||
|
State(mut pooler): State<WorkspacePooler>,
|
||||||
|
AppDbConn(mut app_db): AppDbConn,
|
||||||
|
CurrentUser(user): CurrentUser,
|
||||||
|
navigator: Navigator,
|
||||||
|
Path(PathParams { workspace_id }): Path<PathParams>,
|
||||||
|
Form(form): Form<HashMap<String, Vec<String>>>,
|
||||||
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
|
// FIXME: csrf
|
||||||
|
|
||||||
|
let mut workspace_client = pooler
|
||||||
|
.acquire_for(workspace_id, RoleAssignment::User(user.id))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
WorkspaceAccessor::new()
|
||||||
|
.id(workspace_id)
|
||||||
|
.as_actor(Actor::User(user.id))
|
||||||
|
.using_app_db(&mut app_db)
|
||||||
|
.using_workspace_client(&mut workspace_client)
|
||||||
|
.fetch_one()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let user_id = form
|
||||||
|
.get("user_id")
|
||||||
|
.and_then(|id_strs| id_strs.first())
|
||||||
|
.map_or(Err(bad_request!("expected user_id")), |id_str| {
|
||||||
|
Uuid::parse_str(id_str).map_err(|_| bad_request!("expected user_id to be a UUID"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let target_user = User::with_id(user_id)
|
||||||
|
.fetch_optional(&mut app_db)
|
||||||
|
.await?
|
||||||
|
.ok_or(bad_request!("no user with that ID"))?;
|
||||||
|
workspace_client
|
||||||
|
.ensure_role(&format!("{ROLE_PREFIX_USER}{id}", id = user_id.simple()))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let current_roles =
|
||||||
|
RoleTree::granted_to_rolname(&format!("{ROLE_PREFIX_USER}{id}", id = user_id.simple()))
|
||||||
|
.fetch_tree(&mut workspace_client)
|
||||||
|
.await?
|
||||||
|
.ok_or(anyhow!("unable to fetch role tree"))?
|
||||||
|
.flatten_inherited();
|
||||||
|
|
||||||
|
// From this point on we may need to use `workspace_client` in async
|
||||||
|
// closures, so wrap it in a mutex to safely use it in control flow which
|
||||||
|
// could in theory attempt concurrent access.
|
||||||
|
let workspace_client_mutex = Mutex::new(workspace_client);
|
||||||
|
|
||||||
|
let current_perms: HashSet<RelPermission> = stream::iter(current_roles)
|
||||||
|
.then(async |role| {
|
||||||
|
let mut workspace_client = workspace_client_mutex.lock().await;
|
||||||
|
RelPermission::from_rolname(&role.role.rolname, &mut workspace_client).await
|
||||||
|
})
|
||||||
|
.collect::<Vec<sqlx::Result<_>>>()
|
||||||
|
.await
|
||||||
|
.into_iter()
|
||||||
|
.collect::<sqlx::Result<Vec<_>>>()?
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let desired_perms: HashSet<RelPermission> =
|
||||||
|
stream::iter(form.into_iter().flat_map(|(oid_str, perm_strs)| {
|
||||||
|
perm_strs
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(|perm_str| {
|
||||||
|
if oid_str == "user_id" {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some((oid_str.clone(), perm_str))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
}))
|
||||||
|
.then(async |(oid_str, perm_str)| -> Result<_, AppError> {
|
||||||
|
let rel_oid = Oid(oid_str.parse()?);
|
||||||
|
let mut workspace_client = workspace_client_mutex.lock().await;
|
||||||
|
let rel = PgClass::with_oid(rel_oid)
|
||||||
|
.fetch_one(&mut workspace_client)
|
||||||
|
.await?;
|
||||||
|
Ok(RelPermission {
|
||||||
|
kind: perm_str.as_str().try_into()?,
|
||||||
|
rel_oid,
|
||||||
|
relname: rel.relname,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<Result<_, _>>>()
|
||||||
|
.await
|
||||||
|
.into_iter()
|
||||||
|
.collect::<Result<_, _>>()?;
|
||||||
|
|
||||||
|
let mut root_client = pooler
|
||||||
|
.acquire_for(workspace_id, RoleAssignment::Root)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
revoke_rel_permissions(
|
||||||
|
Grantee::User(target_user.clone()),
|
||||||
|
current_perms
|
||||||
|
.difference(&desired_perms)
|
||||||
|
.cloned()
|
||||||
|
.collect::<HashSet<_>>(),
|
||||||
|
&mut root_client,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
grant_rel_permissions(
|
||||||
|
Grantee::User(target_user),
|
||||||
|
Actor::User(user.id),
|
||||||
|
desired_perms,
|
||||||
|
&mut root_client,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(navigator
|
||||||
|
.workspace_page()
|
||||||
|
.workspace_id(workspace_id)
|
||||||
|
.suffix("settings/")
|
||||||
|
.build()?
|
||||||
|
.redirect_to())
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
|
use anyhow::anyhow;
|
||||||
use axum::{
|
use axum::{
|
||||||
debug_handler,
|
debug_handler,
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
|
|
@ -7,23 +8,22 @@ use axum::{
|
||||||
};
|
};
|
||||||
use axum_extra::extract::Form;
|
use axum_extra::extract::Form;
|
||||||
use futures::{lock::Mutex, prelude::*, stream};
|
use futures::{lock::Mutex, prelude::*, stream};
|
||||||
use phono_backends::{escape_identifier, pg_class::PgClass};
|
use phono_backends::{pg_class::PgClass, pg_role::RoleTree};
|
||||||
use phono_models::{
|
use phono_models::{
|
||||||
accessors::{Accessor as _, Actor, workspace::WorkspaceAccessor},
|
accessors::{Accessor as _, Actor, workspace::WorkspaceAccessor},
|
||||||
service_cred::ServiceCred,
|
service_cred::ServiceCred,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use sqlx::query;
|
use sqlx::postgres::types::Oid;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
app::AppDbConn,
|
app::AppDbConn,
|
||||||
errors::AppError,
|
errors::AppError,
|
||||||
navigator::{Navigator, NavigatorPage},
|
navigator::{Navigator, NavigatorPage},
|
||||||
roles::{get_reader_role, get_writer_role},
|
permissions::{Grantee, RelPermission, grant_rel_permissions, revoke_rel_permissions},
|
||||||
user::CurrentUser,
|
user::CurrentUser,
|
||||||
workspace_pooler::{RoleAssignment, WorkspacePooler},
|
workspace_pooler::{RoleAssignment, WorkspacePooler},
|
||||||
workspace_utils::PHONO_TABLE_NAMESPACE,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
|
@ -32,7 +32,18 @@ pub(super) struct PathParams {
|
||||||
service_cred_id: Uuid,
|
service_cred_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// HTTP POST handler for assigning
|
/// HTTP POST handler for assigning relation permissions to a service
|
||||||
|
/// credential.
|
||||||
|
///
|
||||||
|
/// The form body is expected to encode a one-to-many mapping of relation OID to
|
||||||
|
/// [`RelPermissionKind`]. For example:
|
||||||
|
/// ```text
|
||||||
|
/// 123=reader&
|
||||||
|
/// 123=writer&
|
||||||
|
/// 123=owner&
|
||||||
|
/// 234=reader&
|
||||||
|
/// 345=writer
|
||||||
|
/// ```
|
||||||
#[debug_handler(state = crate::app::App)]
|
#[debug_handler(state = crate::app::App)]
|
||||||
pub(super) async fn post(
|
pub(super) async fn post(
|
||||||
State(mut pooler): State<WorkspacePooler>,
|
State(mut pooler): State<WorkspacePooler>,
|
||||||
|
|
@ -45,94 +56,91 @@ pub(super) async fn post(
|
||||||
}): Path<PathParams>,
|
}): Path<PathParams>,
|
||||||
Form(form): Form<HashMap<String, Vec<String>>>,
|
Form(form): Form<HashMap<String, Vec<String>>>,
|
||||||
) -> Result<impl IntoResponse, AppError> {
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
// FIXME: csrf, auth
|
// FIXME: csrf
|
||||||
|
|
||||||
let workspace_client = Mutex::new(
|
let mut workspace_client = pooler
|
||||||
pooler
|
.acquire_for(workspace_id, RoleAssignment::User(user.id))
|
||||||
.acquire_for(workspace_id, RoleAssignment::User(user.id))
|
.await?;
|
||||||
.await?,
|
|
||||||
);
|
|
||||||
|
|
||||||
{
|
WorkspaceAccessor::new()
|
||||||
// Ensure lock is dropped as soon as we're finished with it, or else
|
.id(workspace_id)
|
||||||
// `workspace_client` mutex will deadlock later on.
|
.as_actor(Actor::User(user.id))
|
||||||
let mut locked_client = workspace_client.lock().await;
|
.using_app_db(&mut app_db)
|
||||||
WorkspaceAccessor::new()
|
.using_workspace_client(&mut workspace_client)
|
||||||
.id(workspace_id)
|
.fetch_one()
|
||||||
.as_actor(Actor::User(user.id))
|
.await?;
|
||||||
.using_app_db(&mut app_db)
|
|
||||||
.using_workspace_client(&mut locked_client)
|
|
||||||
.fetch_one()
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let all_rels = {
|
|
||||||
let mut locked_client = workspace_client.lock().await;
|
|
||||||
PgClass::belonging_to_namespace(PHONO_TABLE_NAMESPACE)
|
|
||||||
.fetch_all(&mut locked_client)
|
|
||||||
.await?
|
|
||||||
.into_iter()
|
|
||||||
.filter(|rel| rel.relkind == 'r' as i8)
|
|
||||||
};
|
|
||||||
|
|
||||||
let cred = ServiceCred::with_id(service_cred_id)
|
let cred = ServiceCred::with_id(service_cred_id)
|
||||||
.fetch_one(&mut app_db)
|
.fetch_one(&mut app_db)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
stream::iter(all_rels)
|
let current_roles = RoleTree::granted_to_rolname(&cred.rolname)
|
||||||
.then(async |rel| -> Result<(), AppError> {
|
.fetch_tree(&mut workspace_client)
|
||||||
let rolname_reader = get_reader_role(&rel)?;
|
.await?
|
||||||
let rolname_writer = get_writer_role(&rel)?;
|
.ok_or(anyhow!("unable to fetch role tree"))?
|
||||||
|
.flatten_inherited();
|
||||||
|
|
||||||
let roles_to_grant: HashSet<_> = form
|
// From this point on we need to use `workspace_client` in async closures,
|
||||||
.get(&rel.oid.0.to_string())
|
// so wrap it in a mutex to safely use it in control flow which could in
|
||||||
.unwrap_or(&vec![])
|
// theory attempt concurrent access.
|
||||||
.iter()
|
let workspace_client_mutex = Mutex::new(workspace_client);
|
||||||
.flat_map(|value| match value.as_str() {
|
|
||||||
"reader" => Some(&rolname_reader),
|
|
||||||
"writer" => Some(&rolname_writer),
|
|
||||||
_ => None,
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
let all_roles = HashSet::from([&rolname_reader, &rolname_writer]);
|
|
||||||
let roles_to_revoke: HashSet<_> = all_roles.difference(&roles_to_grant).collect();
|
|
||||||
|
|
||||||
if !roles_to_revoke.is_empty() {
|
let current_perms: HashSet<RelPermission> = stream::iter(current_roles)
|
||||||
let mut locked_client = workspace_client.lock().await;
|
.then(async |role| {
|
||||||
let roles_to_revoke_snippet = roles_to_revoke
|
let mut workspace_client = workspace_client_mutex.lock().await;
|
||||||
.into_iter()
|
RelPermission::from_rolname(&role.role.rolname, &mut workspace_client).await
|
||||||
.map(|value| escape_identifier(value))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(", ");
|
|
||||||
query(&format!(
|
|
||||||
"revoke {roles_to_revoke_snippet} from {rolname_esc}",
|
|
||||||
rolname_esc = escape_identifier(&cred.rolname),
|
|
||||||
))
|
|
||||||
.execute(locked_client.get_conn())
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
if !roles_to_grant.is_empty() {
|
|
||||||
let mut locked_client = workspace_client.lock().await;
|
|
||||||
let roles_to_grant_snippet = roles_to_grant
|
|
||||||
.into_iter()
|
|
||||||
.map(|value| escape_identifier(value))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(", ");
|
|
||||||
query(&format!(
|
|
||||||
"grant {roles_to_grant_snippet} to {rolname_esc}",
|
|
||||||
rolname_esc = escape_identifier(&cred.rolname),
|
|
||||||
))
|
|
||||||
.execute(locked_client.get_conn())
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<sqlx::Result<_>>>()
|
||||||
.await
|
.await
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.collect::<Result<Vec<_>, AppError>>()?;
|
.collect::<sqlx::Result<Vec<_>>>()?
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let desired_perms: HashSet<RelPermission> =
|
||||||
|
stream::iter(form.into_iter().flat_map(|(oid_str, perm_strs)| {
|
||||||
|
perm_strs
|
||||||
|
.into_iter()
|
||||||
|
.map(|perm_str| (oid_str.clone(), perm_str))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
}))
|
||||||
|
.then(async |(oid_str, perm_str)| -> Result<_, AppError> {
|
||||||
|
let rel_oid = Oid(oid_str.parse()?);
|
||||||
|
let mut workspace_client = workspace_client_mutex.lock().await;
|
||||||
|
let rel = PgClass::with_oid(rel_oid)
|
||||||
|
.fetch_one(&mut workspace_client)
|
||||||
|
.await?;
|
||||||
|
Ok(RelPermission {
|
||||||
|
kind: perm_str.as_str().try_into()?,
|
||||||
|
rel_oid,
|
||||||
|
relname: rel.relname,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<Result<_, _>>>()
|
||||||
|
.await
|
||||||
|
.into_iter()
|
||||||
|
.collect::<Result<_, _>>()?;
|
||||||
|
|
||||||
|
// Finished with async closures, so we may unwrap the mutex.
|
||||||
|
let mut workspace_client = workspace_client_mutex.into_inner();
|
||||||
|
|
||||||
|
revoke_rel_permissions(
|
||||||
|
Grantee::ServiceCred(cred.clone()),
|
||||||
|
current_perms
|
||||||
|
.difference(&desired_perms)
|
||||||
|
.cloned()
|
||||||
|
.collect::<HashSet<_>>(),
|
||||||
|
&mut workspace_client,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
grant_rel_permissions(
|
||||||
|
Grantee::ServiceCred(cred),
|
||||||
|
Actor::User(user.id),
|
||||||
|
desired_perms,
|
||||||
|
&mut workspace_client,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
Ok(navigator
|
Ok(navigator
|
||||||
.workspace_page()
|
.workspace_page()
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
//! Provides an Axum extractor to fetch the authenticated user for a request.
|
//! Provides an Axum extractor to fetch the authenticated user for a request.
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
use async_session::{Session, SessionStore as _};
|
use async_session::{Session, SessionStore as _};
|
||||||
use axum::{
|
use axum::{
|
||||||
RequestPartsExt,
|
RequestPartsExt,
|
||||||
|
|
@ -12,11 +13,10 @@ use axum_extra::extract::{
|
||||||
cookie::{Cookie, SameSite},
|
cookie::{Cookie, SameSite},
|
||||||
};
|
};
|
||||||
use phono_models::user::User;
|
use phono_models::user::User;
|
||||||
use sqlx::query_as;
|
use tracing::{Instrument as _, info_span};
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
app::App,
|
app::{App, AppDbConn},
|
||||||
auth::{AuthInfo, SESSION_KEY_AUTH_INFO, SESSION_KEY_AUTH_REDIRECT},
|
auth::{AuthInfo, SESSION_KEY_AUTH_INFO, SESSION_KEY_AUTH_REDIRECT},
|
||||||
errors::AppError,
|
errors::AppError,
|
||||||
sessions::AppSession,
|
sessions::AppSession,
|
||||||
|
|
@ -32,77 +32,68 @@ impl FromRequestParts<App> for CurrentUser {
|
||||||
type Rejection = CurrentUserRejection;
|
type Rejection = CurrentUserRejection;
|
||||||
|
|
||||||
async fn from_request_parts(parts: &mut Parts, state: &App) -> Result<Self, Self::Rejection> {
|
async fn from_request_parts(parts: &mut Parts, state: &App) -> Result<Self, Self::Rejection> {
|
||||||
let mut session = if let AppSession(Some(value)) = parts.extract_with_state(state).await? {
|
async {
|
||||||
value
|
let mut session =
|
||||||
} else {
|
if let AppSession(Some(value)) = parts.extract_with_state(state).await? {
|
||||||
Session::new()
|
value
|
||||||
};
|
|
||||||
let auth_info = if let Some(value) = session.get::<AuthInfo>(SESSION_KEY_AUTH_INFO) {
|
|
||||||
value
|
|
||||||
} else {
|
|
||||||
let jar: CookieJar = parts.extract().await?;
|
|
||||||
let method: Method = parts.extract().await?;
|
|
||||||
let jar = if method == Method::GET {
|
|
||||||
let OriginalUri(uri) = parts.extract().await?;
|
|
||||||
session.insert(
|
|
||||||
SESSION_KEY_AUTH_REDIRECT,
|
|
||||||
uri.path_and_query()
|
|
||||||
.map(|value| value.to_string())
|
|
||||||
.unwrap_or(format!("{}/", state.settings.root_path)),
|
|
||||||
)?;
|
|
||||||
if let Some(cookie_value) = state.session_store.store_session(session).await? {
|
|
||||||
tracing::debug!("adding session cookie to jar");
|
|
||||||
jar.add(
|
|
||||||
Cookie::build((state.settings.auth.cookie_name.clone(), cookie_value))
|
|
||||||
.same_site(SameSite::Lax)
|
|
||||||
.http_only(true)
|
|
||||||
.path("/"),
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
tracing::debug!("inferred that session cookie already in jar");
|
Session::new()
|
||||||
jar
|
};
|
||||||
}
|
let auth_info = if let Some(value) = session.get::<AuthInfo>(SESSION_KEY_AUTH_INFO) {
|
||||||
|
value
|
||||||
} else {
|
} else {
|
||||||
// If request method is not GET then do not attempt to infer the
|
let jar: CookieJar = parts.extract().await?;
|
||||||
// redirect target, as there may be no GET handler defined for
|
let method: Method = parts.extract().await?;
|
||||||
// it.
|
let jar = if method == Method::GET {
|
||||||
jar
|
let OriginalUri(uri) = parts.extract().await?;
|
||||||
|
session.insert(
|
||||||
|
SESSION_KEY_AUTH_REDIRECT,
|
||||||
|
uri.path_and_query()
|
||||||
|
.map(|value| value.to_string())
|
||||||
|
.unwrap_or(format!("{0}/", state.settings.root_path)),
|
||||||
|
)?;
|
||||||
|
if let Some(cookie_value) = state.session_store.store_session(session).await? {
|
||||||
|
tracing::debug!("adding session cookie to jar");
|
||||||
|
jar.add(
|
||||||
|
Cookie::build((state.settings.auth.cookie_name.clone(), cookie_value))
|
||||||
|
.same_site(SameSite::Lax)
|
||||||
|
.http_only(true)
|
||||||
|
.path("/"),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
tracing::debug!("inferred that session cookie already in jar");
|
||||||
|
jar
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If request method is not GET then do not attempt to infer the
|
||||||
|
// redirect target, as there may be no GET handler defined for
|
||||||
|
// it.
|
||||||
|
jar
|
||||||
|
};
|
||||||
|
return Err(Self::Rejection::SetCookiesAndRedirect(
|
||||||
|
jar,
|
||||||
|
format!("{0}/auth/login", state.settings.root_path),
|
||||||
|
));
|
||||||
};
|
};
|
||||||
return Err(Self::Rejection::SetCookiesAndRedirect(
|
let AppDbConn(mut app_db): AppDbConn = parts.extract_with_state(state).await?;
|
||||||
jar,
|
Ok(CurrentUser(
|
||||||
format!("{}/auth/login", state.settings.root_path),
|
if let Some(user) = User::with_uid(&auth_info.sub)
|
||||||
));
|
.fetch_optional(&mut app_db)
|
||||||
};
|
.await?
|
||||||
let current_user = if let Some(value) =
|
{
|
||||||
query_as!(User, "select * from users where uid = $1", &auth_info.sub)
|
user
|
||||||
.fetch_optional(&state.app_db)
|
} else {
|
||||||
.await?
|
tracing::debug!("user record not found by uid; upserting");
|
||||||
{
|
User::upsert()
|
||||||
value
|
.uid(&auth_info.sub)
|
||||||
} else if let Some(value) = query_as!(
|
.email(&auth_info.email)
|
||||||
User,
|
.execute(&mut app_db)
|
||||||
"
|
.await?
|
||||||
insert into users
|
},
|
||||||
(id, uid, email)
|
))
|
||||||
values ($1, $2, $3)
|
}
|
||||||
on conflict (uid) do nothing
|
.instrument(info_span!("CurrentUser extractor"))
|
||||||
returning *
|
.await
|
||||||
",
|
|
||||||
Uuid::now_v7(),
|
|
||||||
&auth_info.sub,
|
|
||||||
&auth_info.email
|
|
||||||
)
|
|
||||||
.fetch_optional(&state.app_db)
|
|
||||||
.await?
|
|
||||||
{
|
|
||||||
value
|
|
||||||
} else {
|
|
||||||
tracing::debug!("detected race to insert current user record");
|
|
||||||
query_as!(User, "select * from users where uid = $1", &auth_info.sub)
|
|
||||||
.fetch_one(&state.app_db)
|
|
||||||
.await?
|
|
||||||
};
|
|
||||||
Ok(CurrentUser(current_user))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
navigator::Navigator,
|
navigator::Navigator,
|
||||||
workspace_utils::{RelationPortalSet, fetch_all_accessible_portals},
|
workspaces::{RelationPortalSet, fetch_all_accessible_portals},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Builder, Clone, Debug, Template)]
|
#[derive(Builder, Clone, Debug, Template)]
|
||||||
|
|
|
||||||
|
|
@ -103,11 +103,11 @@ discard sequences;
|
||||||
// fairly broad refactor of [`phono-server`] code for
|
// fairly broad refactor of [`phono-server`] code for
|
||||||
// initializing user roles and for performing workspace auth
|
// initializing user roles and for performing workspace auth
|
||||||
// checks.
|
// checks.
|
||||||
client
|
let rolname = format!("{ROLE_PREFIX_USER}{uid}", uid = uid.simple());
|
||||||
.init_role(&format!("{ROLE_PREFIX_USER}{uid}", uid = uid.simple()))
|
client.ensure_role(&rolname).await?;
|
||||||
.await?;
|
client.set_role(&rolname).await?;
|
||||||
}
|
}
|
||||||
RoleAssignment::Root => {}
|
RoleAssignment::Root => { /* no-op */ }
|
||||||
}
|
}
|
||||||
Ok(client)
|
Ok(client)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,3 @@
|
||||||
//! This module is named with the `_utils` suffix to help differentiate it from
|
|
||||||
//! the [`phono_models::workspace`] module, which is also used extensively
|
|
||||||
//! across the server code.
|
|
||||||
|
|
||||||
use phono_backends::{
|
use phono_backends::{
|
||||||
client::WorkspaceClient,
|
client::WorkspaceClient,
|
||||||
pg_class::{PgClass, PgRelKind},
|
pg_class::{PgClass, PgRelKind},
|
||||||
13
phono-server/templates/rel_permission.html
Normal file
13
phono-server/templates/rel_permission.html
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<div class="role-display">
|
||||||
|
<div class="role-display__resource">{{ self.relname }}</div>
|
||||||
|
<div class="role-display__description">
|
||||||
|
{%- match self.kind -%}
|
||||||
|
{%- when RelPermissionKind::Owner -%}
|
||||||
|
owner
|
||||||
|
{%- when RelPermissionKind::Reader -%}
|
||||||
|
reader
|
||||||
|
{%- when RelPermissionKind::Writer -%}
|
||||||
|
writer
|
||||||
|
{%- endmatch -%}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
<div class="role-display">
|
|
||||||
{%- match self -%}
|
|
||||||
{%- when Self::TableOwner { relname, .. } -%}
|
|
||||||
<div class="role-display__resource">{{ relname }}</div>
|
|
||||||
<div class="role-display__description">owner</div>
|
|
||||||
{%- when Self::TableReader { relname, .. } -%}
|
|
||||||
<div class="role-display__resource">{{ relname }}</div>
|
|
||||||
<div class="role-display__description">reader</div>
|
|
||||||
{%- when Self::TableWriter { relname, .. } -%}
|
|
||||||
<div class="role-display__resource">{{ relname }}</div>
|
|
||||||
<div class="role-display__description">writer</div>
|
|
||||||
{%- when Self::Unknown { rolname } -%}
|
|
||||||
<div class="role-display__description">{{ rolname }}</div>
|
|
||||||
<div class="role-display__description">member</div>
|
|
||||||
{%- endmatch -%}
|
|
||||||
</div>
|
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
{% for perm in current_perms.clone() %}
|
||||||
|
{{ perm | safe }}
|
||||||
|
{% endfor %}
|
||||||
|
<button
|
||||||
|
class="button button--secondary button--small"
|
||||||
|
popovertarget="permissions-editor-{{ target }}"
|
||||||
|
popovertargetaction="toggle"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<dialog
|
||||||
|
class="dialog padded--lg"
|
||||||
|
id="permissions-editor-{{ target }}"
|
||||||
|
popover="auto"
|
||||||
|
>
|
||||||
|
<form action="{{ update_endpoint }}" method="post">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Table</th>
|
||||||
|
<th scope="col">Reader</th>
|
||||||
|
<th scope="col">Writer</th>
|
||||||
|
{% if include_owner %}
|
||||||
|
<th scope="col">Owner</th>
|
||||||
|
{% endif %}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for rel in all_rels %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ rel.relname }}</td>
|
||||||
|
<td style="text-align: center;">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="{{ rel.oid.0 }}"
|
||||||
|
value="reader"
|
||||||
|
{%- if current_perms.contains(&(
|
||||||
|
RelPermissionKind::Reader,
|
||||||
|
rel.relname.clone(),
|
||||||
|
rel.oid,
|
||||||
|
).into()) %}
|
||||||
|
checked="true"
|
||||||
|
{%- endif %}
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
<td style="text-align: center;">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="{{ rel.oid.0 }}"
|
||||||
|
value="writer"
|
||||||
|
{%- if current_perms.contains(&(
|
||||||
|
RelPermissionKind::Writer,
|
||||||
|
rel.relname.clone(),
|
||||||
|
rel.oid,
|
||||||
|
).into()) %}
|
||||||
|
checked="true"
|
||||||
|
{%- endif %}
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
{% if include_owner %}
|
||||||
|
<td style="text-align: center;">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="{{ rel.oid.0 }}"
|
||||||
|
value="owner"
|
||||||
|
{%- if current_perms.contains(&(
|
||||||
|
RelPermissionKind::Owner,
|
||||||
|
rel.relname.clone(),
|
||||||
|
rel.oid,
|
||||||
|
).into()) %}
|
||||||
|
checked="true"
|
||||||
|
{%- endif %}
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
{% endif %}
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% for (k, v) in hidden_inputs %}
|
||||||
|
<input type="hidden" name="{{ k }}" value="{{ v }}">
|
||||||
|
{% endfor %}
|
||||||
|
<button
|
||||||
|
class="button--primary"
|
||||||
|
style="margin-top: 16px;"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
@ -51,67 +51,7 @@
|
||||||
</copy-source>
|
</copy-source>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% for role_display in cred.member_of %}
|
{{ cred.permissions_editor | safe }}
|
||||||
{{ role_display | safe }}
|
|
||||||
{% endfor %}
|
|
||||||
<button
|
|
||||||
popovertarget="permissions-editor-{{ cred.service_cred.rolname }}"
|
|
||||||
popovertargetaction="toggle"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
<dialog
|
|
||||||
class="dialog padded--lg"
|
|
||||||
id="permissions-editor-{{ cred.service_cred.rolname }}"
|
|
||||||
popover="auto"
|
|
||||||
>
|
|
||||||
<form action="{{ cred.service_cred.id.simple() }}/update-permissions" method="post">
|
|
||||||
<table class="table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th scope="col">Table</th>
|
|
||||||
<th scope="col">Reader</th>
|
|
||||||
<th scope="col">Writer</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for rel in all_rels %}
|
|
||||||
<tr>
|
|
||||||
<td>{{ rel.relname }}</td>
|
|
||||||
<td style="text-align: center;">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
name="{{ rel.oid.0 }}"
|
|
||||||
value="reader"
|
|
||||||
{%- if cred.is_reader_of(rel.relname) %}
|
|
||||||
checked="true"
|
|
||||||
{%- endif %}
|
|
||||||
>
|
|
||||||
</td>
|
|
||||||
<td style="text-align: center;">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
name="{{ rel.oid.0 }}"
|
|
||||||
value="writer"
|
|
||||||
{%- if cred.is_writer_of(rel.relname) %}
|
|
||||||
checked="true"
|
|
||||||
{%- endif %}
|
|
||||||
>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<button
|
|
||||||
class="button--primary"
|
|
||||||
style="margin-top: 16px;"
|
|
||||||
type="submit"
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</dialog>
|
|
||||||
</td>
|
</td>
|
||||||
<td></td>
|
<td></td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
|
||||||
|
|
@ -18,12 +18,70 @@
|
||||||
<button class="button--primary" type="submit">Save</button>
|
<button class="button--primary" type="submit">Save</button>
|
||||||
</section>
|
</section>
|
||||||
</form>
|
</form>
|
||||||
<form method="post" action="">
|
<section>
|
||||||
<section>
|
<h1>Sharing</h1>
|
||||||
<h1>Sharing</h1>
|
<button class="button button--primary" popovertarget="invite-dialog" type="button">
|
||||||
<button class="button--primary" type="submit">Save</button>
|
Invite
|
||||||
</section>
|
</button>
|
||||||
</form>
|
<dialog
|
||||||
|
class="dialog padded--lg"
|
||||||
|
id="invite-dialog"
|
||||||
|
popover="auto"
|
||||||
|
>
|
||||||
|
<form action="grant-workspace-privilege" method="post">
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-grid-row">
|
||||||
|
<label for="invite-dialog-email-input">Email</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="invite-dialog-email-input"
|
||||||
|
autocomplete="off"
|
||||||
|
data-1p-ignore
|
||||||
|
data-bwignore="true"
|
||||||
|
data-lpignore="true"
|
||||||
|
data-protonpass-ignore="true"
|
||||||
|
inputmode="email"
|
||||||
|
name="email"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="csrf_token" value="FIXME">
|
||||||
|
<button class="button button--primary" type="submit">
|
||||||
|
Invite
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Email</th>
|
||||||
|
<th scope="col">Permissions</th>
|
||||||
|
<th scope="col">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for permissions_editor in collaborators %}
|
||||||
|
<tr>
|
||||||
|
{% let collaborator = User::try_from(permissions_editor.target.clone())? %}
|
||||||
|
<td>{{ collaborator.email }}</td>
|
||||||
|
<td>
|
||||||
|
{{ permissions_editor | safe }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<form action="revoke-workspace-privileges" method="post">
|
||||||
|
<input type="hidden" name="user_id" value="{{ collaborator.id.simple() }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="FIXME">
|
||||||
|
<button type="submit" class="button button--secondary button--small">
|
||||||
|
Revoke
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<button class="button--primary" type="submit">Save</button>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue