implement invites for collaboration

This commit is contained in:
Brent Schroeter 2025-12-16 09:59:30 -08:00
parent 8c284ecc05
commit afba6497af
36 changed files with 1983 additions and 668 deletions

View file

@ -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?;

View file

@ -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()

View file

@ -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.

View file

@ -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);

View file

@ -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,

View file

@ -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);

View file

@ -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")]

View file

@ -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;

View file

@ -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
}
}

View 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(())
}
}

View file

@ -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
View 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(())
}
}

View file

@ -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]

View 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,
)
}

View file

@ -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(),
}))
}
}
}

View file

@ -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?;

View file

@ -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)]

View file

@ -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))

View file

@ -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

View file

@ -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)]

View file

@ -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)]

View file

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

View file

@ -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),

View file

@ -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())

View file

@ -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,
}

View file

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

View file

@ -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()

View file

@ -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
}
}

View file

@ -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)]

View file

@ -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)
}

View file

@ -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},

View 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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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 %}