phonograph/phono-server/src/routes/workspaces_single/add_table_handler.rs

154 lines
5.3 KiB
Rust
Raw Normal View History

use axum::{
extract::{Path, State},
response::IntoResponse,
};
use phono_backends::{
escape_identifier,
rolnames::{
ROLE_PREFIX_TABLE_OWNER, ROLE_PREFIX_TABLE_READER, ROLE_PREFIX_TABLE_WRITER,
ROLE_PREFIX_USER,
},
};
use serde::Deserialize;
use sqlx::{Acquire as _, query};
use uuid::Uuid;
use crate::{
2025-10-22 00:43:53 -07:00
errors::AppError,
navigator::{Navigator, NavigatorPage as _},
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 creating a managed Postgres table within a workspace
/// database. Upon success, it redirects the client back to the workspace
/// homepage, which is expected to display a list of available tables including
/// the newly created one.
///
/// This handler expects 1 path parameter named `workspace_id` which should
/// deserialize to a UUID.
pub(super) async fn post(
State(mut pooler): State<WorkspacePooler>,
CurrentUser(user): CurrentUser,
navigator: Navigator,
Path(PathParams { workspace_id }): Path<PathParams>,
) -> Result<impl IntoResponse, AppError> {
2025-10-22 00:43:53 -07:00
// FIXME: CSRF, Check workspace authorization.
2025-10-22 00:43:53 -07:00
const NAME_LEN_WORDS: usize = 3;
let table_name = phono_namegen::default_generator()
2025-10-22 00:43:53 -07:00
.with_separator('_')
.generate_name(NAME_LEN_WORDS);
let mut root_client = pooler
// FIXME: Should this be scoped down to the unprivileged role after
// setting up the table owner?
.acquire_for(workspace_id, RoleAssignment::Root)
.await?;
let user_rolname = format!("{ROLE_PREFIX_USER}{uid}", uid = user.id.simple());
2025-10-22 00:43:53 -07:00
let rolname_uuid = Uuid::new_v4().simple();
let rolname_table_owner = format!("{ROLE_PREFIX_TABLE_OWNER}{rolname_uuid}");
let rolname_table_reader = format!("{ROLE_PREFIX_TABLE_READER}{rolname_uuid}");
let rolname_table_writer = format!("{ROLE_PREFIX_TABLE_WRITER}{rolname_uuid}");
for rolname in [
&rolname_table_owner,
&rolname_table_reader,
&rolname_table_writer,
] {
2025-10-22 00:43:53 -07:00
query(&format!("create role {0}", escape_identifier(rolname)))
.execute(root_client.get_conn())
.await?;
query(&format!(
"grant {0} to {1} with admin option",
escape_identifier(rolname),
escape_identifier(&user_rolname)
))
2025-10-22 00:43:53 -07:00
.execute(root_client.get_conn())
.await?;
}
query(&format!(
r#"
create table {0}.{1} (
_id uuid primary key not null default uuidv7(),
2025-10-01 22:36:19 -07:00
_created_by text default current_user,
2025-10-22 00:43:53 -07:00
_created_at timestamptz not null default now()
)
"#,
escape_identifier(PHONO_TABLE_NAMESPACE),
2025-10-22 00:43:53 -07:00
escape_identifier(&table_name),
))
2025-10-22 00:43:53 -07:00
.execute(root_client.get_conn())
.await?;
// Postgres requires that a role have "CREATE" privileges on a schema when
// it is given ownership of a relation in that schema. This is at odds with
// our intent here, since the dedicated table owner role should never need
// nor want to have or impart the ability to create unrelated tables.
//
// While not strictly necessary, in order to keep user permissions as clean
// as possible, we run all three of the "GRANT", "ALTER", and "REVOKE"
// commands within a transaction. At least as of Postgres 18, emperical
// testing confirms that permissions updates behave similarly to
// conventional commands and queries executed within transactions, so for
// outside observers, the table owner role and its descendents should never
// appear to actually receive schema "CREATE" privileges, even momentarily,
// as a result of this code block.
{
let mut txn = root_client.get_conn().begin().await?;
query(&format!(
"grant create on schema {nsp} to {rol}",
nsp = escape_identifier(PHONO_TABLE_NAMESPACE),
rol = escape_identifier(&rolname_table_owner)
))
.execute(&mut *txn)
.await?;
query(&format!(
"alter table {nsp}.{tbl} owner to {rol}",
nsp = escape_identifier(PHONO_TABLE_NAMESPACE),
tbl = escape_identifier(&table_name),
rol = escape_identifier(&rolname_table_owner),
))
.execute(&mut *txn)
.await?;
query(&format!(
"revoke create on schema {nsp} from {rol}",
nsp = escape_identifier(PHONO_TABLE_NAMESPACE),
rol = escape_identifier(&rolname_table_owner)
))
.execute(&mut *txn)
.await?;
txn.commit().await?;
}
query(&format!(
"grant select on {nsp}.{tbl} to {rol}",
nsp = escape_identifier(PHONO_TABLE_NAMESPACE),
tbl = escape_identifier(&table_name),
rol = escape_identifier(&rolname_table_reader),
))
2025-10-22 00:43:53 -07:00
.execute(root_client.get_conn())
.await?;
query(&format!(
"grant delete, truncate on {nsp}.{tbl} to {rol}",
nsp = escape_identifier(PHONO_TABLE_NAMESPACE),
tbl = escape_identifier(&table_name),
rol = escape_identifier(&rolname_table_writer),
))
.execute(root_client.get_conn())
.await?;
Ok(navigator
.workspace_page()
.workspace_id(workspace_id)
.build()?
.redirect_to())
}