add scaffolding for clusters and service creds

This commit is contained in:
Brent Schroeter 2025-11-01 00:17:07 +00:00
parent 44ccb2791c
commit 97b5ccc064
42 changed files with 1113 additions and 323 deletions

1
Cargo.lock generated
View file

@ -1800,6 +1800,7 @@ dependencies = [
"oauth2", "oauth2",
"percent-encoding", "percent-encoding",
"rand 0.8.5", "rand 0.8.5",
"redact",
"regex", "regex",
"reqwest 0.12.15", "reqwest 0.12.15",
"scraper", "scraper",

View file

@ -108,11 +108,15 @@ is added; this is simplified by maintaining a single "writer" role per table.
Direct user PostgreSQL connections are performed using secondary `LOGIN` roles Direct user PostgreSQL connections are performed using secondary `LOGIN` roles
created by the user's primary workspace role (where the primary workspace role created by the user's primary workspace role (where the primary workspace role
is e.g. `phono_{user_id}`). The credentials for these secondary roles are is e.g. `usr_{user_id}`). The credentials for these secondary roles are referred
referred to as "service credentials" or "PostgreSQL credentials". Service to as "service credentials" or "PostgreSQL credentials". Service credentials are
credentials are created and assigned permissions by users in the web UI, and created and assigned permissions by users in the web UI, and their permissions
their permissions are revoked manually in the web UI and/or by cascading are revoked manually in the web UI and/or by cascading `REVOKE` commands
`REVOKE` commands targeting the primary workspace role. targeting the primary workspace role.
Service credential role names have the format
`svc_{user_id}_{8 chars (4 bytes) of random hex}`. With the user ID consuming 32
characters, this balances name length with an ample space for possible names.
## Footnotes ## Footnotes

View file

@ -1,9 +1,13 @@
drop table if exists form_touch_points;
drop table if exists form_sessions;
drop table if exists field_form_prompts; drop table if exists field_form_prompts;
drop table if exists form_transitions; drop table if exists form_transitions;
drop table if exists service_creds;
drop table if exists fields; drop table if exists fields;
drop table if exists portals; drop table if exists portals;
drop table if exists rel_invitations; drop table if exists rel_invitations;
drop table if exists workspace_memberships; drop table if exists workspace_memberships;
drop table if exists workspaces; drop table if exists workspaces;
drop table if exists clusters;
drop table if exists browser_sessions; drop table if exists browser_sessions;
drop table if exists users; drop table if exists users;

View file

