improved (read-only) rbac page
This commit is contained in:
parent
53b4dfa130
commit
0f3eecceea
11 changed files with 292 additions and 94 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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
|
||||
",
|
||||
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
|
||||
)
|
||||
.bind(self.role_oid)
|
||||
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)
|
||||
.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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
{
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ mod cli;
|
|||
mod middleware;
|
||||
mod navbar;
|
||||
mod navigator;
|
||||
mod renderable_role_tree;
|
||||
mod router;
|
||||
mod routes;
|
||||
mod sessions;
|
||||
|
|
|
|||
20
interim-server/src/renderable_role_tree.rs
Normal file
20
interim-server/src/renderable_role_tree.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()?,
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
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>
|
||||
<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 class="users-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<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 class="users-table__td">{{ email }}</td>
|
||||
<td class="users-table__td">
|
||||
<code>{% for invite in invites %}{{ invite.privilege }}{% endfor %}</code>
|
||||
</td>
|
||||
<td class="users-table__td"></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</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 %}
|
||||
</ul>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
|
|||
12
interim-server/templates/role_tree.html
Normal file
12
interim-server/templates/role_tree.html
Normal 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>
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue