add scaffolding for clusters and service creds
This commit is contained in:
parent
44ccb2791c
commit
97b5ccc064
42 changed files with 1113 additions and 323 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -1800,6 +1800,7 @@ dependencies = [
|
|||
"oauth2",
|
||||
"percent-encoding",
|
||||
"rand 0.8.5",
|
||||
"redact",
|
||||
"regex",
|
||||
"reqwest 0.12.15",
|
||||
"scraper",
|
||||
|
|
|
|||
14
README.md
14
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
84
interim-models/src/cluster.rs
Normal file
84
interim-models/src/cluster.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
50
interim-models/src/macros.rs
Normal file
50
interim-models/src/macros.rs
Normal 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;
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
92
interim-models/src/service_cred.rs
Normal file
92
interim-models/src/service_cred.rs
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ mod middleware;
|
|||
mod navigator;
|
||||
mod presentation_form;
|
||||
mod renderable_role_tree;
|
||||
mod roles;
|
||||
mod routes;
|
||||
mod sessions;
|
||||
mod settings;
|
||||
|
|
|
|||
|
|
@ -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
156
interim-server/src/roles.rs
Normal 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(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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()?,
|
||||
))
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 => {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
16
interim-server/templates/role_display.html
Normal file
16
interim-server/templates/role_display.html
Normal 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>
|
||||
14
interim-server/templates/toolbar_user.html
Normal file
14
interim-server/templates/toolbar_user.html
Normal 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>
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue