From 97b5ccc0648f69e941d954a672350567411c8583 Mon Sep 17 00:00:00 2001 From: Brent Schroeter Date: Sat, 1 Nov 2025 00:17:07 +0000 Subject: [PATCH] add scaffolding for clusters and service creds --- Cargo.lock | 1 + README.md | 14 +- .../migrations/20250918060948_init.down.sql | 4 + .../migrations/20250918060948_init.up.sql | 30 +++- interim-models/src/cluster.rs | 84 ++++++++++ interim-models/src/lib.rs | 3 + interim-models/src/macros.rs | 50 ++++++ interim-models/src/portal.rs | 52 +----- interim-models/src/service_cred.rs | 92 +++++++++++ interim-models/src/workspace.rs | 70 +++----- interim-models/src/workspace_user_perm.rs | 11 +- interim-pgtypes/src/pg_class.rs | 61 ++++++- interim-pgtypes/src/pg_role.rs | 91 +++++++++- interim-server/Cargo.toml | 1 + interim-server/src/app.rs | 1 - interim-server/src/main.rs | 1 + interim-server/src/navigator.rs | 80 +++++---- interim-server/src/roles.rs | 156 ++++++++++++++++++ .../relations_single/add_field_handler.rs | 2 +- .../relations_single/add_portal_handler.rs | 13 +- .../settings_invite_handler.rs | 3 +- .../update_form_transitions_handler.rs | 4 +- .../update_portal_name_handler.rs | 2 +- .../update_rel_name_handler.rs | 4 +- .../routes/workspaces_multi/add_handlers.rs | 47 +++--- .../routes/workspaces_multi/list_handlers.rs | 6 +- .../add_service_credential_handler.rs | 100 +++++++++++ .../workspaces_single/add_table_handler.rs | 58 ++++--- .../src/routes/workspaces_single/mod.rs | 10 ++ .../service_credentials_handler.rs | 135 +++++++++++++++ interim-server/src/settings.rs | 12 -- interim-server/src/workspace_pooler.rs | 24 ++- interim-server/src/workspace_utils.rs | 75 +-------- interim-server/templates/portal_table.html | 15 +- .../relations_single/portal_settings.html | 2 +- interim-server/templates/role_display.html | 16 ++ interim-server/templates/toolbar_user.html | 14 ++ interim-server/templates/workspace_nav.html | 4 +- .../templates/workspaces_multi/list.html | 6 +- .../templates/workspaces_single/nav.html | 2 +- .../service_credentials.html | 54 ++++++ sass/main.scss | 26 +++ 42 files changed, 1113 insertions(+), 323 deletions(-) create mode 100644 interim-models/src/cluster.rs create mode 100644 interim-models/src/macros.rs create mode 100644 interim-models/src/service_cred.rs create mode 100644 interim-server/src/roles.rs create mode 100644 interim-server/src/routes/workspaces_single/add_service_credential_handler.rs create mode 100644 interim-server/src/routes/workspaces_single/service_credentials_handler.rs create mode 100644 interim-server/templates/role_display.html create mode 100644 interim-server/templates/toolbar_user.html create mode 100644 interim-server/templates/workspaces_single/service_credentials.html diff --git a/Cargo.lock b/Cargo.lock index a3b8803..2ccb3cb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1800,6 +1800,7 @@ dependencies = [ "oauth2", "percent-encoding", "rand 0.8.5", + "redact", "regex", "reqwest 0.12.15", "scraper", diff --git a/README.md b/README.md index 3db1972..ece09f6 100644 --- a/README.md +++ b/README.md @@ -108,11 +108,15 @@ is added; this is simplified by maintaining a single "writer" role per table. Direct user PostgreSQL connections are performed using secondary `LOGIN` roles created by the user's primary workspace role (where the primary workspace role -is e.g. `phono_{user_id}`). The credentials for these secondary roles are -referred to as "service credentials" or "PostgreSQL credentials". Service -credentials are created and assigned permissions by users in the web UI, and -their permissions are revoked manually in the web UI and/or by cascading -`REVOKE` commands targeting the primary workspace role. +is e.g. `usr_{user_id}`). The credentials for these secondary roles are referred +to as "service credentials" or "PostgreSQL credentials". Service credentials are +created and assigned permissions by users in the web UI, and their permissions +are revoked manually in the web UI and/or by cascading `REVOKE` commands +targeting the primary workspace role. + +Service credential role names have the format +`svc_{user_id}_{8 chars (4 bytes) of random hex}`. With the user ID consuming 32 +characters, this balances name length with an ample space for possible names. ## Footnotes diff --git a/interim-models/migrations/20250918060948_init.down.sql b/interim-models/migrations/20250918060948_init.down.sql index 6e52c8c..fe21e7a 100644 --- a/interim-models/migrations/20250918060948_init.down.sql +++ b/interim-models/migrations/20250918060948_init.down.sql @@ -1,9 +1,13 @@ +drop table if exists form_touch_points; +drop table if exists form_sessions; drop table if exists field_form_prompts; drop table if exists form_transitions; +drop table if exists service_creds; drop table if exists fields; drop table if exists portals; drop table if exists rel_invitations; drop table if exists workspace_memberships; drop table if exists workspaces; +drop table if exists clusters; drop table if exists browser_sessions; drop table if exists users; diff --git a/interim-models/migrations/20250918060948_init.up.sql b/interim-models/migrations/20250918060948_init.up.sql index eb680ef..61fcd74 100644 --- a/interim-models/migrations/20250918060948_init.up.sql +++ b/interim-models/migrations/20250918060948_init.up.sql @@ -18,17 +18,28 @@ create table if not exists browser_sessions ( create index on browser_sessions (expiry); create index on browser_sessions (created_at); +-- Clusters -- + +create table if not exists clusters ( + id uuid not null primary key default uuidv7(), + host text not null unique, + username text not null default 'phono', + password text not null +); + -- Workspaces -- create table if not exists workspaces ( id uuid not null primary key default uuidv7(), - name text not null default '', - url text not null, + cluster_id uuid not null references clusters(id) on delete restrict, + db_name text not null, + display_name text not null default '', owner_id uuid not null references users(id) on delete restrict ); +create index on workspaces(cluster_id); create index on workspaces (owner_id); -create table if not exists workpace_memberships ( +create table if not exists workspace_memberships ( id uuid not null primary key default uuidv7(), workspace_id uuid not null references workspaces(id) on delete cascade, user_id uuid not null references users(id) on delete cascade, @@ -74,6 +85,19 @@ create table if not exists fields ( table_width_px int not null default 200 ); +-- Service Credentials -- + +create table if not exists service_creds ( + id uuid not null primary key default uuidv7(), + cluster_id uuid not null references clusters(id) on delete restrict, + owner_id uuid not null references users(id) on delete cascade, + rolname text not null, + password text not null, + unique (cluster_id, rolname) +); +create index on service_creds (cluster_id); +create index on service_creds (owner_id); + -- Forms -- create table if not exists form_transitions ( diff --git a/interim-models/src/cluster.rs b/interim-models/src/cluster.rs new file mode 100644 index 0000000..25b8973 --- /dev/null +++ b/interim-models/src/cluster.rs @@ -0,0 +1,84 @@ +use redact::Secret; +use sqlx::query_as; +use url::Url; +use uuid::Uuid; + +use crate::{client::AppDbClient, macros::with_id_query}; + +/// Represents a Postgres cluster to be used as the backing database for zero +/// or more workspaces. At this time, rows in the `clusters` table are created +/// manually by an administrator rather than through the web application. +#[derive(Clone, Debug)] +pub struct Cluster { + /// Primary key (defaults to UUIDv7). + pub id: Uuid, + + /// Host address, including port specifier if not ":5432". + pub host: String, + + /// Username of the root Phonograph role for this cluster. Defaults to + /// "phono". + pub username: String, + + // TODO: encrypt passwords + /// Password of the root Phonograph role for this cluster. + pub password: Secret, +} + +with_id_query!(Cluster, sql = "select * from clusters where id = $1"); + +impl Cluster { + /// Construct an authenticated postgresql:// connection URL for a specific + /// database in this cluster. URL is wrapped in a [`redact::Secret`] because + /// it contains a plaintext password. + pub fn conn_str_for_db( + &self, + db_name: &str, + auth_override: Option<(&str, Secret<&str>)>, + ) -> Result, ConnStrError> { + let (username, password) = auth_override.unwrap_or(( + self.username.as_str(), + Secret::new(self.password.expose_secret().as_str()), + )); + let mut url = Url::parse(&format!("postgresql://{host}", host = self.host))?; + url.set_path(db_name); + url.set_username(username) + .map_err(|_| ConnStrError::Build { + context: "username", + })?; + url.set_password(Some(password.expose_secret())) + .map_err(|_| ConnStrError::Build { + context: "password", + })?; + Ok(Secret::new(url)) + } + + /// For use only in single-cluster setups. If exactly one row is present, it + /// is fetched and returned. Otherwise, returns an error. + pub async fn fetch_only(app_db: &mut AppDbClient) -> sqlx::Result { + let mut rows = query_as!(Self, "select * from clusters limit 2") + .fetch_all(app_db.get_conn()) + .await?; + if rows.len() == 1 { + Ok(rows.pop().expect("just checked that vec len == 1")) + } else { + Err(sqlx::Error::RowNotFound) + } + } +} + +#[derive(Clone, Copy, Debug, thiserror::Error)] +#[error("error building postgresql:// connection string")] +pub enum ConnStrError { + Parse(url::ParseError), + #[error("error building postgresql:// connection string: {context}")] + Build { + context: &'static str, + }, +} + +impl From for ConnStrError { + fn from(value: url::ParseError) -> Self { + Self::Parse(value) + } +} diff --git a/interim-models/src/lib.rs b/interim-models/src/lib.rs index 9376da4..7b4796d 100644 --- a/interim-models/src/lib.rs +++ b/interim-models/src/lib.rs @@ -1,4 +1,5 @@ pub mod client; +pub mod cluster; pub mod datum; pub mod errors; pub mod expression; @@ -6,9 +7,11 @@ pub mod field; pub mod field_form_prompt; pub mod form_transition; 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; pub mod workspace_user_perm; diff --git a/interim-models/src/macros.rs b/interim-models/src/macros.rs new file mode 100644 index 0000000..e742ea0 --- /dev/null +++ b/interim-models/src/macros.rs @@ -0,0 +1,50 @@ +/// Generates code for a `with_id()` method. The `sql` parameter should be a +/// Postgres query which accepts the UUID as its only argument. +/// +/// ## Example +/// +/// ```ignore +/// use uuid::Uuid; +/// +/// struct Test { +/// id: Uuid; +/// } +/// +/// with_id_query!(Test, sql = "select id from tests where id = $1"); +/// ``` +macro_rules! with_id_query { + ($target:ty, sql = $sql:expr $(,)?) => { + impl $target { + /// Build a single-field query by ID. + pub fn with_id(id: uuid::Uuid) -> WithIdQuery { + WithIdQuery { id } + } + } + + #[derive(Clone, Copy, Debug)] + pub struct WithIdQuery { + id: uuid::Uuid, + } + + impl WithIdQuery { + pub async fn fetch_one( + self, + app_db: &mut crate::client::AppDbClient, + ) -> sqlx::Result<$target> { + query_as!($target, $sql, self.id) + .fetch_one(app_db.get_conn()) + .await + } + + pub async fn fetch_optional( + self, + app_db: &mut crate::client::AppDbClient, + ) -> sqlx::Result> { + query_as!($target, $sql, self.id) + .fetch_optional(app_db.get_conn()) + .await + } + } + }; +} +pub(crate) use with_id_query; diff --git a/interim-models/src/portal.rs b/interim-models/src/portal.rs index 6621cd1..f64ad0e 100644 --- a/interim-models/src/portal.rs +++ b/interim-models/src/portal.rs @@ -7,7 +7,9 @@ use sqlx::{postgres::types::Oid, query, query_as, types::Json}; use uuid::Uuid; use validator::Validate; -use crate::{client::AppDbClient, errors::QueryError, expression::PgExpressionAny}; +use crate::{ + client::AppDbClient, errors::QueryError, expression::PgExpressionAny, macros::with_id_query, +}; pub static RE_PORTAL_NAME: LazyLock = LazyLock::new(|| Regex::new(r"^[a-zA-Z0-9][()a-zA-Z0-9 _-]*[a-zA-Z0-9()_-]$").unwrap()); @@ -48,30 +50,15 @@ impl Portal { UpdateBuilder::default() } - /// Build a single-field query by portal ID. - pub fn with_id(id: Uuid) -> WithIdQuery { - WithIdQuery { id } - } - /// Build a query by workspace ID and relation OID. pub fn belonging_to_workspace(workspace_id: Uuid) -> BelongingToWorkspaceQuery { BelongingToWorkspaceQuery { workspace_id } } } -#[derive(Clone, Debug)] -pub struct WithIdQuery { - id: Uuid, -} - -impl WithIdQuery { - pub async fn fetch_optional( - self, - app_db: &mut AppDbClient, - ) -> Result, sqlx::Error> { - query_as!( - Portal, - r#" +with_id_query!( + Portal, + sql = r#" select id, name, @@ -82,32 +69,7 @@ select from portals where id = $1 "#, - self.id - ) - .fetch_optional(&mut *app_db.conn) - .await - } - - pub async fn fetch_one(self, app_db: &mut AppDbClient) -> Result { - query_as!( - Portal, - r#" -select - id, - name, - workspace_id, - class_oid, - form_public, - table_filter as "table_filter: Json>" -from portals -where id = $1 -"#, - self.id - ) - .fetch_one(&mut *app_db.conn) - .await - } -} +); #[derive(Clone, Debug)] pub struct BelongingToWorkspaceQuery { diff --git a/interim-models/src/service_cred.rs b/interim-models/src/service_cred.rs new file mode 100644 index 0000000..a4881b7 --- /dev/null +++ b/interim-models/src/service_cred.rs @@ -0,0 +1,92 @@ +use derive_builder::Builder; +use redact::Secret; +use sqlx::query_as; +use uuid::Uuid; + +use crate::{client::AppDbClient, macros::with_id_query}; + +/// Information pertaining to a `LOGIN` role used to grant direct PostgreSQL +/// access to a backing database. +#[derive(Clone, Debug)] +pub struct ServiceCred { + /// Primary key (defaults to UUIDv7). + pub id: Uuid, + + /// ID of the database cluster on which this role exists. + pub cluster_id: Uuid, + + /// ID of the user for whom this role was created. + pub owner_id: Uuid, + + /// Postgres role name. + pub rolname: String, + + /// Postgres password. + pub password: Secret, +} + +with_id_query!( + ServiceCred, + sql = "select * from service_creds where id = $1" +); + +impl ServiceCred { + /// Build an insert statement to save information about a new service + /// credential role. + pub fn insert() -> InsertBuilder { + Default::default() + } + + /// Query by ID of the user who owns the credential(s). + pub fn belonging_to_user(owner_id: Uuid) -> BelongingToQuery { + BelongingToQuery { owner_id } + } +} + +#[derive(Builder, Clone, Debug)] +pub struct Insert { + cluster_id: Uuid, + owner_id: Uuid, + rolname: String, + password: Secret, +} + +impl Insert { + pub async fn execute(self, app_db: &mut AppDbClient) -> sqlx::Result { + query_as!( + ServiceCred, + r#" +insert into service_creds ( + cluster_id, + owner_id, + rolname, + password +) values ($1, $2, $3, $4) +returning * +"#, + self.cluster_id, + self.owner_id, + self.rolname, + self.password.expose_secret(), + ) + .fetch_one(app_db.get_conn()) + .await + } +} + +#[derive(Clone, Copy, Debug)] +pub struct BelongingToQuery { + owner_id: Uuid, +} + +impl BelongingToQuery { + pub async fn fetch_all(&self, app_db: &mut AppDbClient) -> sqlx::Result> { + query_as!( + ServiceCred, + "select * from service_creds where owner_id = $1", + self.owner_id + ) + .fetch_all(app_db.get_conn()) + .await + } +} diff --git a/interim-models/src/workspace.rs b/interim-models/src/workspace.rs index a502d7d..a50b6a8 100644 --- a/interim-models/src/workspace.rs +++ b/interim-models/src/workspace.rs @@ -1,10 +1,8 @@ use derive_builder::Builder; -use redact::Secret; use sqlx::query_as; -use url::Url; use uuid::Uuid; -use crate::client::AppDbClient; +use crate::{client::AppDbClient, cluster::Cluster, macros::with_id_query}; /// A workspace is 1:1 with a Postgres "database". #[derive(Clone, Debug)] @@ -12,12 +10,14 @@ pub struct Workspace { /// Primary key (defaults to UUIDv7). pub id: Uuid, - /// Human friendly name for the workspace. - pub name: String, + /// Cluster housing the backing database for this workspace. + pub cluster_id: Uuid, - /// `postgresql://` URL of the instance and database hosting this workspace. - // TODO: Encrypt values in Postgres using `pgp_sym_encrypt()`. - pub url: Secret, + /// Postgres database name. + pub db_name: String, + + /// Human friendly name for the workspace. + pub display_name: String, /// ID of the user account that created this workspace. pub owner_id: Uuid, @@ -25,62 +25,36 @@ pub struct Workspace { impl Workspace { /// Build an insert statement to create a new workspace. - pub fn insert() -> InsertBuilder { + pub fn insert<'a>() -> InsertBuilder<'a> { InsertBuilder::default() } - /// Build a single-field query by workspace ID. - pub fn with_id(id: Uuid) -> WithIdQuery { - WithIdQuery { id } + pub async fn fetch_cluster(&self, app_db: &mut AppDbClient) -> sqlx::Result { + Cluster::with_id(self.cluster_id).fetch_one(app_db).await } } -pub struct WithIdQuery { - id: Uuid, -} +with_id_query!(Workspace, sql = "select * from workspaces where id = $1"); -impl WithIdQuery { - pub async fn fetch_optional( - self, - app_db: &mut AppDbClient, - ) -> Result, sqlx::Error> { - query_as!( - Workspace, - "select * from workspaces where id = $1", - &self.id - ) - .fetch_optional(&mut *app_db.conn) - .await - } - - pub async fn fetch_one(self, app_db: &mut AppDbClient) -> Result { - query_as!( - Workspace, - "select * from workspaces where id = $1", - &self.id - ) - .fetch_one(&mut *app_db.conn) - .await - } -} - -#[derive(Builder)] -pub struct Insert { - url: Url, +#[derive(Builder, Clone, Copy, Debug)] +pub struct Insert<'a> { + cluster_id: Uuid, + db_name: &'a str, owner_id: Uuid, } -impl Insert { - pub async fn insert(self, app_db: &mut AppDbClient) -> Result { +impl<'a> Insert<'a> { + pub async fn insert(self, app_db: &mut AppDbClient) -> sqlx::Result { query_as!( Workspace, " insert into workspaces -(url, owner_id) -values ($1, $2) +(cluster_id, db_name, owner_id) +values ($1, $2, $3) returning * ", - self.url.to_string(), + self.cluster_id, + self.db_name, self.owner_id ) .fetch_one(&mut *app_db.conn) diff --git a/interim-models/src/workspace_user_perm.rs b/interim-models/src/workspace_user_perm.rs index 9102312..253f995 100644 --- a/interim-models/src/workspace_user_perm.rs +++ b/interim-models/src/workspace_user_perm.rs @@ -16,7 +16,7 @@ pub struct WorkspaceMembership { pub workspace_id: Uuid, /// **Synthesized field** generated by joining to the `workspaces` table. - pub workspace_name: String, + pub workspace_display_name: String, /// User to which the permission belongs. pub user_id: Uuid, @@ -52,7 +52,7 @@ select p.id as id, p.workspace_id as workspace_id, p.user_id as user_id, - w.name as workspace_name + w.display_name as workspace_display_name from workspace_memberships as p inner join workspaces as w on w.id = p.workspace_id @@ -90,10 +90,9 @@ select p.id as id, p.workspace_id as workspace_id, p.user_id as user_id, - w.name as workspace_name -from workspace_memberships as p - inner join workspaces as w - on w.id = p.workspace_id + w.display_name as workspace_display_name +from p inner join workspaces as w + on w.id = p.workspace_id "#, self.workspace_id, self.user_id, diff --git a/interim-pgtypes/src/pg_class.rs b/interim-pgtypes/src/pg_class.rs index e60cdd8..48fbc92 100644 --- a/interim-pgtypes/src/pg_class.rs +++ b/interim-pgtypes/src/pg_class.rs @@ -1,4 +1,6 @@ -use sqlx::{postgres::types::Oid, query_as}; +use std::fmt::Display; + +use sqlx::{Encode, Postgres, postgres::types::Oid, query_as, query_as_unchecked}; use crate::{ client::WorkspaceClient, escape_identifier, pg_acl::PgAclItem, pg_namespace::PgNamespace, @@ -65,6 +67,7 @@ impl PgClass { escape_identifier(&self.relname) ) } + pub fn with_oid(oid: Oid) -> WithOidQuery { WithOidQuery { oid } } @@ -74,6 +77,62 @@ impl PgClass { kinds: kinds.into_iter().collect(), } } + + pub fn belonging_to_namespace< + T: Clone + Copy + Display + sqlx::Type + for<'a> Encode<'a, Postgres>, + >( + namespace: T, + ) -> BelongingToNamespaceQuery { + BelongingToNamespaceQuery { namespace } + } +} +#[derive(Clone, Copy, Debug)] +pub struct BelongingToNamespaceQuery< + T: Clone + Copy + Display + sqlx::Type + for<'a> Encode<'a, Postgres>, +> { + namespace: T, +} + +impl + for<'a> Encode<'a, Postgres>> + BelongingToNamespaceQuery +{ + pub async fn fetch_all(self, client: &mut WorkspaceClient) -> sqlx::Result> { + // `query_as!()` rightly complains that there may not be a built-in type + // mapping for `T` to `regnamespace`. + // TODO: Figure out whether it's possible to add a trait bound that + // ensures the mapping exists. + query_as_unchecked!( + PgClass, + r#" +select + oid, + relname, + relnamespace, + relnamespace::regnamespace::text as "regnamespace!", + reltype, + reloftype, + relowner, + relowner::regrole::text as "regowner!", + relkind, + relnatts, + relchecks, + relhasrules, + relhastriggers, + relhassubclass, + relrowsecurity, + relforcerowsecurity, + relispopulated, + relispartition, + relacl::text[] as "relacl: Vec" +from pg_class +where + relnamespace = $1::regnamespace::oid +"#, + self.namespace, + ) + .fetch_all(client.get_conn()) + .await + } } pub struct WithOidQuery { diff --git a/interim-pgtypes/src/pg_role.rs b/interim-pgtypes/src/pg_role.rs index d12ec13..2b165b2 100644 --- a/interim-pgtypes/src/pg_role.rs +++ b/interim-pgtypes/src/pg_role.rs @@ -35,6 +35,10 @@ impl PgRole { pub fn with_name_in(names: Vec) -> WithNameInQuery { WithNameInQuery { names } } + + pub fn with_name_starting_with(prefix: String) -> WithNameStartingWithQuery { + WithNameStartingWithQuery { prefix } + } } #[derive(Clone, Debug)] @@ -72,6 +76,41 @@ where rolname = any($1) } } +#[derive(Clone, Debug)] +pub struct WithNameStartingWithQuery { + prefix: String, +} + +impl WithNameStartingWithQuery { + pub async fn fetch_all( + &self, + client: &mut WorkspaceClient, + ) -> Result, sqlx::Error> { + query_as!( + PgRole, + r#" +select + oid as "oid!", + rolname as "rolname!", + rolsuper as "rolsuper!", + rolinherit as "rolinherit!", + rolcreaterole as "rolcreaterole!", + rolcreatedb as "rolcreatedb!", + rolcanlogin as "rolcanlogin!", + rolreplication as "rolreplication!", + rolconnlimit as "rolconnlimit!", + rolvaliduntil, + rolbypassrls as "rolbypassrls!" +from pg_roles +where starts_with(rolname, $1) +"#, + self.prefix + ) + .fetch_all(&mut *client.conn) + .await + } +} + #[derive(Clone, Debug)] pub struct RoleTree { pub role: PgRole, @@ -102,11 +141,15 @@ impl RoleTree { GrantedToQuery { role_oid } } - pub fn flatten_inherited(&self) -> Vec<&PgRole> { + pub fn granted_to_rolname<'a>(rolname: &'a str) -> GrantedToRolnameQuery<'a> { + GrantedToRolnameQuery { rolname } + } + + pub fn flatten_inherited(self) -> Vec { [ - vec![&self.role], + vec![self.role], self.branches - .iter() + .into_iter() .filter(|member| member.inherit) .map(|member| member.flatten_inherited()) .collect::>() @@ -243,6 +286,48 @@ from ( } } +#[derive(Clone, Copy, Debug)] +pub struct GrantedToRolnameQuery<'a> { + rolname: &'a str, +} + +impl<'a> GrantedToRolnameQuery<'a> { + pub async fn fetch_tree( + 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 +", + ) + .bind(self.rolname) + .fetch_all(&mut *client.conn) + .await?; + Ok(rows + .iter() + .find(|row| row.branch.is_none()) + .map(|root_row| RoleTree { + role: root_row.role.clone(), + branches: compute_members(&rows, root_row.role.oid), + inherit: root_row.inherit, + })) + } +} + fn compute_members(rows: &Vec, root: Oid) -> Vec { rows.iter() .filter(|row| row.branch == Some(root)) diff --git a/interim-server/Cargo.toml b/interim-server/Cargo.toml index e142a7e..9e44de2 100644 --- a/interim-server/Cargo.toml +++ b/interim-server/Cargo.toml @@ -23,6 +23,7 @@ markdown = "1.0.0" oauth2 = "4.4.2" percent-encoding = "2.3.1" rand = { workspace = true } +redact = { workspace = true } regex = { workspace = true } reqwest = { workspace = true } scraper = "0.24.0" diff --git a/interim-server/src/app.rs b/interim-server/src/app.rs index d7a1f40..21d21be 100644 --- a/interim-server/src/app.rs +++ b/interim-server/src/app.rs @@ -36,7 +36,6 @@ impl App { let oauth_client = auth::new_oauth_client(&settings)?; let workspace_pooler = WorkspacePooler::builder() .app_db_pool(app_db.clone()) - .db_role_prefix(settings.db_role_prefix.clone()) .build()?; Ok(Self { diff --git a/interim-server/src/main.rs b/interim-server/src/main.rs index 1cf2fbe..a085286 100644 --- a/interim-server/src/main.rs +++ b/interim-server/src/main.rs @@ -20,6 +20,7 @@ mod middleware; mod navigator; mod presentation_form; mod renderable_role_tree; +mod roles; mod routes; mod sessions; mod settings; diff --git a/interim-server/src/navigator.rs b/interim-server/src/navigator.rs index 39cbca4..2868bc6 100644 --- a/interim-server/src/navigator.rs +++ b/interim-server/src/navigator.rs @@ -26,50 +26,41 @@ pub(crate) trait NavigatorPage { #[derive(Clone, Debug)] pub(crate) struct Navigator { root_path: String, - sub_path: String, } impl Navigator { - pub(crate) fn workspace_page(&self, workspace_id: Uuid) -> Self { - Self { - sub_path: format!("/w/{0}/", workspace_id.simple()), - ..self.clone() + pub(crate) fn workspace_page(&self) -> WorkspacePageBuilder { + WorkspacePageBuilder { + root_path: Some(&self.root_path), + ..Default::default() } } pub(crate) fn portal_page(&self) -> PortalPageBuilder { PortalPageBuilder { - root_path: Some(self.get_root_path()), + root_path: Some(&self.root_path), ..Default::default() } } pub(crate) fn form_page(&self, portal_id: Uuid) -> FormPageBuilder { FormPageBuilder { - root_path: Some(self.get_root_path()), + root_path: Some(&self.root_path), portal_id: Some(portal_id), } } /// Returns a [`NavigatorPage`] builder for navigating to a relation's /// "settings" page. - pub(crate) fn rel_settings_page(&self) -> RelSettingsPageBuilder { - RelSettingsPageBuilder { - root_path: Some(self.get_root_path()), + pub(crate) fn rel_page(&self) -> RelPageBuilder { + RelPageBuilder { + root_path: Some(&self.root_path), ..Default::default() } } pub(crate) fn get_root_path(&self) -> String { - self.root_path.to_owned() - } - - pub(crate) fn abs_path(&self) -> String { - format!("{0}{1}", self.root_path, self.sub_path) - } - - pub(crate) fn redirect_to(&self) -> Response { - Redirect::to(&self.abs_path()).into_response() + self.root_path.clone() } } @@ -79,30 +70,51 @@ impl FromRequestParts for Navigator { async fn from_request_parts(_: &mut Parts, state: &App) -> Result { Ok(Navigator { root_path: state.settings.root_path.clone(), - sub_path: "/".to_owned(), }) } } #[derive(Builder, Clone, Debug)] -pub(crate) struct PortalPage { +pub(crate) struct WorkspacePage<'a> { + #[builder(setter(custom))] + root_path: &'a str, + + #[builder(default, setter(strip_option))] + suffix: Option<&'a str>, + + workspace_id: Uuid, +} + +impl<'a> NavigatorPage for WorkspacePage<'a> { + fn get_path(&self) -> String { + format!( + "{root_path}/w/{workspace_id}/{suffix}", + root_path = self.root_path, + workspace_id = self.workspace_id, + suffix = self.suffix.unwrap_or_default(), + ) + } +} + +#[derive(Builder, Clone, Debug)] +pub(crate) struct PortalPage<'a> { portal_id: Uuid, rel_oid: Oid, #[builder(setter(custom))] - root_path: String, + root_path: &'a str, /// Any value provided for `suffix` will be appended (without %-encoding) to /// the final path value. This may be used for sub-paths and/or search /// parameters. #[builder(default, setter(strip_option))] - suffix: Option, + suffix: Option<&'a str>, workspace_id: Uuid, } -impl NavigatorPage for PortalPage { +impl<'a> NavigatorPage for PortalPage<'a> { fn get_path(&self) -> String { format!( "{root_path}/w/{workspace_id}/r/{rel_oid}/p/{portal_id}/{suffix}", @@ -110,49 +122,49 @@ impl NavigatorPage for PortalPage { workspace_id = self.workspace_id.simple(), rel_oid = self.rel_oid.0, portal_id = self.portal_id.simple(), - suffix = self.suffix.clone().unwrap_or_default() + suffix = self.suffix.unwrap_or_default(), ) } } #[derive(Builder, Clone, Debug)] -pub(crate) struct FormPage { +pub(crate) struct FormPage<'a> { portal_id: Uuid, #[builder(setter(custom))] - root_path: String, + root_path: &'a str, } -impl NavigatorPage for FormPage { +impl<'a> NavigatorPage for FormPage<'a> { fn get_path(&self) -> String { format!( "{root_path}/f/{portal_id}", root_path = self.root_path, - portal_id = self.portal_id + portal_id = self.portal_id, ) } } #[derive(Builder, Clone, Debug)] -pub(crate) struct RelSettingsPage { +pub(crate) struct RelPage<'a> { rel_oid: Oid, #[builder(setter(custom))] - root_path: String, + root_path: &'a str, /// Any value provided for `suffix` will be appended (without %-encoding) to /// the final path value. This may be used for sub-paths and/or search /// parameters. #[builder(default, setter(strip_option))] - suffix: Option, + suffix: Option<&'a str>, workspace_id: Uuid, } -impl NavigatorPage for RelSettingsPage { +impl<'a> NavigatorPage for RelPage<'a> { fn get_path(&self) -> String { format!( - "{root_path}/w/{workspace_id}/r/{rel_oid}/settings/{suffix}", + "{root_path}/w/{workspace_id}/r/{rel_oid}/{suffix}", root_path = self.root_path, workspace_id = self.workspace_id.simple(), rel_oid = self.rel_oid.0, diff --git a/interim-server/src/roles.rs b/interim-server/src/roles.rs new file mode 100644 index 0000000..5850098 --- /dev/null +++ b/interim-server/src/roles.rs @@ -0,0 +1,156 @@ +use std::collections::HashSet; + +use anyhow::anyhow; +use askama::Template; +use interim_pgtypes::{ + client::WorkspaceClient, + pg_acl::{PgAclItem, PgPrivilegeType}, + pg_class::PgClass, +}; +use serde::{Deserialize, Serialize}; +use sqlx::{postgres::types::Oid, prelude::FromRow, query_as}; + +use crate::errors::AppError; + +pub(crate) const ROLE_PREFIX_USER: &str = "usr_"; +pub(crate) const ROLE_PREFIX_SERVICE_CRED: &str = "svc_"; +pub(crate) const ROLE_PREFIX_TABLE_OWNER: &str = "tbo_"; +pub(crate) const ROLE_PREFIX_TABLE_READER: &str = "tbr_"; +pub(crate) const ROLE_PREFIX_TABLE_WRITER: &str = "tbw_"; +pub(crate) const SERVICE_CRED_SUFFIX_LEN: usize = 8; +pub(crate) const SERVICE_CRED_CONN_LIMIT: usize = 4; + +// 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![]; + 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, + [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, + [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 [`interim-pgtypes`]. + 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 + "#, + ) + .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) { + RoleDisplay::TableOwner { + oid: rel.oid, + relname: rel.relname, + } + } else if rolname.starts_with(ROLE_PREFIX_TABLE_READER) { + RoleDisplay::TableReader { + oid: rel.oid, + relname: rel.relname, + } + } else { + RoleDisplay::TableWriter { + oid: rel.oid, + relname: rel.relname, + } + } + })) + } else { + Ok(Some(RoleDisplay::Unknown { + rolname: rolname.to_owned(), + })) + } + } +} diff --git a/interim-server/src/routes/relations_single/add_field_handler.rs b/interim-server/src/routes/relations_single/add_field_handler.rs index 37eb7fc..00ab15c 100644 --- a/interim-server/src/routes/relations_single/add_field_handler.rs +++ b/interim-server/src/routes/relations_single/add_field_handler.rs @@ -19,9 +19,9 @@ use crate::{ errors::AppError, navigator::{Navigator, NavigatorPage}, presentation_form::PresentationForm, + roles::get_writer_role, user::CurrentUser, workspace_pooler::{RoleAssignment, WorkspacePooler}, - workspace_utils::{get_reader_role, get_writer_role}, }; #[derive(Debug, Deserialize)] diff --git a/interim-server/src/routes/relations_single/add_portal_handler.rs b/interim-server/src/routes/relations_single/add_portal_handler.rs index 878e437..b2c99e6 100644 --- a/interim-server/src/routes/relations_single/add_portal_handler.rs +++ b/interim-server/src/routes/relations_single/add_portal_handler.rs @@ -4,7 +4,12 @@ use serde::Deserialize; use sqlx::postgres::types::Oid; use uuid::Uuid; -use crate::{app::AppDbConn, errors::AppError, navigator::Navigator, user::CurrentUser}; +use crate::{ + app::AppDbConn, + errors::AppError, + navigator::{Navigator, NavigatorPage as _}, + user::CurrentUser, +}; #[derive(Debug, Deserialize)] pub(super) struct PathParams { @@ -36,5 +41,9 @@ pub(super) async fn post( .execute(&mut app_db) .await?; - Ok(navigator.workspace_page(workspace_id).redirect_to()) + Ok(navigator + .workspace_page() + .workspace_id(workspace_id) + .build()? + .redirect_to()) } diff --git a/interim-server/src/routes/relations_single/settings_invite_handler.rs b/interim-server/src/routes/relations_single/settings_invite_handler.rs index 5989af5..71e4f54 100644 --- a/interim-server/src/routes/relations_single/settings_invite_handler.rs +++ b/interim-server/src/routes/relations_single/settings_invite_handler.rs @@ -63,9 +63,10 @@ pub(super) async fn post( .await?; } Ok(navigator - .rel_settings_page() + .rel_page() .workspace_id(workspace_id) .rel_oid(Oid(rel_oid)) + .suffix("settings/") .build()? .redirect_to()) } diff --git a/interim-server/src/routes/relations_single/update_form_transitions_handler.rs b/interim-server/src/routes/relations_single/update_form_transitions_handler.rs index e77eac4..735a4e8 100644 --- a/interim-server/src/routes/relations_single/update_form_transitions_handler.rs +++ b/interim-server/src/routes/relations_single/update_form_transitions_handler.rs @@ -39,7 +39,7 @@ pub(super) struct FormBody { #[debug_handler(state = App)] pub(super) async fn post( AppDbConn(mut app_db): AppDbConn, - CurrentUser(user): CurrentUser, + CurrentUser(_user): CurrentUser, navigator: Navigator, Path(PathParams { portal_id, @@ -74,7 +74,7 @@ pub(super) async fn post( .workspace_id(workspace_id) .rel_oid(Oid(rel_oid)) .portal_id(portal_id) - .suffix("form/".to_owned()) + .suffix("form/") .build()? .redirect_to()) } diff --git a/interim-server/src/routes/relations_single/update_portal_name_handler.rs b/interim-server/src/routes/relations_single/update_portal_name_handler.rs index 22836bd..3f08bf3 100644 --- a/interim-server/src/routes/relations_single/update_portal_name_handler.rs +++ b/interim-server/src/routes/relations_single/update_portal_name_handler.rs @@ -74,7 +74,7 @@ pub(super) async fn post( .workspace_id(workspace_id) .rel_oid(Oid(rel_oid)) .portal_id(portal_id) - .suffix("settings/".to_owned()) + .suffix("settings/") .build()? .redirect_to()) } diff --git a/interim-server/src/routes/relations_single/update_rel_name_handler.rs b/interim-server/src/routes/relations_single/update_rel_name_handler.rs index 300486a..8077c73 100644 --- a/interim-server/src/routes/relations_single/update_rel_name_handler.rs +++ b/interim-server/src/routes/relations_single/update_rel_name_handler.rs @@ -41,7 +41,6 @@ pub(super) struct FormBody { /// alphanumeric characters and underscores. #[debug_handler(state = App)] pub(super) async fn post( - AppDbConn(mut app_db): AppDbConn, State(mut pooler): State, CurrentUser(user): CurrentUser, navigator: Navigator, @@ -78,9 +77,10 @@ pub(super) async fn post( .await?; Ok(navigator - .rel_settings_page() + .rel_page() .workspace_id(workspace_id) .rel_oid(Oid(rel_oid)) + .suffix("settings/") .build()? .redirect_to()) } diff --git a/interim-server/src/routes/workspaces_multi/add_handlers.rs b/interim-server/src/routes/workspaces_multi/add_handlers.rs index 9749f43..5d9e83d 100644 --- a/interim-server/src/routes/workspaces_multi/add_handlers.rs +++ b/interim-server/src/routes/workspaces_multi/add_handlers.rs @@ -1,6 +1,7 @@ use axum::{extract::State, response::IntoResponse}; use interim_models::{ - client::AppDbClient, user::User, workspace::Workspace, workspace_user_perm::WorkspaceMembership, + client::AppDbClient, cluster::Cluster, user::User, workspace::Workspace, + workspace_user_perm::WorkspaceMembership, }; use interim_pgtypes::{client::WorkspaceClient, escape_identifier}; use sqlx::{Connection as _, PgConnection, query}; @@ -8,10 +9,12 @@ use sqlx::{Connection as _, PgConnection, query}; use crate::{ app::AppDbConn, errors::AppError, - navigator::Navigator, + navigator::{Navigator, NavigatorPage as _}, + roles::ROLE_PREFIX_USER, settings::Settings, user::CurrentUser, workspace_pooler::{RoleAssignment, WorkspacePooler}, + workspace_utils::PHONO_TABLE_NAMESPACE, }; /// HTTP POST handler for creating a new workspace. This handler does not expect @@ -27,6 +30,8 @@ pub(super) async fn post( ) -> Result { // FIXME: csrf + let cluster = Cluster::fetch_only(&mut app_db).await?; + const NAME_LEN_WORDS: usize = 3; // WARNING: `db_name` is injected directly into the `create database` SQL // command. It **must not** contain spaces or any other unsafe characters. @@ -37,8 +42,13 @@ pub(super) async fn post( { // No need to pool these connections, since we don't expect to be using them // often. One less thing to keep track of in application state. - let mut workspace_creator_conn = - PgConnection::connect(settings.new_workspace_db_url.as_str()).await?; + let mut workspace_creator_conn = PgConnection::connect( + cluster + .conn_str_for_db("postgres", None)? + .expose_secret() + .as_str(), + ) + .await?; query(&format!( // `db_name` is an underscore-separated sequence of alphabetical words, // which should be safe to inject directly into the SQL statement. @@ -55,13 +65,10 @@ pub(super) async fn post( workspace_creator_conn.close().await?; } - let mut workspace_url = settings.new_workspace_db_url.clone(); - // Alter database name but preserve auth and any query parameters. - workspace_url.set_path(&db_name); - let workspace = Workspace::insert() .owner_id(user.id) - .url(workspace_url) + .cluster_id(cluster.id) + .db_name(&db_name) .build()? .insert(&mut app_db) .await?; @@ -77,13 +84,12 @@ pub(super) async fn post( .await?; query(&format!( "create schema {nsp}", - nsp = escape_identifier(&settings.phono_table_namespace) + nsp = escape_identifier(PHONO_TABLE_NAMESPACE) )) .execute(workspace_root_conn.get_conn()) .await?; grant_workspace_membership( &db_name, - settings.clone(), &mut app_db, &mut workspace_root_conn, &user, @@ -91,24 +97,23 @@ pub(super) async fn post( ) .await?; - Ok(navigator.workspace_page(workspace.id).redirect_to()) + Ok(navigator + .workspace_page() + .workspace_id(workspace.id) + .build()? + .redirect_to()) } async fn grant_workspace_membership( db_name: &str, - settings: Settings, app_db_client: &mut AppDbClient, workspace_root_client: &mut WorkspaceClient, user: &User, workspace: &Workspace, ) -> Result<(), AppError> { - let rolname = format!( - "{prefix}{user_id}", - prefix = settings.db_role_prefix, - user_id = user.id.simple() - ); + let rolname = format!("{ROLE_PREFIX_USER}{uid}", uid = user.id.simple()); query(&format!( - "grant connect on database {db_name} to {db_user}", + "grant connect on database {db_name} to {db_user} with grant option", db_user = escape_identifier(&rolname), )) .execute(workspace_root_client.get_conn()) @@ -117,8 +122,8 @@ async fn grant_workspace_membership( // TODO: There may be cases in which we will want to grant granular // workspace access which excludes privileges to create tables. query(&format!( - "grant usage, create on schema {nsp} to {rolname}", - nsp = escape_identifier(&settings.phono_table_namespace), + "grant usage, create on schema {nsp} to {rolname} with grant option", + nsp = escape_identifier(PHONO_TABLE_NAMESPACE), rolname = escape_identifier(&rolname) )) .execute(workspace_root_client.get_conn()) diff --git a/interim-server/src/routes/workspaces_multi/list_handlers.rs b/interim-server/src/routes/workspaces_multi/list_handlers.rs index 30b6da4..6e73f18 100644 --- a/interim-server/src/routes/workspaces_multi/list_handlers.rs +++ b/interim-server/src/routes/workspaces_multi/list_handlers.rs @@ -6,7 +6,11 @@ use axum::{ use interim_models::workspace_user_perm::WorkspaceMembership; use crate::{ - app::AppDbConn, errors::AppError, navigator::Navigator, settings::Settings, user::CurrentUser, + app::AppDbConn, + errors::AppError, + navigator::{Navigator, NavigatorPage as _}, + settings::Settings, + user::CurrentUser, }; pub(super) async fn get( diff --git a/interim-server/src/routes/workspaces_single/add_service_credential_handler.rs b/interim-server/src/routes/workspaces_single/add_service_credential_handler.rs new file mode 100644 index 0000000..9b41577 --- /dev/null +++ b/interim-server/src/routes/workspaces_single/add_service_credential_handler.rs @@ -0,0 +1,100 @@ +use axum::{ + debug_handler, + extract::{Path, State}, + response::IntoResponse, +}; +use interim_models::{service_cred::ServiceCred, workspace::Workspace}; +use interim_pgtypes::escape_identifier; +use rand::distributions::{Alphanumeric, DistString}; +use redact::Secret; +use serde::Deserialize; +use sqlx::query; +use uuid::Uuid; + +use crate::{ + app::{App, AppDbConn}, + errors::AppError, + navigator::{Navigator, NavigatorPage}, + roles::{ROLE_PREFIX_SERVICE_CRED, SERVICE_CRED_CONN_LIMIT, SERVICE_CRED_SUFFIX_LEN}, + user::CurrentUser, + workspace_pooler::{RoleAssignment, WorkspacePooler}, + workspace_utils::PHONO_TABLE_NAMESPACE, +}; + +#[derive(Debug, Deserialize)] +pub(super) struct PathParams { + workspace_id: Uuid, +} + +/// HTTP POST handler for generating a new "service credential" role for a user +/// and workspace. +#[debug_handler(state = 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, +) -> Result { + // FIXME: auth and csrf + + let workspace = Workspace::with_id(workspace_id) + .fetch_one(&mut app_db) + .await?; + + let mut workspace_client = pooler + .acquire_for(workspace_id, RoleAssignment::User(user.id)) + .await?; + + let rolname = format!( + "{ROLE_PREFIX_SERVICE_CRED}{uid}_{suffix}", + uid = user.id.simple(), + suffix = Alphanumeric + .sample_string(&mut rand::thread_rng(), SERVICE_CRED_SUFFIX_LEN) + // FIXME: exclude uppercase letters from the original distribution. + // This is a quick, dirty, and arguably incorrect hack for expediency. + .to_lowercase(), + ); + + let password = Secret::new(format!("phpwd{0}", Uuid::new_v4().simple())); + + assert!(!password.expose_secret().contains('\'') && !password.expose_secret().contains('\\')); + query(&format!( + "create user {rolname_esc} password '{password_dangerous}' connection limit {SERVICE_CRED_CONN_LIMIT}", + password_dangerous = password.expose_secret(), + rolname_esc = escape_identifier(&rolname) + )) + .execute(workspace_client.get_conn()) + .await?; + + query(&format!( + "grant connect on database {db_name} to {rolname_esc}", + db_name = workspace.db_name, + rolname_esc = escape_identifier(&rolname) + )) + .execute(workspace_client.get_conn()) + .await?; + query(&format!( + "grant usage on schema {nsp} to {rolname_esc}", + nsp = PHONO_TABLE_NAMESPACE, + rolname_esc = escape_identifier(&rolname) + )) + .execute(workspace_client.get_conn()) + .await?; + + ServiceCred::insert() + .cluster_id(workspace.cluster_id) + .owner_id(user.id) + .rolname(rolname) + .password(password) + .build()? + .execute(&mut app_db) + .await?; + + Ok(navigator + .workspace_page() + .workspace_id(workspace_id) + .suffix("service-credentials/") + .build()? + .redirect_to()) +} diff --git a/interim-server/src/routes/workspaces_single/add_table_handler.rs b/interim-server/src/routes/workspaces_single/add_table_handler.rs index 3190b72..4369af3 100644 --- a/interim-server/src/routes/workspaces_single/add_table_handler.rs +++ b/interim-server/src/routes/workspaces_single/add_table_handler.rs @@ -8,15 +8,15 @@ use sqlx::query; use uuid::Uuid; use crate::{ - app::AppDbConn, errors::AppError, - navigator::Navigator, - settings::Settings, + navigator::{Navigator, NavigatorPage as _}, + roles::{ + ROLE_PREFIX_TABLE_OWNER, ROLE_PREFIX_TABLE_READER, ROLE_PREFIX_TABLE_WRITER, + ROLE_PREFIX_USER, + }, user::CurrentUser, workspace_pooler::{RoleAssignment, WorkspacePooler}, - workspace_utils::{ - TABLE_OWNER_ROLE_PREFIX, TABLE_READER_ROLE_PREFIX, TABLE_WRITER_ROLE_PREFIX, - }, + workspace_utils::PHONO_TABLE_NAMESPACE, }; #[derive(Debug, Deserialize)] @@ -32,11 +32,9 @@ pub(super) struct PathParams { /// This handler expects 1 path parameter named `workspace_id` which should /// deserialize to a UUID. pub(super) async fn post( - State(settings): State, State(mut pooler): State, CurrentUser(user): CurrentUser, navigator: Navigator, - AppDbConn(mut app_db): AppDbConn, Path(PathParams { workspace_id }): Path, ) -> Result { // FIXME: CSRF, Check workspace authorization. @@ -52,15 +50,11 @@ pub(super) async fn post( .acquire_for(workspace_id, RoleAssignment::Root) .await?; - let user_rolname = format!( - "{prefix}{user_id}", - prefix = settings.db_role_prefix, - user_id = user.id.simple() - ); + let user_rolname = format!("{ROLE_PREFIX_USER}{uid}", uid = user.id.simple()); let rolname_uuid = Uuid::new_v4().simple(); - let rolname_table_owner = format!("{TABLE_OWNER_ROLE_PREFIX}{rolname_uuid}"); - let rolname_table_reader = format!("{TABLE_READER_ROLE_PREFIX}{rolname_uuid}"); - let rolname_table_writer = format!("{TABLE_WRITER_ROLE_PREFIX}{rolname_uuid}"); + let rolname_table_owner = format!("{ROLE_PREFIX_TABLE_OWNER}{rolname_uuid}"); + let rolname_table_reader = format!("{ROLE_PREFIX_TABLE_READER}{rolname_uuid}"); + let rolname_table_writer = format!("{ROLE_PREFIX_TABLE_WRITER}{rolname_uuid}"); for rolname in [ &rolname_table_owner, &rolname_table_reader, @@ -86,35 +80,39 @@ create table {0}.{1} ( _created_at timestamptz not null default now() ) "#, - escape_identifier(&settings.phono_table_namespace), + escape_identifier(PHONO_TABLE_NAMESPACE), escape_identifier(&table_name), )) .execute(root_client.get_conn()) .await?; query(&format!( - "alter table {nsp}.{ident} owner to {owner}", - nsp = escape_identifier(&settings.phono_table_namespace), - ident = escape_identifier(&table_name), - owner = escape_identifier(&rolname_table_owner), + "alter table {nsp}.{tbl} owner to {rol}", + nsp = escape_identifier(PHONO_TABLE_NAMESPACE), + tbl = escape_identifier(&table_name), + rol = escape_identifier(&rolname_table_owner), )) .execute(root_client.get_conn()) .await?; query(&format!( - "grant select on {0}.{1} to {2}", - escape_identifier(&settings.phono_table_namespace), - escape_identifier(&table_name), - escape_identifier(&rolname_table_reader), + "grant select on {nsp}.{tbl} to {rol}", + nsp = escape_identifier(PHONO_TABLE_NAMESPACE), + tbl = escape_identifier(&table_name), + rol = escape_identifier(&rolname_table_reader), )) .execute(root_client.get_conn()) .await?; query(&format!( - "grant delete, truncate on {0}.{1} to {2}", - escape_identifier(&settings.phono_table_namespace), - escape_identifier(&table_name), - escape_identifier(&rolname_table_writer), + "grant delete, truncate on {nsp}.{tbl} to {rol}", + nsp = escape_identifier(PHONO_TABLE_NAMESPACE), + tbl = escape_identifier(&table_name), + rol = escape_identifier(&rolname_table_writer), )) .execute(root_client.get_conn()) .await?; - Ok(navigator.workspace_page(workspace_id).redirect_to()) + Ok(navigator + .workspace_page() + .workspace_id(workspace_id) + .build()? + .redirect_to()) } diff --git a/interim-server/src/routes/workspaces_single/mod.rs b/interim-server/src/routes/workspaces_single/mod.rs index 2ac47ad..d96b3b4 100644 --- a/interim-server/src/routes/workspaces_single/mod.rs +++ b/interim-server/src/routes/workspaces_single/mod.rs @@ -12,8 +12,10 @@ use crate::{Settings, app::App}; use super::relations_single; +mod add_service_credential_handler; mod add_table_handler; mod nav_handler; +mod service_credentials_handler; #[derive(Clone, Debug, Deserialize)] struct PathParams { @@ -34,8 +36,16 @@ pub(super) fn new_router() -> Router { }, ), ) + .route( + "/{workspace_id}/add-service-credential", + post(add_service_credential_handler::post), + ) .route("/{workspace_id}/add-table", post(add_table_handler::post)) .route_with_tsr("/{workspace_id}/nav/", get(nav_handler::get)) + .route_with_tsr( + "/{workspace_id}/service-credentials", + get(service_credentials_handler::get), + ) .nest( "/{workspace_id}/r/{rel_oid}", relations_single::new_router(), diff --git a/interim-server/src/routes/workspaces_single/service_credentials_handler.rs b/interim-server/src/routes/workspaces_single/service_credentials_handler.rs new file mode 100644 index 0000000..a0378b0 --- /dev/null +++ b/interim-server/src/routes/workspaces_single/service_credentials_handler.rs @@ -0,0 +1,135 @@ +use askama::Template; +use axum::{ + debug_handler, + extract::{Path, State}, + response::{Html, IntoResponse}, +}; +use futures::{lock::Mutex, prelude::*, stream}; +use interim_models::{service_cred::ServiceCred, workspace::Workspace}; +use interim_pgtypes::pg_role::RoleTree; +use redact::Secret; +use serde::Deserialize; +use url::Url; +use uuid::Uuid; + +use crate::{ + app::{App, AppDbConn}, + errors::AppError, + navigator::Navigator, + roles::RoleDisplay, + settings::Settings, + user::CurrentUser, + workspace_nav::WorkspaceNav, + workspace_pooler::{RoleAssignment, WorkspacePooler}, +}; + +#[derive(Debug, Deserialize)] +pub(super) struct PathParams { + workspace_id: Uuid, +} + +/// HTTP GET handler for the page at which a user manages their service +/// credentials for a workspace. +#[debug_handler(state = App)] +pub(super) async fn get( + State(settings): State, + CurrentUser(user): CurrentUser, + AppDbConn(mut app_db): AppDbConn, + Path(PathParams { workspace_id }): Path, + navigator: Navigator, + State(mut pooler): State, +) -> Result { + // FIXME: auth + + let workspace = Workspace::with_id(workspace_id) + .fetch_one(&mut app_db) + .await?; + let cluster = workspace.fetch_cluster(&mut app_db).await?; + + // Mutex is required to use client in async closures. + let workspace_client = Mutex::new( + pooler + .acquire_for(workspace_id, RoleAssignment::User(user.id)) + .await?, + ); + + struct ServiceCredInfo { + service_cred: ServiceCred, + member_of: Vec, + conn_string: Secret, + conn_string_redacted: String, + } + + let service_cred_info = stream::iter( + ServiceCred::belonging_to_user(user.id) + .fetch_all(&mut app_db) + .await?, + ) + .then(async |cred| { + let member_of: Vec = stream::iter({ + let mut locked_client = workspace_client.lock().await; + let tree = RoleTree::granted_to_rolname(&cred.rolname) + .fetch_tree(&mut locked_client) + .await?; + tree.unwrap().flatten_inherited() + }) + .then(async |role| { + tracing::debug!("111"); + let mut locked_client = workspace_client.lock().await; + RoleDisplay::from_rolname(&role.rolname, &mut locked_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(); + tracing::debug!("222"); + let conn_string = cluster.conn_str_for_db( + &workspace.db_name, + Some(( + cred.rolname.as_str(), + Secret::new(cred.password.expose_secret().as_str()), + )), + )?; + Ok(ServiceCredInfo { + conn_string, + conn_string_redacted: "postgresql://********".to_owned(), + member_of, + service_cred: cred, + }) + }) + .collect::>>() + .await + // [`futures::stream::StreamExt::collect`] can only collect to types that + // implement [`Default`], so we must do result handling with the sync + // version of `collect()`. + .into_iter() + .collect::, AppError>>()?; + + #[derive(Template)] + #[template(path = "workspaces_single/service_credentials.html")] + struct ResponseTemplate { + service_cred_info: Vec, + settings: Settings, + workspace_nav: WorkspaceNav, + } + + Ok(Html( + ResponseTemplate { + workspace_nav: WorkspaceNav::builder() + .navigator(navigator) + .workspace(workspace.clone()) + .populate_rels(&mut app_db, &mut *workspace_client.lock().await) + .await? + .build()?, + service_cred_info, + settings, + } + .render()?, + )) +} diff --git a/interim-server/src/settings.rs b/interim-server/src/settings.rs index b6d858c..01836b3 100644 --- a/interim-server/src/settings.rs +++ b/interim-server/src/settings.rs @@ -25,10 +25,6 @@ pub(crate) struct Settings { /// postgresql:// URL for Interim's application database. pub(crate) database_url: Url, - /// postgresql:// URL to use for creating backing databases for new - /// workspaces. - pub(crate) new_workspace_db_url: Url, - #[serde(default = "default_app_db_max_connections")] pub(crate) app_db_max_connections: u32, @@ -48,14 +44,6 @@ pub(crate) struct Settings { pub(crate) frontend_host: String, pub(crate) auth: AuthSettings, - - /// String to prepend to user IDs in order to construct Postgres role names. - #[serde(default = "default_db_role_prefix")] - pub(crate) db_role_prefix: String, - - /// Postgres schema in which to create managed backing tables. - #[serde(default = "default_phono_table_namespace")] - pub(crate) phono_table_namespace: String, } fn default_app_db_max_connections() -> u32 { diff --git a/interim-server/src/workspace_pooler.rs b/interim-server/src/workspace_pooler.rs index e8b7897..ff131e4 100644 --- a/interim-server/src/workspace_pooler.rs +++ b/interim-server/src/workspace_pooler.rs @@ -9,7 +9,7 @@ use sqlx::{Executor, PgPool, postgres::PgPoolOptions, raw_sql}; use tokio::sync::{OnceCell, RwLock}; use uuid::Uuid; -use crate::app::App; +use crate::{app::App, roles::ROLE_PREFIX_USER}; const MAX_CONNECTIONS: u32 = 4; const IDLE_SECONDS: u64 = 3600; @@ -20,7 +20,6 @@ pub struct WorkspacePooler { #[builder(default, setter(skip))] pools: Arc>>>, app_db_pool: PgPool, - db_role_prefix: String, } impl WorkspacePooler { @@ -42,7 +41,7 @@ impl WorkspacePooler { Box::pin(async move { // Essentially "DISCARD ALL" without "DEALLOCATE ALL" conn.execute(raw_sql( - " + r#" close all; set session authorization default; reset all; @@ -51,13 +50,20 @@ select pg_advisory_unlock_all(); discard plans; discard temp; discard sequences; -", +"#, )) .await?; Ok(true) }) }) - .connect(workspace.url.expose_secret()) + .connect( + workspace + .fetch_cluster(&mut app_db) + .await? + .conn_str_for_db(&workspace.db_name, None)? + .expose_secret() + .as_str(), + ) .await?) }; @@ -91,10 +97,10 @@ discard sequences; let pool = self.get_pool_for(base_id).await?; let mut client = WorkspaceClient::from_pool_conn(pool.acquire().await?); match set_role { - RoleAssignment::User(id) => { - let prefix = &self.db_role_prefix; - let user_id = id.simple(); - client.init_role(&format!("{prefix}{user_id}")).await?; + RoleAssignment::User(uid) => { + client + .init_role(&format!("{ROLE_PREFIX_USER}{uid}", uid = uid.simple())) + .await?; } RoleAssignment::Root => {} } diff --git a/interim-server/src/workspace_utils.rs b/interim-server/src/workspace_utils.rs index 3498e49..7ec0057 100644 --- a/interim-server/src/workspace_utils.rs +++ b/interim-server/src/workspace_utils.rs @@ -2,22 +2,14 @@ //! the [`interim_models::workspace`] module, which is also used extensively //! across the server code. -use std::collections::HashSet; - -use anyhow::anyhow; use interim_models::{client::AppDbClient, portal::Portal}; use interim_pgtypes::{ client::WorkspaceClient, - pg_acl::{PgAclItem, PgPrivilegeType}, pg_class::{PgClass, PgRelKind}, }; use uuid::Uuid; -use crate::errors::AppError; - -pub(crate) const TABLE_OWNER_ROLE_PREFIX: &str = "table_owner_"; -pub(crate) const TABLE_READER_ROLE_PREFIX: &str = "table_reader_"; -pub(crate) const TABLE_WRITER_ROLE_PREFIX: &str = "table_writer_"; +pub const PHONO_TABLE_NAMESPACE: &str = "phono"; #[derive(Clone, Debug)] pub(crate) struct RelationPortalSet { @@ -55,68 +47,3 @@ pub(crate) async fn fetch_all_accessible_portals( Ok(portal_sets) } - -// 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![]; - 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, - [PgPrivilegeType::Select].into(), - [ - PgPrivilegeType::Insert, - PgPrivilegeType::Update, - PgPrivilegeType::Delete, - PgPrivilegeType::Truncate, - ] - .into(), - TABLE_READER_ROLE_PREFIX, - ) -} - -/// 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, - [PgPrivilegeType::Delete, PgPrivilegeType::Truncate].into(), - [PgPrivilegeType::Select].into(), - TABLE_WRITER_ROLE_PREFIX, - ) -} diff --git a/interim-server/templates/portal_table.html b/interim-server/templates/portal_table.html index 807ec91..e0b56fb 100644 --- a/interim-server/templates/portal_table.html +++ b/interim-server/templates/portal_table.html @@ -15,20 +15,7 @@ initial-value="{{ filter | json }}" > -
- - - - - -
  • - - Log out - -
  • -
    -
    -
    + {% include "toolbar_user.html" %}
    diff --git a/interim-server/templates/relations_single/portal_settings.html b/interim-server/templates/relations_single/portal_settings.html index b82172c..dbb53a6 100644 --- a/interim-server/templates/relations_single/portal_settings.html +++ b/interim-server/templates/relations_single/portal_settings.html @@ -19,7 +19,7 @@ {{ workspace_nav | safe }}
    -
    +

    Name

    diff --git a/interim-server/templates/role_display.html b/interim-server/templates/role_display.html new file mode 100644 index 0000000..3f7e705 --- /dev/null +++ b/interim-server/templates/role_display.html @@ -0,0 +1,16 @@ +
    + {%- 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/interim-server/templates/toolbar_user.html b/interim-server/templates/toolbar_user.html new file mode 100644 index 0000000..1980e1e --- /dev/null +++ b/interim-server/templates/toolbar_user.html @@ -0,0 +1,14 @@ +
    + + + + + +
  • + + Log out + +
  • +
    +
    +
    diff --git a/interim-server/templates/workspace_nav.html b/interim-server/templates/workspace_nav.html index b299c1f..12d339b 100644 --- a/interim-server/templates/workspace_nav.html +++ b/interim-server/templates/workspace_nav.html @@ -1,10 +1,10 @@