add ability to create new workspaces through the ui
This commit is contained in:
parent
8c38ad10b2
commit
e031766790
13 changed files with 8033 additions and 3 deletions
10
Cargo.lock
generated
10
Cargo.lock
generated
|
|
@ -1754,6 +1754,14 @@ dependencies = [
|
||||||
"validator",
|
"validator",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "interim-namegen"
|
||||||
|
version = "0.0.1"
|
||||||
|
dependencies = [
|
||||||
|
"rand 0.8.5",
|
||||||
|
"thiserror 2.0.12",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "interim-pgtypes"
|
name = "interim-pgtypes"
|
||||||
version = "0.0.1"
|
version = "0.0.1"
|
||||||
|
|
@ -1785,6 +1793,7 @@ dependencies = [
|
||||||
"futures",
|
"futures",
|
||||||
"headers",
|
"headers",
|
||||||
"interim-models",
|
"interim-models",
|
||||||
|
"interim-namegen",
|
||||||
"interim-pgtypes",
|
"interim-pgtypes",
|
||||||
"markdown",
|
"markdown",
|
||||||
"oauth2",
|
"oauth2",
|
||||||
|
|
@ -1803,6 +1812,7 @@ dependencies = [
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
"url",
|
||||||
"uuid",
|
"uuid",
|
||||||
"validator",
|
"validator",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ chrono = { version = "0.4.41", features = ["serde"] }
|
||||||
derive_builder = "0.20.2"
|
derive_builder = "0.20.2"
|
||||||
futures = "0.3.31"
|
futures = "0.3.31"
|
||||||
interim-models = { path = "./interim-models" }
|
interim-models = { path = "./interim-models" }
|
||||||
|
interim-namegen = { path = "./interim-namegen" }
|
||||||
interim-pgtypes = { path = "./interim-pgtypes" }
|
interim-pgtypes = { path = "./interim-pgtypes" }
|
||||||
rand = "0.8.5"
|
rand = "0.8.5"
|
||||||
redact = { version = "0.1.11", features = ["serde", "zeroize"] }
|
redact = { version = "0.1.11", features = ["serde", "zeroize"] }
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ pub struct Workspace {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
|
||||||
/// `postgresql://` URL of the instance and database hosting this workspace.
|
/// `postgresql://` URL of the instance and database hosting this workspace.
|
||||||
|
// TODO: Encrypt values in Postgres using `pgp_sym_encrypt()`.
|
||||||
pub url: Secret<String>,
|
pub url: Secret<String>,
|
||||||
|
|
||||||
/// ID of the user account that created this workspace.
|
/// ID of the user account that created this workspace.
|
||||||
|
|
|
||||||
8
interim-namegen/Cargo.toml
Normal file
8
interim-namegen/Cargo.toml
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
[package]
|
||||||
|
name = "interim-namegen"
|
||||||
|
edition.workspace = true
|
||||||
|
version.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
rand = { workspace = true }
|
||||||
|
thiserror = { workspace = true }
|
||||||
7776
interim-namegen/src/eff_large_wordlist.txt
Normal file
7776
interim-namegen/src/eff_large_wordlist.txt
Normal file
File diff suppressed because it is too large
Load diff
102
interim-namegen/src/lib.rs
Normal file
102
interim-namegen/src/lib.rs
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
//! A very simple utility crate for generating randomized, memorable names from
|
||||||
|
//! the EFF passphrase wordlist.
|
||||||
|
//!
|
||||||
|
//! ## Examples
|
||||||
|
//!
|
||||||
|
//! ### Basic Usage
|
||||||
|
//!
|
||||||
|
//! ```
|
||||||
|
//! let name: String = interim_namegen::default_generator().generate_name(3);
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
use rand::{Rng, rngs::ThreadRng, seq::SliceRandom};
|
||||||
|
|
||||||
|
// EFF wordlist for random password phrases.
|
||||||
|
//
|
||||||
|
// > We took all words between 3 and 9 characters from the list, prioritizing
|
||||||
|
// > the most recognized words and then the most concrete words. We manually
|
||||||
|
// > checked and attempted to remove as many profane, insulting, sensitive, or
|
||||||
|
// > emotionally-charged words as possible, and also filtered based on several
|
||||||
|
// > public lists of vulgar English words (for example this one published by
|
||||||
|
// > Luis von Ahn). We further removed words which are difficult to spell as
|
||||||
|
// > well as homophones (which might be confused during recall). We also ensured
|
||||||
|
// > that no word is an exact prefix of any other word.
|
||||||
|
//
|
||||||
|
// [Deep Dive: EFF's New Wordlists for Random Passphrases](
|
||||||
|
// https://www.eff.org/deeplinks/2016/07/new-wordlists-random-passphrases
|
||||||
|
// )
|
||||||
|
//
|
||||||
|
// Note that the wordlist file should have the dice values removed, such that
|
||||||
|
// each line contains only the word. This is to simplify data processing as well
|
||||||
|
// as to help shrink the raw string data packaged with the compiled binary.
|
||||||
|
const WORDLIST_LEN: usize = 7776;
|
||||||
|
// TODO: Rewrite this as a compile time macro generating a const instead of a
|
||||||
|
// LazyLock.
|
||||||
|
static WORDLIST: LazyLock<[&str; WORDLIST_LEN]> = LazyLock::new(|| {
|
||||||
|
let mut words = [""; WORDLIST_LEN];
|
||||||
|
for (i, word) in include_str!("./eff_large_wordlist.txt")
|
||||||
|
.split('\n')
|
||||||
|
.enumerate()
|
||||||
|
{
|
||||||
|
assert!(i <= WORDLIST_LEN); // The wordlist may contain a trailing newline.
|
||||||
|
if i < WORDLIST_LEN {
|
||||||
|
words[i] = word;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert that wordlist fills the entire array--in other words, that
|
||||||
|
// `WORDLIST_LEN` is accurate.
|
||||||
|
assert!(!words[WORDLIST_LEN - 1].is_empty());
|
||||||
|
|
||||||
|
words
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Constructs a [`NameGenerator`] configured to use [`rand::ThreadRng`].
|
||||||
|
pub fn default_generator() -> NameGenerator<ThreadRng> {
|
||||||
|
NameGenerator::<ThreadRng>::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Name generator client.
|
||||||
|
pub struct NameGenerator<T: Rng> {
|
||||||
|
rng: T,
|
||||||
|
sep: char,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Default + Rng> Default for NameGenerator<T> {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
rng: T::default(),
|
||||||
|
sep: '_',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Rng> NameGenerator<T> {
|
||||||
|
/// Set the separator between words in generated names (`'_'` by default).
|
||||||
|
pub fn with_separator(mut self, sep: char) -> Self {
|
||||||
|
self.sep = sep;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return `n` randomly selected words from the wordlist, without
|
||||||
|
/// repetition.
|
||||||
|
pub fn choose_words(&mut self, n: usize) -> Vec<&'static str> {
|
||||||
|
WORDLIST
|
||||||
|
.choose_multiple(&mut self.rng, n)
|
||||||
|
.copied()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a randomized name with `n` words, joined into a single
|
||||||
|
/// [`String`].
|
||||||
|
pub fn generate_name(&mut self, n: usize) -> String {
|
||||||
|
// Temporarily store the UTF8 representation of the [`char`] on the
|
||||||
|
// stack to avoid a heap allocation for the conversion to [`&str`].
|
||||||
|
let mut sep_buf: [u8; 4] = [0; 4];
|
||||||
|
|
||||||
|
self.choose_words(n)
|
||||||
|
.join(self.sep.encode_utf8(&mut sep_buf))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -17,6 +17,7 @@ dotenvy = "0.15.7"
|
||||||
futures = { workspace = true }
|
futures = { workspace = true }
|
||||||
headers = "0.4.1"
|
headers = "0.4.1"
|
||||||
interim-models = { workspace = true }
|
interim-models = { workspace = true }
|
||||||
|
interim-namegen = { workspace = true }
|
||||||
interim-pgtypes = { workspace = true }
|
interim-pgtypes = { workspace = true }
|
||||||
markdown = "1.0.0"
|
markdown = "1.0.0"
|
||||||
oauth2 = "4.4.2"
|
oauth2 = "4.4.2"
|
||||||
|
|
@ -35,5 +36,6 @@ tower = "0.5.2"
|
||||||
tower-http = { version = "0.6.2", features = ["compression-gzip", "fs", "normalize-path", "set-header", "trace"] }
|
tower-http = { version = "0.6.2", features = ["compression-gzip", "fs", "normalize-path", "set-header", "trace"] }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
tracing-subscriber = { version = "0.3.19", features = ["chrono", "env-filter"] }
|
tracing-subscriber = { version = "0.3.19", features = ["chrono", "env-filter"] }
|
||||||
|
url = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
validator = { workspace = true }
|
validator = { workspace = true }
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ impl App {
|
||||||
pub async fn from_settings(settings: Settings) -> Result<Self> {
|
pub async fn from_settings(settings: Settings) -> Result<Self> {
|
||||||
let app_db = PgPoolOptions::new()
|
let app_db = PgPoolOptions::new()
|
||||||
.max_connections(settings.app_db_max_connections)
|
.max_connections(settings.app_db_max_connections)
|
||||||
.connect(&settings.database_url)
|
.connect(settings.database_url.as_str())
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let session_store = PgStore::new(app_db.clone());
|
let session_store = PgStore::new(app_db.clone());
|
||||||
|
|
|
||||||
106
interim-server/src/routes/workspaces_multi/add_handlers.rs
Normal file
106
interim-server/src/routes/workspaces_multi/add_handlers.rs
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
use axum::{extract::State, response::IntoResponse};
|
||||||
|
use interim_models::workspace::Workspace;
|
||||||
|
use interim_pgtypes::escape_identifier;
|
||||||
|
use sqlx::{Connection as _, PgConnection, query};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
app::AppDbConn,
|
||||||
|
errors::AppError,
|
||||||
|
navigator::Navigator,
|
||||||
|
settings::Settings,
|
||||||
|
user::CurrentUser,
|
||||||
|
workspace_pooler::{RoleAssignment, WorkspacePooler},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// HTTP POST handler for creating a new workspace. This handler does not expect
|
||||||
|
/// any arguments, as the backing database name is generated pseudo-randomly and
|
||||||
|
/// the human-friendly name may be changed later rather than specified
|
||||||
|
/// permenantly upon creation.
|
||||||
|
pub(super) async fn post(
|
||||||
|
State(settings): State<Settings>,
|
||||||
|
State(mut pooler): State<WorkspacePooler>,
|
||||||
|
AppDbConn(mut app_db): AppDbConn,
|
||||||
|
navigator: Navigator,
|
||||||
|
CurrentUser(user): CurrentUser,
|
||||||
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
|
// FIXME: csrf
|
||||||
|
|
||||||
|
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.
|
||||||
|
// Additionally, it **must** be URL safe without percent encoding.
|
||||||
|
let db_name = interim_namegen::default_generator()
|
||||||
|
.with_separator('_')
|
||||||
|
.generate_name(NAME_LEN_WORDS);
|
||||||
|
// 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?;
|
||||||
|
query(&format!(
|
||||||
|
// `db_name` is an underscore-separated sequence of alphabetical words,
|
||||||
|
// which should be safe to inject directly into the SQL statement.
|
||||||
|
"create database {db_name}"
|
||||||
|
))
|
||||||
|
.execute(&mut workspace_creator_conn)
|
||||||
|
.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)
|
||||||
|
.build()?
|
||||||
|
.insert(&mut app_db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
pooler
|
||||||
|
.acquire_for(workspace.id, RoleAssignment::User(user.id))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let rolname = format!(
|
||||||
|
"{prefix}{user_id}",
|
||||||
|
prefix = settings.db_role_prefix,
|
||||||
|
user_id = user.id.simple()
|
||||||
|
);
|
||||||
|
|
||||||
|
query(&format!("revoke connect on database {db_name} from public"))
|
||||||
|
.execute(&mut workspace_creator_conn)
|
||||||
|
.await?;
|
||||||
|
query(&format!(
|
||||||
|
"grant connect on database {db_name} to {db_user}",
|
||||||
|
db_user = escape_identifier(&rolname),
|
||||||
|
))
|
||||||
|
.execute(&mut workspace_creator_conn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut workspace_root_conn = pooler
|
||||||
|
.acquire_for(workspace.id, RoleAssignment::Root)
|
||||||
|
.await?;
|
||||||
|
query(&format!(
|
||||||
|
"create schema {nsp}",
|
||||||
|
nsp = escape_identifier(&settings.phono_table_namespace)
|
||||||
|
))
|
||||||
|
.execute(workspace_root_conn.get_conn())
|
||||||
|
.await?;
|
||||||
|
query(&format!(
|
||||||
|
"grant usage, create on schema {nsp} to {rolname}",
|
||||||
|
nsp = escape_identifier(&settings.phono_table_namespace),
|
||||||
|
rolname = escape_identifier(&rolname)
|
||||||
|
))
|
||||||
|
.execute(workspace_root_conn.get_conn())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
crate::workspace_user_perms::sync_for_workspace(
|
||||||
|
workspace.id,
|
||||||
|
&mut app_db,
|
||||||
|
&mut pooler
|
||||||
|
.acquire_for(workspace.id, RoleAssignment::Root)
|
||||||
|
.await?,
|
||||||
|
&settings.db_role_prefix,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(navigator.workspace_page(workspace.id).redirect_to())
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,18 @@
|
||||||
use axum::{Router, response::Redirect, routing::get};
|
use axum::{
|
||||||
|
Router,
|
||||||
|
response::Redirect,
|
||||||
|
routing::{get, post},
|
||||||
|
};
|
||||||
use axum_extra::routing::RouterExt as _;
|
use axum_extra::routing::RouterExt as _;
|
||||||
|
|
||||||
use crate::app::App;
|
use crate::app::App;
|
||||||
|
|
||||||
|
mod add_handlers;
|
||||||
mod list_handlers;
|
mod list_handlers;
|
||||||
|
|
||||||
pub(super) fn new_router() -> Router<App> {
|
pub(super) fn new_router() -> Router<App> {
|
||||||
Router::<App>::new()
|
Router::<App>::new()
|
||||||
.route("/", get(|| async move { Redirect::to("list/") }))
|
.route("/", get(|| async move { Redirect::to("list/") }))
|
||||||
.route_with_tsr("/list/", get(list_handlers::get))
|
.route_with_tsr("/list/", get(list_handlers::get))
|
||||||
|
.route("/list/add", post(add_handlers::post))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ use axum::extract::FromRef;
|
||||||
use config::{Config, Environment};
|
use config::{Config, Environment};
|
||||||
use dotenvy::dotenv;
|
use dotenvy::dotenv;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
use crate::app::App;
|
use crate::app::App;
|
||||||
|
|
||||||
|
|
@ -22,7 +23,11 @@ pub(crate) struct Settings {
|
||||||
pub(crate) dev: u8,
|
pub(crate) dev: u8,
|
||||||
|
|
||||||
/// postgresql:// URL for Interim's application database.
|
/// postgresql:// URL for Interim's application database.
|
||||||
pub(crate) database_url: String,
|
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,
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,9 @@ pub(crate) async fn sync_for_workspace(
|
||||||
workspace_client: &mut WorkspaceClient,
|
workspace_client: &mut WorkspaceClient,
|
||||||
db_role_prefix: &str,
|
db_role_prefix: &str,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
|
tracing::debug!("determining current database");
|
||||||
let db = PgDatabase::current().fetch_one(workspace_client).await?;
|
let db = PgDatabase::current().fetch_one(workspace_client).await?;
|
||||||
|
tracing::debug!("querying explicit role grants");
|
||||||
let explicit_roles = PgRole::with_name_in(
|
let explicit_roles = PgRole::with_name_in(
|
||||||
db.datacl
|
db.datacl
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
|
|
@ -37,6 +39,7 @@ pub(crate) async fn sync_for_workspace(
|
||||||
)
|
)
|
||||||
.fetch_all(workspace_client)
|
.fetch_all(workspace_client)
|
||||||
.await?;
|
.await?;
|
||||||
|
tracing::debug!("querying inherited role grants");
|
||||||
let mut all_roles: HashSet<PgRole> = HashSet::new();
|
let mut all_roles: HashSet<PgRole> = HashSet::new();
|
||||||
for explicit_role in explicit_roles {
|
for explicit_role in explicit_roles {
|
||||||
if let Some(role_tree) = RoleTree::members_of_oid(explicit_role.oid)
|
if let Some(role_tree) = RoleTree::members_of_oid(explicit_role.oid)
|
||||||
|
|
@ -52,6 +55,7 @@ pub(crate) async fn sync_for_workspace(
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|role| user_id_from_rolname(&role.rolname, db_role_prefix).ok())
|
.filter_map(|role| user_id_from_rolname(&role.rolname, db_role_prefix).ok())
|
||||||
.collect();
|
.collect();
|
||||||
|
tracing::debug!("clearing outdated workspace_user_perms");
|
||||||
query!(
|
query!(
|
||||||
"delete from workspace_user_perms where workspace_id = $1 and not (user_id = any($2))",
|
"delete from workspace_user_perms where workspace_id = $1 and not (user_id = any($2))",
|
||||||
workspace_id,
|
workspace_id,
|
||||||
|
|
@ -59,6 +63,7 @@ pub(crate) async fn sync_for_workspace(
|
||||||
)
|
)
|
||||||
.execute(app_db.get_conn())
|
.execute(app_db.get_conn())
|
||||||
.await?;
|
.await?;
|
||||||
|
tracing::debug!("inserting new workspace_user_perms");
|
||||||
for user_id in user_ids {
|
for user_id in user_ids {
|
||||||
WorkspaceUserPerm::insert()
|
WorkspaceUserPerm::insert()
|
||||||
.workspace_id(workspace_id)
|
.workspace_id(workspace_id)
|
||||||
|
|
@ -68,5 +73,6 @@ pub(crate) async fn sync_for_workspace(
|
||||||
.execute(app_db)
|
.execute(app_db)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
tracing::debug!("finished syncing workspace_user_perms");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,18 @@
|
||||||
{% block main %}
|
{% block main %}
|
||||||
<main>
|
<main>
|
||||||
<h1>Workspaces</h1>
|
<h1>Workspaces</h1>
|
||||||
|
<form method="post" action="add">
|
||||||
|
<button class="button--primary" type="submit">+</button>
|
||||||
|
</form>
|
||||||
<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_perm.workspace_id).abs_path() }}">
|
||||||
|
{% if workspace_perm.workspace_name.is_empty() %}
|
||||||
|
[Untitled Workspace]
|
||||||
|
{% else %}
|
||||||
{{ workspace_perm.workspace_name }}
|
{{ workspace_perm.workspace_name }}
|
||||||
|
{% endif %}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue