diff --git a/phono-backends/src/client.rs b/phono-backends/src/client.rs index 704fc45..7fb1ec7 100644 --- a/phono-backends/src/client.rs +++ b/phono-backends/src/client.rs @@ -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?; diff --git a/phono-backends/src/pg_role.rs b/phono-backends/src/pg_role.rs index ed4e413..1c06897 100644 --- a/phono-backends/src/pg_role.rs +++ b/phono-backends/src/pg_role.rs @@ -131,14 +131,16 @@ pub struct RoleTree { pub role: PgRole, pub branches: Vec, pub inherit: bool, + pub admin: bool, } #[derive(Clone, Debug, FromRow)] struct RoleTreeRow { #[sqlx(flatten)] role: PgRole, - branch: Option, + parent: Option, inherit: bool, + admin: bool, } impl RoleTree { @@ -160,9 +162,15 @@ impl RoleTree { GrantedToRolnameQuery { rolname } } - pub fn flatten_inherited(self) -> Vec { + /// 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 { [ - 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, sqlx::Error> { - let rows: Vec = 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 = + 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, 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 = 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 = 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, sqlx::Error> { - let rows: Vec = 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 = + 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, sqlx::Error> { - let rows: Vec = 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 = 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, root: Oid) -> Vec { 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() diff --git a/phono-models/migrations/20251212200434_invitations.down.sql b/phono-models/migrations/20251212200434_invitations.down.sql new file mode 100644 index 0000000..fae2b64 --- /dev/null +++ b/phono-models/migrations/20251212200434_invitations.down.sql @@ -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. diff --git a/phono-models/migrations/20251212200434_invitations.up.sql b/phono-models/migrations/20251212200434_invitations.up.sql new file mode 100644 index 0000000..d77f030 --- /dev/null +++ b/phono-models/migrations/20251212200434_invitations.up.sql @@ -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); diff --git a/phono-models/src/accessors/mod.rs b/phono-models/src/accessors/mod.rs index e7dcdaf..2388521 100644 --- a/phono-models/src/accessors/mod.rs +++ b/phono-models/src/accessors/mod.rs @@ -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, diff --git a/phono-models/src/accessors/portal.rs b/phono-models/src/accessors/portal.rs index 0e87dad..9f20382 100644 --- a/phono-models/src/accessors/portal.rs +++ b/phono-models/src/accessors/portal.rs @@ -178,7 +178,7 @@ impl<'a> Accessor 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 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); diff --git a/phono-models/src/language.rs b/phono-models/src/language.rs index 42c102e..01178c2 100644 --- a/phono-models/src/language.rs +++ b/phono-models/src/language.rs @@ -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")] diff --git a/phono-models/src/lib.rs b/phono-models/src/lib.rs index 0c2ae59..64bda8b 100644 --- a/phono-models/src/lib.rs +++ b/phono-models/src/lib.rs @@ -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; diff --git a/phono-models/src/rel_invitation.rs b/phono-models/src/rel_invitation.rs deleted file mode 100644 index aa215ce..0000000 --- a/phono-models/src/rel_invitation.rs +++ /dev/null @@ -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>, -} - -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, 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>, -} - -impl UpsertableRelInvitation { - pub async fn upsert(self, app_db: &mut AppDbClient) -> Result { - 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 - } -} diff --git a/phono-models/src/rel_invite.rs b/phono-models/src/rel_invite.rs new file mode 100644 index 0000000..1335e63 --- /dev/null +++ b/phono-models/src/rel_invite.rs @@ -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>, +} + +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: ::ValueRef<'_>, + ) -> Result { + let value = <&str as Decode>::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> { + 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> { + 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> { + 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>, +} + +impl UpsertBuilder { + pub async fn execute(self, app_db: &mut AppDbClient) -> QueryResult { + 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, +} + +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(()) + } +} diff --git a/phono-models/src/user.rs b/phono-models/src/user.rs index 3de5feb..eae9521 100644 --- a/phono-models/src/user.rs +++ b/phono-models/src/user.rs @@ -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, + + /// 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>(ids: I) -> WithIdInQuery { let ids: Vec = 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 { + 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> { + 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, 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> { + 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> { + Ok(query_as!( + User, + "select id, uid, email from users where email = lower($1)", + self.email, + ) + .fetch_optional(app_db.get_conn()) + .await?) + } +} diff --git a/phono-server/src/invites.rs b/phono-server/src/invites.rs new file mode 100644 index 0000000..5f05c6d --- /dev/null +++ b/phono-server/src/invites.rs @@ -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 for User { + type Error = InvalidVariantError; + + fn try_from(value: Grantee) -> Result { + if let Grantee::User(user) = value { + Ok(user) + } else { + Err(InvalidVariantError) + } + } +} + +impl TryFrom for Invite { + type Error = InvalidVariantError; + + fn try_from(value: Grantee) -> Result { + if let Grantee::Invite(invite) = value { + Ok(invite) + } else { + Err(InvalidVariantError) + } + } +} + +impl TryFrom for ServiceCred { + type Error = InvalidVariantError; + + fn try_from(value: Grantee) -> Result { + 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, + 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::>() + .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::>() + .join(", "), + ); + + match target.kind { + GranteeKind::Invite(invite) => { + let ids_to_delete: Vec = 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(()) + } +} diff --git a/phono-server/src/main.rs b/phono-server/src/main.rs index 5471964..af03312 100644 --- a/phono-server/src/main.rs +++ b/phono-server/src/main.rs @@ -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] diff --git a/phono-server/src/permissions.rs b/phono-server/src/permissions.rs new file mode 100644 index 0000000..ebc7f7b --- /dev/null +++ b/phono-server/src/permissions.rs @@ -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> { + 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 = 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 for User { + type Error = InvalidVariantError; + + fn try_from(value: Grantee) -> Result { + 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, + + /// Endpoint to use in the `
` 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, + + /// Additional form fields to be rendered as ``. + pub(crate) hidden_inputs: HashMap, +} + +/// 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, + 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> = 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, + 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>, + required_privileges: HashSet, + disallowed_privileges: HashSet, + 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 = 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, + ) +} diff --git a/phono-server/src/roles.rs b/phono-server/src/roles.rs deleted file mode 100644 index 80c5bb5..0000000 --- a/phono-server/src/roles.rs +++ /dev/null @@ -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>, - required_privileges: HashSet, - disallowed_privileges: HashSet, - role_prefix: &str, -) -> Result { - let mut roles: Vec = 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 = 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 { - 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 { - 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> { - 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 = 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(), - })) - } - } -} diff --git a/phono-server/src/routes/relations_single/add_field_handler.rs b/phono-server/src/routes/relations_single/add_field_handler.rs index fbf60ae..853465d 100644 --- a/phono-server/src/routes/relations_single/add_field_handler.rs +++ b/phono-server/src/routes/relations_single/add_field_handler.rs @@ -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?; diff --git a/phono-server/src/routes/relations_single/form_handler.rs b/phono-server/src/routes/relations_single/form_handler.rs index c34a4ce..61f065f 100644 --- a/phono-server/src/routes/relations_single/form_handler.rs +++ b/phono-server/src/routes/relations_single/form_handler.rs @@ -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)] diff --git a/phono-server/src/routes/relations_single/mod.rs b/phono-server/src/routes/relations_single/mod.rs index f87e765..d743ac1 100644 --- a/phono-server/src/routes/relations_single/mod.rs +++ b/phono-server/src/routes/relations_single/mod.rs @@ -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 { Router::::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)) diff --git a/phono-server/src/routes/workspaces_multi/add_handlers.rs b/phono-server/src/routes/workspaces_multi/add_handlers.rs index 957f2f3..50074c0 100644 --- a/phono-server/src/routes/workspaces_multi/add_handlers.rs +++ b/phono-server/src/routes/workspaces_multi/add_handlers.rs @@ -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 diff --git a/phono-server/src/routes/workspaces_single/add_service_credential_handler.rs b/phono-server/src/routes/workspaces_single/add_service_credential_handler.rs index 1061bd6..341dffe 100644 --- a/phono-server/src/routes/workspaces_single/add_service_credential_handler.rs +++ b/phono-server/src/routes/workspaces_single/add_service_credential_handler.rs @@ -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)] diff --git a/phono-server/src/routes/workspaces_single/add_table_handler.rs b/phono-server/src/routes/workspaces_single/add_table_handler.rs index ebfab40..662b9a6 100644 --- a/phono-server/src/routes/workspaces_single/add_table_handler.rs +++ b/phono-server/src/routes/workspaces_single/add_table_handler.rs @@ -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)] diff --git a/phono-server/src/routes/workspaces_single/grant_workspace_privilege_handler.rs b/phono-server/src/routes/workspaces_single/grant_workspace_privilege_handler.rs new file mode 100644 index 0000000..ac90aef --- /dev/null +++ b/phono-server/src/routes/workspaces_single/grant_workspace_privilege_handler.rs @@ -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, + AppDbConn(mut app_db): AppDbConn, + CurrentUser(user): CurrentUser, + navigator: Navigator, + Path(PathParams { workspace_id }): Path, + ValidatedForm(form): ValidatedForm, +) -> Result { + // 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()) +} diff --git a/phono-server/src/routes/workspaces_single/mod.rs b/phono-server/src/routes/workspaces_single/mod.rs index d1a2e03..b5562f6 100644 --- a/phono-server/src/routes/workspaces_single/mod.rs +++ b/phono-server/src/routes/workspaces_single/mod.rs @@ -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 { "/{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), diff --git a/phono-server/src/routes/workspaces_single/service_credentials_handler.rs b/phono-server/src/routes/workspaces_single/service_credentials_handler.rs index e752e76..bd5afa9 100644 --- a/phono-server/src/routes/workspaces_single/service_credentials_handler.rs +++ b/phono-server/src/routes/workspaces_single/service_credentials_handler.rs @@ -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, conn_string: Secret, 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 = stream::iter({ + let current_perms: HashSet = 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::>>() .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::>>() @@ -169,7 +145,6 @@ pub(super) async fn get( #[derive(Template)] #[template(path = "workspaces_single/service_credentials.html")] struct ResponseTemplate { - all_rels: Vec, service_cred_info: Vec, 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()) diff --git a/phono-server/src/routes/workspaces_single/settings_handler.rs b/phono-server/src/routes/workspaces_single/settings_handler.rs index 72a50ce..f737f73 100644 --- a/phono-server/src/routes/workspaces_single/settings_handler.rs +++ b/phono-server/src/routes/workspaces_single/settings_handler.rs @@ -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 = stream::iter(collaborator_users) + .then(async |target_user| -> Result { + let target_rolname = format!("{ROLE_PREFIX_USER}{id}", id = target_user.id.simple()); + let current_perms: HashSet = 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::>>() + .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::, _>>()? + .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::>() + .await + .into_iter() + .collect::, 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, 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, } diff --git a/phono-server/src/routes/workspaces_single/update_rel_privileges_handler.rs b/phono-server/src/routes/workspaces_single/update_rel_privileges_handler.rs new file mode 100644 index 0000000..d5ad219 --- /dev/null +++ b/phono-server/src/routes/workspaces_single/update_rel_privileges_handler.rs @@ -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, + AppDbConn(mut app_db): AppDbConn, + CurrentUser(user): CurrentUser, + navigator: Navigator, + Path(PathParams { workspace_id }): Path, + Form(form): Form>>, +) -> Result { + // 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 = 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::>>() + .await + .into_iter() + .collect::>>()? + .into_iter() + .flatten() + .collect(); + + let desired_perms: HashSet = + 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::>() + })) + .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::>>() + .await + .into_iter() + .collect::>()?; + + 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::>(), + &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()) +} diff --git a/phono-server/src/routes/workspaces_single/update_service_cred_permissions_handler.rs b/phono-server/src/routes/workspaces_single/update_service_cred_permissions_handler.rs index fb78b9d..08f1448 100644 --- a/phono-server/src/routes/workspaces_single/update_service_cred_permissions_handler.rs +++ b/phono-server/src/routes/workspaces_single/update_service_cred_permissions_handler.rs @@ -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, @@ -45,94 +56,91 @@ pub(super) async fn post( }): Path, Form(form): Form>>, ) -> Result { - // 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::>() - .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::>() - .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 = 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::>() + .collect::>>() .await .into_iter() - .collect::, AppError>>()?; + .collect::>>()? + .into_iter() + .flatten() + .collect(); + + let desired_perms: HashSet = + stream::iter(form.into_iter().flat_map(|(oid_str, perm_strs)| { + perm_strs + .into_iter() + .map(|perm_str| (oid_str.clone(), perm_str)) + .collect::>() + })) + .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::>>() + .await + .into_iter() + .collect::>()?; + + // 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::>(), + &mut workspace_client, + ) + .await?; + grant_rel_permissions( + Grantee::ServiceCred(cred), + Actor::User(user.id), + desired_perms, + &mut workspace_client, + ) + .await?; Ok(navigator .workspace_page() diff --git a/phono-server/src/user.rs b/phono-server/src/user.rs index 37745e7..446bbc1 100644 --- a/phono-server/src/user.rs +++ b/phono-server/src/user.rs @@ -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 for CurrentUser { type Rejection = CurrentUserRejection; async fn from_request_parts(parts: &mut Parts, state: &App) -> Result { - 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::(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::(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 } } diff --git a/phono-server/src/workspace_nav.rs b/phono-server/src/workspace_nav.rs index e432b21..14980de 100644 --- a/phono-server/src/workspace_nav.rs +++ b/phono-server/src/workspace_nav.rs @@ -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)] diff --git a/phono-server/src/workspace_pooler.rs b/phono-server/src/workspace_pooler.rs index 24559f4..5b1b124 100644 --- a/phono-server/src/workspace_pooler.rs +++ b/phono-server/src/workspace_pooler.rs @@ -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) } diff --git a/phono-server/src/workspace_utils.rs b/phono-server/src/workspaces.rs similarity index 89% rename from phono-server/src/workspace_utils.rs rename to phono-server/src/workspaces.rs index 6653e67..2dcdd0c 100644 --- a/phono-server/src/workspace_utils.rs +++ b/phono-server/src/workspaces.rs @@ -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}, diff --git a/phono-server/templates/rel_permission.html b/phono-server/templates/rel_permission.html new file mode 100644 index 0000000..7b994d7 --- /dev/null +++ b/phono-server/templates/rel_permission.html @@ -0,0 +1,13 @@ +
+
{{ self.relname }}
+
+ {%- match self.kind -%} + {%- when RelPermissionKind::Owner -%} + owner + {%- when RelPermissionKind::Reader -%} + reader + {%- when RelPermissionKind::Writer -%} + writer + {%- endmatch -%} +
+
diff --git a/phono-server/templates/role_display.html b/phono-server/templates/role_display.html deleted file mode 100644 index 3f7e705..0000000 --- a/phono-server/templates/role_display.html +++ /dev/null @@ -1,16 +0,0 @@ -
- {%- match self -%} - {%- when Self::TableOwner { relname, .. } -%} -
{{ relname }}
-
owner
- {%- when Self::TableReader { relname, .. } -%} -
{{ relname }}
-
reader
- {%- when Self::TableWriter { relname, .. } -%} -
{{ relname }}
-
writer
- {%- when Self::Unknown { rolname } -%} -
{{ rolname }}
-
member
- {%- endmatch -%} -
diff --git a/phono-server/templates/workspaces_single/permissions_editor.html b/phono-server/templates/workspaces_single/permissions_editor.html new file mode 100644 index 0000000..dd628b5 --- /dev/null +++ b/phono-server/templates/workspaces_single/permissions_editor.html @@ -0,0 +1,92 @@ +{% for perm in current_perms.clone() %} +{{ perm | safe }} +{% endfor %} + + + + + + + + + + {% if include_owner %} + + {% endif %} + + + + {% for rel in all_rels %} + + + + + {% if include_owner %} + + {% endif %} + + {% endfor %} + +
TableReaderWriterOwner
{{ rel.relname }} + + + + + +
+ {% for (k, v) in hidden_inputs %} + + {% endfor %} + + +
diff --git a/phono-server/templates/workspaces_single/service_credentials.html b/phono-server/templates/workspaces_single/service_credentials.html index 2230e3f..9b339e4 100644 --- a/phono-server/templates/workspaces_single/service_credentials.html +++ b/phono-server/templates/workspaces_single/service_credentials.html @@ -51,67 +51,7 @@ - {% for role_display in cred.member_of %} - {{ role_display | safe }} - {% endfor %} - - -
- - - - - - - - - - {% for rel in all_rels %} - - - - - - {% endfor %} - -
TableReaderWriter
{{ rel.relname }} - - - -
- -
-
+ {{ cred.permissions_editor | safe }} diff --git a/phono-server/templates/workspaces_single/settings.html b/phono-server/templates/workspaces_single/settings.html index 129628d..a4a7c1e 100644 --- a/phono-server/templates/workspaces_single/settings.html +++ b/phono-server/templates/workspaces_single/settings.html @@ -18,12 +18,70 @@ -
-
-

Sharing

- -
-
+
+

Sharing

+ + +
+
+
+ + +
+ + +
+
+
+ + + + + + + + + + {% for permissions_editor in collaborators %} + + {% let collaborator = User::try_from(permissions_editor.target.clone())? %} + + + + + {% endfor %} + +
EmailPermissionsActions
{{ collaborator.email }} + {{ permissions_editor | safe }} + +
+ + + +
+
+ +
{% endblock %}