From 10959e6c2b6cb1e86d643278755b409cbc40935e Mon Sep 17 00:00:00 2001 From: Brent Schroeter Date: Wed, 28 May 2025 16:35:00 -0700 Subject: [PATCH] relation invites --- .../20250528060837_rel_invitations.down.sql | 1 + .../20250528060837_rel_invitations.up.sql | 12 ++ src/app_error.rs | 12 ++ src/base_user_perms.rs | 1 - src/bases.rs | 12 +- src/main.rs | 1 + src/pg_acls.rs | 22 +++ src/pg_classes.rs | 39 ++++- src/rel_invitations.rs | 79 ++++++++++ src/router.rs | 12 ++ src/routes/bases.rs | 2 + src/routes/relations.rs | 138 +++++++++++++++++- src/users.rs | 22 ++- templates/base_config.html | 15 ++ templates/rbac_invite.html | 13 ++ templates/rel_rbac.html | 48 ++++++ 16 files changed, 411 insertions(+), 18 deletions(-) create mode 100644 migrations/20250528060837_rel_invitations.down.sql create mode 100644 migrations/20250528060837_rel_invitations.up.sql create mode 100644 src/rel_invitations.rs create mode 100644 templates/base_config.html create mode 100644 templates/rbac_invite.html create mode 100644 templates/rel_rbac.html diff --git a/migrations/20250528060837_rel_invitations.down.sql b/migrations/20250528060837_rel_invitations.down.sql new file mode 100644 index 0000000..36a261f --- /dev/null +++ b/migrations/20250528060837_rel_invitations.down.sql @@ -0,0 +1 @@ +drop table if exists rel_invitations; diff --git a/migrations/20250528060837_rel_invitations.up.sql b/migrations/20250528060837_rel_invitations.up.sql new file mode 100644 index 0000000..74ce487 --- /dev/null +++ b/migrations/20250528060837_rel_invitations.up.sql @@ -0,0 +1,12 @@ +create table if not exists rel_invitations ( + id uuid not null primary key, + email text not null, + base_id uuid not null references bases(id) on delete cascade, + class_oid oid not null, + created_by uuid not null references users(id) on delete restrict, + privilege text not null, + expires_at timestamptz, + unique (email, base_id, class_oid, privilege) +); +create index on rel_invitations (base_id, class_oid); +create index on rel_invitations (email); diff --git a/src/app_error.rs b/src/app_error.rs index 67681d4..edb78c2 100644 --- a/src/app_error.rs +++ b/src/app_error.rs @@ -4,6 +4,18 @@ use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use validator::ValidationErrors; +macro_rules! not_found { + ($message:literal) => { + AppError::NotFound($message.to_owned()) + }; + + ($message:literal, $($param:expr),+) => { + AppError::NotFound(format!($message, $($param)+)) + }; +} + +pub(crate) use not_found; + /// Custom error type that maps to appropriate HTTP responses. #[derive(Debug)] pub enum AppError { diff --git a/src/base_user_perms.rs b/src/base_user_perms.rs index 7795af2..0b84d84 100644 --- a/src/base_user_perms.rs +++ b/src/base_user_perms.rs @@ -53,7 +53,6 @@ pub async fn sync_perms_for_base( .iter() .filter_map(|role| user_id_from_rolname(&role.rolname, &base.user_role_prefix).ok()) .collect(); - dbg!(&all_roles); query!( "delete from base_user_perms where base_id = $1 and not (user_id = any($2))", base_id, diff --git a/src/bases.rs b/src/bases.rs index cb768e0..27b6db1 100644 --- a/src/bases.rs +++ b/src/bases.rs @@ -17,17 +17,17 @@ impl Base { pub async fn fetch_by_id<'a, E: PgExecutor<'a>>( id: Uuid, - client: E, + app_db: E, ) -> Result, sqlx::Error> { query_as!(Self, "select * from bases where id = $1", &id) - .fetch_optional(client) + .fetch_optional(app_db) .await } pub async fn fetch_by_perm_any<'a, E: PgExecutor<'a>>( user_id: Uuid, perms: Vec<&str>, - client: E, + app_db: E, ) -> Result, sqlx::Error> { let perms = perms .into_iter() @@ -44,7 +44,7 @@ where p.user_id = $1 and perm = ANY($2) user_id, perms.as_slice(), ) - .fetch_all(client) + .fetch_all(app_db) .await } } @@ -56,7 +56,7 @@ pub struct InsertableBase { } impl InsertableBase { - pub async fn insert<'a, E: PgExecutor<'a>>(self, client: E) -> Result { + pub async fn insert<'a, E: PgExecutor<'a>>(self, app_db: E) -> Result { query_as!( Base, " @@ -69,7 +69,7 @@ returning * self.url, self.owner_id ) - .fetch_one(client) + .fetch_one(app_db) .await } } diff --git a/src/main.rs b/src/main.rs index 3232a96..d7fd46e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -23,6 +23,7 @@ mod pg_attributes; mod pg_classes; mod pg_databases; mod pg_roles; +mod rel_invitations; mod router; mod routes; mod sessions; diff --git a/src/pg_acls.rs b/src/pg_acls.rs index d03fcd1..a02c1b9 100644 --- a/src/pg_acls.rs +++ b/src/pg_acls.rs @@ -54,6 +54,28 @@ pub enum PgPrivilegeType { Maintain, } +impl PgPrivilegeType { + pub fn to_abbrev(&self) -> char { + match self { + Self::Select => 'r', + Self::Insert => 'a', + Self::Update => 'w', + Self::Delete => 'd', + Self::Truncate => 'D', + Self::References => 'x', + Self::Trigger => 't', + Self::Create => 'C', + Self::Connect => 'c', + Self::Temporary => 'T', + Self::Execute => 'X', + Self::Usage => 'U', + Self::Set => 's', + Self::AlterSystem => 'A', + Self::Maintain => 'm', + } + } +} + impl<'a> Decode<'a, Postgres> for PgAclItem { fn decode(value: PgValueRef<'a>) -> Result { let acl_item_str = <&str as Decode>::decode(value)?; diff --git a/src/pg_classes.rs b/src/pg_classes.rs index 17c7396..1806232 100644 --- a/src/pg_classes.rs +++ b/src/pg_classes.rs @@ -39,16 +39,51 @@ pub struct PgClass { } impl PgClass { + pub async fn fetch_by_oid<'a, E: PgExecutor<'a>>( + oid: Oid, + client: E, + ) -> Result, sqlx::Error> { + query_as!( + Self, + r#" +select + oid, + relname, + relnamespace, + reltype, + reloftype, + relowner, + relkind, + relnatts, + relchecks, + relhasrules, + relhastriggers, + relhassubclass, + relrowsecurity, + relforcerowsecurity, + relispopulated, + relispartition, + relacl::text[] as "relacl: Vec" +from pg_class +where + oid = $1 +"#, + oid, + ) + .fetch_optional(client) + .await + } + pub async fn fetch_all_by_kind_any<'a, I: IntoIterator, E: PgExecutor<'a>>( kinds: I, client: E, - ) -> Result, sqlx::Error> { + ) -> Result, sqlx::Error> { let kinds_i8 = kinds .into_iter() .map(|kind| kind.to_u8() as i8) .collect::>(); query_as!( - PgClass, + Self, r#" select oid, diff --git a/src/rel_invitations.rs b/src/rel_invitations.rs new file mode 100644 index 0000000..bf19c60 --- /dev/null +++ b/src/rel_invitations.rs @@ -0,0 +1,79 @@ +use chrono::{DateTime, Utc}; +use derive_builder::Builder; +use sqlx::{postgres::types::Oid, query_as, PgExecutor}; +use uuid::Uuid; + +use crate::pg_acls::PgPrivilegeType; + +#[derive(Clone, Debug)] +pub struct RelInvitation { + pub id: Uuid, + pub email: String, + pub base_id: Uuid, + pub class_oid: Oid, + pub created_by: Uuid, + pub privilege: String, + pub expires_at: Option>, +} + +impl RelInvitation { + pub async fn fetch_by_class_oid<'a, E: PgExecutor<'a>>( + oid: Oid, + app_db: E, + ) -> Result, sqlx::Error> { + query_as!( + Self, + " +select * from rel_invitations +where class_oid = $1 +", + oid + ) + .fetch_all(app_db) + .await + } + + pub fn upsertable() -> UpsertableRelInvitationBuilder { + UpsertableRelInvitationBuilder::default() + } +} + +#[derive(Builder, Clone, Debug)] +pub struct UpsertableRelInvitation { + email: String, + base_id: Uuid, + class_oid: Oid, + created_by: Uuid, + privilege: PgPrivilegeType, + #[builder(default, setter(strip_option))] + expires_at: Option>, +} + +impl UpsertableRelInvitation { + pub async fn upsert<'a, E: PgExecutor<'a>>( + self, + app_db: E, + ) -> Result { + query_as!( + RelInvitation, + " +insert into rel_invitations +(id, email, base_id, class_oid, privilege, created_by, expires_at) +values ($1, $2, $3, $4, $5, $6, $7) +on conflict (email, base_id, class_oid, privilege) do update set + created_by = excluded.created_by, + expires_at = excluded.expires_at +returning * +", + Uuid::now_v7(), + self.email, + self.base_id, + self.class_oid, + self.privilege.to_abbrev().to_string(), + self.created_by, + self.expires_at, + ) + .fetch_one(app_db) + .await + } +} diff --git a/src/router.rs b/src/router.rs index 026e7b9..d9e2b80 100644 --- a/src/router.rs +++ b/src/router.rs @@ -28,6 +28,18 @@ pub fn new_router(state: AppState) -> Router<()> { "/d/{base_id}/relations", get(routes::relations::list_relations_page), ) + .route( + "/d/{base_id}/r/{class_oid}/rbac", + get(routes::relations::rel_rbac_page), + ) + .route( + "/d/{base_id}/r/{class_oid}/rbac/invite", + get(routes::relations::rel_rbac_invite_page_get), + ) + .route( + "/d/{base_id}/r/{class_oid}/rbac/invite", + post(routes::relations::rel_rbac_invite_page_post), + ) .route( "/d/{base_id}/r/{class_oid}/viewer", get(routes::relations::viewer_page), diff --git a/src/routes/bases.rs b/src/routes/bases.rs index faf05b0..6cca2e5 100644 --- a/src/routes/bases.rs +++ b/src/routes/bases.rs @@ -16,6 +16,8 @@ use crate::{ base_user_perms::sync_perms_for_base, bases::Base, db_conns::{escape_identifier, init_role}, + pg_databases::PgDatabase, + pg_roles::PgRole, settings::Settings, users::CurrentUser, }; diff --git a/src/routes/relations.rs b/src/routes/relations.rs index 5f37f46..57603d8 100644 --- a/src/routes/relations.rs +++ b/src/routes/relations.rs @@ -1,11 +1,12 @@ -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use anyhow::Context as _; use askama::Template; use axum::{ extract::{Path, State}, - response::{Html, IntoResponse as _, Response}, + response::{Html, IntoResponse as _, Redirect, Response}, }; +use axum_extra::extract::Form; use serde::Deserialize; use sqlx::{ postgres::{types::Oid, PgRow}, @@ -14,18 +15,19 @@ use sqlx::{ use uuid::Uuid; use crate::{ - app_error::AppError, + app_error::{not_found, AppError}, app_state::AppDbConn, - base_pooler::{self, BasePooler}, + base_pooler::BasePooler, bases::Base, data_layer::{Field, FieldOptionsBuilder, ToHtmlString as _, Value}, db_conns::{escape_identifier, init_role}, - pg_acls::{PgAclItem, PgPrivilegeType}, + pg_acls::PgPrivilegeType, pg_attributes::fetch_attributes_for_rel, pg_classes::{PgClass, PgRelKind}, - pg_roles::{PgRole, RoleTree}, + pg_roles::{user_id_from_rolname, PgRole, RoleTree}, + rel_invitations::RelInvitation, settings::Settings, - users::CurrentUser, + users::{CurrentUser, User}, }; #[derive(Deserialize)] @@ -66,7 +68,7 @@ pub async fn list_relations_page( let privileges: HashSet = rel .relacl .clone() - .unwrap_or(vec![]) + .unwrap_or_default() .into_iter() .filter(|item| granted_roles.contains(&item.grantee)) .flat_map(|item| item.privileges) @@ -168,3 +170,123 @@ pub async fn viewer_page( ) .into_response()) } + +#[derive(Deserialize)] +pub struct RelRbacPagePath { + base_id: Uuid, + class_oid: u32, +} + +pub async fn rel_rbac_page( + State(Settings { base_path, .. }): State, + State(mut base_pooler): State, + AppDbConn(mut app_db): AppDbConn, + CurrentUser(current_user): CurrentUser, + Path(RelRbacPagePath { base_id, class_oid }): Path, +) -> Result { + // 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 = 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 = 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> = 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, + invites_by_email: HashMap>, + 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, +) -> Result { + #[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, + AppDbConn(mut app_db): AppDbConn, + CurrentUser(current_user): CurrentUser, + Path(RelRbacPagePath { base_id, class_oid }): Path, + Form(form): Form, +) -> Result { + // 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()) +} diff --git a/src/users.rs b/src/users.rs index 4010fac..08ca021 100644 --- a/src/users.rs +++ b/src/users.rs @@ -9,7 +9,7 @@ use axum_extra::extract::{ cookie::{Cookie, SameSite}, CookieJar, }; -use sqlx::query_as; +use sqlx::{query_as, PgExecutor}; use uuid::Uuid; use crate::{ @@ -26,6 +26,26 @@ pub struct User { pub email: String, } +impl User { + pub async fn fetch_by_ids_any<'a, I: IntoIterator, E: PgExecutor<'a>>( + ids: I, + app_db: E, + ) -> Result, sqlx::Error> { + let ids: Vec = ids.into_iter().collect(); + query_as!( + Self, + " +select * from users +where id = any($1) +", + ids.as_slice() + ) + .fetch_all(app_db) + .await + .map_err(Into::into) + } +} + #[derive(Clone, Debug)] pub struct CurrentUser(pub User); diff --git a/templates/base_config.html b/templates/base_config.html new file mode 100644 index 0000000..e0c57e1 --- /dev/null +++ b/templates/base_config.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} + +{% block main %} +
+
+ + +
+
+ + +
+ +
+{% endblock %} diff --git a/templates/rbac_invite.html b/templates/rbac_invite.html new file mode 100644 index 0000000..7e72af6 --- /dev/null +++ b/templates/rbac_invite.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} + +{% block main %} +
+
+ + + +
+
+{% endblock %} diff --git a/templates/rel_rbac.html b/templates/rel_rbac.html new file mode 100644 index 0000000..a68e289 --- /dev/null +++ b/templates/rel_rbac.html @@ -0,0 +1,48 @@ +{% extends "base.html" %} + +{% block main %} +

Invitations

+ + Invite Collaborators + + + + + + + + + + + {% for (email, invites) in invites_by_email %} + + + + + + {% endfor %} + +
EmailPrivilegesActions
{{ email }} + {% for invite in invites %}{{ invite.privilege }}{% endfor %} +
+ +

Interim Users

+ + + + + + + + + + {% for (grantee, user) in interim_users %} + + + + + + {% endfor %} + +
EmailUser IDActions
{{ user.email }}{{ grantee }}
+{% endblock %}