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",
|
"oauth2",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"rand 0.8.5",
|
"rand 0.8.5",
|
||||||
|
"redact",
|
||||||
"regex",
|
"regex",
|
||||||
"reqwest 0.12.15",
|
"reqwest 0.12.15",
|
||||||
"scraper",
|
"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
|
Direct user PostgreSQL connections are performed using secondary `LOGIN` roles
|
||||||
created by the user's primary workspace role (where the primary workspace role
|
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
|
is e.g. `usr_{user_id}`). The credentials for these secondary roles are referred
|
||||||
referred to as "service credentials" or "PostgreSQL credentials". Service
|
to as "service credentials" or "PostgreSQL credentials". Service credentials are
|
||||||
credentials are created and assigned permissions by users in the web UI, and
|
created and assigned permissions by users in the web UI, and their permissions
|
||||||
their permissions are revoked manually in the web UI and/or by cascading
|
are revoked manually in the web UI and/or by cascading `REVOKE` commands
|
||||||
`REVOKE` commands targeting the primary workspace role.
|
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
|
## 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 field_form_prompts;
|
||||||
drop table if exists form_transitions;
|
drop table if exists form_transitions;
|
||||||
|
drop table if exists service_creds;
|
||||||
drop table if exists fields;
|
drop table if exists fields;
|
||||||
drop table if exists portals;
|
drop table if exists portals;
|
||||||
drop table if exists rel_invitations;
|
drop table if exists rel_invitations;
|
||||||
drop table if exists workspace_memberships;
|
drop table if exists workspace_memberships;
|
||||||
drop table if exists workspaces;
|
drop table if exists workspaces;
|
||||||
|
drop table if exists clusters;
|
||||||
drop table if exists browser_sessions;
|
drop table if exists browser_sessions;
|
||||||
drop table if exists users;
|
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 (expiry);
|
||||||
create index on browser_sessions (created_at);
|
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 --
|
-- Workspaces --
|
||||||
|
|
||||||
create table if not exists workspaces (
|
create table if not exists workspaces (
|
||||||
id uuid not null primary key default uuidv7(),
|
id uuid not null primary key default uuidv7(),
|
||||||
name text not null default '',
|
cluster_id uuid not null references clusters(id) on delete restrict,
|
||||||
url text not null,
|
db_name text not null,
|
||||||
|
display_name text not null default '',
|
||||||
owner_id uuid not null references users(id) on delete restrict
|
owner_id uuid not null references users(id) on delete restrict
|
||||||
);
|
);
|
||||||
|
create index on workspaces(cluster_id);
|
||||||
create index on workspaces (owner_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(),
|
id uuid not null primary key default uuidv7(),
|
||||||
workspace_id uuid not null references workspaces(id) on delete cascade,
|
workspace_id uuid not null references workspaces(id) on delete cascade,
|
||||||
user_id uuid not null references users(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
|
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 --
|
-- Forms --
|
||||||
|
|
||||||
create table if not exists form_transitions (
|
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 client;
|
||||||
|
pub mod cluster;
|
||||||
pub mod datum;
|
pub mod datum;
|
||||||
pub mod errors;
|
pub mod errors;
|
||||||
pub mod expression;
|
pub mod expression;
|
||||||
|
|
@ -6,9 +7,11 @@ pub mod field;
|
||||||
pub mod field_form_prompt;
|
pub mod field_form_prompt;
|
||||||
pub mod form_transition;
|
pub mod form_transition;
|
||||||
pub mod language;
|
pub mod language;
|
||||||
|
mod macros;
|
||||||
pub mod portal;
|
pub mod portal;
|
||||||
pub mod presentation;
|
pub mod presentation;
|
||||||
pub mod rel_invitation;
|
pub mod rel_invitation;
|
||||||
|
pub mod service_cred;
|
||||||
pub mod user;
|
pub mod user;
|
||||||
pub mod workspace;
|
pub mod workspace;
|
||||||
pub mod workspace_user_perm;
|
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 uuid::Uuid;
|
||||||
use validator::Validate;
|
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> =
|
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());
|
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()
|
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.
|
/// Build a query by workspace ID and relation OID.
|
||||||
pub fn belonging_to_workspace(workspace_id: Uuid) -> BelongingToWorkspaceQuery {
|
pub fn belonging_to_workspace(workspace_id: Uuid) -> BelongingToWorkspaceQuery {
|
||||||
BelongingToWorkspaceQuery { workspace_id }
|
BelongingToWorkspaceQuery { workspace_id }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
with_id_query!(
|
||||||
pub struct WithIdQuery {
|
|
||||||
id: Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl WithIdQuery {
|
|
||||||
pub async fn fetch_optional(
|
|
||||||
self,
|
|
||||||
app_db: &mut AppDbClient,
|
|
||||||
) -> Result<Option<Portal>, sqlx::Error> {
|
|
||||||
query_as!(
|
|
||||||
Portal,
|
Portal,
|
||||||
r#"
|
sql = r#"
|
||||||
select
|
select
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
|
|
@ -82,32 +69,7 @@ select
|
||||||
from portals
|
from portals
|
||||||
where id = $1
|
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)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct BelongingToWorkspaceQuery {
|
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 derive_builder::Builder;
|
||||||
use redact::Secret;
|
|
||||||
use sqlx::query_as;
|
use sqlx::query_as;
|
||||||
use url::Url;
|
|
||||||
use uuid::Uuid;
|
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".
|
/// A workspace is 1:1 with a Postgres "database".
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
|
|
@ -12,12 +10,14 @@ pub struct Workspace {
|
||||||
/// Primary key (defaults to UUIDv7).
|
/// Primary key (defaults to UUIDv7).
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
|
|
||||||
/// Human friendly name for the workspace.
|
/// Cluster housing the backing database for this workspace.
|
||||||
pub name: String,
|
pub cluster_id: Uuid,
|
||||||
|
|
||||||
/// `postgresql://` URL of the instance and database hosting this workspace.
|
/// Postgres database name.
|
||||||
// TODO: Encrypt values in Postgres using `pgp_sym_encrypt()`.
|
pub db_name: String,
|
||||||
pub url: Secret<String>,
|
|
||||||
|
/// Human friendly name for the workspace.
|
||||||
|
pub display_name: String,
|
||||||
|
|
||||||
/// ID of the user account that created this workspace.
|
/// ID of the user account that created this workspace.
|
||||||
pub owner_id: Uuid,
|
pub owner_id: Uuid,
|
||||||
|
|
@ -25,62 +25,36 @@ pub struct Workspace {
|
||||||
|
|
||||||
impl Workspace {
|
impl Workspace {
|
||||||
/// Build an insert statement to create a new workspace.
|
/// Build an insert statement to create a new workspace.
|
||||||
pub fn insert() -> InsertBuilder {
|
pub fn insert<'a>() -> InsertBuilder<'a> {
|
||||||
InsertBuilder::default()
|
InsertBuilder::default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a single-field query by workspace ID.
|
pub async fn fetch_cluster(&self, app_db: &mut AppDbClient) -> sqlx::Result<Cluster> {
|
||||||
pub fn with_id(id: Uuid) -> WithIdQuery {
|
Cluster::with_id(self.cluster_id).fetch_one(app_db).await
|
||||||
WithIdQuery { id }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct WithIdQuery {
|
with_id_query!(Workspace, sql = "select * from workspaces where id = $1");
|
||||||
id: Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl WithIdQuery {
|
#[derive(Builder, Clone, Copy, Debug)]
|
||||||
pub async fn fetch_optional(
|
pub struct Insert<'a> {
|
||||||
self,
|
cluster_id: Uuid,
|
||||||
app_db: &mut AppDbClient,
|
db_name: &'a str,
|
||||||
) -> 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,
|
|
||||||
owner_id: Uuid,
|
owner_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Insert {
|
impl<'a> Insert<'a> {
|
||||||
pub async fn insert(self, app_db: &mut AppDbClient) -> Result<Workspace, sqlx::Error> {
|
pub async fn insert(self, app_db: &mut AppDbClient) -> sqlx::Result<Workspace> {
|
||||||
query_as!(
|
query_as!(
|
||||||
Workspace,
|
Workspace,
|
||||||
"
|
"
|
||||||
insert into workspaces
|
insert into workspaces
|
||||||
(url, owner_id)
|
(cluster_id, db_name, owner_id)
|
||||||
values ($1, $2)
|
values ($1, $2, $3)
|
||||||
returning *
|
returning *
|
||||||
",
|
",
|
||||||
self.url.to_string(),
|
self.cluster_id,
|
||||||
|
self.db_name,
|
||||||
self.owner_id
|
self.owner_id
|
||||||
)
|
)
|
||||||
.fetch_one(&mut *app_db.conn)
|
.fetch_one(&mut *app_db.conn)
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ pub struct WorkspaceMembership {
|
||||||
pub workspace_id: Uuid,
|
pub workspace_id: Uuid,
|
||||||
|
|
||||||
/// **Synthesized field** generated by joining to the `workspaces` table.
|
/// **Synthesized field** generated by joining to the `workspaces` table.
|
||||||
pub workspace_name: String,
|
pub workspace_display_name: String,
|
||||||
|
|
||||||
/// User to which the permission belongs.
|
/// User to which the permission belongs.
|
||||||
pub user_id: Uuid,
|
pub user_id: Uuid,
|
||||||
|
|
@ -52,7 +52,7 @@ select
|
||||||
p.id as id,
|
p.id as id,
|
||||||
p.workspace_id as workspace_id,
|
p.workspace_id as workspace_id,
|
||||||
p.user_id as user_id,
|
p.user_id as user_id,
|
||||||
w.name as workspace_name
|
w.display_name as workspace_display_name
|
||||||
from workspace_memberships as p
|
from workspace_memberships as p
|
||||||
inner join workspaces as w
|
inner join workspaces as w
|
||||||
on w.id = p.workspace_id
|
on w.id = p.workspace_id
|
||||||
|
|
@ -90,9 +90,8 @@ select
|
||||||
p.id as id,
|
p.id as id,
|
||||||
p.workspace_id as workspace_id,
|
p.workspace_id as workspace_id,
|
||||||
p.user_id as user_id,
|
p.user_id as user_id,
|
||||||
w.name as workspace_name
|
w.display_name as workspace_display_name
|
||||||
from workspace_memberships as p
|
from p inner join workspaces as w
|
||||||
inner join workspaces as w
|
|
||||||
on w.id = p.workspace_id
|
on w.id = p.workspace_id
|
||||||
"#,
|
"#,
|
||||||
self.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::{
|
use crate::{
|
||||||
client::WorkspaceClient, escape_identifier, pg_acl::PgAclItem, pg_namespace::PgNamespace,
|
client::WorkspaceClient, escape_identifier, pg_acl::PgAclItem, pg_namespace::PgNamespace,
|
||||||
|
|
@ -65,6 +67,7 @@ impl PgClass {
|
||||||
escape_identifier(&self.relname)
|
escape_identifier(&self.relname)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn with_oid(oid: Oid) -> WithOidQuery {
|
pub fn with_oid(oid: Oid) -> WithOidQuery {
|
||||||
WithOidQuery { oid }
|
WithOidQuery { oid }
|
||||||
}
|
}
|
||||||
|
|
@ -74,6 +77,62 @@ impl PgClass {
|
||||||
kinds: kinds.into_iter().collect(),
|
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 {
|
pub struct WithOidQuery {
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,10 @@ impl PgRole {
|
||||||
pub fn with_name_in(names: Vec<String>) -> WithNameInQuery {
|
pub fn with_name_in(names: Vec<String>) -> WithNameInQuery {
|
||||||
WithNameInQuery { names }
|
WithNameInQuery { names }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn with_name_starting_with(prefix: String) -> WithNameStartingWithQuery {
|
||||||
|
WithNameStartingWithQuery { prefix }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[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)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct RoleTree {
|
pub struct RoleTree {
|
||||||
pub role: PgRole,
|
pub role: PgRole,
|
||||||
|
|
@ -102,11 +141,15 @@ impl RoleTree {
|
||||||
GrantedToQuery { role_oid }
|
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
|
self.branches
|
||||||
.iter()
|
.into_iter()
|
||||||
.filter(|member| member.inherit)
|
.filter(|member| member.inherit)
|
||||||
.map(|member| member.flatten_inherited())
|
.map(|member| member.flatten_inherited())
|
||||||
.collect::<Vec<_>>()
|
.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> {
|
fn compute_members(rows: &Vec<RoleTreeRow>, root: Oid) -> Vec<RoleTree> {
|
||||||
rows.iter()
|
rows.iter()
|
||||||
.filter(|row| row.branch == Some(root))
|
.filter(|row| row.branch == Some(root))
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ markdown = "1.0.0"
|
||||||
oauth2 = "4.4.2"
|
oauth2 = "4.4.2"
|
||||||
percent-encoding = "2.3.1"
|
percent-encoding = "2.3.1"
|
||||||
rand = { workspace = true }
|
rand = { workspace = true }
|
||||||
|
redact = { workspace = true }
|
||||||
regex = { workspace = true }
|
regex = { workspace = true }
|
||||||
reqwest = { workspace = true }
|
reqwest = { workspace = true }
|
||||||
scraper = "0.24.0"
|
scraper = "0.24.0"
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,6 @@ impl App {
|
||||||
let oauth_client = auth::new_oauth_client(&settings)?;
|
let oauth_client = auth::new_oauth_client(&settings)?;
|
||||||
let workspace_pooler = WorkspacePooler::builder()
|
let workspace_pooler = WorkspacePooler::builder()
|
||||||
.app_db_pool(app_db.clone())
|
.app_db_pool(app_db.clone())
|
||||||
.db_role_prefix(settings.db_role_prefix.clone())
|
|
||||||
.build()?;
|
.build()?;
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ mod middleware;
|
||||||
mod navigator;
|
mod navigator;
|
||||||
mod presentation_form;
|
mod presentation_form;
|
||||||
mod renderable_role_tree;
|
mod renderable_role_tree;
|
||||||
|
mod roles;
|
||||||
mod routes;
|
mod routes;
|
||||||
mod sessions;
|
mod sessions;
|
||||||
mod settings;
|
mod settings;
|
||||||
|
|
|
||||||
|
|
@ -26,50 +26,41 @@ pub(crate) trait NavigatorPage {
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub(crate) struct Navigator {
|
pub(crate) struct Navigator {
|
||||||
root_path: String,
|
root_path: String,
|
||||||
sub_path: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Navigator {
|
impl Navigator {
|
||||||
pub(crate) fn workspace_page(&self, workspace_id: Uuid) -> Self {
|
pub(crate) fn workspace_page(&self) -> WorkspacePageBuilder {
|
||||||
Self {
|
WorkspacePageBuilder {
|
||||||
sub_path: format!("/w/{0}/", workspace_id.simple()),
|
root_path: Some(&self.root_path),
|
||||||
..self.clone()
|
..Default::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn portal_page(&self) -> PortalPageBuilder {
|
pub(crate) fn portal_page(&self) -> PortalPageBuilder {
|
||||||
PortalPageBuilder {
|
PortalPageBuilder {
|
||||||
root_path: Some(self.get_root_path()),
|
root_path: Some(&self.root_path),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn form_page(&self, portal_id: Uuid) -> FormPageBuilder {
|
pub(crate) fn form_page(&self, portal_id: Uuid) -> FormPageBuilder {
|
||||||
FormPageBuilder {
|
FormPageBuilder {
|
||||||
root_path: Some(self.get_root_path()),
|
root_path: Some(&self.root_path),
|
||||||
portal_id: Some(portal_id),
|
portal_id: Some(portal_id),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns a [`NavigatorPage`] builder for navigating to a relation's
|
/// Returns a [`NavigatorPage`] builder for navigating to a relation's
|
||||||
/// "settings" page.
|
/// "settings" page.
|
||||||
pub(crate) fn rel_settings_page(&self) -> RelSettingsPageBuilder {
|
pub(crate) fn rel_page(&self) -> RelPageBuilder {
|
||||||
RelSettingsPageBuilder {
|
RelPageBuilder {
|
||||||
root_path: Some(self.get_root_path()),
|
root_path: Some(&self.root_path),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn get_root_path(&self) -> String {
|
pub(crate) fn get_root_path(&self) -> String {
|
||||||
self.root_path.to_owned()
|
self.root_path.clone()
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -79,30 +70,51 @@ impl FromRequestParts<App> for Navigator {
|
||||||
async fn from_request_parts(_: &mut Parts, state: &App) -> Result<Self, Self::Rejection> {
|
async fn from_request_parts(_: &mut Parts, state: &App) -> Result<Self, Self::Rejection> {
|
||||||
Ok(Navigator {
|
Ok(Navigator {
|
||||||
root_path: state.settings.root_path.clone(),
|
root_path: state.settings.root_path.clone(),
|
||||||
sub_path: "/".to_owned(),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Builder, Clone, Debug)]
|
#[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,
|
portal_id: Uuid,
|
||||||
|
|
||||||
rel_oid: Oid,
|
rel_oid: Oid,
|
||||||
|
|
||||||
#[builder(setter(custom))]
|
#[builder(setter(custom))]
|
||||||
root_path: String,
|
root_path: &'a str,
|
||||||
|
|
||||||
/// Any value provided for `suffix` will be appended (without %-encoding) to
|
/// 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
|
/// the final path value. This may be used for sub-paths and/or search
|
||||||
/// parameters.
|
/// parameters.
|
||||||
#[builder(default, setter(strip_option))]
|
#[builder(default, setter(strip_option))]
|
||||||
suffix: Option<String>,
|
suffix: Option<&'a str>,
|
||||||
|
|
||||||
workspace_id: Uuid,
|
workspace_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl NavigatorPage for PortalPage {
|
impl<'a> NavigatorPage for PortalPage<'a> {
|
||||||
fn get_path(&self) -> String {
|
fn get_path(&self) -> String {
|
||||||
format!(
|
format!(
|
||||||
"{root_path}/w/{workspace_id}/r/{rel_oid}/p/{portal_id}/{suffix}",
|
"{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(),
|
workspace_id = self.workspace_id.simple(),
|
||||||
rel_oid = self.rel_oid.0,
|
rel_oid = self.rel_oid.0,
|
||||||
portal_id = self.portal_id.simple(),
|
portal_id = self.portal_id.simple(),
|
||||||
suffix = self.suffix.clone().unwrap_or_default()
|
suffix = self.suffix.unwrap_or_default(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Builder, Clone, Debug)]
|
#[derive(Builder, Clone, Debug)]
|
||||||
pub(crate) struct FormPage {
|
pub(crate) struct FormPage<'a> {
|
||||||
portal_id: Uuid,
|
portal_id: Uuid,
|
||||||
|
|
||||||
#[builder(setter(custom))]
|
#[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 {
|
fn get_path(&self) -> String {
|
||||||
format!(
|
format!(
|
||||||
"{root_path}/f/{portal_id}",
|
"{root_path}/f/{portal_id}",
|
||||||
root_path = self.root_path,
|
root_path = self.root_path,
|
||||||
portal_id = self.portal_id
|
portal_id = self.portal_id,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Builder, Clone, Debug)]
|
#[derive(Builder, Clone, Debug)]
|
||||||
pub(crate) struct RelSettingsPage {
|
pub(crate) struct RelPage<'a> {
|
||||||
rel_oid: Oid,
|
rel_oid: Oid,
|
||||||
|
|
||||||
#[builder(setter(custom))]
|
#[builder(setter(custom))]
|
||||||
root_path: String,
|
root_path: &'a str,
|
||||||
|
|
||||||
/// Any value provided for `suffix` will be appended (without %-encoding) to
|
/// 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
|
/// the final path value. This may be used for sub-paths and/or search
|
||||||
/// parameters.
|
/// parameters.
|
||||||
#[builder(default, setter(strip_option))]
|
#[builder(default, setter(strip_option))]
|
||||||
suffix: Option<String>,
|
suffix: Option<&'a str>,
|
||||||
|
|
||||||
workspace_id: Uuid,
|
workspace_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl NavigatorPage for RelSettingsPage {
|
impl<'a> NavigatorPage for RelPage<'a> {
|
||||||
fn get_path(&self) -> String {
|
fn get_path(&self) -> String {
|
||||||
format!(
|
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,
|
root_path = self.root_path,
|
||||||
workspace_id = self.workspace_id.simple(),
|
workspace_id = self.workspace_id.simple(),
|
||||||
rel_oid = self.rel_oid.0,
|
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,
|
errors::AppError,
|
||||||
navigator::{Navigator, NavigatorPage},
|
navigator::{Navigator, NavigatorPage},
|
||||||
presentation_form::PresentationForm,
|
presentation_form::PresentationForm,
|
||||||
|
roles::get_writer_role,
|
||||||
user::CurrentUser,
|
user::CurrentUser,
|
||||||
workspace_pooler::{RoleAssignment, WorkspacePooler},
|
workspace_pooler::{RoleAssignment, WorkspacePooler},
|
||||||
workspace_utils::{get_reader_role, get_writer_role},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,12 @@ use serde::Deserialize;
|
||||||
use sqlx::postgres::types::Oid;
|
use sqlx::postgres::types::Oid;
|
||||||
use uuid::Uuid;
|
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)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub(super) struct PathParams {
|
pub(super) struct PathParams {
|
||||||
|
|
@ -36,5 +41,9 @@ pub(super) async fn post(
|
||||||
.execute(&mut app_db)
|
.execute(&mut app_db)
|
||||||
.await?;
|
.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?;
|
.await?;
|
||||||
}
|
}
|
||||||
Ok(navigator
|
Ok(navigator
|
||||||
.rel_settings_page()
|
.rel_page()
|
||||||
.workspace_id(workspace_id)
|
.workspace_id(workspace_id)
|
||||||
.rel_oid(Oid(rel_oid))
|
.rel_oid(Oid(rel_oid))
|
||||||
|
.suffix("settings/")
|
||||||
.build()?
|
.build()?
|
||||||
.redirect_to())
|
.redirect_to())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ pub(super) struct FormBody {
|
||||||
#[debug_handler(state = App)]
|
#[debug_handler(state = App)]
|
||||||
pub(super) async fn post(
|
pub(super) async fn post(
|
||||||
AppDbConn(mut app_db): AppDbConn,
|
AppDbConn(mut app_db): AppDbConn,
|
||||||
CurrentUser(user): CurrentUser,
|
CurrentUser(_user): CurrentUser,
|
||||||
navigator: Navigator,
|
navigator: Navigator,
|
||||||
Path(PathParams {
|
Path(PathParams {
|
||||||
portal_id,
|
portal_id,
|
||||||
|
|
@ -74,7 +74,7 @@ pub(super) async fn post(
|
||||||
.workspace_id(workspace_id)
|
.workspace_id(workspace_id)
|
||||||
.rel_oid(Oid(rel_oid))
|
.rel_oid(Oid(rel_oid))
|
||||||
.portal_id(portal_id)
|
.portal_id(portal_id)
|
||||||
.suffix("form/".to_owned())
|
.suffix("form/")
|
||||||
.build()?
|
.build()?
|
||||||
.redirect_to())
|
.redirect_to())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,7 @@ pub(super) async fn post(
|
||||||
.workspace_id(workspace_id)
|
.workspace_id(workspace_id)
|
||||||
.rel_oid(Oid(rel_oid))
|
.rel_oid(Oid(rel_oid))
|
||||||
.portal_id(portal_id)
|
.portal_id(portal_id)
|
||||||
.suffix("settings/".to_owned())
|
.suffix("settings/")
|
||||||
.build()?
|
.build()?
|
||||||
.redirect_to())
|
.redirect_to())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,6 @@ pub(super) struct FormBody {
|
||||||
/// alphanumeric characters and underscores.
|
/// alphanumeric characters and underscores.
|
||||||
#[debug_handler(state = App)]
|
#[debug_handler(state = App)]
|
||||||
pub(super) async fn post(
|
pub(super) async fn post(
|
||||||
AppDbConn(mut app_db): AppDbConn,
|
|
||||||
State(mut pooler): State<WorkspacePooler>,
|
State(mut pooler): State<WorkspacePooler>,
|
||||||
CurrentUser(user): CurrentUser,
|
CurrentUser(user): CurrentUser,
|
||||||
navigator: Navigator,
|
navigator: Navigator,
|
||||||
|
|
@ -78,9 +77,10 @@ pub(super) async fn post(
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(navigator
|
Ok(navigator
|
||||||
.rel_settings_page()
|
.rel_page()
|
||||||
.workspace_id(workspace_id)
|
.workspace_id(workspace_id)
|
||||||
.rel_oid(Oid(rel_oid))
|
.rel_oid(Oid(rel_oid))
|
||||||
|
.suffix("settings/")
|
||||||
.build()?
|
.build()?
|
||||||
.redirect_to())
|
.redirect_to())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
use axum::{extract::State, response::IntoResponse};
|
use axum::{extract::State, response::IntoResponse};
|
||||||
use interim_models::{
|
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 interim_pgtypes::{client::WorkspaceClient, escape_identifier};
|
||||||
use sqlx::{Connection as _, PgConnection, query};
|
use sqlx::{Connection as _, PgConnection, query};
|
||||||
|
|
@ -8,10 +9,12 @@ use sqlx::{Connection as _, PgConnection, query};
|
||||||
use crate::{
|
use crate::{
|
||||||
app::AppDbConn,
|
app::AppDbConn,
|
||||||
errors::AppError,
|
errors::AppError,
|
||||||
navigator::Navigator,
|
navigator::{Navigator, NavigatorPage as _},
|
||||||
|
roles::ROLE_PREFIX_USER,
|
||||||
settings::Settings,
|
settings::Settings,
|
||||||
user::CurrentUser,
|
user::CurrentUser,
|
||||||
workspace_pooler::{RoleAssignment, WorkspacePooler},
|
workspace_pooler::{RoleAssignment, WorkspacePooler},
|
||||||
|
workspace_utils::PHONO_TABLE_NAMESPACE,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// HTTP POST handler for creating a new workspace. This handler does not expect
|
/// 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> {
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
// FIXME: csrf
|
// FIXME: csrf
|
||||||
|
|
||||||
|
let cluster = Cluster::fetch_only(&mut app_db).await?;
|
||||||
|
|
||||||
const NAME_LEN_WORDS: usize = 3;
|
const NAME_LEN_WORDS: usize = 3;
|
||||||
// WARNING: `db_name` is injected directly into the `create database` SQL
|
// WARNING: `db_name` is injected directly into the `create database` SQL
|
||||||
// command. It **must not** contain spaces or any other unsafe characters.
|
// 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
|
// 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.
|
// often. One less thing to keep track of in application state.
|
||||||
let mut workspace_creator_conn =
|
let mut workspace_creator_conn = PgConnection::connect(
|
||||||
PgConnection::connect(settings.new_workspace_db_url.as_str()).await?;
|
cluster
|
||||||
|
.conn_str_for_db("postgres", None)?
|
||||||
|
.expose_secret()
|
||||||
|
.as_str(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
query(&format!(
|
query(&format!(
|
||||||
// `db_name` is an underscore-separated sequence of alphabetical words,
|
// `db_name` is an underscore-separated sequence of alphabetical words,
|
||||||
// which should be safe to inject directly into the SQL statement.
|
// 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?;
|
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()
|
let workspace = Workspace::insert()
|
||||||
.owner_id(user.id)
|
.owner_id(user.id)
|
||||||
.url(workspace_url)
|
.cluster_id(cluster.id)
|
||||||
|
.db_name(&db_name)
|
||||||
.build()?
|
.build()?
|
||||||
.insert(&mut app_db)
|
.insert(&mut app_db)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
@ -77,13 +84,12 @@ pub(super) async fn post(
|
||||||
.await?;
|
.await?;
|
||||||
query(&format!(
|
query(&format!(
|
||||||
"create schema {nsp}",
|
"create schema {nsp}",
|
||||||
nsp = escape_identifier(&settings.phono_table_namespace)
|
nsp = escape_identifier(PHONO_TABLE_NAMESPACE)
|
||||||
))
|
))
|
||||||
.execute(workspace_root_conn.get_conn())
|
.execute(workspace_root_conn.get_conn())
|
||||||
.await?;
|
.await?;
|
||||||
grant_workspace_membership(
|
grant_workspace_membership(
|
||||||
&db_name,
|
&db_name,
|
||||||
settings.clone(),
|
|
||||||
&mut app_db,
|
&mut app_db,
|
||||||
&mut workspace_root_conn,
|
&mut workspace_root_conn,
|
||||||
&user,
|
&user,
|
||||||
|
|
@ -91,24 +97,23 @@ pub(super) async fn post(
|
||||||
)
|
)
|
||||||
.await?;
|
.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(
|
async fn grant_workspace_membership(
|
||||||
db_name: &str,
|
db_name: &str,
|
||||||
settings: Settings,
|
|
||||||
app_db_client: &mut AppDbClient,
|
app_db_client: &mut AppDbClient,
|
||||||
workspace_root_client: &mut WorkspaceClient,
|
workspace_root_client: &mut WorkspaceClient,
|
||||||
user: &User,
|
user: &User,
|
||||||
workspace: &Workspace,
|
workspace: &Workspace,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
let rolname = format!(
|
let rolname = format!("{ROLE_PREFIX_USER}{uid}", uid = user.id.simple());
|
||||||
"{prefix}{user_id}",
|
|
||||||
prefix = settings.db_role_prefix,
|
|
||||||
user_id = user.id.simple()
|
|
||||||
);
|
|
||||||
query(&format!(
|
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),
|
db_user = escape_identifier(&rolname),
|
||||||
))
|
))
|
||||||
.execute(workspace_root_client.get_conn())
|
.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
|
// TODO: There may be cases in which we will want to grant granular
|
||||||
// workspace access which excludes privileges to create tables.
|
// workspace access which excludes privileges to create tables.
|
||||||
query(&format!(
|
query(&format!(
|
||||||
"grant usage, create on schema {nsp} to {rolname}",
|
"grant usage, create on schema {nsp} to {rolname} with grant option",
|
||||||
nsp = escape_identifier(&settings.phono_table_namespace),
|
nsp = escape_identifier(PHONO_TABLE_NAMESPACE),
|
||||||
rolname = escape_identifier(&rolname)
|
rolname = escape_identifier(&rolname)
|
||||||
))
|
))
|
||||||
.execute(workspace_root_client.get_conn())
|
.execute(workspace_root_client.get_conn())
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,11 @@ use axum::{
|
||||||
use interim_models::workspace_user_perm::WorkspaceMembership;
|
use interim_models::workspace_user_perm::WorkspaceMembership;
|
||||||
|
|
||||||
use crate::{
|
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(
|
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 uuid::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
app::AppDbConn,
|
|
||||||
errors::AppError,
|
errors::AppError,
|
||||||
navigator::Navigator,
|
navigator::{Navigator, NavigatorPage as _},
|
||||||
settings::Settings,
|
roles::{
|
||||||
|
ROLE_PREFIX_TABLE_OWNER, ROLE_PREFIX_TABLE_READER, ROLE_PREFIX_TABLE_WRITER,
|
||||||
|
ROLE_PREFIX_USER,
|
||||||
|
},
|
||||||
user::CurrentUser,
|
user::CurrentUser,
|
||||||
workspace_pooler::{RoleAssignment, WorkspacePooler},
|
workspace_pooler::{RoleAssignment, WorkspacePooler},
|
||||||
workspace_utils::{
|
workspace_utils::PHONO_TABLE_NAMESPACE,
|
||||||
TABLE_OWNER_ROLE_PREFIX, TABLE_READER_ROLE_PREFIX, TABLE_WRITER_ROLE_PREFIX,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|
@ -32,11 +32,9 @@ pub(super) struct PathParams {
|
||||||
/// This handler expects 1 path parameter named `workspace_id` which should
|
/// This handler expects 1 path parameter named `workspace_id` which should
|
||||||
/// deserialize to a UUID.
|
/// deserialize to a UUID.
|
||||||
pub(super) async fn post(
|
pub(super) async fn post(
|
||||||
State(settings): State<Settings>,
|
|
||||||
State(mut pooler): State<WorkspacePooler>,
|
State(mut pooler): State<WorkspacePooler>,
|
||||||
CurrentUser(user): CurrentUser,
|
CurrentUser(user): CurrentUser,
|
||||||
navigator: Navigator,
|
navigator: Navigator,
|
||||||
AppDbConn(mut app_db): AppDbConn,
|
|
||||||
Path(PathParams { workspace_id }): Path<PathParams>,
|
Path(PathParams { workspace_id }): Path<PathParams>,
|
||||||
) -> Result<impl IntoResponse, AppError> {
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
// FIXME: CSRF, Check workspace authorization.
|
// FIXME: CSRF, Check workspace authorization.
|
||||||
|
|
@ -52,15 +50,11 @@ pub(super) async fn post(
|
||||||
.acquire_for(workspace_id, RoleAssignment::Root)
|
.acquire_for(workspace_id, RoleAssignment::Root)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let user_rolname = format!(
|
let user_rolname = format!("{ROLE_PREFIX_USER}{uid}", uid = user.id.simple());
|
||||||
"{prefix}{user_id}",
|
|
||||||
prefix = settings.db_role_prefix,
|
|
||||||
user_id = user.id.simple()
|
|
||||||
);
|
|
||||||
let rolname_uuid = Uuid::new_v4().simple();
|
let rolname_uuid = Uuid::new_v4().simple();
|
||||||
let rolname_table_owner = format!("{TABLE_OWNER_ROLE_PREFIX}{rolname_uuid}");
|
let rolname_table_owner = format!("{ROLE_PREFIX_TABLE_OWNER}{rolname_uuid}");
|
||||||
let rolname_table_reader = format!("{TABLE_READER_ROLE_PREFIX}{rolname_uuid}");
|
let rolname_table_reader = format!("{ROLE_PREFIX_TABLE_READER}{rolname_uuid}");
|
||||||
let rolname_table_writer = format!("{TABLE_WRITER_ROLE_PREFIX}{rolname_uuid}");
|
let rolname_table_writer = format!("{ROLE_PREFIX_TABLE_WRITER}{rolname_uuid}");
|
||||||
for rolname in [
|
for rolname in [
|
||||||
&rolname_table_owner,
|
&rolname_table_owner,
|
||||||
&rolname_table_reader,
|
&rolname_table_reader,
|
||||||
|
|
@ -86,35 +80,39 @@ create table {0}.{1} (
|
||||||
_created_at timestamptz not null default now()
|
_created_at timestamptz not null default now()
|
||||||
)
|
)
|
||||||
"#,
|
"#,
|
||||||
escape_identifier(&settings.phono_table_namespace),
|
escape_identifier(PHONO_TABLE_NAMESPACE),
|
||||||
escape_identifier(&table_name),
|
escape_identifier(&table_name),
|
||||||
))
|
))
|
||||||
.execute(root_client.get_conn())
|
.execute(root_client.get_conn())
|
||||||
.await?;
|
.await?;
|
||||||
query(&format!(
|
query(&format!(
|
||||||
"alter table {nsp}.{ident} owner to {owner}",
|
"alter table {nsp}.{tbl} owner to {rol}",
|
||||||
nsp = escape_identifier(&settings.phono_table_namespace),
|
nsp = escape_identifier(PHONO_TABLE_NAMESPACE),
|
||||||
ident = escape_identifier(&table_name),
|
tbl = escape_identifier(&table_name),
|
||||||
owner = escape_identifier(&rolname_table_owner),
|
rol = escape_identifier(&rolname_table_owner),
|
||||||
))
|
))
|
||||||
.execute(root_client.get_conn())
|
.execute(root_client.get_conn())
|
||||||
.await?;
|
.await?;
|
||||||
query(&format!(
|
query(&format!(
|
||||||
"grant select on {0}.{1} to {2}",
|
"grant select on {nsp}.{tbl} to {rol}",
|
||||||
escape_identifier(&settings.phono_table_namespace),
|
nsp = escape_identifier(PHONO_TABLE_NAMESPACE),
|
||||||
escape_identifier(&table_name),
|
tbl = escape_identifier(&table_name),
|
||||||
escape_identifier(&rolname_table_reader),
|
rol = escape_identifier(&rolname_table_reader),
|
||||||
))
|
))
|
||||||
.execute(root_client.get_conn())
|
.execute(root_client.get_conn())
|
||||||
.await?;
|
.await?;
|
||||||
query(&format!(
|
query(&format!(
|
||||||
"grant delete, truncate on {0}.{1} to {2}",
|
"grant delete, truncate on {nsp}.{tbl} to {rol}",
|
||||||
escape_identifier(&settings.phono_table_namespace),
|
nsp = escape_identifier(PHONO_TABLE_NAMESPACE),
|
||||||
escape_identifier(&table_name),
|
tbl = escape_identifier(&table_name),
|
||||||
escape_identifier(&rolname_table_writer),
|
rol = escape_identifier(&rolname_table_writer),
|
||||||
))
|
))
|
||||||
.execute(root_client.get_conn())
|
.execute(root_client.get_conn())
|
||||||
.await?;
|
.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;
|
use super::relations_single;
|
||||||
|
|
||||||
|
mod add_service_credential_handler;
|
||||||
mod add_table_handler;
|
mod add_table_handler;
|
||||||
mod nav_handler;
|
mod nav_handler;
|
||||||
|
mod service_credentials_handler;
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
struct PathParams {
|
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("/{workspace_id}/add-table", post(add_table_handler::post))
|
||||||
.route_with_tsr("/{workspace_id}/nav/", get(nav_handler::get))
|
.route_with_tsr("/{workspace_id}/nav/", get(nav_handler::get))
|
||||||
|
.route_with_tsr(
|
||||||
|
"/{workspace_id}/service-credentials",
|
||||||
|
get(service_credentials_handler::get),
|
||||||
|
)
|
||||||
.nest(
|
.nest(
|
||||||
"/{workspace_id}/r/{rel_oid}",
|
"/{workspace_id}/r/{rel_oid}",
|
||||||
relations_single::new_router(),
|
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.
|
/// postgresql:// URL for Interim's application database.
|
||||||
pub(crate) database_url: Url,
|
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")]
|
#[serde(default = "default_app_db_max_connections")]
|
||||||
pub(crate) app_db_max_connections: u32,
|
pub(crate) app_db_max_connections: u32,
|
||||||
|
|
||||||
|
|
@ -48,14 +44,6 @@ pub(crate) struct Settings {
|
||||||
pub(crate) frontend_host: String,
|
pub(crate) frontend_host: String,
|
||||||
|
|
||||||
pub(crate) auth: AuthSettings,
|
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 {
|
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 tokio::sync::{OnceCell, RwLock};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::app::App;
|
use crate::{app::App, roles::ROLE_PREFIX_USER};
|
||||||
|
|
||||||
const MAX_CONNECTIONS: u32 = 4;
|
const MAX_CONNECTIONS: u32 = 4;
|
||||||
const IDLE_SECONDS: u64 = 3600;
|
const IDLE_SECONDS: u64 = 3600;
|
||||||
|
|
@ -20,7 +20,6 @@ pub struct WorkspacePooler {
|
||||||
#[builder(default, setter(skip))]
|
#[builder(default, setter(skip))]
|
||||||
pools: Arc<RwLock<HashMap<Uuid, OnceCell<PgPool>>>>,
|
pools: Arc<RwLock<HashMap<Uuid, OnceCell<PgPool>>>>,
|
||||||
app_db_pool: PgPool,
|
app_db_pool: PgPool,
|
||||||
db_role_prefix: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WorkspacePooler {
|
impl WorkspacePooler {
|
||||||
|
|
@ -42,7 +41,7 @@ impl WorkspacePooler {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
// Essentially "DISCARD ALL" without "DEALLOCATE ALL"
|
// Essentially "DISCARD ALL" without "DEALLOCATE ALL"
|
||||||
conn.execute(raw_sql(
|
conn.execute(raw_sql(
|
||||||
"
|
r#"
|
||||||
close all;
|
close all;
|
||||||
set session authorization default;
|
set session authorization default;
|
||||||
reset all;
|
reset all;
|
||||||
|
|
@ -51,13 +50,20 @@ select pg_advisory_unlock_all();
|
||||||
discard plans;
|
discard plans;
|
||||||
discard temp;
|
discard temp;
|
||||||
discard sequences;
|
discard sequences;
|
||||||
",
|
"#,
|
||||||
))
|
))
|
||||||
.await?;
|
.await?;
|
||||||
Ok(true)
|
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?)
|
.await?)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -91,10 +97,10 @@ discard sequences;
|
||||||
let pool = self.get_pool_for(base_id).await?;
|
let pool = self.get_pool_for(base_id).await?;
|
||||||
let mut client = WorkspaceClient::from_pool_conn(pool.acquire().await?);
|
let mut client = WorkspaceClient::from_pool_conn(pool.acquire().await?);
|
||||||
match set_role {
|
match set_role {
|
||||||
RoleAssignment::User(id) => {
|
RoleAssignment::User(uid) => {
|
||||||
let prefix = &self.db_role_prefix;
|
client
|
||||||
let user_id = id.simple();
|
.init_role(&format!("{ROLE_PREFIX_USER}{uid}", uid = uid.simple()))
|
||||||
client.init_role(&format!("{prefix}{user_id}")).await?;
|
.await?;
|
||||||
}
|
}
|
||||||
RoleAssignment::Root => {}
|
RoleAssignment::Root => {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,22 +2,14 @@
|
||||||
//! the [`interim_models::workspace`] module, which is also used extensively
|
//! the [`interim_models::workspace`] module, which is also used extensively
|
||||||
//! across the server code.
|
//! across the server code.
|
||||||
|
|
||||||
use std::collections::HashSet;
|
|
||||||
|
|
||||||
use anyhow::anyhow;
|
|
||||||
use interim_models::{client::AppDbClient, portal::Portal};
|
use interim_models::{client::AppDbClient, portal::Portal};
|
||||||
use interim_pgtypes::{
|
use interim_pgtypes::{
|
||||||
client::WorkspaceClient,
|
client::WorkspaceClient,
|
||||||
pg_acl::{PgAclItem, PgPrivilegeType},
|
|
||||||
pg_class::{PgClass, PgRelKind},
|
pg_class::{PgClass, PgRelKind},
|
||||||
};
|
};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::errors::AppError;
|
pub const PHONO_TABLE_NAMESPACE: &str = "phono";
|
||||||
|
|
||||||
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_";
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub(crate) struct RelationPortalSet {
|
pub(crate) struct RelationPortalSet {
|
||||||
|
|
@ -55,68 +47,3 @@ pub(crate) async fn fetch_all_accessible_portals(
|
||||||
|
|
||||||
Ok(portal_sets)
|
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 }}"
|
initial-value="{{ filter | json }}"
|
||||||
></filter-menu>
|
></filter-menu>
|
||||||
</div>
|
</div>
|
||||||
<div class="page-grid__toolbar-user">
|
{% include "toolbar_user.html" %}
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="page-grid__sidebar">
|
<div class="page-grid__sidebar">
|
||||||
<div style="padding: 1rem;">
|
<div style="padding: 1rem;">
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@
|
||||||
{{ workspace_nav | safe }}
|
{{ workspace_nav | safe }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<main class="page-grid__main">
|
<main class="page-grid__main padded--lg">
|
||||||
<form method="post" action="update-name">
|
<form method="post" action="update-name">
|
||||||
<section>
|
<section>
|
||||||
<h1>Name</h1>
|
<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">
|
<nav class="workspace-nav">
|
||||||
<div class="workspace-nav__heading">
|
<div class="workspace-nav__heading">
|
||||||
<h1>
|
<h1>
|
||||||
{% if workspace.name.is_empty() %}
|
{% if workspace.display_name.is_empty() %}
|
||||||
Untitled Workspace
|
Untitled Workspace
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ workspace.name }}
|
{{ workspace.display_name }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</h1>
|
</h1>
|
||||||
<basic-dropdown button-class="button--secondary button--small" button-aria-label="Workspace Menu">
|
<basic-dropdown button-class="button--secondary button--small" button-aria-label="Workspace Menu">
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,11 @@
|
||||||
<ul>
|
<ul>
|
||||||
{% for workspace_perm in workspace_perms %}
|
{% for workspace_perm in workspace_perms %}
|
||||||
<li>
|
<li>
|
||||||
<a href="{{ navigator.workspace_page(*workspace_perm.workspace_id).abs_path() }}">
|
<a href="{{ navigator.workspace_page().workspace_id(*workspace_perm.workspace_id).build()?.get_path() }}">
|
||||||
{% if workspace_perm.workspace_name.is_empty() %}
|
{% if workspace_perm.workspace_display_name.is_empty() %}
|
||||||
[Untitled Workspace]
|
[Untitled Workspace]
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ workspace_perm.workspace_name }}
|
{{ workspace_perm.workspace_display_name }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
{% block main %}
|
{% block main %}
|
||||||
<main style="position: relative; margin: 0 auto; max-width: 32rem;">
|
<main style="position: relative; margin: 0 auto; max-width: 32rem;">
|
||||||
<h1>{{ workspace.name }}</h1>
|
<h1>{{ workspace.display_name }}</h1>
|
||||||
{{ workspace_nav | safe }}
|
{{ workspace_nav | safe }}
|
||||||
</main>
|
</main>
|
||||||
{% endblock %}
|
{% 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 {
|
.page-grid {
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
width: 100vw;
|
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