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 phono_models::accessors::{Accessor as _, Actor, workspace::WorkspaceAccessor}; use serde::Deserialize; use sqlx::{Acquire as _, query}; use uuid::Uuid; use crate::{ app::AppDbConn, 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, AppDbConn(mut app_db): AppDbConn, CurrentUser(user): CurrentUser, navigator: Navigator, Path(PathParams { workspace_id }): Path, ) -> Result { // FIXME: CSRF // TODO: Condition table creation on schema "CREATE" privileges, which will // allow this client to be configured with user-level permissions rather // than root-level. let mut root_client = pooler .acquire_for(workspace_id, RoleAssignment::Root) .await?; // For authorization only. WorkspaceAccessor::new() .id(workspace_id) .as_actor(Actor::User(user.id)) .using_workspace_client(&mut root_client) .using_app_db(&mut app_db) .fetch_one() .await?; const NAME_LEN_WORDS: usize = 3; let table_name = phono_namegen::default_generator() .with_separator('_') .generate_name(NAME_LEN_WORDS); let user_rolname = format!("{ROLE_PREFIX_USER}{uid}", uid = user.id.simple()); 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, ] { 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) )) .execute(root_client.get_conn()) .await?; } query(&format!( r#" create table {0}.{1} ( _id uuid primary key not null default uuidv7(), _created_by text default current_user, _created_at timestamptz not null default now() ) "#, escape_identifier(PHONO_TABLE_NAMESPACE), escape_identifier(&table_name), )) .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), )) .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()) }