add scaffolding for clusters and service creds

This commit is contained in:
Brent Schroeter 2025-11-01 00:17:07 +00:00
parent 44ccb2791c
commit 97b5ccc064
42 changed files with 1113 additions and 323 deletions

1
Cargo.lock generated
View file

@ -1800,6 +1800,7 @@ dependencies = [
"oauth2",
"percent-encoding",
"rand 0.8.5",
"redact",
"regex",
"reqwest 0.12.15",
"scraper",

View file

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

View file

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

View file

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

View file

@ -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<String>,
}
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<Secret<Url>, 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<Self> {
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<url::ParseError> for ConnStrError {
fn from(value: url::ParseError) -> Self {
Self::Parse(value)
}
}

View file

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

View file

@ -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<Option<$target>> {
query_as!($target, $sql, self.id)
.fetch_optional(app_db.get_conn())
.await
}
}
};
}
pub(crate) use with_id_query;

View file

@ -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<Regex> =
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<Option<Portal>, sqlx::Error> {
query_as!(
with_id_query!(
Portal,
r#"
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<Portal, sqlx::Error> {
query_as!(
Portal,
r#"
select
id,
name,
workspace_id,
class_oid,
form_public,
table_filter as "table_filter: Json<Option<PgExpressionAny>>"
from portals
where id = $1
"#,
self.id
)
.fetch_one(&mut *app_db.conn)
.await
}
}
);
#[derive(Clone, Debug)]
pub struct BelongingToWorkspaceQuery {

View file

@ -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<String>,
}
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<String>,
}
impl Insert {
pub async fn execute(self, app_db: &mut AppDbClient) -> sqlx::Result<ServiceCred> {
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<Vec<ServiceCred>> {
query_as!(
ServiceCred,
"select * from service_creds where owner_id = $1",
self.owner_id
)
.fetch_all(app_db.get_conn())
.await
}
}

View file

@ -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<String>,
/// 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> {
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<Option<Workspace>, 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<Workspace, sqlx::Error> {
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<Workspace, sqlx::Error> {
impl<'a> Insert<'a> {
pub async fn insert(self, app_db: &mut AppDbClient) -> sqlx::Result<Workspace> {
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)

View file

@ -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,9 +90,8 @@ 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
w.display_name as workspace_display_name
from p inner join workspaces as w
on w.id = p.workspace_id
"#,
self.workspace_id,

View file

@ -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<Postgres> + for<'a> Encode<'a, Postgres>,
>(
namespace: T,
) -> BelongingToNamespaceQuery<T> {
BelongingToNamespaceQuery { namespace }
}
}
#[derive(Clone, Copy, Debug)]
pub struct BelongingToNamespaceQuery<
T: Clone + Copy + Display + sqlx::Type<Postgres> + for<'a> Encode<'a, Postgres>,
> {
namespace: T,
}
impl<T: Clone + Copy + Display + sqlx::Type<Postgres> + for<'a> Encode<'a, Postgres>>
BelongingToNamespaceQuery<T>
{
pub async fn fetch_all(self, client: &mut WorkspaceClient) -> sqlx::Result<Vec<PgClass>> {
// `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<PgAclItem>"
from pg_class
where
relnamespace = $1::regnamespace::oid
"#,
self.namespace,
)
.fetch_all(client.get_conn())
.await
}
}
pub struct WithOidQuery {

View file

@ -35,6 +35,10 @@ impl PgRole {
pub fn with_name_in(names: Vec<String>) -> 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<Vec<PgRole>, 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<PgRole> {
[
vec![&self.role],
vec![self.role],
self.branches
.iter()
.into_iter()
.filter(|member| member.inherit)
.map(|member| member.flatten_inherited())
.collect::<Vec<_>>()
@ -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<Option<RoleTree>, sqlx::Error> {
let rows: Vec<RoleTreeRow> = query_as(
"
with recursive cte as (
select $1::regrole::oid as roleid, null::oid as branch, true as inherit
union all
select m.roleid, m.member as branch, c.inherit and m.inherit_option
from cte as c
join pg_auth_members m on m.member = c.roleid
)
select pg_roles.*, branch, inherit
from (
select roleid, branch, bool_or(inherit) as inherit
from cte
group by roleid, branch
) as subquery
join pg_roles on pg_roles.oid = subquery.roleid
",
)
.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<RoleTreeRow>, root: Oid) -> Vec<RoleTree> {
rows.iter()
.filter(|row| row.branch == Some(root))

View file

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

View file

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

View file

@ -20,6 +20,7 @@ mod middleware;
mod navigator;
mod presentation_form;
mod renderable_role_tree;
mod roles;
mod routes;
mod sessions;
mod settings;

View file

@ -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<App> for Navigator {
async fn from_request_parts(_: &mut Parts, state: &App) -> Result<Self, Self::Rejection> {
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<String>,
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<String>,
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,

156
interim-server/src/roles.rs Normal file
View file

@ -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<Vec<PgAclItem>>,
required_privileges: HashSet<PgPrivilegeType>,
disallowed_privileges: HashSet<PgPrivilegeType>,
role_prefix: &str,
) -> Result<String, AppError> {
let mut roles: Vec<String> = 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<PgPrivilegeType> = acl_item
.privileges
.iter()
.map(|privilege| privilege.privilege)
.collect();
assert!(
privileges_set.intersection(&required_privileges).count()
== required_privileges.len()
);
assert!(privileges_set.intersection(&disallowed_privileges).count() == 0);
roles.push(acl_item.grantee)
}
}
assert!(roles.len() == 1);
Ok(roles
.first()
.expect("already asserted that `roles` has len 1")
.clone())
}
/// Returns the name of the "table_reader" role created by Phonograph for a
/// particular workspace table. The role is assessed based on its name and the
/// table permissions directly granted to it. Returns an error if no matching
/// role is found, and panics if a role is found with excess permissions
/// granted to it directly.
pub(crate) fn get_reader_role(rel: PgClass) -> Result<String, AppError> {
get_table_role(
rel.relacl,
[PgPrivilegeType::Select].into(),
[
PgPrivilegeType::Insert,
PgPrivilegeType::Update,
PgPrivilegeType::Delete,
PgPrivilegeType::Truncate,
]
.into(),
ROLE_PREFIX_TABLE_READER,
)
}
/// Returns the name of the "table_writer" role created by Phonograph for a
/// particular workspace table. The role is assessed based on its name and the
/// table permissions directly granted to it. Returns an error if no matching
/// role is found, and panics if a role is found with excess permissions
/// granted to it directly.
pub(crate) fn get_writer_role(rel: PgClass) -> Result<String, AppError> {
get_table_role(
rel.relacl,
[PgPrivilegeType::Delete, PgPrivilegeType::Truncate].into(),
[PgPrivilegeType::Select].into(),
ROLE_PREFIX_TABLE_WRITER,
)
}
#[derive(Clone, Debug, Deserialize, Template, Serialize)]
#[template(path = "role_display.html")]
pub(crate) enum RoleDisplay {
TableOwner { oid: Oid, relname: String },
TableReader { oid: Oid, relname: String },
TableWriter { oid: Oid, relname: String },
Unknown { rolname: String },
}
impl RoleDisplay {
/// Attempt to infer value from a role name in a specific workspace. If the
/// role corresponds specifically to a relation and that relation is not
/// present in the current workspace, the returned value is `None`.
pub async fn from_rolname(
rolname: &str,
client: &mut WorkspaceClient,
) -> sqlx::Result<Option<RoleDisplay>> {
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<RelInfo> = query_as(
r#"
select oid, any_value(relname) as relname
from (
select
oid,
relname,
(aclexplode(relacl)).grantee as grantee
from pg_class
)
where grantee = $1::regrole::oid
"#,
)
.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(),
}))
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<impl IntoResponse, AppError> {
// 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())

View file

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

View file

@ -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<WorkspacePooler>,
AppDbConn(mut app_db): AppDbConn,
CurrentUser(user): CurrentUser,
navigator: Navigator,
Path(PathParams { workspace_id }): Path<PathParams>,
) -> Result<impl IntoResponse, AppError> {
// 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())
}

View file

@ -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<Settings>,
State(mut pooler): State<WorkspacePooler>,
CurrentUser(user): CurrentUser,
navigator: Navigator,
AppDbConn(mut app_db): AppDbConn,
Path(PathParams { workspace_id }): Path<PathParams>,
) -> Result<impl IntoResponse, AppError> {
// 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())
}

View file

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

View file

@ -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<Settings>,
CurrentUser(user): CurrentUser,
AppDbConn(mut app_db): AppDbConn,
Path(PathParams { workspace_id }): Path<PathParams>,
navigator: Navigator,
State(mut pooler): State<WorkspacePooler>,
) -> Result<impl IntoResponse, AppError> {
// 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<RoleDisplay>,
conn_string: Secret<Url>,
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<RoleDisplay> = 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::<Vec<Result<_, _>>>()
.await
.into_iter()
// [`futures::stream::StreamExt::collect`] can only collect to types
// that implement [`Default`], so we must do result handling with the
// sync version of `collect()`.
.collect::<Result<Vec<_>, _>>()?
.into_iter()
.flatten()
.collect();
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::<Vec<Result<ServiceCredInfo, AppError>>>()
.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::<Result<Vec<ServiceCredInfo>, AppError>>()?;
#[derive(Template)]
#[template(path = "workspaces_single/service_credentials.html")]
struct ResponseTemplate {
service_cred_info: Vec<ServiceCredInfo>,
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()?,
))
}

View file

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

View file

@ -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<RwLock<HashMap<Uuid, OnceCell<PgPool>>>>,
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 => {}
}

View file

@ -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<Vec<PgAclItem>>,
required_privileges: HashSet<PgPrivilegeType>,
disallowed_privileges: HashSet<PgPrivilegeType>,
role_prefix: &str,
) -> Result<String, AppError> {
let mut roles: Vec<String> = 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<PgPrivilegeType> = acl_item
.privileges
.iter()
.map(|privilege| privilege.privilege)
.collect();
assert!(
privileges_set.intersection(&required_privileges).count()
== required_privileges.len()
);
assert!(privileges_set.intersection(&disallowed_privileges).count() == 0);
roles.push(acl_item.grantee)
}
}
assert!(roles.len() == 1);
Ok(roles
.first()
.expect("already asserted that `roles` has len 1")
.clone())
}
/// Returns the name of the "table_reader" role created by Phonograph for a
/// particular workspace table. The role is assessed based on its name and the
/// table permissions directly granted to it. Returns an error if no matching
/// role is found, and panics if a role is found with excess permissions
/// granted to it directly.
pub(crate) fn get_reader_role(rel: PgClass) -> Result<String, AppError> {
get_table_role(
rel.relacl,
[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<String, AppError> {
get_table_role(
rel.relacl,
[PgPrivilegeType::Delete, PgPrivilegeType::Truncate].into(),
[PgPrivilegeType::Select].into(),
TABLE_WRITER_ROLE_PREFIX,
)
}

View file

@ -15,20 +15,7 @@
initial-value="{{ filter | json }}"
></filter-menu>
</div>
<div class="page-grid__toolbar-user">
<basic-dropdown alignment="right">
<span slot="button-contents" aria-label="Account menu" title="Account menu">
<i aria-hidden="true" class="ti ti-user"></i>
</span>
<menu class="basic-dropdown__menu" slot="popover">
<li>
<a href="{{ settings.root_path }}/auth/logout" role="button">
Log out
</a>
</li>
</menu>
</basic-dropdown>
</div>
{% include "toolbar_user.html" %}
</div>
<div class="page-grid__sidebar">
<div style="padding: 1rem;">

View file

@ -19,7 +19,7 @@
{{ workspace_nav | safe }}
</div>
</div>
<main class="page-grid__main">
<main class="page-grid__main padded--lg">
<form method="post" action="update-name">
<section>
<h1>Name</h1>

View file

@ -0,0 +1,16 @@
<div class="role-display">
{%- match self -%}
{%- when Self::TableOwner { relname, .. } -%}
<div class="role-display__resource">{{ relname }}</div>
<div class="role-display__description">owner</div>
{%- when Self::TableReader { relname, .. } -%}
<div class="role-display__resource">{{ relname }}</div>
<div class="role-display__description">reader</div>
{%- when Self::TableWriter { relname, .. } -%}
<div class="role-display__resource">{{ relname }}</div>
<div class="role-display__description">writer</div>
{%- when Self::Unknown { rolname } -%}
<div class="role-display__description">{{ rolname }}</div>
<div class="role-display__description">member</div>
{%- endmatch -%}
</div>

View file

@ -0,0 +1,14 @@
<div class="page-grid__toolbar-user">
<basic-dropdown alignment="right">
<span slot="button-contents" aria-label="Account menu" title="Account menu">
<i aria-hidden="true" class="ti ti-user"></i>
</span>
<menu class="basic-dropdown__menu" slot="popover">
<li>
<a href="{{ settings.root_path }}/auth/logout" role="button">
Log out
</a>
</li>
</menu>
</basic-dropdown>
</div>

View file

@ -1,10 +1,10 @@
<nav class="workspace-nav">
<div class="workspace-nav__heading">
<h1>
{% if workspace.name.is_empty() %}
{% if workspace.display_name.is_empty() %}
Untitled Workspace
{% else %}
{{ workspace.name }}
{{ workspace.display_name }}
{% endif %}
</h1>
<basic-dropdown button-class="button--secondary button--small" button-aria-label="Workspace Menu">

View file

@ -9,11 +9,11 @@
<ul>
{% for workspace_perm in workspace_perms %}
<li>
<a href="{{ navigator.workspace_page(*workspace_perm.workspace_id).abs_path() }}">
{% if workspace_perm.workspace_name.is_empty() %}
<a href="{{ navigator.workspace_page().workspace_id(*workspace_perm.workspace_id).build()?.get_path() }}">
{% if workspace_perm.workspace_display_name.is_empty() %}
[Untitled Workspace]
{% else %}
{{ workspace_perm.workspace_name }}
{{ workspace_perm.workspace_display_name }}
{% endif %}
</a>
</li>

View file

@ -2,7 +2,7 @@
{% block main %}
<main style="position: relative; margin: 0 auto; max-width: 32rem;">
<h1>{{ workspace.name }}</h1>
<h1>{{ workspace.display_name }}</h1>
{{ workspace_nav | safe }}
</main>
{% endblock %}

View file

@ -0,0 +1,54 @@
{% extends "base.html" %}
{% block main %}
<div class="page-grid">
<div class="page-grid__toolbar">
<div class="page-grid__toolbar-utilities">
<basic-dropdown button-class="button--secondary" button-label="New Credential">
<span slot="button-contents">New Credential</span>
<div class="padded" slot="popover">
<form action="add-service-credential" method="post">
<button class="button--secondary" type="submit">Confirm</button>
</form>
</div>
</basic-dropdown>
</div>
{% include "toolbar_user.html" %}
</div>
<div class="page-grid__sidebar">
<div style="padding: 1rem;">
{{ workspace_nav | safe }}
</div>
</div>
<main class="page-grid__main padded--lg">
<table class="table">
<thead>
<tr>
<th>Connection String</th>
<th>Permissions</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% if service_cred_info.is_empty() %}
<tr class="table__message">
<td colspan="999">No data</td>
</tr>
{% endif %}
{% for cred in service_cred_info %}
<tr>
<td>
<copy-source copy-data="{{ cred.conn_string.expose_secret() }}">
<slot><code class="code">{{ cred.conn_string_redacted }}</code></slot>
<slot name="fallback">
<code class="code">{{ cred.conn_string.expose_secret() }}</code>
</slot>
</copy-source>
</td>
</tr>
{% endfor %}
</tbody>
<table>
</main>
</div>
{% endblock %}

View file

@ -74,6 +74,14 @@ button, input[type="submit"] {
}
}
.padded {
padding: 8px;
&--lg {
padding: 32px;
}
}
.page-grid {
height: 100vh;
width: 100vw;
@ -221,3 +229,21 @@ button, input[type="submit"] {
}
}
}
.table {
border-collapse: collapse;
th, td {
border: globals.$default-border;
padding: 8px;
}
th {
background: #eee;
}
&__message {
opacity: 0.5;
text-align: center;
}
}