add ability to create new workspaces through the ui

This commit is contained in:
Brent Schroeter 2025-10-07 06:23:50 +00:00
parent 8c38ad10b2
commit e031766790
13 changed files with 8033 additions and 3 deletions

10
Cargo.lock generated
View file

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

View file

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

View file

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

View file

@ -0,0 +1,8 @@
[package]
name = "interim-namegen"
edition.workspace = true
version.workspace = true
[dependencies]
rand = { workspace = true }
thiserror = { workspace = true }

File diff suppressed because it is too large Load diff

102
interim-namegen/src/lib.rs Normal file
View 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))
}
}

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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