@ -18,17 +18,28 @@ create table if not exists browser_sessions (
create index on browser_sessions (expiry); create index on browser_sessions (expiry);
create index on browser_sessions (created_at); create index on browser_sessions (created_at);
-- Clusters --
create table if not exists clusters (
id uuid not null primary key default uuidv7(),
host text not null unique,
username text not null default 'phono',
password text not null
);
-- Workspaces -- -- Workspaces --
create table if not exists workspaces ( create table if not exists workspaces (
id uuid not null primary key default uuidv7(), id uuid not null primary key default uuidv7(),
name text not null default '', cluster_id uuid not null references clusters(id) on delete restrict,
url text not null, db_name text not null,
display_name text not null default '',
owner_id uuid not null references users(id) on delete restrict owner_id uuid not null references users(id) on delete restrict
); );
create index on workspaces(cluster_id);
create index on workspaces (owner_id); create index on workspaces (owner_id);
create table if not exists workpace_memberships ( create table if not exists workspace_memberships (
id uuid not null primary key default uuidv7(), id uuid not null primary key default uuidv7(),
workspace_id uuid not null references workspaces(id) on delete cascade, workspace_id uuid not null references workspaces(id) on delete cascade,
user_id uuid not null references users(id) on delete cascade, user_id uuid not null references users(id) on delete cascade,
@ -74,6 +85,19 @@ create table if not exists fields (
table_width_px int not null default 200 table_width_px int not null default 200
); );
-- Service Credentials --
create table if not exists service_creds (
id uuid not null primary key default uuidv7(),
cluster_id uuid not null references clusters(id) on delete restrict,
owner_id uuid not null references users(id) on delete cascade,
rolname text not null,
password text not null,
unique (cluster_id, rolname)
);
create index on service_creds (cluster_id);
create index on service_creds (owner_id);
-- Forms -- -- Forms --
create table if not exists form_transitions ( create table if not exists form_transitions (

View file

@ -0,0 +1,84 @@
use redact::Secret;
use sqlx::query_as;
use url::Url;
use uuid::Uuid;
use crate::{client::AppDbClient, macros::with_id_query};
/// Represents a Postgres cluster to be used as the backing database for zero
/// or more workspaces. At this time, rows in the `clusters` table are created
/// manually by an administrator rather than through the web application.
#[derive(Clone, Debug)]
pub struct Cluster {
/// Primary key (defaults to UUIDv7).
pub id: Uuid,
/// Host address, including port specifier if not ":5432".
pub host: String,
/// Username of the root Phonograph role for this cluster. Defaults to
/// "phono".
pub username: String,
// TODO: encrypt passwords
/// Password of the root Phonograph role for this cluster.
pub password: Secret<String>,
}
with_id_query!(Cluster, sql = "select * from clusters where id = $1");
impl Cluster {
/// Construct an authenticated postgresql:// connection URL for a specific
/// database in this cluster. URL is wrapped in a [`redact::Secret`] because
/// it contains a plaintext password.
pub fn conn_str_for_db(
&self,
db_name: &str,
auth_override: Option<(&str, Secret<&str>)>,
) -> Result<Secret<Url>, ConnStrError> {
let (username, password) = auth_override.unwrap_or((
self.username.as_str(),
Secret::new(self.password.expose_secret().as_str()),
));
let mut url = Url::parse(&format!("postgresql://{host}", host = self.host))?;
url.set_path(db_name);
url.set_username(username)
.map_err(|_| ConnStrError::Build {
context: "username",
})?;
url.set_password(Some(password.expose_secret()))
.map_err(|_| ConnStrError::Build {
context: "password",
})?;
Ok(Secret::new(url))
}
/// For use only in single-cluster setups. If exactly one row is present, it
/// is fetched and returned. Otherwise, returns an error.
pub async fn fetch_only(app_db: &mut AppDbClient) -> sqlx::Result<Self> {
let mut rows = query_as!(Self, "select * from clusters limit 2")
.fetch_all(app_db.get_conn())
.await?;
if rows.len() == 1 {
Ok(rows.pop().expect("just checked that vec len == 1"))
} else {
Err(sqlx::Error::RowNotFound)
}
}
}
#[derive(Clone, Copy, Debug, thiserror::Error)]
#[error("error building postgresql:// connection string")]
pub enum ConnStrError {
Parse(url::ParseError),
#[error("error building postgresql:// connection string: {context}")]
Build {
context: &'static str,
},
}
impl From<url::ParseError> for ConnStrError {
fn from(value: url::ParseError) -> Self {
Self::Parse(value)
}
}

View file

@ -1,4 +1,5 @@
pub mod client; pub mod client;
pub mod cluster;
pub mod datum; pub mod datum;
pub mod errors; pub mod errors;
pub mod expression; pub mod expression;
@ -6,9 +7,11 @@ pub mod field;
pub mod field_form_prompt; pub mod field_form_prompt;
pub mod form_transition; pub mod form_transition;
pub mod language; pub mod language;
mod macros;
pub mod portal; pub mod portal;
pub mod presentation; pub mod presentation;
pub mod rel_invitation; pub mod rel_invitation;
pub mod service_cred;
pub mod user; pub mod user;
pub mod workspace; pub mod workspace;
pub mod workspace_user_perm; pub mod workspace_user_perm;

View file

@ -0,0 +1,50 @@
/// Generates code for a `with_id()` method. The `sql` parameter should be a
/// Postgres query which accepts the UUID as its only argument.
///
/// ## Example
///
/// ```ignore
/// use uuid::Uuid;
///
/// struct Test {
/// id: Uuid;
/// }
///
/// with_id_query!(Test, sql = "select id from tests where id = $1");
/// ```
macro_rules! with_id_query {
($target:ty, sql = $sql:expr $(,)?) => {
impl $target {
/// Build a single-field query by ID.
pub fn with_id(id: uuid::Uuid) -> WithIdQuery {
WithIdQuery { id }
}
}
#[derive(Clone, Copy, Debug)]
pub struct WithIdQuery {
id: uuid::Uuid,
}
impl WithIdQuery {
pub async fn fetch_one(
self,
app_db: &mut crate::client::AppDbClient,
) -> sqlx::Result<$target> {
query_as!($target, $sql, self.id)
.fetch_one(app_db.get_conn())
.await
}
pub async fn fetch_optional(
self,
app_db: &mut crate::client::AppDbClient,
) -> sqlx::Result<Option<$target>> {
query_as!($target, $sql, self.id)
.fetch_optional(app_db.get_conn())
.await
}
}
};
}
pub(crate) use with_id_query;

View file

@ -7,7 +7,9 @@ use sqlx::{postgres::types::Oid, query, query_as, types::Json};
use uuid::Uuid; use uuid::Uuid;
use validator::Validate; use validator::Validate;
use crate::{client::AppDbClient, errors::QueryError, expression::PgExpressionAny}; use crate::{
client::AppDbClient, errors::QueryError, expression::PgExpressionAny, macros::with_id_query,
};
pub static RE_PORTAL_NAME: LazyLock<Regex> = pub static RE_PORTAL_NAME: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^[a-zA-Z0-9][()a-zA-Z0-9 _-]*[a-zA-Z0-9()_-]$").unwrap()); LazyLock::new(|| Regex::new(r"^[a-zA-Z0-9][()a-zA-Z0-9 _-]*[a-zA-Z0-9()_-]$").unwrap());
@ -48,30 +50,15 @@ impl Portal {
UpdateBuilder::default() UpdateBuilder::default()
} }
/// Build a single-field query by portal ID.
pub fn with_id(id: Uuid) -> WithIdQuery {
WithIdQuery { id }
}
/// Build a query by workspace ID and relation OID. /// Build a query by workspace ID and relation OID.
pub fn belonging_to_workspace(workspace_id: Uuid) -> BelongingToWorkspaceQuery { pub fn belonging_to_workspace(workspace_id: Uuid) -> BelongingToWorkspaceQuery {
BelongingToWorkspaceQuery { workspace_id } BelongingToWorkspaceQuery { workspace_id }
} }
} }
#[derive(Clone, Debug)] with_id_query!(
pub struct WithIdQuery { Portal,
id: Uuid, sql = r#"
}
impl WithIdQuery {
pub async fn fetch_optional(
self,
app_db: &mut AppDbClient,
) -> Result<Option<Portal>, sqlx::Error> {
query_as!(
Portal,
r#"
select select
id, id,
name, name,
@ -82,32 +69,7 @@ select
from portals from portals
where id = $1 where id = $1
"#, "#,
self.id );
)
.fetch_optional(&mut *app_db.conn)
.await
}
pub async fn fetch_one(self, app_db: &mut AppDbClient) -> Result<Portal, sqlx::Error> {
query_as!(
Portal,
r#"
select
id,
name,
workspace_id,
class_oid,
form_public,
table_filter as "table_filter: Json<Option<PgExpressionAny>>"
from portals
where id = $1
"#,
self.id
)
.fetch_one(&mut *app_db.conn)
.await
}
}
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct BelongingToWorkspaceQuery { pub struct BelongingToWorkspaceQuery {

View file

@ -0,0 +1,92 @@
use derive_builder::Builder;
use redact::Secret;
use sqlx::query_as;
use uuid::Uuid;
use crate::{client::AppDbClient, macros::with_id_query};
/// Information pertaining to a `LOGIN` role used to grant direct PostgreSQL
/// access to a backing database.
#[derive(Clone, Debug)]
pub struct ServiceCred {
/// Primary key (defaults to UUIDv7).
pub id: Uuid,
/// ID of the database cluster on which this role exists.
pub cluster_id: Uuid,
/// ID of the user for whom this role was created.
pub owner_id: Uuid,
/// Postgres role name.
pub rolname: String,
/// Postgres password.
pub password: Secret<String>,
}
with_id_query!(
ServiceCred,
sql = "select * from service_creds where id = $1"
);
impl ServiceCred {
/// Build an insert statement to save information about a new service
/// credential role.
pub fn insert() -> InsertBuilder {
Default::default()
}
/// Query by ID of the user who owns the credential(s).
pub fn belonging_to_user(owner_id: Uuid) -> BelongingToQuery {
BelongingToQuery { owner_id }
}
}
#[derive(Builder, Clone, Debug)]
pub struct Insert {
cluster_id: Uuid,
owner_id: Uuid,
rolname: String,
password: Secret<String>,
}
impl Insert {
pub async fn execute(self, app_db: &mut AppDbClient) -> sqlx::Result<ServiceCred> {
query_as!(
ServiceCred,
r#"
insert into service_creds (
cluster_id,
owner_id,
rolname,
password
) values ($1, $2, $3, $4)
returning *
"#,
self.cluster_id,
self.owner_id,
self.rolname,
self.password.expose_secret(),
)
.fetch_one(app_db.get_conn())
.await
}
}
#[derive(Clone, Copy, Debug)]
pub struct BelongingToQuery {
owner_id: Uuid,
}
impl BelongingToQuery {
pub async fn fetch_all(&self, app_db: &mut AppDbClient) -> sqlx::Result<Vec<ServiceCred>> {
query_as!(
ServiceCred,
"select * from service_creds where owner_id = $1",
self.owner_id
)
.fetch_all(app_db.get_conn())
.await
}
}

View file

@ -1,10 +1,8 @@
use derive_builder::Builder; use derive_builder::Builder;
use redact::Secret;
use sqlx::query_as; use sqlx::query_as;
use url::Url;
use uuid::Uuid; use uuid::Uuid;
use crate::client::AppDbClient; use crate::{client::AppDbClient, cluster::Cluster, macros::with_id_query};
/// A workspace is 1:1 with a Postgres "database". /// A workspace is 1:1 with a Postgres "database".
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@ -12,12 +10,14 @@ pub struct Workspace {
/// Primary key (defaults to UUIDv7). /// Primary key (defaults to UUIDv7).
pub id: Uuid, pub id: Uuid,
/// Human friendly name for the workspace. /// Cluster housing the backing database for this workspace.
pub name: String, pub cluster_id: Uuid,
/// `postgresql://` URL of the instance and database hosting this workspace. /// Postgres database name.
// TODO: Encrypt values in Postgres using `pgp_sym_encrypt()`. pub db_name: String,
pub url: Secret<String>,
/// Human friendly name for the workspace.
pub display_name: String,
/// ID of the user account that created this workspace. /// ID of the user account that created this workspace.
pub owner_id: Uuid, pub owner_id: Uuid,
@ -25,62 +25,36 @@ pub struct Workspace {
impl Workspace { impl Workspace {
/// Build an insert statement to create a new workspace. /// Build an insert statement to create a new workspace.
pub fn insert() -> InsertBuilder { pub fn insert<'a>() -> InsertBuilder<'a> {
InsertBuilder::default() InsertBuilder::default()
} }
/// Build a single-field query by workspace ID. pub async fn fetch_cluster(&self, app_db: &mut AppDbClient) -> sqlx::Result<Cluster> {
pub fn with_id(id: Uuid) -> WithIdQuery { Cluster::with_id(self.cluster_id).fetch_one(app_db).await
WithIdQuery { id }
} }
} }
pub struct WithIdQuery { with_id_query!(Workspace, sql = "select * from workspaces where id = $1");
id: Uuid,
}
impl WithIdQuery { #[derive(Builder, Clone, Copy, Debug)]
pub async fn fetch_optional( pub struct Insert<'a> {
self, cluster_id: Uuid,
app_db: &mut AppDbClient, db_name: &'a str,
) -> Result<Option<Workspace>, sqlx::Error> {
query_as!(
Workspace,
"select * from workspaces where id = $1",
&self.id
)
.fetch_optional(&mut *app_db.conn)
.await
}
pub async fn fetch_one(self, app_db: &mut AppDbClient) -> Result<Workspace, sqlx::Error> {
query_as!(
Workspace,
"select * from workspaces where id = $1",
&self.id
)
.fetch_one(&mut *app_db.conn)
.await
}
}
#[derive(Builder)]
pub struct Insert {
url: Url,
owner_id: Uuid, owner_id: Uuid,
} }
impl Insert { impl<'a> Insert<'a> {
pub async fn insert(self, app_db: &mut AppDbClient) -> Result<Workspace, sqlx::Error> { pub async fn insert(self, app_db: &mut AppDbClient) -> sqlx::Result<Workspace> {
query_as!( query_as!(
Workspace, Workspace,
" "
insert into workspaces insert into workspaces
(url, owner_id) (cluster_id, db_name, owner_id)
values ($1, $2) values ($1, $2, $3)
returning * returning *
", ",
self.url.to_string(), self.cluster_id,
self.db_name,
self.owner_id self.owner_id
) )
.fetch_one(&mut *app_db.conn) .fetch_one(&mut *app_db.conn)

View file

@ -16,7 +16,7 @@ pub struct WorkspaceMembership {
pub workspace_id: Uuid, pub workspace_id: Uuid,
/// **Synthesized field** generated by joining to the `workspaces` table. /// **Synthesized field** generated by joining to the `workspaces` table.
pub workspace_name: String, pub workspace_display_name: String,
/// User to which the permission belongs. /// User to which the permission belongs.
pub user_id: Uuid, pub user_id: Uuid,
@ -52,7 +52,7 @@ select
p.id as id, p.id as id,
p.workspace_id as workspace_id, p.workspace_id as workspace_id,
p.user_id as user_id, p.user_id as user_id,
w.name as workspace_name w.display_name as workspace_display_name
from workspace_memberships as p from workspace_memberships as p
inner join workspaces as w inner join workspaces as w
on w.id = p.workspace_id on w.id = p.workspace_id
@ -90,10 +90,9 @@ select
p.id as id, p.id as id,
p.workspace_id as workspace_id, p.workspace_id as workspace_id,
p.user_id as user_id, p.user_id as user_id,
w.name as workspace_name w.display_name as workspace_display_name
from workspace_memberships as p from p inner join workspaces as w
inner join workspaces as w on w.id = p.workspace_id
on w.id = p.workspace_id
"#, "#,
self.workspace_id, self.workspace_id,
self.user_id, self.user_id,

View file

@ -1,4 +1,6 @@
use sqlx::{postgres::types::Oid, query_as}; use std::fmt::Display;
use sqlx::{Encode, Postgres, postgres::types::Oid, query_as, query_as_unchecked};
use crate::{ use crate::{
client::WorkspaceClient, escape_identifier, pg_acl::PgAclItem, pg_namespace::PgNamespace, client::WorkspaceClient, escape_identifier, pg_acl::PgAclItem, pg_namespace::PgNamespace,
@ -65,6 +67,7 @@ impl PgClass {
escape_identifier(&self.relname) escape_identifier(&self.relname)
) )
} }
pub fn with_oid(oid: Oid) -> WithOidQuery { pub fn with_oid(oid: Oid) -> WithOidQuery {
WithOidQuery { oid } WithOidQuery { oid }
} }
@ -74,6 +77,62 @@ impl PgClass {
kinds: kinds.into_iter().collect(), kinds: kinds.into_iter().collect(),
} }
} }
pub fn belonging_to_namespace<
T: Clone + Copy + Display + sqlx::Type<Postgres> + for<'a> Encode<'a, Postgres>,
>(
namespace: T,
) -> BelongingToNamespaceQuery<T> {
BelongingToNamespaceQuery { namespace }
}
}
#[derive(Clone, Copy, Debug)]
pub struct BelongingToNamespaceQuery<
T: Clone + Copy + Display + sqlx::Type<Postgres> + for<'a> Encode<'a, Postgres>,
> {
namespace: T,
}
impl<T: Clone + Copy + Display + sqlx::Type<Postgres> + for<'a> Encode<'a, Postgres>>
BelongingToNamespaceQuery<T>
{
pub async fn fetch_all(self, client: &mut WorkspaceClient) -> sqlx::Result<Vec<PgClass>> {
// `query_as!()` rightly complains that there may not be a built-in type
// mapping for `T` to `regnamespace`.
// TODO: Figure out whether it's possible to add a trait bound that
// ensures the mapping exists.
query_as_unchecked!(
PgClass,
r#"
select
oid,
relname,
relnamespace,
relnamespace::regnamespace::text as "regnamespace!",
reltype,
reloftype,
relowner,
relowner::regrole::text as "regowner!",
relkind,
relnatts,
relchecks,
relhasrules,
relhastriggers,
relhassubclass,
relrowsecurity,
relforcerowsecurity,
relispopulated,
relispartition,
relacl::text[] as "relacl: Vec<PgAclItem>"
from pg_class
where
relnamespace = $1::regnamespace::oid
"#,
self.namespace,
)
.fetch_all(client.get_conn())
.await
}
} }
pub struct WithOidQuery { pub struct WithOidQuery {

View file

@ -35,6 +35,10 @@ impl PgRole {
pub fn with_name_in(names: Vec<String>) -> WithNameInQuery { pub fn with_name_in(names: Vec<String>) -> WithNameInQuery {
WithNameInQuery { names } WithNameInQuery { names }
} }
pub fn with_name_starting_with(prefix: String) -> WithNameStartingWithQuery {
WithNameStartingWithQuery { prefix }
}
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@ -72,6 +76,41 @@ where rolname = any($1)
} }
} }
#[derive(Clone, Debug)]
pub struct WithNameStartingWithQuery {
prefix: String,
}
impl WithNameStartingWithQuery {
pub async fn fetch_all(
&self,
client: &mut WorkspaceClient,
) -> Result<Vec<PgRole>, sqlx::Error> {
query_as!(
PgRole,
r#"
select
oid as "oid!",
rolname as "rolname!",
rolsuper as "rolsuper!",
rolinherit as "rolinherit!",
rolcreaterole as "rolcreaterole!",
rolcreatedb as "rolcreatedb!",
rolcanlogin as "rolcanlogin!",
rolreplication as "rolreplication!",
rolconnlimit as "rolconnlimit!",
rolvaliduntil,
rolbypassrls as "rolbypassrls!"
from pg_roles
where starts_with(rolname, $1)
"#,
self.prefix
)
.fetch_all(&mut *client.conn)
.await
}
}
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct RoleTree { pub struct RoleTree {
pub role: PgRole, pub role: PgRole,
@ -102,11 +141,15 @@ impl RoleTree {
GrantedToQuery { role_oid } GrantedToQuery { role_oid }
} }
pub fn flatten_inherited(&self) -> Vec<&PgRole> { pub fn granted_to_rolname<'a>(rolname: &'a str) -> GrantedToRolnameQuery<'a> {
GrantedToRolnameQuery { rolname }
}
pub fn flatten_inherited(self) -> Vec<PgRole> {
[ [
vec![&self.role], vec![self.role],
self.branches self.branches
.iter() .into_iter()
.filter(|member| member.inherit) .filter(|member| member.inherit)
.map(|member| member.flatten_inherited()) .map(|member| member.flatten_inherited())
.collect::<Vec<_>>() .collect::<Vec<_>>()
@ -243,6 +286,48 @@ from (
} }
} }
#[derive(Clone, Copy, Debug)]
pub struct GrantedToRolnameQuery<'a> {
rolname: &'a str,
}
impl<'a> GrantedToRolnameQuery<'a> {
pub async fn fetch_tree(
self,
client: &mut WorkspaceClient,
) -> Result<Option<RoleTree>, sqlx::Error> {
let rows: Vec<RoleTreeRow> = query_as(
"
with recursive cte as (
select $1::regrole::oid as roleid, null::oid as branch, true as inherit
union all
select m.roleid, m.member as branch, c.inherit and m.inherit_option
from cte as c
join pg_auth_members m on m.member = 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.rolname)
.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,
}))
}
}
fn compute_members(rows: &Vec<RoleTreeRow>, root: Oid) -> Vec<RoleTree> { fn compute_members(rows: &Vec<RoleTreeRow>, root: Oid) -> Vec<RoleTree> {
rows.iter() rows.iter()
.filter(|row| row.branch == Some(root)) .filter(|row| row.branch == Some(root))

View file

@ -23,6 +23,7 @@ markdown = "1.0.0"
oauth2 = "4.4.2" oauth2 = "4.4.2"
percent-encoding = "2.3.1" percent-encoding = "2.3.1"
rand = { workspace = true } rand = { workspace = true }
redact = { workspace = true }
regex = { workspace = true } regex = { workspace = true }
reqwest = { workspace = true } reqwest = { workspace = true }
scraper = "0.24.0" scraper = "0.24.0"

View file

@ -36,7 +36,6 @@ impl App {
let oauth_client = auth::new_oauth_client(&settings)?; let oauth_client = auth::new_oauth_client(&settings)?;
let workspace_pooler = WorkspacePooler::builder() let workspace_pooler = WorkspacePooler::builder()
.app_db_pool(app_db.clone()) .app_db_pool(app_db.clone())
.db_role_prefix(settings.db_role_prefix.clone())
.build()?; .build()?;
Ok(Self { Ok(Self {

View file

@ -20,6 +20,7 @@ mod middleware;
mod navigator; mod navigator;
mod presentation_form; mod presentation_form;
mod renderable_role_tree; mod renderable_role_tree;
mod roles;
mod routes; mod routes;
mod sessions; mod sessions;
mod settings; mod settings;

View file

@ -26,50 +26,41 @@ pub(crate) trait NavigatorPage {
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub(crate) struct Navigator { pub(crate) struct Navigator {
root_path: String, root_path: String,
sub_path: String,
} }
impl Navigator { impl Navigator {
pub(crate) fn workspace_page(&self, workspace_id: Uuid) -> Self { pub(crate) fn workspace_page(&self) -> WorkspacePageBuilder {
Self { WorkspacePageBuilder {
sub_path: format!("/w/{0}/", workspace_id.simple()), root_path: Some(&self.root_path),
..self.clone() ..Default::default()
} }
} }
pub(crate) fn portal_page(&self) -> PortalPageBuilder { pub(crate) fn portal_page(&self) -> PortalPageBuilder {
PortalPageBuilder { PortalPageBuilder {
root_path: Some(self.get_root_path()), root_path: Some(&self.root_path),
..Default::default() ..Default::default()
} }
} }
pub(crate) fn form_page(&self, portal_id: Uuid) -> FormPageBuilder { pub(crate) fn form_page(&self, portal_id: Uuid) -> FormPageBuilder {
FormPageBuilder { FormPageBuilder {
root_path: Some(self.get_root_path()), root_path: Some(&self.root_path),
portal_id: Some(portal_id), portal_id: Some(portal_id),
} }
} }
/// Returns a [`NavigatorPage`] builder for navigating to a relation's /// Returns a [`NavigatorPage`] builder for navigating to a relation's
/// "settings" page. /// "settings" page.
pub(crate) fn rel_settings_page(&self) -> RelSettingsPageBuilder { pub(crate) fn rel_page(&self) -> RelPageBuilder {
RelSettingsPageBuilder { RelPageBuilder {
root_path: Some(self.get_root_path()), root_path: Some(&self.root_path),
..Default::default() ..Default::default()
} }
} }
pub(crate) fn get_root_path(&self) -> String { pub(crate) fn get_root_path(&self) -> String {
self.root_path.to_owned() self.root_path.clone()
}
pub(crate) fn abs_path(&self) -> String {
format!("{0}{1}", self.root_path, self.sub_path)
}
pub(crate) fn redirect_to(&self) -> Response {
Redirect::to(&self.abs_path()).into_response()
} }
} }
@ -79,30 +70,51 @@ impl FromRequestParts<App> for Navigator {
async fn from_request_parts(_: &mut Parts, state: &App) -> Result<Self, Self::Rejection> { async fn from_request_parts(_: &mut Parts, state: &App) -> Result<Self, Self::Rejection> {
Ok(Navigator { Ok(Navigator {
root_path: state.settings.root_path.clone(), root_path: state.settings.root_path.clone(),
sub_path: "/".to_owned(),
}) })
} }
} }
#[derive(Builder, Clone, Debug)] #[derive(Builder, Clone, Debug)]
pub(crate) struct PortalPage { pub(crate) struct WorkspacePage<'a> {
#[builder(setter(custom))]
root_path: &'a str,
#[builder(default, setter(strip_option))]
suffix: Option<&'a str>,
workspace_id: Uuid,
}
impl<'a> NavigatorPage for WorkspacePage<'a> {
fn get_path(&self) -> String {
format!(
"{root_path}/w/{workspace_id}/{suffix}",
root_path = self.root_path,
workspace_id = self.workspace_id,
suffix = self.suffix.unwrap_or_default(),
)
}
}
#[derive(Builder, Clone, Debug)]
pub(crate) struct PortalPage<'a> {
portal_id: Uuid, portal_id: Uuid,
rel_oid: Oid, rel_oid: Oid,
#[builder(setter(custom))] #[builder(setter(custom))]
root_path: String, root_path: &'a str,
/// Any value provided for `suffix` will be appended (without %-encoding) to /// Any value provided for `suffix` will be appended (without %-encoding) to
/// the final path value. This may be used for sub-paths and/or search /// the final path value. This may be used for sub-paths and/or search
/// parameters. /// parameters.
#[builder(default, setter(strip_option))] #[builder(default, setter(strip_option))]
suffix: Option<String>, suffix: Option<&'a str>,
workspace_id: Uuid, workspace_id: Uuid,
} }
impl NavigatorPage for PortalPage { impl<'a> NavigatorPage for PortalPage<'a> {
fn get_path(&self) -> String { fn get_path(&self) -> String {
format!( format!(
"{root_path}/w/{workspace_id}/r/{rel_oid}/p/{portal_id}/{suffix}", "{root_path}/w/{workspace_id}/r/{rel_oid}/p/{portal_id}/{suffix}",
@ -110,49 +122,49 @@ impl NavigatorPage for PortalPage {
workspace_id = self.workspace_id.simple(), workspace_id = self.workspace_id.simple(),
rel_oid = self.rel_oid.0, rel_oid = self.rel_oid.0,
portal_id = self.portal_id.simple(), portal_id = self.portal_id.simple(),
suffix = self.suffix.clone().unwrap_or_default() suffix = self.suffix.unwrap_or_default(),
) )
} }
} }
#[derive(Builder, Clone, Debug)] #[derive(Builder, Clone, Debug)]
pub(crate) struct FormPage { pub(crate) struct FormPage<'a> {
portal_id: Uuid, portal_id: Uuid,
#[builder(setter(custom))] #[builder(setter(custom))]
root_path: String, root_path: &'a str,
} }
impl NavigatorPage for FormPage { impl<'a> NavigatorPage for FormPage<'a> {
fn get_path(&self) -> String { fn get_path(&self) -> String {
format!( format!(
"{root_path}/f/{portal_id}", "{root_path}/f/{portal_id}",
root_path = self.root_path, root_path = self.root_path,
portal_id = self.portal_id portal_id = self.portal_id,
) )
} }
} }
#[derive(Builder, Clone, Debug)] #[derive(Builder, Clone, Debug)]
pub(crate) struct RelSettingsPage { pub(crate) struct RelPage<'a> {
rel_oid: Oid, rel_oid: Oid,
#[builder(setter(custom))] #[builder(setter(custom))]
root_path: String, root_path: &'a str,
/// Any value provided for `suffix` will be appended (without %-encoding) to /// Any value provided for `suffix` will be appended (without %-encoding) to
/// the final path value. This may be used for sub-paths and/or search /// the final path value. This may be used for sub-paths and/or search
/// parameters. /// parameters.
#[builder(default, setter(strip_option))] #[builder(default, setter(strip_option))]
suffix: Option<String>, suffix: Option<&'a str>,
workspace_id: Uuid, workspace_id: Uuid,
} }
impl NavigatorPage for RelSettingsPage { impl<'a> NavigatorPage for RelPage<'a> {
fn get_path(&self) -> String { fn get_path(&self) -> String {
format!( format!(
"{root_path}/w/{workspace_id}/r/{rel_oid}/settings/{suffix}", "{root_path}/w/{workspace_id}/r/{rel_oid}/{suffix}",
root_path = self.root_path, root_path = self.root_path,
workspace_id = self.workspace_id.simple(), workspace_id = self.workspace_id.simple(),
rel_oid = self.rel_oid.0, rel_oid = self.rel_oid.0,

156
interim-server/src/roles.rs Normal file
View file

@ -0,0 +1,156 @@
use std::collections::HashSet;
use anyhow::anyhow;
use askama::Template;
use interim_pgtypes::{
client::WorkspaceClient,
pg_acl::{PgAclItem, PgPrivilegeType},
pg_class::PgClass,
};
use serde::{Deserialize, Serialize};
use sqlx::{postgres::types::Oid, prelude::FromRow, query_as};
use crate::errors::AppError;
pub(crate) const ROLE_PREFIX_USER: &str = "usr_";
pub(crate) const ROLE_PREFIX_SERVICE_CRED: &str = "svc_";
pub(crate) const ROLE_PREFIX_TABLE_OWNER: &str = "tbo_";
pub(crate) const ROLE_PREFIX_TABLE_READER: &str = "tbr_";
pub(crate) const ROLE_PREFIX_TABLE_WRITER: &str = "tbw_";
pub(crate) const SERVICE_CRED_SUFFIX_LEN: usize = 8;
pub(crate) const SERVICE_CRED_CONN_LIMIT: usize = 4;
// TODO: custom error type
// TODO: make params and result references
fn get_table_role(
relacl: Option<Vec<PgAclItem>>,
required_privileges: HashSet<PgPrivilegeType>,
disallowed_privileges: HashSet<PgPrivilegeType>,
role_prefix: &str,
) -> Result<String, AppError> {
let mut roles: Vec<String> = vec![];
for acl_item in relacl.ok_or(anyhow!("acl not present on class"))? {
if acl_item.grantee.starts_with(role_prefix) {
let privileges_set: HashSet<PgPrivilegeType> = acl_item
.privileges
.iter()
.map(|privilege| privilege.privilege)
.collect();
assert!(
privileges_set.intersection(&required_privileges).count()
== required_privileges.len()
);
assert!(privileges_set.intersection(&disallowed_privileges).count() == 0);
roles.push(acl_item.grantee)
}
}
assert!(roles.len() == 1);
Ok(roles
.first()
.expect("already asserted that `roles` has len 1")
.clone())
}
/// Returns the name of the "table_reader" role created by Phonograph for a
/// particular workspace table. The role is assessed based on its name and the
/// table permissions directly granted to it. Returns an error if no matching
/// role is found, and panics if a role is found with excess permissions
/// granted to it directly.
pub(crate) fn get_reader_role(rel: PgClass) -> Result<String, AppError> {
get_table_role(
rel.relacl,
[PgPrivilegeType::Select].into(),
[
PgPrivilegeType::Insert,
PgPrivilegeType::Update,
PgPrivilegeType::Delete,
PgPrivilegeType::Truncate,
]
.into(),
ROLE_PREFIX_TABLE_READER,
)
}
/// Returns the name of the "table_writer" role created by Phonograph for a
/// particular workspace table. The role is assessed based on its name and the
/// table permissions directly granted to it. Returns an error if no matching
/// role is found, and panics if a role is found with excess permissions
/// granted to it directly.
pub(crate) fn get_writer_role(rel: PgClass) -> Result<String, AppError> {
get_table_role(
rel.relacl,
[PgPrivilegeType::Delete, PgPrivilegeType::Truncate].into(),
[PgPrivilegeType::Select].into(),
ROLE_PREFIX_TABLE_WRITER,
)
}
#[derive(Clone, Debug, Deserialize, Template, Serialize)]
#[template(path = "role_display.html")]
pub(crate) enum RoleDisplay {
TableOwner { oid: Oid, relname: String },
TableReader { oid: Oid, relname: String },
TableWriter { oid: Oid, relname: String },
Unknown { rolname: String },
}
impl RoleDisplay {
/// Attempt to infer value from a role name in a specific workspace. If the
/// role corresponds specifically to a relation and that relation is not
/// present in the current workspace, the returned value is `None`.
pub async fn from_rolname(
rolname: &str,
client: &mut WorkspaceClient,
) -> sqlx::Result<Option<RoleDisplay>> {
if rolname.starts_with(ROLE_PREFIX_TABLE_OWNER)
|| rolname.starts_with(ROLE_PREFIX_TABLE_READER)
|| rolname.starts_with(ROLE_PREFIX_TABLE_WRITER)
{
#[derive(FromRow)]
struct RelInfo {
oid: Oid,
relname: String,
}
// TODO: Consider moving this to [`interim-pgtypes`].
let mut rels: Vec<RelInfo> = query_as(
r#"
select oid, any_value(relname) as relname
from (
select
oid,
relname,
(aclexplode(relacl)).grantee as grantee
from pg_class
)
where grantee = $1::regrole::oid
"#,
)
.bind(rolname)
.fetch_all(client.get_conn())
.await?;
assert!(rels.len() <= 1);
Ok(rels.pop().map(|rel| {
if rolname.starts_with(ROLE_PREFIX_TABLE_OWNER) {
RoleDisplay::TableOwner {
oid: rel.oid,
relname: rel.relname,
}
} else if rolname.starts_with(ROLE_PREFIX_TABLE_READER) {
RoleDisplay::TableReader {
oid: rel.oid,
relname: rel.relname,
}
} else {
RoleDisplay::TableWriter {
oid: rel.oid,
relname: rel.relname,
}
}
}))
} else {
Ok(Some(RoleDisplay::Unknown {
rolname: rolname.to_owned(),
}))
}
}
}

View file

@ -19,9 +19,9 @@ use crate::{
errors::AppError, errors::AppError,
navigator::{Navigator, NavigatorPage}, navigator::{Navigator, NavigatorPage},
presentation_form::PresentationForm, presentation_form::PresentationForm,
roles::get_writer_role,
user::CurrentUser, user::CurrentUser,
workspace_pooler::{RoleAssignment, WorkspacePooler}, workspace_pooler::{RoleAssignment, WorkspacePooler},
workspace_utils::{get_reader_role, get_writer_role},
}; };
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]

View file

@ -4,7 +4,12 @@ use serde::Deserialize;
use sqlx::postgres::types::Oid; use sqlx::postgres::types::Oid;
use uuid::Uuid; use uuid::Uuid;
use crate::{app::AppDbConn, errors::AppError, navigator::Navigator, user::CurrentUser}; use crate::{
app::AppDbConn,
errors::AppError,
navigator::{Navigator, NavigatorPage as _},
user::CurrentUser,
};
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub(super) struct PathParams { pub(super) struct PathParams {
@ -36,5 +41,9 @@ pub(super) async fn post(
.execute(&mut app_db) .execute(&mut app_db)
.await?; .await?;
Ok(navigator.workspace_page(workspace_id).redirect_to()) Ok(navigator
.workspace_page()
.workspace_id(workspace_id)
.build()?
.redirect_to())
} }

View file

@ -63,9 +63,10 @@ pub(super) async fn post(
.await?; .await?;
} }
Ok(navigator Ok(navigator
.rel_settings_page() .rel_page()
.workspace_id(workspace_id) .workspace_id(workspace_id)
.rel_oid(Oid(rel_oid)) .rel_oid(Oid(rel_oid))
.suffix("settings/")
.build()? .build()?
.redirect_to()) .redirect_to())
} }

View file

@ -39,7 +39,7 @@ pub(super) struct FormBody {
#[debug_handler(state = App)] #[debug_handler(state = App)]
pub(super) async fn post( pub(super) async fn post(
AppDbConn(mut app_db): AppDbConn, AppDbConn(mut app_db): AppDbConn,
CurrentUser(user): CurrentUser, CurrentUser(_user): CurrentUser,
navigator: Navigator, navigator: Navigator,
Path(PathParams { Path(PathParams {
portal_id, portal_id,
@ -74,7 +74,7 @@ pub(super) async fn post(
.workspace_id(workspace_id) .workspace_id(workspace_id)
.rel_oid(Oid(rel_oid)) .rel_oid(Oid(rel_oid))
.portal_id(portal_id) .portal_id(portal_id)
.suffix("form/".to_owned()) .suffix("form/")
.build()? .build()?
.redirect_to()) .redirect_to())
} }

View file

@ -74,7 +74,7 @@ pub(super) async fn post(
.workspace_id(workspace_id) .workspace_id(workspace_id)
.rel_oid(Oid(rel_oid)) .rel_oid(Oid(rel_oid))
.portal_id(portal_id) .portal_id(portal_id)
.suffix("settings/".to_owned()) .suffix("settings/")
.build()? .build()?
.redirect_to()) .redirect_to())
} }

View file

@ -41,7 +41,6 @@ pub(super) struct FormBody {
/// alphanumeric characters and underscores. /// alphanumeric characters and underscores.
#[debug_handler(state = App)] #[debug_handler(state = App)]
pub(super) async fn post( pub(super) async fn post(
AppDbConn(mut app_db): AppDbConn,
State(mut pooler): State<WorkspacePooler>, State(mut pooler): State<WorkspacePooler>,
CurrentUser(user): CurrentUser, CurrentUser(user): CurrentUser,
navigator: Navigator, navigator: Navigator,
@ -78,9 +77,10 @@ pub(super) async fn post(
.await?; .await?;
Ok(navigator Ok(navigator
.rel_settings_page() .rel_page()
.workspace_id(workspace_id) .workspace_id(workspace_id)
.rel_oid(Oid(rel_oid)) .rel_oid(Oid(rel_oid))
.suffix("settings/")
.build()? .build()?
.redirect_to()) .redirect_to())
} }

View file

@ -1,6 +1,7 @@
use axum::{extract::State, response::IntoResponse}; use axum::{extract::State, response::IntoResponse};
use interim_models::{ use interim_models::{
client::AppDbClient, user::User, workspace::Workspace, workspace_user_perm::WorkspaceMembership, client::AppDbClient, cluster::Cluster, user::User, workspace::Workspace,
workspace_user_perm::WorkspaceMembership,
}; };
use interim_pgtypes::{client::WorkspaceClient, escape_identifier}; use interim_pgtypes::{client::WorkspaceClient, escape_identifier};
use sqlx::{Connection as _, PgConnection, query}; use sqlx::{Connection as _, PgConnection, query};
@ -8,10 +9,12 @@ use sqlx::{Connection as _, PgConnection, query};
use crate::{ use crate::{
app::AppDbConn, app::AppDbConn,
errors::AppError, errors::AppError,
navigator::Navigator, navigator::{Navigator, NavigatorPage as _},
roles::ROLE_PREFIX_USER,
settings::Settings, settings::Settings,
user::CurrentUser, user::CurrentUser,
workspace_pooler::{RoleAssignment, WorkspacePooler}, workspace_pooler::{RoleAssignment, WorkspacePooler},
workspace_utils::PHONO_TABLE_NAMESPACE,
}; };
/// HTTP POST handler for creating a new workspace. This handler does not expect /// HTTP POST handler for creating a new workspace. This handler does not expect
@ -27,6 +30,8 @@ pub(super) async fn post(
) -> Result<impl IntoResponse, AppError> { ) -> Result<impl IntoResponse, AppError> {
// FIXME: csrf // FIXME: csrf
let cluster = Cluster::fetch_only(&mut app_db).await?;
const NAME_LEN_WORDS: usize = 3; const NAME_LEN_WORDS: usize = 3;
// WARNING: `db_name` is injected directly into the `create database` SQL // WARNING: `db_name` is injected directly into the `create database` SQL
// command. It **must not** contain spaces or any other unsafe characters. // command. It **must not** contain spaces or any other unsafe characters.
@ -37,8 +42,13 @@ pub(super) async fn post(
{ {
// No need to pool these connections, since we don't expect to be using them // No need to pool these connections, since we don't expect to be using them
// often. One less thing to keep track of in application state. // often. One less thing to keep track of in application state.
let mut workspace_creator_conn = let mut workspace_creator_conn = PgConnection::connect(
PgConnection::connect(settings.new_workspace_db_url.as_str()).await?; cluster
.conn_str_for_db("postgres", None)?
.expose_secret()
.as_str(),
)
.await?;
query(&format!( query(&format!(
// `db_name` is an underscore-separated sequence of alphabetical words, // `db_name` is an underscore-separated sequence of alphabetical words,
// which should be safe to inject directly into the SQL statement. // which should be safe to inject directly into the SQL statement.
@ -55,13 +65,10 @@ pub(super) async fn post(
workspace_creator_conn.close().await?; workspace_creator_conn.close().await?;
} }
let mut workspace_url = settings.new_workspace_db_url.clone();
// Alter database name but preserve auth and any query parameters.
workspace_url.set_path(&db_name);
let workspace = Workspace::insert() let workspace = Workspace::insert()
.owner_id(user.id) .owner_id(user.id)
.url(workspace_url) .cluster_id(cluster.id)
.db_name(&db_name)
.build()? .build()?
.insert(&mut app_db) .insert(&mut app_db)
.await?; .await?;
@ -77,13 +84,12 @@ pub(super) async fn post(
.await?; .await?;
query(&format!( query(&format!(
"create schema {nsp}", "create schema {nsp}",
nsp = escape_identifier(&settings.phono_table_namespace) nsp = escape_identifier(PHONO_TABLE_NAMESPACE)
)) ))
.execute(workspace_root_conn.get_conn()) .execute(workspace_root_conn.get_conn())
.await?; .await?;
grant_workspace_membership( grant_workspace_membership(
&db_name, &db_name,
settings.clone(),
&mut app_db, &mut app_db,
&mut workspace_root_conn, &mut workspace_root_conn,
&user, &user,
@ -91,24 +97,23 @@ pub(super) async fn post(
) )
.await?; .await?;
Ok(navigator.workspace_page(workspace.id).redirect_to()) Ok(navigator
.workspace_page()
.workspace_id(workspace.id)
.build()?
.redirect_to())
} }
async fn grant_workspace_membership( async fn grant_workspace_membership(
db_name: &str, db_name: &str,
settings: Settings,
app_db_client: &mut AppDbClient, app_db_client: &mut AppDbClient,
workspace_root_client: &mut WorkspaceClient, workspace_root_client: &mut WorkspaceClient,
user: &User, user: &User,
workspace: &Workspace, workspace: &Workspace,
) -> Result<(), AppError> { ) -> Result<(), AppError> {
let rolname = format!( let rolname = format!("{ROLE_PREFIX_USER}{uid}", uid = user.id.simple());
"{prefix}{user_id}",
prefix = settings.db_role_prefix,
user_id = user.id.simple()
);
query(&format!( query(&format!(
"grant connect on database {db_name} to {db_user}", "grant connect on database {db_name} to {db_user} with grant option",
db_user = escape_identifier(&rolname), db_user = escape_identifier(&rolname),
)) ))
.execute(workspace_root_client.get_conn()) .execute(workspace_root_client.get_conn())
@ -117,8 +122,8 @@ async fn grant_workspace_membership(
// TODO: There may be cases in which we will want to grant granular // TODO: There may be cases in which we will want to grant granular
// workspace access which excludes privileges to create tables. // workspace access which excludes privileges to create tables.
query(&format!( query(&format!(
"grant usage, create on schema {nsp} to {rolname}", "grant usage, create on schema {nsp} to {rolname} with grant option",
nsp = escape_identifier(&settings.phono_table_namespace), nsp = escape_identifier(PHONO_TABLE_NAMESPACE),
rolname = escape_identifier(&rolname) rolname = escape_identifier(&rolname)
)) ))
.execute(workspace_root_client.get_conn()) .execute(workspace_root_client.get_conn())

View file

@ -6,7 +6,11 @@ use axum::{
use interim_models::workspace_user_perm::WorkspaceMembership; use interim_models::workspace_user_perm::WorkspaceMembership;
use crate::{ use crate::{
app::AppDbConn, errors::AppError, navigator::Navigator, settings::Settings, user::CurrentUser, app::AppDbConn,
errors::AppError,
navigator::{Navigator, NavigatorPage as _},
settings::Settings,
user::CurrentUser,
}; };
pub(super) async fn get( pub(super) async fn get(

View file

@ -0,0 +1,100 @@
use axum::{
debug_handler,
extract::{Path, State},
response::IntoResponse,
};
use interim_models::{service_cred::ServiceCred, workspace::Workspace};
use interim_pgtypes::escape_identifier;
use rand::distributions::{Alphanumeric, DistString};
use redact::Secret;
use serde::Deserialize;
use sqlx::query;
use uuid::Uuid;
use crate::{
app::{App, AppDbConn},
errors::AppError,
navigator::{Navigator, NavigatorPage},
roles::{ROLE_PREFIX_SERVICE_CRED, SERVICE_CRED_CONN_LIMIT, SERVICE_CRED_SUFFIX_LEN},
user::CurrentUser,
workspace_pooler::{RoleAssignment, WorkspacePooler},
workspace_utils::PHONO_TABLE_NAMESPACE,
};
#[derive(Debug, Deserialize)]
pub(super) struct PathParams {
workspace_id: Uuid,
}
/// HTTP POST handler for generating a new "service credential" role for a user
/// and workspace.
#[debug_handler(state = App)]
pub(super) async fn post(
State(mut pooler): State<WorkspacePooler>,
AppDbConn(mut app_db): AppDbConn,
CurrentUser(user): CurrentUser,
navigator: Navigator,
Path(PathParams { workspace_id }): Path<PathParams>,
) -> Result<impl IntoResponse, AppError> {
// FIXME: auth and csrf
let workspace = Workspace::with_id(workspace_id)
.fetch_one(&mut app_db)
.await?;
let mut workspace_client = pooler
.acquire_for(workspace_id, RoleAssignment::User(user.id))
.await?;
let rolname = format!(
"{ROLE_PREFIX_SERVICE_CRED}{uid}_{suffix}",
uid = user.id.simple(),
suffix = Alphanumeric
.sample_string(&mut rand::thread_rng(), SERVICE_CRED_SUFFIX_LEN)
// FIXME: exclude uppercase letters from the original distribution.
// This is a quick, dirty, and arguably incorrect hack for expediency.
.to_lowercase(),
);
let password = Secret::new(format!("phpwd{0}", Uuid::new_v4().simple()));
assert!(!password.expose_secret().contains('\'') && !password.expose_secret().contains('\\'));
query(&format!(
"create user {rolname_esc} password '{password_dangerous}' connection limit {SERVICE_CRED_CONN_LIMIT}",
password_dangerous = password.expose_secret(),
rolname_esc = escape_identifier(&rolname)
))
.execute(workspace_client.get_conn())
.await?;
query(&format!(
"grant connect on database {db_name} to {rolname_esc}",
db_name = workspace.db_name,
rolname_esc = escape_identifier(&rolname)
))
.execute(workspace_client.get_conn())
.await?;
query(&format!(
"grant usage on schema {nsp} to {rolname_esc}",
nsp = PHONO_TABLE_NAMESPACE,
rolname_esc = escape_identifier(&rolname)
))
.execute(workspace_client.get_conn())
.await?;
ServiceCred::insert()
.cluster_id(workspace.cluster_id)
.owner_id(user.id)
.rolname(rolname)
.password(password)
.build()?
.execute(&mut app_db)
.await?;
Ok(navigator
.workspace_page()
.workspace_id(workspace_id)
.suffix("service-credentials/")
.build()?
.redirect_to())
}

View file

@ -8,15 +8,15 @@ use sqlx::query;
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
app::AppDbConn,
errors::AppError, errors::AppError,
navigator::Navigator, navigator::{Navigator, NavigatorPage as _},
settings::Settings, roles::{
ROLE_PREFIX_TABLE_OWNER, ROLE_PREFIX_TABLE_READER, ROLE_PREFIX_TABLE_WRITER,
ROLE_PREFIX_USER,
},
user::CurrentUser, user::CurrentUser,
workspace_pooler::{RoleAssignment, WorkspacePooler}, workspace_pooler::{RoleAssignment, WorkspacePooler},
workspace_utils::{ workspace_utils::PHONO_TABLE_NAMESPACE,
TABLE_OWNER_ROLE_PREFIX, TABLE_READER_ROLE_PREFIX, TABLE_WRITER_ROLE_PREFIX,
},
}; };
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@ -32,11 +32,9 @@ pub(super) struct PathParams {
/// This handler expects 1 path parameter named `workspace_id` which should /// This handler expects 1 path parameter named `workspace_id` which should
/// deserialize to a UUID. /// deserialize to a UUID.
pub(super) async fn post( pub(super) async fn post(
State(settings): State<Settings>,
State(mut pooler): State<WorkspacePooler>, State(mut pooler): State<WorkspacePooler>,
CurrentUser(user): CurrentUser, CurrentUser(user): CurrentUser,
navigator: Navigator, navigator: Navigator,
AppDbConn(mut app_db): AppDbConn,
Path(PathParams { workspace_id }): Path<PathParams>, Path(PathParams { workspace_id }): Path<PathParams>,
) -> Result<impl IntoResponse, AppError> { ) -> Result<impl IntoResponse, AppError> {
// FIXME: CSRF, Check workspace authorization. // FIXME: CSRF, Check workspace authorization.
@ -52,15 +50,11 @@ pub(super) async fn post(
.acquire_for(workspace_id, RoleAssignment::Root) .acquire_for(workspace_id, RoleAssignment::Root)
.await?; .await?;
let user_rolname = format!( let user_rolname = format!("{ROLE_PREFIX_USER}{uid}", uid = user.id.simple());
"{prefix}{user_id}",
prefix = settings.db_role_prefix,
user_id = user.id.simple()
);
let rolname_uuid = Uuid::new_v4().simple(); let rolname_uuid = Uuid::new_v4().simple();
let rolname_table_owner = format!("{TABLE_OWNER_ROLE_PREFIX}{rolname_uuid}"); let rolname_table_owner = format!("{ROLE_PREFIX_TABLE_OWNER}{rolname_uuid}");
let rolname_table_reader = format!("{TABLE_READER_ROLE_PREFIX}{rolname_uuid}"); let rolname_table_reader = format!("{ROLE_PREFIX_TABLE_READER}{rolname_uuid}");
let rolname_table_writer = format!("{TABLE_WRITER_ROLE_PREFIX}{rolname_uuid}"); let rolname_table_writer = format!("{ROLE_PREFIX_TABLE_WRITER}{rolname_uuid}");
for rolname in [ for rolname in [
&rolname_table_owner, &rolname_table_owner,
&rolname_table_reader, &rolname_table_reader,
@ -86,35 +80,39 @@ create table {0}.{1} (
_created_at timestamptz not null default now() _created_at timestamptz not null default now()
) )
"#, "#,
escape_identifier(&settings.phono_table_namespace), escape_identifier(PHONO_TABLE_NAMESPACE),
escape_identifier(&table_name), escape_identifier(&table_name),
)) ))
.execute(root_client.get_conn()) .execute(root_client.get_conn())
.await?; .await?;
query(&format!( query(&format!(
"alter table {nsp}.{ident} owner to {owner}", "alter table {nsp}.{tbl} owner to {rol}",
nsp = escape_identifier(&settings.phono_table_namespace), nsp = escape_identifier(PHONO_TABLE_NAMESPACE),
ident = escape_identifier(&table_name), tbl = escape_identifier(&table_name),
owner = escape_identifier(&rolname_table_owner), rol = escape_identifier(&rolname_table_owner),
)) ))
.execute(root_client.get_conn()) .execute(root_client.get_conn())
.await?; .await?;
query(&format!( query(&format!(
"grant select on {0}.{1} to {2}", "grant select on {nsp}.{tbl} to {rol}",
escape_identifier(&settings.phono_table_namespace), nsp = escape_identifier(PHONO_TABLE_NAMESPACE),
escape_identifier(&table_name), tbl = escape_identifier(&table_name),
escape_identifier(&rolname_table_reader), rol = escape_identifier(&rolname_table_reader),
)) ))
.execute(root_client.get_conn()) .execute(root_client.get_conn())
.await?; .await?;
query(&format!( query(&format!(
"grant delete, truncate on {0}.{1} to {2}", "grant delete, truncate on {nsp}.{tbl} to {rol}",
escape_identifier(&settings.phono_table_namespace), nsp = escape_identifier(PHONO_TABLE_NAMESPACE),
escape_identifier(&table_name), tbl = escape_identifier(&table_name),
escape_identifier(&rolname_table_writer), rol = escape_identifier(&rolname_table_writer),
)) ))
.execute(root_client.get_conn()) .execute(root_client.get_conn())
.await?; .await?;
Ok(navigator.workspace_page(workspace_id).redirect_to()) Ok(navigator
.workspace_page()
.workspace_id(workspace_id)
.build()?
.redirect_to())
} }

View file

@ -12,8 +12,10 @@ use crate::{Settings, app::App};
use super::relations_single; use super::relations_single;
mod add_service_credential_handler;
mod add_table_handler; mod add_table_handler;
mod nav_handler; mod nav_handler;
mod service_credentials_handler;
#[derive(Clone, Debug, Deserialize)] #[derive(Clone, Debug, Deserialize)]
struct PathParams { struct PathParams {
@ -34,8 +36,16 @@ pub(super) fn new_router() -> Router<App> {
}, },
), ),
) )
.route(
"/{workspace_id}/add-service-credential",
post(add_service_credential_handler::post),
)
.route("/{workspace_id}/add-table", post(add_table_handler::post)) .route("/{workspace_id}/add-table", post(add_table_handler::post))
.route_with_tsr("/{workspace_id}/nav/", get(nav_handler::get)) .route_with_tsr("/{workspace_id}/nav/", get(nav_handler::get))
.route_with_tsr(
"/{workspace_id}/service-credentials",
get(service_credentials_handler::get),
)
.nest( .nest(
"/{workspace_id}/r/{rel_oid}", "/{workspace_id}/r/{rel_oid}",
relations_single::new_router(), relations_single::new_router(),

View file

@ -0,0 +1,135 @@
use askama::Template;
use axum::{
debug_handler,
extract::{Path, State},
response::{Html, IntoResponse},
};
use futures::{lock::Mutex, prelude::*, stream};
use interim_models::{service_cred::ServiceCred, workspace::Workspace};
use interim_pgtypes::pg_role::RoleTree;
use redact::Secret;
use serde::Deserialize;
use url::Url;
use uuid::Uuid;
use crate::{
app::{App, AppDbConn},
errors::AppError,
navigator::Navigator,
roles::RoleDisplay,
settings::Settings,
user::CurrentUser,
workspace_nav::WorkspaceNav,
workspace_pooler::{RoleAssignment, WorkspacePooler},
};
#[derive(Debug, Deserialize)]
pub(super) struct PathParams {
workspace_id: Uuid,
}
/// HTTP GET handler for the page at which a user manages their service
/// credentials for a workspace.
#[debug_handler(state = App)]
pub(super) async fn get(
State(settings): State<Settings>,
CurrentUser(user): CurrentUser,
AppDbConn(mut app_db): AppDbConn,
Path(PathParams { workspace_id }): Path<PathParams>,
navigator: Navigator,
State(mut pooler): State<WorkspacePooler>,
) -> Result<impl IntoResponse, AppError> {
// FIXME: auth
let workspace = Workspace::with_id(workspace_id)
.fetch_one(&mut app_db)
.await?;
let cluster = workspace.fetch_cluster(&mut app_db).await?;
// Mutex is required to use client in async closures.
let workspace_client = Mutex::new(
pooler
.acquire_for(workspace_id, RoleAssignment::User(user.id))
.await?,
);
struct ServiceCredInfo {
service_cred: ServiceCred,
member_of: Vec<RoleDisplay>,
conn_string: Secret<Url>,
conn_string_redacted: String,
}
let service_cred_info = stream::iter(
ServiceCred::belonging_to_user(user.id)
.fetch_all(&mut app_db)
.await?,
)
.then(async |cred| {
let member_of: Vec<RoleDisplay> = stream::iter({
let mut locked_client = workspace_client.lock().await;
let tree = RoleTree::granted_to_rolname(&cred.rolname)
.fetch_tree(&mut locked_client)
.await?;
tree.unwrap().flatten_inherited()
})
.then(async |role| {
tracing::debug!("111");
let mut locked_client = workspace_client.lock().await;
RoleDisplay::from_rolname(&role.rolname, &mut locked_client).await
})
.collect::<Vec<Result<_, _>>>()
.await
.into_iter()
// [`futures::stream::StreamExt::collect`] can only collect to types
// that implement [`Default`], so we must do result handling with the
// sync version of `collect()`.
.collect::<Result<Vec<_>, _>>()?
.into_iter()
.flatten()
.collect();
tracing::debug!("222");
let conn_string = cluster.conn_str_for_db(
&workspace.db_name,
Some((
cred.rolname.as_str(),
Secret::new(cred.password.expose_secret().as_str()),
)),
)?;
Ok(ServiceCredInfo {
conn_string,
conn_string_redacted: "postgresql://********".to_owned(),
member_of,
service_cred: cred,
})
})
.collect::<Vec<Result<ServiceCredInfo, AppError>>>()
.await
// [`futures::stream::StreamExt::collect`] can only collect to types that
// implement [`Default`], so we must do result handling with the sync
// version of `collect()`.
.into_iter()
.collect::<Result<Vec<ServiceCredInfo>, AppError>>()?;
#[derive(Template)]
#[template(path = "workspaces_single/service_credentials.html")]
struct ResponseTemplate {
service_cred_info: Vec<ServiceCredInfo>,
settings: Settings,
workspace_nav: WorkspaceNav,
}
Ok(Html(
ResponseTemplate {
workspace_nav: WorkspaceNav::builder()
.navigator(navigator)
.workspace(workspace.clone())
.populate_rels(&mut app_db, &mut *workspace_client.lock().await)
.await?
.build()?,
service_cred_info,
settings,
}
.render()?,
))
}

View file

@ -25,10 +25,6 @@ pub(crate) struct Settings {
/// postgresql:// URL for Interim's application database. /// postgresql:// URL for Interim's application database.
pub(crate) database_url: Url, pub(crate) database_url: Url,
/// postgresql:// URL to use for creating backing databases for new
/// workspaces.
pub(crate) new_workspace_db_url: Url,
#[serde(default = "default_app_db_max_connections")] #[serde(default = "default_app_db_max_connections")]
pub(crate) app_db_max_connections: u32, pub(crate) app_db_max_connections: u32,
@ -48,14 +44,6 @@ pub(crate) struct Settings {
pub(crate) frontend_host: String, pub(crate) frontend_host: String,
pub(crate) auth: AuthSettings, pub(crate) auth: AuthSettings,
/// String to prepend to user IDs in order to construct Postgres role names.
#[serde(default = "default_db_role_prefix")]
pub(crate) db_role_prefix: String,
/// Postgres schema in which to create managed backing tables.
#[serde(default = "default_phono_table_namespace")]
pub(crate) phono_table_namespace: String,
} }
fn default_app_db_max_connections() -> u32 { fn default_app_db_max_connections() -> u32 {

View file

@ -9,7 +9,7 @@ use sqlx::{Executor, PgPool, postgres::PgPoolOptions, raw_sql};
use tokio::sync::{OnceCell, RwLock}; use tokio::sync::{OnceCell, RwLock};
use uuid::Uuid; use uuid::Uuid;
use crate::app::App; use crate::{app::App, roles::ROLE_PREFIX_USER};
const MAX_CONNECTIONS: u32 = 4; const MAX_CONNECTIONS: u32 = 4;
const IDLE_SECONDS: u64 = 3600; const IDLE_SECONDS: u64 = 3600;
@ -20,7 +20,6 @@ pub struct WorkspacePooler {
#[builder(default, setter(skip))] #[builder(default, setter(skip))]
pools: Arc<RwLock<HashMap<Uuid, OnceCell<PgPool>>>>, pools: Arc<RwLock<HashMap<Uuid, OnceCell<PgPool>>>>,
app_db_pool: PgPool, app_db_pool: PgPool,
db_role_prefix: String,
} }
impl WorkspacePooler { impl WorkspacePooler {
@ -42,7 +41,7 @@ impl WorkspacePooler {
Box::pin(async move { Box::pin(async move {
// Essentially "DISCARD ALL" without "DEALLOCATE ALL" // Essentially "DISCARD ALL" without "DEALLOCATE ALL"
conn.execute(raw_sql( conn.execute(raw_sql(
" r#"
close all; close all;
set session authorization default; set session authorization default;
reset all; reset all;
@ -51,13 +50,20 @@ select pg_advisory_unlock_all();
discard plans; discard plans;
discard temp; discard temp;
discard sequences; discard sequences;
", "#,
)) ))
.await?; .await?;
Ok(true) Ok(true)
}) })
}) })
.connect(workspace.url.expose_secret()) .connect(
workspace
.fetch_cluster(&mut app_db)
.await?
.conn_str_for_db(&workspace.db_name, None)?
.expose_secret()
.as_str(),
)
.await?) .await?)
}; };
@ -91,10 +97,10 @@ discard sequences;
let pool = self.get_pool_for(base_id).await?; let pool = self.get_pool_for(base_id).await?;
let mut client = WorkspaceClient::from_pool_conn(pool.acquire().await?); let mut client = WorkspaceClient::from_pool_conn(pool.acquire().await?);
match set_role { match set_role {
RoleAssignment::User(id) => { RoleAssignment::User(uid) => {
let prefix = &self.db_role_prefix; client
let user_id = id.simple(); .init_role(&format!("{ROLE_PREFIX_USER}{uid}", uid = uid.simple()))
client.init_role(&format!("{prefix}{user_id}")).await?; .await?;
} }
RoleAssignment::Root => {} RoleAssignment::Root => {}
} }

View file

@ -2,22 +2,14 @@
//! the [`interim_models::workspace`] module, which is also used extensively //! the [`interim_models::workspace`] module, which is also used extensively
//! across the server code. //! across the server code.
use std::collections::HashSet;
use anyhow::anyhow;
use interim_models::{client::AppDbClient, portal::Portal}; use interim_models::{client::AppDbClient, portal::Portal};
use interim_pgtypes::{ use interim_pgtypes::{
client::WorkspaceClient, client::WorkspaceClient,
pg_acl::{PgAclItem, PgPrivilegeType},
pg_class::{PgClass, PgRelKind}, pg_class::{PgClass, PgRelKind},
}; };
use uuid::Uuid; use uuid::Uuid;
use crate::errors::AppError; pub const PHONO_TABLE_NAMESPACE: &str = "phono";
pub(crate) const TABLE_OWNER_ROLE_PREFIX: &str = "table_owner_";
pub(crate) const TABLE_READER_ROLE_PREFIX: &str = "table_reader_";
pub(crate) const TABLE_WRITER_ROLE_PREFIX: &str = "table_writer_";
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub(crate) struct RelationPortalSet { pub(crate) struct RelationPortalSet {
@ -55,68 +47,3 @@ pub(crate) async fn fetch_all_accessible_portals(
Ok(portal_sets) Ok(portal_sets)
} }
// TODO: custom error type
// TODO: make params and result references
fn get_table_role(
relacl: Option<Vec<PgAclItem>>,
required_privileges: HashSet<PgPrivilegeType>,
disallowed_privileges: HashSet<PgPrivilegeType>,
role_prefix: &str,
) -> Result<String, AppError> {
let mut roles: Vec<String> = vec![];
for acl_item in relacl.ok_or(anyhow!("acl not present on class"))? {
if acl_item.grantee.starts_with(role_prefix) {
let privileges_set: HashSet<PgPrivilegeType> = acl_item
.privileges
.iter()
.map(|privilege| privilege.privilege)
.collect();
assert!(
privileges_set.intersection(&required_privileges).count()
== required_privileges.len()
);
assert!(privileges_set.intersection(&disallowed_privileges).count() == 0);
roles.push(acl_item.grantee)
}
}
assert!(roles.len() == 1);
Ok(roles
.first()
.expect("already asserted that `roles` has len 1")
.clone())
}
/// Returns the name of the "table_reader" role created by Phonograph for a
/// particular workspace table. The role is assessed based on its name and the
/// table permissions directly granted to it. Returns an error if no matching
/// role is found, and panics if a role is found with excess permissions
/// granted to it directly.
pub(crate) fn get_reader_role(rel: PgClass) -> Result<String, AppError> {
get_table_role(
rel.relacl,
[PgPrivilegeType::Select].into(),
[
PgPrivilegeType::Insert,
PgPrivilegeType::Update,
PgPrivilegeType::Delete,
PgPrivilegeType::Truncate,
]
.into(),
TABLE_READER_ROLE_PREFIX,
)
}
/// Returns the name of the "table_writer" role created by Phonograph for a
/// particular workspace table. The role is assessed based on its name and the
/// table permissions directly granted to it. Returns an error if no matching
/// role is found, and panics if a role is found with excess permissions
/// granted to it directly.
pub(crate) fn get_writer_role(rel: PgClass) -> Result<String, AppError> {
get_table_role(
rel.relacl,
[PgPrivilegeType::Delete, PgPrivilegeType::Truncate].into(),
[PgPrivilegeType::Select].into(),
TABLE_WRITER_ROLE_PREFIX,
)
}

View file

@ -15,20 +15,7 @@
initial-value="{{ filter | json }}" initial-value="{{ filter | json }}"
></filter-menu> ></filter-menu>
</div> </div>
<div class="page-grid__toolbar-user"> {% include "toolbar_user.html" %}
<basic-dropdown alignment="right">
<span slot="button-contents" aria-label="Account menu" title="Account menu">
<i aria-hidden="true" class="ti ti-user"></i>
</span>
<menu class="basic-dropdown__menu" slot="popover">
<li>
<a href="{{ settings.root_path }}/auth/logout" role="button">
Log out
</a>
</li>
</menu>
</basic-dropdown>
</div>
</div> </div>
<div class="page-grid__sidebar"> <div class="page-grid__sidebar">
<div style="padding: 1rem;"> <div style="padding: 1rem;">

View file

@ -19,7 +19,7 @@
{{ workspace_nav | safe }} {{ workspace_nav | safe }}
</div> </div>
</div> </div>
<main class="page-grid__main"> <main class="page-grid__main padded--lg">
<form method="post" action="update-name"> <form method="post" action="update-name">
<section> <section>
<h1>Name</h1> <h1>Name</h1>

View file

@ -0,0 +1,16 @@
<div class="role-display">
{%- match self -%}
{%- when Self::TableOwner { relname, .. } -%}
<div class="role-display__resource">{{ relname }}</div>
<div class="role-display__description">owner</div>
{%- when Self::TableReader { relname, .. } -%}
<div class="role-display__resource">{{ relname }}</div>
<div class="role-display__description">reader</div>
{%- when Self::TableWriter { relname, .. } -%}
<div class="role-display__resource">{{ relname }}</div>
<div class="role-display__description">writer</div>
{%- when Self::Unknown { rolname } -%}
<div class="role-display__description">{{ rolname }}</div>
<div class="role-display__description">member</div>
{%- endmatch -%}
</div>

View file

@ -0,0 +1,14 @@
<div class="page-grid__toolbar-user">
<basic-dropdown alignment="right">
<span slot="button-contents" aria-label="Account menu" title="Account menu">
<i aria-hidden="true" class="ti ti-user"></i>
</span>
<menu class="basic-dropdown__menu" slot="popover">
<li>
<a href="{{ settings.root_path }}/auth/logout" role="button">
Log out
</a>
</li>
</menu>
</basic-dropdown>
</div>

View file

@ -1,10 +1,10 @@
<nav class="workspace-nav"> <nav class="workspace-nav">
<div class="workspace-nav__heading"> <div class="workspace-nav__heading">
<h1> <h1>
{% if workspace.name.is_empty() %} {% if workspace.display_name.is_empty() %}
Untitled Workspace Untitled Workspace
{% else %} {% else %}
{{ workspace.name }} {{ workspace.display_name }}
{% endif %} {% endif %}
</h1> </h1>
<basic-dropdown button-class="button--secondary button--small" button-aria-label="Workspace Menu"> <basic-dropdown button-class="button--secondary button--small" button-aria-label="Workspace Menu">

View file

@ -9,11 +9,11 @@
<ul> <ul>
{% for workspace_perm in workspace_perms %} {% for workspace_perm in workspace_perms %}
<li> <li>
<a href="{{ navigator.workspace_page(*workspace_perm.workspace_id).abs_path() }}"> <a href="{{ navigator.workspace_page().workspace_id(*workspace_perm.workspace_id).build()?.get_path() }}">
{% if workspace_perm.workspace_name.is_empty() %} {% if workspace_perm.workspace_display_name.is_empty() %}
[Untitled Workspace] [Untitled Workspace]
{% else %} {% else %}
{{ workspace_perm.workspace_name }} {{ workspace_perm.workspace_display_name }}
{% endif %} {% endif %}
</a> </a>
</li> </li>

View file

@ -2,7 +2,7 @@
{% block main %} {% block main %}
<main style="position: relative; margin: 0 auto; max-width: 32rem;"> <main style="position: relative; margin: 0 auto; max-width: 32rem;">
<h1>{{ workspace.name }}</h1> <h1>{{ workspace.display_name }}</h1>
{{ workspace_nav | safe }} {{ workspace_nav | safe }}
</main> </main>
{% endblock %} {% endblock %}

View file

@ -0,0 +1,54 @@
{% extends "base.html" %}
{% block main %}
<div class="page-grid">
<div class="page-grid__toolbar">
<div class="page-grid__toolbar-utilities">
<basic-dropdown button-class="button--secondary" button-label="New Credential">
<span slot="button-contents">New Credential</span>
<div class="padded" slot="popover">
<form action="add-service-credential" method="post">
<button class="button--secondary" type="submit">Confirm</button>
</form>
</div>
</basic-dropdown>
</div>
{% include "toolbar_user.html" %}
</div>
<div class="page-grid__sidebar">
<div style="padding: 1rem;">
{{ workspace_nav | safe }}
</div>
</div>
<main class="page-grid__main padded--lg">
<table class="table">
<thead>
<tr>
<th>Connection String</th>
<th>Permissions</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% if service_cred_info.is_empty() %}
<tr class="table__message">
<td colspan="999">No data</td>
</tr>
{% endif %}
{% for cred in service_cred_info %}
<tr>
<td>
<copy-source copy-data="{{ cred.conn_string.expose_secret() }}">
<slot><code class="code">{{ cred.conn_string_redacted }}</code></slot>
<slot name="fallback">
<code class="code">{{ cred.conn_string.expose_secret() }}</code>
</slot>
</copy-source>
</td>
</tr>
{% endfor %}
</tbody>
<table>
</main>
</div>
{% endblock %}

View file

@ -74,6 +74,14 @@ button, input[type="submit"] {
} }
} }
.padded {
padding: 8px;
&--lg {
padding: 32px;
}
}
.page-grid { .page-grid {
height: 100vh; height: 100vh;
width: 100vw; width: 100vw;
@ -221,3 +229,21 @@ button, input[type="submit"] {
} }
} }
} }
.table {
border-collapse: collapse;
th, td {
border: globals.$default-border;
padding: 8px;
}
th {
background: #eee;
}
&__message {
opacity: 0.5;
text-align: center;
}
}