relation invites
This commit is contained in:
parent
ced7eced4a
commit
10959e6c2b
16 changed files with 411 additions and 18 deletions
1
migrations/20250528060837_rel_invitations.down.sql
Normal file
1
migrations/20250528060837_rel_invitations.down.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
drop table if exists rel_invitations;
|
||||
12
migrations/20250528060837_rel_invitations.up.sql
Normal file
12
migrations/20250528060837_rel_invitations.up.sql
Normal 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);
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
12
src/bases.rs
12
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<Option<Base>, 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<Vec<Base>, 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<Base, sqlx::Error> {
|
||||
pub async fn insert<'a, E: PgExecutor<'a>>(self, app_db: E) -> Result<Base, sqlx::Error> {
|
||||
query_as!(
|
||||
Base,
|
||||
"
|
||||
|
|
@ -69,7 +69,7 @@ returning *
|
|||
self.url,
|
||||
self.owner_id
|
||||
)
|
||||
.fetch_one(client)
|
||||
.fetch_one(app_db)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<Self, BoxDynError> {
|
||||
let acl_item_str = <&str as Decode<Postgres>>::decode(value)?;
|
||||
|
|
|
|||
|
|
@ -39,16 +39,51 @@ pub struct 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>>(
|
||||
kinds: I,
|
||||
client: E,
|
||||
) -> Result<Vec<PgClass>, sqlx::Error> {
|
||||
) -> Result<Vec<Self>, sqlx::Error> {
|
||||
let kinds_i8 = kinds
|
||||
.into_iter()
|
||||
.map(|kind| kind.to_u8() as i8)
|
||||
.collect::<Vec<i8>>();
|
||||
query_as!(
|
||||
PgClass,
|
||||
Self,
|
||||
r#"
|
||||
select
|
||||
oid,
|
||||
|
|
|
|||
79
src/rel_invitations.rs
Normal file
79
src/rel_invitations.rs
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<PgPrivilegeType> = 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<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())
|
||||
}
|
||||
|
|
|
|||
22
src/users.rs
22
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<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)]
|
||||
pub struct CurrentUser(pub User);
|
||||
|
||||
|
|
|
|||
15
templates/base_config.html
Normal file
15
templates/base_config.html
Normal 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 %}
|
||||
13
templates/rbac_invite.html
Normal file
13
templates/rbac_invite.html
Normal 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
48
templates/rel_rbac.html
Normal 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 %}
|
||||
Loading…
Add table
Reference in a new issue