improved (read-only) rbac page

This commit is contained in:
Brent Schroeter 2025-08-09 00:14:58 -07:00
parent 53b4dfa130
commit 0f3eecceea
11 changed files with 292 additions and 94 deletions

View file

@ -18,6 +18,8 @@ pub struct PgClass {
pub reloftype: Oid,
/// Owner of the relation
pub relowner: Oid,
/// SYNTHESIZED: name of this relation's owner role as text
pub regowner: String,
/// r = ordinary table, i = index, S = sequence, t = TOAST table, v = view, m = materialized view, c = composite type, f = foreign table, p = partitioned table, I = partitioned index
pub relkind: i8,
/// Number of user columns in the relation (system columns not counted). There must be this many corresponding entries in pg_attribute. See also pg_attribute.attnum.
@ -91,6 +93,7 @@ select
reltype,
reloftype,
relowner,
relowner::regrole::text as "regowner!",
relkind,
relnatts,
relchecks,
@ -150,6 +153,7 @@ select
reltype,
reloftype,
relowner,
relowner::regrole::text as "regowner!",
relkind,
relnatts,
relchecks,

View file

@ -59,7 +59,9 @@ select
rolconnlimit as "rolconnlimit!",
rolvaliduntil,
rolbypassrls as "rolbypassrls!"
from pg_roles where rolname = any($1)"#,
from pg_roles
where rolname = any($1)
"#,
self.names.as_slice()
)
.fetch_all(&mut *client.conn)
@ -83,8 +85,14 @@ struct RoleTreeRow {
}
impl RoleTree {
pub fn members_of(role_oid: Oid) -> MembersOfQuery {
MembersOfQuery { role_oid }
pub fn members_of_oid(role_oid: Oid) -> MembersOfOidQuery {
MembersOfOidQuery { role: role_oid }
}
pub fn members_of_rolname(rolname: &str) -> MembersOfRolnameQuery {
MembersOfRolnameQuery {
role: rolname.to_owned(),
}
}
pub fn granted_to(role_oid: Oid) -> GrantedToQuery {
@ -106,34 +114,77 @@ impl RoleTree {
}
#[derive(Clone, Debug)]
pub struct MembersOfQuery {
role_oid: Oid,
pub struct MembersOfOidQuery {
role: Oid,
}
impl MembersOfQuery {
impl MembersOfOidQuery {
pub async fn fetch_tree(
self,
client: &mut BaseClient,
) -> Result<Option<RoleTree>, sqlx::Error> {
let rows: Vec<RoleTreeRow> = query_as(
"
with recursive cte as (
select $1 as roleid, null::oid as branch, true as inherit
with recursive cte as (
select $1::regrole::oid as roleid, null::oid as branch, true as inherit
union all
select m.member, m.roleid, c.inherit and m.inherit_option
from cte as c
join pg_auth_members m on m.roleid = c.roleid
)
select pg_roles.*, branch, inherit
from (
)
select pg_roles.*, branch, inherit
from (
select roleid, branch, bool_or(inherit) as inherit
from cte
group by roleid, branch
) as subquery
) as subquery
join pg_roles on pg_roles.oid = subquery.roleid
",
",
)
.bind(self.role_oid)
.bind(self.role)
.fetch_all(&mut *client.conn)
.await?;
Ok(rows
.iter()
.find(|row| row.branch.is_none())
.map(|root_row| RoleTree {
role: root_row.role.clone(),
branches: compute_members(&rows, root_row.role.oid),
inherit: root_row.inherit,
}))
}
}
#[derive(Clone, Debug)]
pub struct MembersOfRolnameQuery {
role: String,
}
impl MembersOfRolnameQuery {
pub async fn fetch_tree(
self,
client: &mut BaseClient,
) -> Result<Option<RoleTree>, sqlx::Error> {
// This could almost be a macro to DRY with MembersOfOidQuery, except
// for the extra ::text:: cast required on the parameter in this query.
let rows: Vec<RoleTreeRow> = query_as(
"
with recursive cte as (
select $1::text::regrole::oid as roleid, null::oid as branch, true as inherit
union all
select m.member, m.roleid, c.inherit and m.inherit_option
from cte as c
join pg_auth_members m on m.roleid = c.roleid
)
select pg_roles.*, branch, inherit
from (
select roleid, branch, bool_or(inherit) as inherit
from cte
group by roleid, branch
) as subquery
join pg_roles on pg_roles.oid = subquery.roleid
",
)
.bind(self.role.as_str() as &str)
.fetch_all(&mut *client.conn)
.await?;
Ok(rows

View file

@ -4,6 +4,16 @@ use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use validator::ValidationErrors;
macro_rules! forbidden {
($message:literal) => {
AppError::Forbidden($message.to_owned())
};
($message:literal, $($param:expr),+) => {
AppError::Forbidden(format!($message, $($param)+))
};
}
macro_rules! not_found {
($message:literal) => {
AppError::NotFound($message.to_owned())
@ -25,6 +35,7 @@ macro_rules! bad_request {
}
pub(crate) use bad_request;
pub(crate) use forbidden;
pub(crate) use not_found;
/// Custom error type that maps to appropriate HTTP responses.

View file

@ -40,7 +40,7 @@ pub async fn sync_perms_for_base(
.await?;
let mut all_roles: HashSet<PgRole> = HashSet::new();
for explicit_role in explicit_roles {
if let Some(role_tree) = RoleTree::members_of(explicit_role.oid)
if let Some(role_tree) = RoleTree::members_of_oid(explicit_role.oid)
.fetch_tree(base_client)
.await?
{

View file

@ -18,6 +18,7 @@ mod cli;
mod middleware;
mod navbar;
mod navigator;
mod renderable_role_tree;
mod router;
mod routes;
mod sessions;

View file

@ -0,0 +1,20 @@
use askama::Template;
use interim_pgtypes::pg_role::{PgRole, RoleTree};
#[derive(Clone, Debug, Template)]
#[template(path = "role_tree.html")]
pub struct RenderableRoleTree {
role: PgRole,
branches: Vec<RenderableRoleTree>,
inherit: bool,
}
impl From<RoleTree> for RenderableRoleTree {
fn from(value: RoleTree) -> Self {
Self {
role: value.role,
branches: value.branches.into_iter().map(Self::from).collect(),
inherit: value.inherit,
}
}
}

View file

@ -7,20 +7,22 @@ use axum::{
response::{Html, IntoResponse as _, Redirect, Response},
};
use axum_extra::extract::Form;
use interim_models::{base::Base, rel_invitation::RelInvitation, user::User};
use interim_models::{base::Base, rel_invitation::RelInvitation};
use interim_pgtypes::{
pg_acl::PgPrivilegeType,
pg_acl::{PgAclItem, PgPrivilegeType},
pg_class::{PgClass, PgRelKind},
pg_role::{PgRole, RoleTree, user_id_from_rolname},
pg_role::{PgRole, RoleTree},
};
use serde::Deserialize;
use sqlx::postgres::types::Oid;
use uuid::Uuid;
use crate::{
app_error::AppError,
app_error::{AppError, forbidden},
app_state::AppDbConn,
base_pooler::{self, BasePooler},
navbar::{NavLocation, Navbar, RelLocation},
renderable_role_tree::RenderableRoleTree,
settings::Settings,
user::CurrentUser,
};
@ -120,29 +122,20 @@ pub async fn rel_rbac_page(
) -> Result<Response, AppError> {
// FIXME: auth
let base = Base::with_id(base_id).fetch_one(&mut app_db).await?;
let mut client = base_pooler
let mut base_client = base_pooler
.acquire_for(base_id, base_pooler::RoleAssignment::User(current_user.id))
.await?;
let class = PgClass::with_oid(Oid(class_oid))
.fetch_one(&mut client)
.fetch_one(&mut base_client)
.await?;
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::with_id_in(user_ids).fetch_all(&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 owners: RenderableRoleTree = RoleTree::members_of_oid(class.relowner)
.fetch_tree(&mut base_client)
.await?
.ok_or(forbidden!(
"user does not have permission to determine relation owner"
))?
.into();
let all_invites = RelInvitation::belonging_to_rel(Oid(class_oid))
.fetch_all(&mut app_db)
@ -153,22 +146,51 @@ pub async fn rel_rbac_page(
entry.push(invite);
}
struct AclTree {
acl_item: PgAclItem,
grantees: RenderableRoleTree,
}
let mut acl_trees: Vec<AclTree> = vec![];
for item in class.relacl.clone().unwrap_or_default() {
acl_trees.push(AclTree {
acl_item: item.clone(),
grantees: RoleTree::members_of_rolname(&item.grantee)
.fetch_tree(&mut base_client)
.await?
.ok_or(forbidden!(
"unable to construct full acl tree for role {0}",
&item.grantee
))?
.into(),
});
}
#[derive(Template)]
#[template(path = "rel_rbac.html")]
struct ResponseTemplate {
acl_trees: Vec<AclTree>,
base: Base,
interim_users: HashMap<String, User>,
invites_by_email: HashMap<String, Vec<RelInvitation>>,
navbar: Navbar,
owners: RenderableRoleTree,
pg_class: PgClass,
settings: Settings,
}
Ok(Html(
ResponseTemplate {
base,
interim_users,
acl_trees,
invites_by_email,
pg_class: class,
navbar: Navbar::builder()
.root_path(settings.root_path.clone())
.base(base.clone())
.populate_rels(&mut app_db, &mut base_client)
.await?
.current(NavLocation::Rel(Oid(class_oid), Some(RelLocation::Rbac)))
.build()?,
owners,
base,
settings,
}
.render()?,

View file

@ -1,48 +1,72 @@
{% extends "base.html" %}
{% block main %}
<h2>Invitations</h2>
<a href="{{ settings.root_path }}/d/{{ base.id.simple() }}/r/{{ pg_class.oid.0 }}/rbac/invite">
<div class="page-grid">
<div class="page-grid__toolbar"></div>
<div class="page-grid__sidebar">
{{ navbar | safe }}
</div>
<main class="page-grid__main">
<section class="section">
<h1>Sharing</h1>
</section>
<section class="section">
<h2>Table Owners</h2>
<p class="notice notice--info">
Owners are able to edit table structure, including configuring columns,
adding, updating, and deleting record data, and dropping the table
entirely from the database.
</p>
<p class="notice notice--info">
Each table in Postgres has exactly one owner role, so it's typically
best practice to create a dedicated role for this purpose and then grant
membership of that role to one or more users.
</p>
{{ owners | safe }}
</section>
<section class="section">
<h2>Invitations</h2>
<a href="{{ settings.root_path }}/d/{{ base.id.simple() }}/r/{{ pg_class.oid.0 }}/rbac/invite">
Invite Collaborators
</a>
<table>
</a>
<table class="users-table">
<thead>
<tr>
<th>Email</th>
<th>Privileges</th>
<th>Actions</th>
<th class="users-table__th">Email</th>
{# rolname is intentionally hidden in a submenu (todo), as it is
likely to confuse new users #}
<th class="users-table__th">Privileges</th>
<th class="users-table__th"><span class="sr-only">Actions</span></th>
</tr>
</thead>
<tbody>
{# place invitations at beginning of list as they're liable to cause
unpleasant surprises if forgotten #}
{% for (email, invites) in invites_by_email %}
<tr>
<td>{{ email }}</td>
<td>
<td class="users-table__td">{{ email }}</td>
<td class="users-table__td">
<code>{% for invite in invites %}{{ invite.privilege }}{% endfor %}</code>
</td>
<td></td>
<td class="users-table__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>
</table>
</section>
<section class="section">
<h2>Permissions</h2>
<ul>
{% for acl_tree in acl_trees %}
<li>
<div>
{% for privilege in acl_tree.acl_item.privileges %}{{ privilege.privilege.to_abbrev() }}{% endfor %}
</div>
{{ acl_tree.grantees | safe }}
</li>
{% endfor %}
</tbody>
</table>
</ul>
</section>
</main>
</div>
{% endblock %}

View file

@ -0,0 +1,12 @@
<div class="role-tree {%- if !inherit %} role-tree--no-inherit{% endif -%}">
<div class="role-tree__rolname">
{{ role.rolname }}
</div>
{% if !branches.is_empty() %}
<ul class="role-tree__branches">
{% for branch in branches %}
<li class="role-tree__branch">{{ branch.render()? | safe }}</li>
{% endfor %}
</ul>
{% endif %}
</div>

View file

@ -11,6 +11,7 @@ $popover-shadow: 0 0.5rem 0.5rem #3333;
$border-radius-rounded-sm: 0.25rem;
$border-radius-rounded: 0.5rem;
$link-color: #069;
$notice-color-info: #39d;
@mixin reset-button {
appearance: none;

View file

@ -1,3 +1,5 @@
@use 'sass:color';
@use 'globals';
@use 'modern-normalize';
@ -31,6 +33,17 @@ button, input[type="submit"] {
src: url("../funnel_sans/funnel_sans_variable.ttf");
}
// https://css-tricks.com/inclusively-hidden/
.sr-only:not(:focus):not(:active) {
clip: rect(0 0 0 0);
clip-path: inset(50%);
height: 1px;
overflow: hidden;
position: absolute;
white-space: nowrap;
width: 1px;
}
.page-grid {
height: 100vh;
width: 100vw;
@ -56,3 +69,42 @@ button, input[type="submit"] {
grid-area: main;
}
}
.section {
padding: 1rem 2rem;
}
.notice {
@include globals.rounded;
margin: 1rem 0rem;
padding: 1rem;
max-width: 40rem;
&--info {
border: solid 1px globals.$notice-color-info;
background: color.scale(globals.$notice-color-info, $lightness: 90%, $space: hsl);
color: color.scale(globals.$notice-color-info, $lightness: -80%, $space: hsl);
}
}
.role-tree {
font-family: globals.$font-family-data;
&--no-inherit {
opacity: 0.6;
font-style: italic;
}
&__branches {
border-left: solid 1px #000;
list-style-type: none;
margin: 0;
margin-top: 0.25rem;
padding: 0;
}
&__branch {
padding-top: 0.25rem;
padding-left: 1rem;
}
}