phonograph/src/routes/relations.rs

293 lines
8.8 KiB
Rust
Raw Normal View History

2025-05-28 16:35:00 -07:00
use std::collections::{HashMap, HashSet};
2025-05-26 22:08:21 -07:00
use anyhow::Context as _;
use askama::Template;
use axum::{
extract::{Path, State},
2025-05-28 16:35:00 -07:00
response::{Html, IntoResponse as _, Redirect, Response},
2025-05-26 22:08:21 -07:00
};
2025-05-28 16:35:00 -07:00
use axum_extra::extract::Form;
2025-05-26 22:08:21 -07:00
use serde::Deserialize;
use sqlx::{
postgres::{types::Oid, PgRow},
query,
};
use uuid::Uuid;
use crate::{
2025-05-28 16:35:00 -07:00
app_error::{not_found, AppError},
2025-05-26 22:08:21 -07:00
app_state::AppDbConn,
2025-05-28 16:35:00 -07:00
base_pooler::BasePooler,
2025-05-26 22:08:21 -07:00
bases::Base,
data_layer::{Field, FieldOptionsBuilder, ToHtmlString as _, Value},
db_conns::{escape_identifier, init_role},
2025-05-28 16:35:00 -07:00
pg_acls::PgPrivilegeType,
2025-05-26 22:08:21 -07:00
pg_attributes::fetch_attributes_for_rel,
pg_classes::{PgClass, PgRelKind},
2025-05-28 16:35:00 -07:00
pg_roles::{user_id_from_rolname, PgRole, RoleTree},
rel_invitations::RelInvitation,
2025-05-26 22:08:21 -07:00
settings::Settings,
2025-05-28 16:35:00 -07:00
users::{CurrentUser, User},
2025-05-26 22:08:21 -07:00
};
#[derive(Deserialize)]
pub struct ListRelationsPagePath {
base_id: Uuid,
}
pub async fn list_relations_page(
State(Settings { base_path, .. }): State<Settings>,
State(mut base_pooler): State<BasePooler>,
AppDbConn(mut app_db): AppDbConn,
CurrentUser(current_user): CurrentUser,
Path(ListRelationsPagePath { base_id }): Path<ListRelationsPagePath>,
) -> Result<Response, AppError> {
// FIXME auth
let base = Base::fetch_by_id(base_id, &mut *app_db)
.await?
.ok_or(AppError::NotFound("no base found with that id".to_owned()))?;
let mut client = base_pooler.acquire_for(base_id).await?;
let rolname = format!("{}{}", &base.user_role_prefix, current_user.id.simple());
init_role(&rolname, &mut client).await?;
let roles = PgRole::fetch_by_names_any(vec![rolname], &mut *client).await?;
let role = roles.first().context("role not found in pg_roles")?;
let granted_role_tree = RoleTree::fetch_granted(role.oid, &mut *client)
.await?
.context("unable to construct role tree")?;
let granted_roles: HashSet<String> = granted_role_tree
.flatten_inherited()
.into_iter()
.map(|role| role.rolname.clone())
.collect();
let all_rels = PgClass::fetch_all_by_kind_any([PgRelKind::OrdinaryTable], &mut *client).await?;
let accessible_rels: Vec<PgClass> = all_rels
.into_iter()
.filter(|rel| {
let privileges: HashSet<PgPrivilegeType> = rel
.relacl
.clone()
2025-05-28 16:35:00 -07:00
.unwrap_or_default()
2025-05-26 22:08:21 -07:00
.into_iter()
.filter(|item| granted_roles.contains(&item.grantee))
.flat_map(|item| item.privileges)
.map(|privilege| privilege.privilege)
.collect();
privileges.contains(&PgPrivilegeType::Select)
})
.collect();
#[derive(Template)]
#[template(path = "list_rels.html")]
struct ResponseTemplate {
base_path: String,
base: Base,
rels: Vec<PgClass>,
}
Ok(Html(
ResponseTemplate {
base,
base_path,
rels: accessible_rels,
}
.render()?,
)
.into_response())
}
#[derive(Deserialize)]
pub struct ViewerPagePath {
base_id: Uuid,
class_oid: u32,
}
pub async fn viewer_page(
State(Settings { base_path, .. }): State<Settings>,
State(mut base_pooler): State<BasePooler>,
AppDbConn(mut app_db): AppDbConn,
CurrentUser(current_user): CurrentUser,
Path(params): Path<ViewerPagePath>,
) -> Result<Response, AppError> {
let base = Base::fetch_by_id(params.base_id, &mut *app_db)
.await?
.ok_or(AppError::NotFound("no base found with that id".to_owned()))?;
let mut client = base_pooler.acquire_for(params.base_id).await?;
init_role(
&format!("{}{}", &base.user_role_prefix, &current_user.id.simple()),
&mut client,
)
.await?;
// FIXME: Ensure user has access to database and relation
let class = query!(
"select relname from pg_class where oid = $1",
Oid(params.class_oid)
)
.fetch_optional(&mut *client)
.await?
.ok_or(AppError::NotFound(
"no relation found with that oid".to_owned(),
))?;
let attrs = fetch_attributes_for_rel(Oid(params.class_oid), &mut *client).await?;
const FRONTEND_ROW_LIMIT: i64 = 1000;
let rows = query(&format!(
"select {} from {} limit $1",
attrs
.iter()
.map(|attr| attr.attname.clone())
.collect::<Vec<_>>()
.join(", "),
escape_identifier(&class.relname),
))
.bind(FRONTEND_ROW_LIMIT)
.fetch_all(&mut *client)
.await?;
#[derive(Template)]
#[template(path = "class-viewer.html")]
struct ResponseTemplate {
base_path: String,
fields: Vec<Field>,
rows: Vec<PgRow>,
}
Ok(Html(
ResponseTemplate {
base_path,
fields: attrs
.into_iter()
.map(|attr| Field {
options: FieldOptionsBuilder::default().build().unwrap(),
name: attr.attname,
})
.collect(),
rows,
}
.render()?,
)
.into_response())
}
2025-05-28 16:35:00 -07:00
#[derive(Deserialize)]
pub struct RelRbacPagePath {
base_id: Uuid,
class_oid: u32,
}
pub async fn rel_rbac_page(
State(Settings { base_path, .. }): State<Settings>,
State(mut base_pooler): State<BasePooler>,
AppDbConn(mut app_db): AppDbConn,
CurrentUser(current_user): CurrentUser,
Path(RelRbacPagePath { base_id, class_oid }): Path<RelRbacPagePath>,
) -> Result<Response, AppError> {
// FIXME: auth
let base = Base::fetch_by_id(base_id, &mut *app_db)
.await?
.ok_or(not_found!("no base found with id {}", base_id))?;
let mut client = base_pooler.acquire_for(base_id).await?;
let rolname = format!("{}{}", &base.user_role_prefix, current_user.id.simple());
init_role(&rolname, &mut client).await?;
let class = PgClass::fetch_by_oid(Oid(class_oid), &mut *client)
.await?
.ok_or(not_found!("no relation found with oid {}", class_oid))?;
let user_ids: Vec<Uuid> = class
.relacl
.clone()
.unwrap_or_default()
.iter()
.filter_map(|item| user_id_from_rolname(&item.grantee, &base.user_role_prefix).ok())
.collect();
let all_users = User::fetch_by_ids_any(user_ids, &mut *app_db).await?;
let interim_users: HashMap<String, User> = all_users
.into_iter()
.map(|user| {
(
format!("{}{}", base.user_role_prefix, user.id.simple()),
user,
)
})
.collect();
let all_invites = RelInvitation::fetch_by_class_oid(Oid(class_oid), &mut *app_db).await?;
let mut invites_by_email: HashMap<String, Vec<RelInvitation>> = HashMap::new();
for invite in all_invites {
let entry = invites_by_email
.entry(invite.email.clone())
.or_insert(vec![]);
entry.push(invite);
}
#[derive(Template)]
#[template(path = "rel_rbac.html")]
struct ResponseTemplate {
base_path: String,
base: Base,
interim_users: HashMap<String, User>,
invites_by_email: HashMap<String, Vec<RelInvitation>>,
pg_class: PgClass,
}
Ok(Html(
ResponseTemplate {
base,
base_path,
interim_users,
invites_by_email,
pg_class: class,
}
.render()?,
)
.into_response())
}
pub async fn rel_rbac_invite_page_get(
State(Settings { base_path, .. }): State<Settings>,
) -> Result<Response, AppError> {
#[derive(Template)]
#[template(path = "rbac_invite.html")]
struct ResponseTemplate {
base_path: String,
}
Ok(Html(ResponseTemplate { base_path }.render()?).into_response())
}
#[derive(Deserialize)]
pub struct RbacInvitePagePostForm {
email: String,
}
pub async fn rel_rbac_invite_page_post(
State(Settings { base_path, .. }): State<Settings>,
AppDbConn(mut app_db): AppDbConn,
CurrentUser(current_user): CurrentUser,
Path(RelRbacPagePath { base_id, class_oid }): Path<RelRbacPagePath>,
Form(form): Form<RbacInvitePagePostForm>,
) -> Result<Response, AppError> {
// FIXME auth
// FIXME form validation
for privilege in [
PgPrivilegeType::Select,
PgPrivilegeType::Insert,
PgPrivilegeType::Update,
PgPrivilegeType::Delete,
PgPrivilegeType::Truncate,
PgPrivilegeType::References,
PgPrivilegeType::Trigger,
] {
RelInvitation::upsertable()
.email(form.email.clone())
.base_id(base_id)
.class_oid(Oid(class_oid))
.privilege(privilege)
.created_by(current_user.id)
.build()?
.upsert(&mut *app_db)
.await?;
}
Ok(Redirect::to(&format!("{base_path}/d/{base_id}/r/{class_oid}/rbac")).into_response())
}