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",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "interim-namegen"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"rand 0.8.5",
|
||||
"thiserror 2.0.12",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "interim-pgtypes"
|
||||
version = "0.0.1"
|
||||
|
|
@ -1785,6 +1793,7 @@ dependencies = [
|
|||
"futures",
|
||||
"headers",
|
||||
"interim-models",
|
||||
"interim-namegen",
|
||||
"interim-pgtypes",
|
||||
"markdown",
|
||||
"oauth2",
|
||||
|
|
@ -1803,6 +1812,7 @@ dependencies = [
|
|||
"tower-http",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
"uuid",
|
||||
"validator",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ chrono = { version = "0.4.41", features = ["serde"] }
|
|||
derive_builder = "0.20.2"
|
||||
futures = "0.3.31"
|
||||
interim-models = { path = "./interim-models" }
|
||||
interim-namegen = { path = "./interim-namegen" }
|
||||
interim-pgtypes = { path = "./interim-pgtypes" }
|
||||
rand = "0.8.5"
|
||||
redact = { version = "0.1.11", features = ["serde", "zeroize"] }
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ pub struct Workspace {
|
|||
pub name: String,
|
||||
|
||||
/// `postgresql://` URL of the instance and database hosting this workspace.
|
||||
// TODO: Encrypt values in Postgres using `pgp_sym_encrypt()`.
|
||||
pub url: Secret<String>,
|
||||
|
||||
/// 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 }
|
||||
headers = "0.4.1"
|
||||
interim-models = { workspace = true }
|
||||
interim-namegen = { workspace = true }
|
||||
interim-pgtypes = { workspace = true }
|
||||
markdown = "1.0.0"
|
||||
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"] }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { version = "0.3.19", features = ["chrono", "env-filter"] }
|
||||
url = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
validator = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ impl App {
|
|||
pub async fn from_settings(settings: Settings) -> Result<Self> {
|
||||
let app_db = PgPoolOptions::new()
|
||||
.max_connections(settings.app_db_max_connections)
|
||||
.connect(&settings.database_url)
|
||||
.connect(settings.database_url.as_str())
|
||||
.await?;
|
||||
|
||||
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 crate::app::App;
|
||||
|
||||
mod add_handlers;
|
||||
mod list_handlers;
|
||||
|
||||
pub(super) fn new_router() -> Router<App> {
|
||||
Router::<App>::new()
|
||||
.route("/", get(|| async move { Redirect::to("list/") }))
|
||||
.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 dotenvy::dotenv;
|
||||
use serde::Deserialize;
|
||||
use url::Url;
|
||||
|
||||
use crate::app::App;
|
||||
|
||||
|
|
@ -22,7 +23,11 @@ pub(crate) struct Settings {
|
|||
pub(crate) dev: u8,
|
||||
|
||||
/// 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")]
|
||||
pub(crate) app_db_max_connections: u32,
|
||||
|
|
|
|||
|
|
@ -22,7 +22,9 @@ pub(crate) async fn sync_for_workspace(
|
|||
workspace_client: &mut WorkspaceClient,
|
||||
db_role_prefix: &str,
|
||||
) -> Result<()> {
|
||||
tracing::debug!("determining current database");
|
||||
let db = PgDatabase::current().fetch_one(workspace_client).await?;
|
||||
tracing::debug!("querying explicit role grants");
|
||||
let explicit_roles = PgRole::with_name_in(
|
||||
db.datacl
|
||||
.unwrap_or_default()
|
||||
|
|
@ -37,6 +39,7 @@ pub(crate) async fn sync_for_workspace(
|
|||
)
|
||||
.fetch_all(workspace_client)
|
||||
.await?;
|
||||
tracing::debug!("querying inherited role grants");
|
||||
let mut all_roles: HashSet<PgRole> = HashSet::new();
|
||||
for explicit_role in explicit_roles {
|
||||
if let Some(role_tree) = RoleTree::members_of_oid(explicit_role.oid)
|
||||
|
|
@ -52,6 +55,7 @@ pub(crate) async fn sync_for_workspace(
|
|||
.iter()
|
||||
.filter_map(|role| user_id_from_rolname(&role.rolname, db_role_prefix).ok())
|
||||
.collect();
|
||||
tracing::debug!("clearing outdated workspace_user_perms");
|
||||
query!(
|
||||
"delete from workspace_user_perms where workspace_id = $1 and not (user_id = any($2))",
|
||||
workspace_id,
|
||||
|
|
@ -59,6 +63,7 @@ pub(crate) async fn sync_for_workspace(
|
|||
)
|
||||
.execute(app_db.get_conn())
|
||||
.await?;
|
||||
tracing::debug!("inserting new workspace_user_perms");
|
||||
for user_id in user_ids {
|
||||
WorkspaceUserPerm::insert()
|
||||
.workspace_id(workspace_id)
|
||||
|
|
@ -68,5 +73,6 @@ pub(crate) async fn sync_for_workspace(
|
|||
.execute(app_db)
|
||||
.await?;
|
||||
}
|
||||
tracing::debug!("finished syncing workspace_user_perms");
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,11 +3,18 @@
|
|||
{% block main %}
|
||||
<main>
|
||||
<h1>Workspaces</h1>
|
||||
<form method="post" action="add">
|
||||
<button class="button--primary" type="submit">+</button>
|
||||
</form>
|
||||
<ul>
|
||||
{% for workspace_perm in workspace_perms %}
|
||||
<li>
|
||||
<a href="{{ navigator.workspace_page(*workspace_perm.workspace_id).abs_path() }}">
|
||||
{% if workspace_perm.workspace_name.is_empty() %}
|
||||
[Untitled Workspace]
|
||||
{% else %}
|
||||
{{ workspace_perm.workspace_name }}
|
||||
{% endif %}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue