relation invites

This commit is contained in:
Brent Schroeter 2025-05-28 16:35:00 -07:00
parent ced7eced4a
commit 10959e6c2b
16 changed files with 411 additions and 18 deletions

View file

@ -0,0 +1 @@
drop table if exists rel_invitations;

View file

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

View file

@ -4,6 +4,18 @@ use axum::http::StatusCode;
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use validator::ValidationErrors; 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. /// Custom error type that maps to appropriate HTTP responses.
#[derive(Debug)] #[derive(Debug)]
pub enum AppError { pub enum AppError {

View file

@ -53,7 +53,6 @@ pub async fn sync_perms_for_base(
.iter() .iter()
.filter_map(|role| user_id_from_rolname(&role.rolname, &base.user_role_prefix).ok()) .filter_map(|role| user_id_from_rolname(&role.rolname, &base.user_role_prefix).ok())
.collect(); .collect();
dbg!(&all_roles);
query!( query!(
"delete from base_user_perms where base_id = $1 and not (user_id = any($2))", "delete from base_user_perms where base_id = $1 and not (user_id = any($2))",
base_id, base_id,

View file

@ -17,17 +17,17 @@ impl Base {
pub async fn fetch_by_id<'a, E: PgExecutor<'a>>( pub async fn fetch_by_id<'a, E: PgExecutor<'a>>(
id: Uuid, id: Uuid,
client: E, app_db: E,
) -> Result<Option<Base>, sqlx::Error> { ) -> Result<Option<Base>, sqlx::Error> {
query_as!(Self, "select * from bases where id = $1", &id) query_as!(Self, "select * from bases where id = $1", &id)
.fetch_optional(client) .fetch_optional(app_db)
.await .await
} }
pub async fn fetch_by_perm_any<'a, E: PgExecutor<'a>>( pub async fn fetch_by_perm_any<'a, E: PgExecutor<'a>>(
user_id: Uuid, user_id: Uuid,
perms: Vec<&str>, perms: Vec<&str>,
client: E, app_db: E,
) -> Result<Vec<Base>, sqlx::Error> { ) -> Result<Vec<Base>, sqlx::Error> {
let perms = perms let perms = perms
.into_iter() .into_iter()
@ -44,7 +44,7 @@ where p.user_id = $1 and perm = ANY($2)
user_id, user_id,
perms.as_slice(), perms.as_slice(),
) )
.fetch_all(client) .fetch_all(app_db)
.await .await
} }
} }
@ -56,7 +56,7 @@ pub struct InsertableBase {
} }
impl InsertableBase { impl InsertableBase {
pub async fn insert<'a, E: PgExecutor<'a>>(self, client: E) -> Result<Base, sqlx::Error> { pub async fn insert<'a, E: PgExecutor<'a>>(self, app_db: E) -> Result<Base, sqlx::Error> {
query_as!( query_as!(
Base, Base,
" "
@ -69,7 +69,7 @@ returning *
self.url, self.url,
self.owner_id self.owner_id
) )
.fetch_one(client) .fetch_one(app_db)
.await .await
} }
} }

View file

@ -23,6 +23,7 @@ mod pg_attributes;
mod pg_classes; mod pg_classes;
mod pg_databases; mod pg_databases;
mod pg_roles; mod pg_roles;
mod rel_invitations;
mod router; mod router;
mod routes; mod routes;
mod sessions; mod sessions;

View file

@ -54,6 +54,28 @@ pub enum PgPrivilegeType {
Maintain, 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 { impl<'a> Decode<'a, Postgres> for PgAclItem {
fn decode(value: PgValueRef<'a>) -> Result<Self, BoxDynError> { fn decode(value: PgValueRef<'a>) -> Result<Self, BoxDynError> {
let acl_item_str = <&str as Decode<Postgres>>::decode(value)?; let acl_item_str = <&str as Decode<Postgres>>::decode(value)?;

View file

@ -39,16 +39,51 @@ pub struct PgClass {
} }
impl PgClass { impl PgClass {
pub async fn fetch_by_oid<'a, E: PgExecutor<'a>>(
oid: Oid,
client: E,
) -> Result<Option<Self>, 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<PgAclItem>"
from pg_class
where
oid = $1
"#,
oid,
)
.fetch_optional(client)
.await
}
pub async fn fetch_all_by_kind_any<'a, I: IntoIterator<Item = PgRelKind>, E: PgExecutor<'a>>( pub async fn fetch_all_by_kind_any<'a, I: IntoIterator<Item = PgRelKind>, E: PgExecutor<'a>>(
kinds: I, kinds: I,
client: E, client: E,
) -> Result<Vec<PgClass>, sqlx::Error> { ) -> Result<Vec<Self>, sqlx::Error> {
let kinds_i8 = kinds let kinds_i8 = kinds
.into_iter() .into_iter()
.map(|kind| kind.to_u8() as i8) .map(|kind| kind.to_u8() as i8)
.collect::<Vec<i8>>(); .collect::<Vec<i8>>();
query_as!( query_as!(
PgClass, Self,
r#" r#"
select select
oid, oid,

79
src/rel_invitations.rs Normal file
View file

@ -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<DateTime<Utc>>,
}
impl RelInvitation {
pub async fn fetch_by_class_oid<'a, E: PgExecutor<'a>>(
oid: Oid,
app_db: E,
) -> Result<Vec<Self>, 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<DateTime<Utc>>,
}
impl UpsertableRelInvitation {
pub async fn upsert<'a, E: PgExecutor<'a>>(
self,
app_db: E,
) -> Result<RelInvitation, sqlx::Error> {
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
}
}

View file

@ -28,6 +28,18 @@ pub fn new_router(state: AppState) -> Router<()> {
"/d/{base_id}/relations", "/d/{base_id}/relations",
get(routes::relations::list_relations_page), 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( .route(
"/d/{base_id}/r/{class_oid}/viewer", "/d/{base_id}/r/{class_oid}/viewer",
get(routes::relations::viewer_page), get(routes::relations::viewer_page),

View file

@ -16,6 +16,8 @@ use crate::{
base_user_perms::sync_perms_for_base, base_user_perms::sync_perms_for_base,
bases::Base, bases::Base,
db_conns::{escape_identifier, init_role}, db_conns::{escape_identifier, init_role},
pg_databases::PgDatabase,
pg_roles::PgRole,
settings::Settings, settings::Settings,
users::CurrentUser, users::CurrentUser,
}; };

View file

@ -1,11 +1,12 @@
use std::collections::HashSet; use std::collections::{HashMap, HashSet};
use anyhow::Context as _; use anyhow::Context as _;
use askama::Template; use askama::Template;
use axum::{ use axum::{
extract::{Path, State}, extract::{Path, State},
response::{Html, IntoResponse as _, Response}, response::{Html, IntoResponse as _, Redirect, Response},
}; };
use axum_extra::extract::Form;
use serde::Deserialize; use serde::Deserialize;
use sqlx::{ use sqlx::{
postgres::{types::Oid, PgRow}, postgres::{types::Oid, PgRow},
@ -14,18 +15,19 @@ use sqlx::{
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
app_error::AppError, app_error::{not_found, AppError},
app_state::AppDbConn, app_state::AppDbConn,
base_pooler::{self, BasePooler}, base_pooler::BasePooler,
bases::Base, bases::Base,
data_layer::{Field, FieldOptionsBuilder, ToHtmlString as _, Value}, data_layer::{Field, FieldOptionsBuilder, ToHtmlString as _, Value},
db_conns::{escape_identifier, init_role}, db_conns::{escape_identifier, init_role},
pg_acls::{PgAclItem, PgPrivilegeType}, pg_acls::PgPrivilegeType,
pg_attributes::fetch_attributes_for_rel, pg_attributes::fetch_attributes_for_rel,
pg_classes::{PgClass, PgRelKind}, pg_classes::{PgClass, PgRelKind},
pg_roles::{PgRole, RoleTree}, pg_roles::{user_id_from_rolname, PgRole, RoleTree},
rel_invitations::RelInvitation,
settings::Settings, settings::Settings,
users::CurrentUser, users::{CurrentUser, User},
}; };
#[derive(Deserialize)] #[derive(Deserialize)]
@ -66,7 +68,7 @@ pub async fn list_relations_page(
let privileges: HashSet<PgPrivilegeType> = rel let privileges: HashSet<PgPrivilegeType> = rel
.relacl .relacl
.clone() .clone()
.unwrap_or(vec![]) .unwrap_or_default()
.into_iter() .into_iter()
.filter(|item| granted_roles.contains(&item.grantee)) .filter(|item| granted_roles.contains(&item.grantee))
.flat_map(|item| item.privileges) .flat_map(|item| item.privileges)
@ -168,3 +170,123 @@ pub async fn viewer_page(
) )
.into_response()) .into_response())
} }
#[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())
}

View file

@ -9,7 +9,7 @@ use axum_extra::extract::{
cookie::{Cookie, SameSite}, cookie::{Cookie, SameSite},
CookieJar, CookieJar,
}; };
use sqlx::query_as; use sqlx::{query_as, PgExecutor};
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
@ -26,6 +26,26 @@ pub struct User {
pub email: String, pub email: String,
} }
impl User {
pub async fn fetch_by_ids_any<'a, I: IntoIterator<Item = Uuid>, E: PgExecutor<'a>>(
ids: I,
app_db: E,
) -> Result<Vec<Self>, sqlx::Error> {
let ids: Vec<Uuid> = 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)] #[derive(Clone, Debug)]
pub struct CurrentUser(pub User); pub struct CurrentUser(pub User);

View file

@ -0,0 +1,15 @@
{% extends "base.html" %}
{% block main %}
<form method="post" action="">
<div>
<label for="input-name">Name:</label>
<input type="text" name="name" value="{{ base.name }}">
</div>
<div>
<label for="input-url">Database URL:</label>
<input type="text" name="url" value="{{ base.url }}">
</div>
<button type="submit">Save</button>
</form>
{% endblock %}

View file

@ -0,0 +1,13 @@
{% extends "base.html" %}
{% block main %}
<form method="post" action="">
<div>
<label for="email">
Email Address
</label>
<input type="text" name="email" inputmode="email">
<button type="submit">Invite</button>
</div>
</form>
{% endblock %}

48
templates/rel_rbac.html Normal file
View file

@ -0,0 +1,48 @@
{% extends "base.html" %}
{% block main %}
<h2>Invitations</h2>
<a href="{{ base_path }}/d/{{ base.id.simple() }}/r/{{ pg_class.oid.0 }}/rbac/invite">
Invite Collaborators
</a>
<table>
<thead>
<tr>
<th>Email</th>
<th>Privileges</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for (email, invites) in invites_by_email %}
<tr>
<td>{{ email }}</td>
<td>
<code>{% for invite in invites %}{{ invite.privilege }}{% endfor %}</code>
</td>
<td></td>
</tr>
{% endfor %}
</tbody>
</table>
<h2>Interim Users</h2>
<table>
<thead>
<tr>
<th>Email</th>
<th>User ID</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for (grantee, user) in interim_users %}
<tr>
<td>{{ user.email }}</td>
<td>{{ grantee }}</td>
<td></td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}