diff --git a/interim-pgtypes/src/pg_class.rs b/interim-pgtypes/src/pg_class.rs index 64796e6..2155a23 100644 --- a/interim-pgtypes/src/pg_class.rs +++ b/interim-pgtypes/src/pg_class.rs @@ -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, diff --git a/interim-pgtypes/src/pg_role.rs b/interim-pgtypes/src/pg_role.rs index 04cacd2..12dcf82 100644 --- a/interim-pgtypes/src/pg_role.rs +++ b/interim-pgtypes/src/pg_role.rs @@ -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, sqlx::Error> { let rows: Vec = 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, 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 = 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 diff --git a/interim-server/src/app_error.rs b/interim-server/src/app_error.rs index bddf6c6..9b99fbd 100644 --- a/interim-server/src/app_error.rs +++ b/interim-server/src/app_error.rs @@ -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. diff --git a/interim-server/src/base_user_perms.rs b/interim-server/src/base_user_perms.rs index 4af9440..094c194 100644 --- a/interim-server/src/base_user_perms.rs +++ b/interim-server/src/base_user_perms.rs @@ -40,7 +40,7 @@ pub async fn sync_perms_for_base( .await?; let mut all_roles: HashSet = 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? { diff --git a/interim-server/src/main.rs b/interim-server/src/main.rs index 8015ed2..04ed372 100644 --- a/interim-server/src/main.rs +++ b/interim-server/src/main.rs @@ -18,6 +18,7 @@ mod cli; mod middleware; mod navbar; mod navigator; +mod renderable_role_tree; mod router; mod routes; mod sessions; diff --git a/interim-server/src/renderable_role_tree.rs b/interim-server/src/renderable_role_tree.rs new file mode 100644 index 0000000..12f7941 --- /dev/null +++ b/interim-server/src/renderable_role_tree.rs @@ -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, + inherit: bool, +} + +impl From for RenderableRoleTree { + fn from(value: RoleTree) -> Self { + Self { + role: value.role, + branches: value.branches.into_iter().map(Self::from).collect(), + inherit: value.inherit, + } + } +} diff --git a/interim-server/src/routes/relations.rs b/interim-server/src/routes/relations.rs index 1a30b78..41a6373 100644 --- a/interim-server/src/routes/relations.rs +++ b/interim-server/src/routes/relations.rs @@ -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 { // 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 = 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 = 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 = 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, base: Base, - interim_users: HashMap, invites_by_email: HashMap>, + 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()?, diff --git a/interim-server/templates/rel_rbac.html b/interim-server/templates/rel_rbac.html index e53b426..542c432 100644 --- a/interim-server/templates/rel_rbac.html +++ b/interim-server/templates/rel_rbac.html @@ -1,48 +1,72 @@ {% 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 }}
+
+
+
+ {{ navbar | safe }} +
+
+
+

Sharing

+
+
+

Table Owners

+

+ Owners are able to edit table structure, including configuring columns, + adding, updating, and deleting record data, and dropping the table + entirely from the database. +

+

+ 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. +

+ {{ owners | safe }} +
+
+

Invitations

+ + Invite Collaborators + + + + + + {# rolname is intentionally hidden in a submenu (todo), as it is + likely to confuse new users #} + + + + + + {# place invitations at beginning of list as they're liable to cause + unpleasant surprises if forgotten #} + {% for (email, invites) in invites_by_email %} + + + + + + {% endfor %} + +
EmailPrivilegesActions
{{ email }} + {% for invite in invites %}{{ invite.privilege }}{% endfor %} +
+
+
+

Permissions

+
    + {% for acl_tree in acl_trees %} +
  • +
    + {% for privilege in acl_tree.acl_item.privileges %}{{ privilege.privilege.to_abbrev() }}{% endfor %} +
    + {{ acl_tree.grantees | safe }} +
  • + {% endfor %} +
+
+
+
{% endblock %} diff --git a/interim-server/templates/role_tree.html b/interim-server/templates/role_tree.html new file mode 100644 index 0000000..999b0ce --- /dev/null +++ b/interim-server/templates/role_tree.html @@ -0,0 +1,12 @@ +
+
+ {{ role.rolname }} +
+ {% if !branches.is_empty() %} +
    + {% for branch in branches %} +
  • {{ branch.render()? | safe }}
  • + {% endfor %} +
+ {% endif %} +
diff --git a/sass/_globals.scss b/sass/_globals.scss index b2898eb..4ad9cbe 100644 --- a/sass/_globals.scss +++ b/sass/_globals.scss @@ -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; diff --git a/sass/main.scss b/sass/main.scss index e804d14..fadb8e0 100644 --- a/sass/main.scss +++ b/sass/main.scss @@ -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; + } +}