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
|
||||
}
|
||||
|
||||
/// Runs the Postgres `set role` command for the underlying connection. If
|
||||
/// the given role does not exist, it is created and granted to the
|
||||
/// If the given role does not exist, create it and grant it to the
|
||||
/// `session_user`. Roles are created with the `createrole` option.
|
||||
///
|
||||
/// Note that while using `set role` simulates impersonation for most data
|
||||
/// access and RLS purposes, it is both incomplete and easily reversible:
|
||||
/// 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> {
|
||||
pub async fn ensure_role(&mut self, rolname: &str) -> Result<(), sqlx::Error> {
|
||||
let session_user = query!("select session_user;")
|
||||
.fetch_one(&mut *self.conn)
|
||||
.await?
|
||||
|
|
@ -53,6 +46,20 @@ impl WorkspaceClient {
|
|||
.execute(&mut *self.conn)
|
||||
.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)))
|
||||
.execute(&mut *self.conn)
|
||||
.await?;
|
||||
|
|
|
|||
|
|
@ -131,14 +131,16 @@ pub struct RoleTree {
|
|||
pub role: PgRole,
|
||||
pub branches: Vec<RoleTree>,
|
||||
pub inherit: bool,
|
||||
pub admin: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, FromRow)]
|
||||
struct RoleTreeRow {
|
||||
#[sqlx(flatten)]
|
||||
role: PgRole,
|
||||
branch: Option<Oid>,
|
||||
parent: Option<Oid>,
|
||||
inherit: bool,
|
||||
admin: bool,
|
||||
}
|
||||
|
||||
impl RoleTree {
|
||||
|
|
@ -160,9 +162,15 @@ impl RoleTree {
|
|||
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
|
||||
.into_iter()
|
||||
.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)]
|
||||
pub struct MembersOfOidQuery {
|
||||
role: Oid,
|
||||
|
|
@ -183,33 +268,18 @@ impl MembersOfOidQuery {
|
|||
self,
|
||||
client: &mut WorkspaceClient,
|
||||
) -> Result<Option<RoleTree>, sqlx::Error> {
|
||||
let rows: Vec<RoleTreeRow> = query_as(
|
||||
"
|
||||
with recursive cte as (
|
||||
select $1::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)
|
||||
.fetch_all(client.get_conn())
|
||||
.await?;
|
||||
let rows: Vec<RoleTreeRow> =
|
||||
query_as(&construct_role_tree_query("", RoleTreeDirection::MembersOf))
|
||||
.bind(self.role)
|
||||
.fetch_all(client.get_conn())
|
||||
.await?;
|
||||
Ok(rows
|
||||
.iter()
|
||||
.find(|row| row.branch.is_none())
|
||||
.find(|row| row.parent.is_none())
|
||||
.map(|root_row| RoleTree {
|
||||
role: root_row.role.clone(),
|
||||
branches: compute_members(&rows, root_row.role.oid),
|
||||
admin: root_row.admin,
|
||||
inherit: root_row.inherit,
|
||||
}))
|
||||
}
|
||||
|
|
@ -225,35 +295,20 @@ impl MembersOfRolnameQuery {
|
|||
self,
|
||||
client: &mut WorkspaceClient,
|
||||
) -> Result<Option<RoleTree>, sqlx::Error> {
|
||||
// This could almost be a macro to DRY with MembersOfOidQuery, except
|
||||
// for the extra ::text:: cast required on the parameter in this query.
|
||||
let rows: Vec<RoleTreeRow> = query_as(
|
||||
"
|
||||
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
|
||||
",
|
||||
)
|
||||
let rows: Vec<RoleTreeRow> = query_as(&construct_role_tree_query(
|
||||
"::regrole::oid",
|
||||
RoleTreeDirection::MembersOf,
|
||||
))
|
||||
.bind(self.role.as_str() as &str)
|
||||
.fetch_all(client.get_conn())
|
||||
.await?;
|
||||
Ok(rows
|
||||
.iter()
|
||||
.find(|row| row.branch.is_none())
|
||||
.find(|row| row.parent.is_none())
|
||||
.map(|root_row| RoleTree {
|
||||
role: root_row.role.clone(),
|
||||
branches: compute_members(&rows, root_row.role.oid),
|
||||
admin: root_row.admin,
|
||||
inherit: root_row.inherit,
|
||||
}))
|
||||
}
|
||||
|
|
@ -269,33 +324,18 @@ impl GrantedToQuery {
|
|||
self,
|
||||
client: &mut WorkspaceClient,
|
||||
) -> Result<Option<RoleTree>, sqlx::Error> {
|
||||
let rows: Vec<RoleTreeRow> = query_as(
|
||||
"
|
||||
with recursive cte as (
|
||||
select $1 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.role_oid)
|
||||
.fetch_all(client.get_conn())
|
||||
.await?;
|
||||
let rows: Vec<RoleTreeRow> =
|
||||
query_as(&construct_role_tree_query("", RoleTreeDirection::GrantedTo))
|
||||
.bind(self.role_oid)
|
||||
.fetch_all(client.get_conn())
|
||||
.await?;
|
||||
Ok(rows
|
||||
.iter()
|
||||
.find(|row| row.branch.is_none())
|
||||
.find(|row| row.parent.is_none())
|
||||
.map(|root_row| RoleTree {
|
||||
role: root_row.role.clone(),
|
||||
branches: compute_members(&rows, root_row.role.oid),
|
||||
admin: root_row.admin,
|
||||
inherit: root_row.inherit,
|
||||
}))
|
||||
}
|
||||
|
|
@ -311,33 +351,20 @@ impl<'a> GrantedToRolnameQuery<'a> {
|
|||
self,
|
||||
client: &mut WorkspaceClient,
|
||||
) -> Result<Option<RoleTree>, sqlx::Error> {
|
||||
let rows: Vec<RoleTreeRow> = query_as(
|
||||
"
|
||||
with recursive cte as (
|
||||
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
|
||||
",
|
||||
)
|
||||
let rows: Vec<RoleTreeRow> = query_as(&construct_role_tree_query(
|
||||
"::regrole::oid",
|
||||
RoleTreeDirection::GrantedTo,
|
||||
))
|
||||
.bind(self.rolname)
|
||||
.fetch_all(client.get_conn())
|
||||
.await?;
|
||||
Ok(rows
|
||||
.iter()
|
||||
.find(|row| row.branch.is_none())
|
||||
.find(|row| row.parent.is_none())
|
||||
.map(|root_row| RoleTree {
|
||||
role: root_row.role.clone(),
|
||||
branches: compute_members(&rows, root_row.role.oid),
|
||||
admin: root_row.admin,
|
||||
inherit: root_row.inherit,
|
||||
}))
|
||||
}
|
||||
|
|
@ -345,10 +372,11 @@ from (
|
|||
|
||||
fn compute_members(rows: &Vec<RoleTreeRow>, root: Oid) -> Vec<RoleTree> {
|
||||
rows.iter()
|
||||
.filter(|row| row.branch == Some(root))
|
||||
.filter(|row| row.parent == Some(root))
|
||||
.map(|row| RoleTree {
|
||||
role: row.role.clone(),
|
||||
branches: compute_members(rows, row.role.oid),
|
||||
admin: row.admin,
|
||||
inherit: row.inherit,
|
||||
})
|
||||
.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 workspace;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, strum::Display)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, strum::Display, strum::EnumIs)]
|
||||
pub enum Actor {
|
||||
/// Bypass explicit auth checks.
|
||||
Bypass,
|
||||
|
|
|
|||
|
|
@ -178,7 +178,7 @@ impl<'a> Accessor<Portal> for PortalAccessor<'a> {
|
|||
.map(|tree| tree.flatten_inherited())
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.any(|value| value.rolname == actor_rolname)
|
||||
.any(|value| value.role.rolname == actor_rolname)
|
||||
{
|
||||
for permission in acl_item.privileges.iter() {
|
||||
actor_permissions.insert(permission.privilege);
|
||||
|
|
@ -202,7 +202,7 @@ impl<'a> Accessor<Portal> for PortalAccessor<'a> {
|
|||
.map(|tree| tree.flatten_inherited())
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.any(|value| value.rolname == actor_rolname))
|
||||
.any(|value| value.role.rolname == actor_rolname))
|
||||
{
|
||||
debug!("actor is not relation owner");
|
||||
return Err(AccessError::NotFound);
|
||||
|
|
|
|||
|
|
@ -1,13 +1,22 @@
|
|||
use std::str::FromStr;
|
||||
use std::str::FromStr as _;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{Decode, Postgres};
|
||||
use strum::{EnumIter, EnumString};
|
||||
|
||||
/// Languages represented as
|
||||
/// [ISO 639-3 codes](https://en.wikipedia.org/wiki/List_of_ISO_639-3_codes).
|
||||
#[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")]
|
||||
#[strum(serialize_all = "lowercase")]
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ pub mod language;
|
|||
mod macros;
|
||||
pub mod portal;
|
||||
pub mod presentation;
|
||||
pub mod rel_invitation;
|
||||
pub mod service_cred;
|
||||
pub mod user;
|
||||
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 uuid::Uuid;
|
||||
|
||||
use crate::client::AppDbClient;
|
||||
use crate::{
|
||||
client::AppDbClient,
|
||||
errors::{QueryError, QueryResult},
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct User {
|
||||
/// Phonograph ID of the user. This is the unique identifier which should be
|
||||
/// used in most cases.
|
||||
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,
|
||||
}
|
||||
|
||||
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 {
|
||||
let ids: Vec<Uuid> = ids.into_iter().collect();
|
||||
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)]
|
||||
|
|
@ -26,13 +164,44 @@ impl WithIdInQuery {
|
|||
pub async fn fetch_all(self, app_db: &mut AppDbClient) -> Result<Vec<User>, sqlx::Error> {
|
||||
query_as!(
|
||||
User,
|
||||
"
|
||||
select * from users
|
||||
where id = any($1)
|
||||
",
|
||||
"select id, uid, email from users where id = any($1)",
|
||||
self.ids.as_slice()
|
||||
)
|
||||
.fetch_all(&mut *app_db.conn)
|
||||
.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 field_info;
|
||||
mod navigator;
|
||||
mod permissions;
|
||||
mod presentation_form;
|
||||
mod roles;
|
||||
mod routes;
|
||||
mod sessions;
|
||||
mod settings;
|
||||
|
|
@ -41,7 +41,7 @@ mod user;
|
|||
mod worker;
|
||||
mod workspace_nav;
|
||||
mod workspace_pooler;
|
||||
mod workspace_utils;
|
||||
mod workspaces;
|
||||
|
||||
/// Run CLI
|
||||
#[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,
|
||||
errors::AppError,
|
||||
navigator::{Navigator, NavigatorPage},
|
||||
permissions::get_writer_role,
|
||||
presentation_form::PresentationForm,
|
||||
roles::get_writer_role,
|
||||
user::CurrentUser,
|
||||
workspace_pooler::{RoleAssignment, WorkspacePooler},
|
||||
};
|
||||
|
|
@ -122,7 +122,7 @@ pub(super) async fn post(
|
|||
"grant insert ({col}), update ({col}) on table {ident} to {writer_role}",
|
||||
col = escape_identifier(&form.name),
|
||||
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())
|
||||
.await?;
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ use crate::{
|
|||
user::CurrentUser,
|
||||
workspace_nav::{NavLocation, RelLocation, WorkspaceNav},
|
||||
workspace_pooler::{RoleAssignment, WorkspacePooler},
|
||||
workspace_utils::{RelationPortalSet, fetch_all_accessible_portals},
|
||||
workspaces::{RelationPortalSet, fetch_all_accessible_portals},
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ mod portal_settings_handler;
|
|||
mod remove_field_handler;
|
||||
mod set_filter_handler;
|
||||
mod settings_handler;
|
||||
mod settings_invite_handler;
|
||||
mod update_field_handler;
|
||||
mod update_field_ordinality_handler;
|
||||
mod update_form_transitions_handler;
|
||||
|
|
@ -28,7 +27,6 @@ mod update_values_handler;
|
|||
pub(super) fn new_router() -> Router<App> {
|
||||
Router::<App>::new()
|
||||
.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("/add-portal", post(add_portal_handler::post))
|
||||
.route_with_tsr("/p/{portal_id}/", get(portal_handler::get))
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ use crate::{
|
|||
navigator::{Navigator, NavigatorPage as _},
|
||||
user::CurrentUser,
|
||||
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
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ use crate::{
|
|||
navigator::{Navigator, NavigatorPage},
|
||||
user::CurrentUser,
|
||||
workspace_pooler::{RoleAssignment, WorkspacePooler},
|
||||
workspace_utils::PHONO_TABLE_NAMESPACE,
|
||||
workspaces::PHONO_TABLE_NAMESPACE,
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ use crate::{
|
|||
navigator::{Navigator, NavigatorPage as _},
|
||||
user::CurrentUser,
|
||||
workspace_pooler::{RoleAssignment, WorkspacePooler},
|
||||
workspace_utils::PHONO_TABLE_NAMESPACE,
|
||||
workspaces::PHONO_TABLE_NAMESPACE,
|
||||
};
|
||||
|
||||
#[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_table_handler;
|
||||
mod grant_workspace_privilege_handler;
|
||||
mod nav_handler;
|
||||
mod service_credentials_handler;
|
||||
mod settings_handler;
|
||||
mod update_name_handler;
|
||||
mod update_rel_privileges_handler;
|
||||
mod update_service_cred_permissions_handler;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
|
|
@ -44,6 +46,15 @@ pub(super) fn new_router() -> Router<App> {
|
|||
"/{workspace_id}/settings/update-name",
|
||||
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(
|
||||
"/{workspace_id}/service-credentials/add-service-credential",
|
||||
post(add_service_credential_handler::post),
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
use std::collections::HashSet;
|
||||
|
||||
use anyhow::anyhow;
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
|
|
@ -20,12 +22,12 @@ use crate::{
|
|||
app::AppDbConn,
|
||||
errors::AppError,
|
||||
navigator::Navigator,
|
||||
roles::RoleDisplay,
|
||||
permissions::{Grantee, PermissionsEditor, RelPermission},
|
||||
settings::Settings,
|
||||
user::CurrentUser,
|
||||
workspace_nav::WorkspaceNav,
|
||||
workspace_pooler::{RoleAssignment, WorkspacePooler},
|
||||
workspace_utils::PHONO_TABLE_NAMESPACE,
|
||||
workspaces::PHONO_TABLE_NAMESPACE,
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
|
@ -64,55 +66,20 @@ pub(super) async fn get(
|
|||
|
||||
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 {
|
||||
service_cred: ServiceCred,
|
||||
member_of: Vec<RoleDisplay>,
|
||||
conn_string: Secret<Url>,
|
||||
conn_string_redacted: String,
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
})
|
||||
}
|
||||
permissions_editor: PermissionsEditor,
|
||||
}
|
||||
|
||||
let service_cred_info = stream::iter(
|
||||
|
|
@ -121,7 +88,7 @@ pub(super) async fn get(
|
|||
.await?,
|
||||
)
|
||||
.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,
|
||||
// lest the mutex become deadlocked.
|
||||
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"))?
|
||||
.flatten_inherited()
|
||||
.into_iter()
|
||||
.filter(|role| role.rolname != cred.rolname)
|
||||
.filter(|role| role.role.rolname != cred.rolname)
|
||||
})
|
||||
.then(async |role| {
|
||||
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<_, _>>>()
|
||||
.await
|
||||
|
|
@ -157,8 +124,17 @@ pub(super) async fn get(
|
|||
Ok(ServiceCredInfo {
|
||||
conn_string,
|
||||
conn_string_redacted: "postgresql://********".to_owned(),
|
||||
member_of,
|
||||
service_cred: cred,
|
||||
permissions_editor: PermissionsEditor {
|
||||
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>>>()
|
||||
|
|
@ -169,7 +145,6 @@ pub(super) async fn get(
|
|||
#[derive(Template)]
|
||||
#[template(path = "workspaces_single/service_credentials.html")]
|
||||
struct ResponseTemplate {
|
||||
all_rels: Vec<PgClass>,
|
||||
service_cred_info: Vec<ServiceCredInfo>,
|
||||
settings: Settings,
|
||||
workspace_nav: WorkspaceNav,
|
||||
|
|
@ -180,12 +155,6 @@ pub(super) async fn get(
|
|||
.ok_or(anyhow!("mutex should be unlocked"))?;
|
||||
Ok(Html(
|
||||
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()
|
||||
.navigator(navigator)
|
||||
.workspace(workspace.clone())
|
||||
|
|
|
|||
|
|
@ -1,11 +1,22 @@
|
|||
use std::collections::HashSet;
|
||||
|
||||
use anyhow::anyhow;
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
debug_handler,
|
||||
extract::{Path, State},
|
||||
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::{
|
||||
accessors::{Accessor as _, Actor, workspace::WorkspaceAccessor},
|
||||
user::User,
|
||||
workspace::Workspace,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
|
@ -15,10 +26,12 @@ use crate::{
|
|||
app::AppDbConn,
|
||||
errors::AppError,
|
||||
navigator::Navigator,
|
||||
permissions::{Grantee, PermissionsEditor, RelPermission},
|
||||
settings::Settings,
|
||||
user::CurrentUser,
|
||||
workspace_nav::WorkspaceNav,
|
||||
workspace_pooler::{RoleAssignment, WorkspacePooler},
|
||||
workspaces::PHONO_TABLE_NAMESPACE,
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
|
@ -50,21 +63,99 @@ pub(super) async fn get(
|
|||
.fetch_one()
|
||||
.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)]
|
||||
#[template(path = "workspaces_single/settings.html")]
|
||||
struct ResponseTemplate {
|
||||
collaborators: Vec<PermissionsEditor>,
|
||||
settings: Settings,
|
||||
workspace: Workspace,
|
||||
workspace_nav: WorkspaceNav,
|
||||
}
|
||||
Ok(Html(
|
||||
ResponseTemplate {
|
||||
workspace_nav: WorkspaceNav::builder()
|
||||
.navigator(navigator)
|
||||
.workspace(workspace.clone())
|
||||
.populate_rels(&mut app_db, &mut workspace_client)
|
||||
.await?
|
||||
.build()?,
|
||||
collaborators,
|
||||
workspace_nav,
|
||||
workspace,
|
||||
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 anyhow::anyhow;
|
||||
use axum::{
|
||||
debug_handler,
|
||||
extract::{Path, State},
|
||||
|
|
@ -7,23 +8,22 @@ use axum::{
|
|||
};
|
||||
use axum_extra::extract::Form;
|
||||
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::{
|
||||
accessors::{Accessor as _, Actor, workspace::WorkspaceAccessor},
|
||||
service_cred::ServiceCred,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use sqlx::query;
|
||||
use sqlx::postgres::types::Oid;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
app::AppDbConn,
|
||||
errors::AppError,
|
||||
navigator::{Navigator, NavigatorPage},
|
||||
roles::{get_reader_role, get_writer_role},
|
||||
permissions::{Grantee, RelPermission, grant_rel_permissions, revoke_rel_permissions},
|
||||
user::CurrentUser,
|
||||
workspace_pooler::{RoleAssignment, WorkspacePooler},
|
||||
workspace_utils::PHONO_TABLE_NAMESPACE,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
|
|
@ -32,7 +32,18 @@ pub(super) struct PathParams {
|
|||
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)]
|
||||
pub(super) async fn post(
|
||||
State(mut pooler): State<WorkspacePooler>,
|
||||
|
|
@ -45,94 +56,91 @@ pub(super) async fn post(
|
|||
}): Path<PathParams>,
|
||||
Form(form): Form<HashMap<String, Vec<String>>>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
// FIXME: csrf, auth
|
||||
// FIXME: csrf
|
||||
|
||||
let workspace_client = Mutex::new(
|
||||
pooler
|
||||
.acquire_for(workspace_id, RoleAssignment::User(user.id))
|
||||
.await?,
|
||||
);
|
||||
let mut workspace_client = pooler
|
||||
.acquire_for(workspace_id, RoleAssignment::User(user.id))
|
||||
.await?;
|
||||
|
||||
{
|
||||
// Ensure lock is dropped as soon as we're finished with it, or else
|
||||
// `workspace_client` mutex will deadlock later on.
|
||||
let mut locked_client = workspace_client.lock().await;
|
||||
WorkspaceAccessor::new()
|
||||
.id(workspace_id)
|
||||
.as_actor(Actor::User(user.id))
|
||||
.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)
|
||||
};
|
||||
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 cred = ServiceCred::with_id(service_cred_id)
|
||||
.fetch_one(&mut app_db)
|
||||
.await?;
|
||||
|
||||
stream::iter(all_rels)
|
||||
.then(async |rel| -> Result<(), AppError> {
|
||||
let rolname_reader = get_reader_role(&rel)?;
|
||||
let rolname_writer = get_writer_role(&rel)?;
|
||||
let current_roles = RoleTree::granted_to_rolname(&cred.rolname)
|
||||
.fetch_tree(&mut workspace_client)
|
||||
.await?
|
||||
.ok_or(anyhow!("unable to fetch role tree"))?
|
||||
.flatten_inherited();
|
||||
|
||||
let roles_to_grant: HashSet<_> = form
|
||||
.get(&rel.oid.0.to_string())
|
||||
.unwrap_or(&vec![])
|
||||
.iter()
|
||||
.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();
|
||||
// From this point on we 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);
|
||||
|
||||
if !roles_to_revoke.is_empty() {
|
||||
let mut locked_client = workspace_client.lock().await;
|
||||
let roles_to_revoke_snippet = roles_to_revoke
|
||||
.into_iter()
|
||||
.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(())
|
||||
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<_>>()
|
||||
.collect::<Vec<sqlx::Result<_>>>()
|
||||
.await
|
||||
.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
|
||||
.workspace_page()
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
//! Provides an Axum extractor to fetch the authenticated user for a request.
|
||||
|
||||
use anyhow::Result;
|
||||
use async_session::{Session, SessionStore as _};
|
||||
use axum::{
|
||||
RequestPartsExt,
|
||||
|
|
@ -12,11 +13,10 @@ use axum_extra::extract::{
|
|||
cookie::{Cookie, SameSite},
|
||||
};
|
||||
use phono_models::user::User;
|
||||
use sqlx::query_as;
|
||||
use uuid::Uuid;
|
||||
use tracing::{Instrument as _, info_span};
|
||||
|
||||
use crate::{
|
||||
app::App,
|
||||
app::{App, AppDbConn},
|
||||
auth::{AuthInfo, SESSION_KEY_AUTH_INFO, SESSION_KEY_AUTH_REDIRECT},
|
||||
errors::AppError,
|
||||
sessions::AppSession,
|
||||
|
|
@ -32,77 +32,68 @@ impl FromRequestParts<App> for CurrentUser {
|
|||
type Rejection = CurrentUserRejection;
|
||||
|
||||
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? {
|
||||
value
|
||||
} else {
|
||||
Session::new()
|
||||
};
|
||||
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("/"),
|
||||
)
|
||||
async {
|
||||
let mut session =
|
||||
if let AppSession(Some(value)) = parts.extract_with_state(state).await? {
|
||||
value
|
||||
} else {
|
||||
tracing::debug!("inferred that session cookie already in jar");
|
||||
jar
|
||||
}
|
||||
Session::new()
|
||||
};
|
||||
let auth_info = if let Some(value) = session.get::<AuthInfo>(SESSION_KEY_AUTH_INFO) {
|
||||
value
|
||||
} 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
|
||||
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!("{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(
|
||||
jar,
|
||||
format!("{}/auth/login", state.settings.root_path),
|
||||
));
|
||||
};
|
||||
let current_user = if let Some(value) =
|
||||
query_as!(User, "select * from users where uid = $1", &auth_info.sub)
|
||||
.fetch_optional(&state.app_db)
|
||||
.await?
|
||||
{
|
||||
value
|
||||
} else if let Some(value) = query_as!(
|
||||
User,
|
||||
"
|
||||
insert into users
|
||||
(id, uid, email)
|
||||
values ($1, $2, $3)
|
||||
on conflict (uid) do nothing
|
||||
returning *
|
||||
",
|
||||
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))
|
||||
let AppDbConn(mut app_db): AppDbConn = parts.extract_with_state(state).await?;
|
||||
Ok(CurrentUser(
|
||||
if let Some(user) = User::with_uid(&auth_info.sub)
|
||||
.fetch_optional(&mut app_db)
|
||||
.await?
|
||||
{
|
||||
user
|
||||
} else {
|
||||
tracing::debug!("user record not found by uid; upserting");
|
||||
User::upsert()
|
||||
.uid(&auth_info.sub)
|
||||
.email(&auth_info.email)
|
||||
.execute(&mut app_db)
|
||||
.await?
|
||||
},
|
||||
))
|
||||
}
|
||||
.instrument(info_span!("CurrentUser extractor"))
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ use uuid::Uuid;
|
|||
|
||||
use crate::{
|
||||
navigator::Navigator,
|
||||
workspace_utils::{RelationPortalSet, fetch_all_accessible_portals},
|
||||
workspaces::{RelationPortalSet, fetch_all_accessible_portals},
|
||||
};
|
||||
|
||||
#[derive(Builder, Clone, Debug, Template)]
|
||||
|
|
|
|||
|
|
@ -103,11 +103,11 @@ discard sequences;
|
|||
// fairly broad refactor of [`phono-server`] code for
|
||||
// initializing user roles and for performing workspace auth
|
||||
// checks.
|
||||
client
|
||||
.init_role(&format!("{ROLE_PREFIX_USER}{uid}", uid = uid.simple()))
|
||||
.await?;
|
||||
let rolname = format!("{ROLE_PREFIX_USER}{uid}", uid = uid.simple());
|
||||
client.ensure_role(&rolname).await?;
|
||||
client.set_role(&rolname).await?;
|
||||
}
|
||||
RoleAssignment::Root => {}
|
||||
RoleAssignment::Root => { /* no-op */ }
|
||||
}
|
||||
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::{
|
||||
client::WorkspaceClient,
|
||||
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>
|
||||
</td>
|
||||
<td>
|
||||
{% for role_display in cred.member_of %}
|
||||
{{ 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>
|
||||
{{ cred.permissions_editor | safe }}
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
|
|
|
|||
|
|
@ -18,12 +18,70 @@
|
|||
<button class="button--primary" type="submit">Save</button>
|
||||
</section>
|
||||
</form>
|
||||
<form method="post" action="">
|
||||
<section>
|
||||
<h1>Sharing</h1>
|
||||
<button class="button--primary" type="submit">Save</button>
|
||||
</section>
|
||||
</form>
|
||||
<section>
|
||||
<h1>Sharing</h1>
|
||||
<button class="button button--primary" popovertarget="invite-dialog" type="button">
|
||||
Invite
|
||||
</button>
|
||||
<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>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue