check database connect privilege when accessing portal

This commit is contained in:
Brent Schroeter 2025-11-22 06:30:50 +00:00
parent fd78a386e2
commit 75fcfecda1
3 changed files with 170 additions and 135 deletions

146
README.md
View file

@ -16,7 +16,22 @@ configuration to automatically manage the development environment:
## Configuration ## Configuration
Refer to [the .env.example file](./.env.example) for configuration options. Refer to [the .env.example file](./.env.example) for configuration options. An
external OAuth2 provider is required to manage authentication.
# The Phonograph Authorization Model
Refer to documentation in [docs/auth.md](./docs/auth.md).
# Copyright and License
All original source code in this repository is copyright (C) 2025 Second System
Technologies LLC and distributed under the terms in
[the "LICENSE" file](./LICENSE). Certain third-party assets within
[the "static" directory](./static) may be governed by different licenses, for
example the Open Font License or MIT License, as stated by their original
authors. Copies of each relevant license have been included alongside these
files as needed.
# LLM Code Policy # LLM Code Policy
@ -44,136 +59,9 @@ Examples of LLM-assisted changes in practice:
- Replacing SVG icons with similar webfont icons from a different icon pack. - Replacing SVG icons with similar webfont icons from a different icon pack.
(Revision `ztrnxzqv` (Git `a8dd49f7`)) (Revision `ztrnxzqv` (Git `a8dd49f7`))
# The Phonograph Authorization Model
Postgres provides a sophisticated role based access control (RBAC) system, which
Phonograph leverages to apply permissions consistently across the web UI and
inbound PostgreSQL[^1] connections.
In order to efficiently pool database connections from the application server,
most actions initiated via the web UI are run as the corresponding database user
role using the
[`SET ROLE` command](https://www.postgresql.org/docs/current/sql-set-role.html).
`SET ROLE` **does not** provide great insulation against privilege escalation.
**Queries which are not thoroughly validated and escaped must only be run via a
dedicated connection initiated with the user-level role's credentials.**
Given complete freedom it is possible, in fact easy, to configure a Postgres
table into what would be considered an "invalid" state by Phonograph, so table
creation and ownership is restricted to the "root" Phonograph role, which acts
on the behalf of the user in order to facilitate schema updates via the web
interface.
## Permissions Granted via User Roles
### Accessing workspace databases
`GRANT CONNECT ON <database> TO <role>;`
This permission is granted when initially creating the workspace, as well as
when accepting an invitation to a table.
Access to workspaces is controlled via the `CONNECT ON DATABASE` permission.
However, it is unreasonable to query every backing database to compute the set
of workspaces to which a user has access, so Phonograph caches workspace-level
"connect" permissions in its own centralized table (`workspace_memberships`).
`workspace_memberships` rows are added whenever the `GRANT CONNECT` command is
run, and are deleted after a `REVOKE CONNECT` command is run.
It is possible that an error occurs after `REVOKE CONNECT` but before the
membership record is deleted. Therefore for authorization purposes, membership
of a workspace is not a guarantee that the user has `CONNECT` privileges, just
that they might. In cases where the root Postgres user is fetching potentially
sensitive data on behalf of a user, the user's actual ability to connect to the
database should always be confirmed.
### Accessing the `phono` schema
`GRANT USAGE ON <schema> TO <role>;`
This permission is granted when initially creating the workspace, as well as
when accepting an invitation to a table.
## Creating, updating, and deleting columns
`GRANT <table owner role> TO <role>;`
This permission is granted when initially creating the table, as well as when
accepting an invitation to a table, if the invitation includes "owner"
permissions.
**This permission is only used via the web UI and must not be granted to service
credentials, lest users alter table structure in unsupported ways.**
### Reading table data
`GRANT SELECT ON <table> TO <role>;`
This permission is granted when initially creating the table, as well as when
accepting an invitation to the table.
Phonograph uses `SELECT` permissions to infer whether a table should be
accessible to a user via the web UI.
### Inserting rows
`GRANT INSERT (<columns>) ON <table> TO <role>;`
Write-protected columns (`_id`, etc.) are excluded.
This permission is granted when initially creating the table, as well as when
accepting an invitation to the table, if the invitation includes "edit"
permissions.
These permissions must be updated for each relevant user role whenever a column
is added; this is simplified by maintaining a single "writer" role per table.
### Updating rows
`GRANT UPDATE (<columns>) ON <table> TO <role>;`
Write-protected columns (`_id`, etc.) are excluded.
This permission is granted when initially creating the table, as well as when
accepting an invitation to the table, if the invitation includes "edit"
permissions.
These permissions must be updated for each relevant user role whenever a column
is added; this is simplified by maintaining a single "writer" role per table.
## Actions Facilitated by Root
- Creating tables
## Service Credentials
Direct user PostgreSQL connections are performed using secondary `LOGIN` roles
created by the user's primary workspace role (where the primary workspace role
is e.g. `usr_{user_id}`). The credentials for these secondary roles are referred
to as "service credentials" or "PostgreSQL credentials". Service credentials are
created and assigned permissions by users in the web UI, and their permissions
are revoked manually in the web UI and/or by cascading `REVOKE` commands
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.
# Copyright and License
All original source code in this repository is copyright (C) 2025 Second System
Technologies LLC and distributed under the terms in
[the "LICENSE" file](./LICENSE). Certain third-party assets within
[the "static" directory](./static) may be governed by different licenses, for
example the Open Font License or MIT License, as stated by their original
authors. Copies of each relevant license have been included alongside these
files as needed.
# Footnotes # Footnotes
[^1]: [^1]: Barring historical pedantry, "Postgres" and "PostgreSQL" are essentially
Barring historical pedantry, "Postgres" and "PostgreSQL" are essentially
synonymous and are often used interchangeably. As a matter of convention synonymous and are often used interchangeably. As a matter of convention
throughout Phonograph docs, "Postgres" is largely used to refer to the throughout Phonograph docs, "Postgres" is largely used to refer to the
database software, while "PostgreSQL" is typically used to refer to the database software, while "PostgreSQL" is typically used to refer to the

133
docs/auth.md Normal file
View file

@ -0,0 +1,133 @@
# The Phonograph Authorization Model
Postgres provides a sophisticated role based access control (RBAC) system, which
Phonograph leverages to apply permissions consistently across the web UI and
inbound PostgreSQL[^1] connections.
In order to efficiently pool database connections from the application server,
most actions initiated via the web UI are run as the corresponding database user
role using the
[`SET ROLE` command](https://www.postgresql.org/docs/current/sql-set-role.html).
`SET ROLE` **does not** provide great insulation against privilege escalation.
**Queries which are not thoroughly validated and escaped must only be run via a
dedicated connection initiated with the user-level role's credentials.**
Given complete freedom it is possible, in fact easy, to configure a Postgres
table into what would be considered an "invalid" state by Phonograph, so table
creation and ownership is restricted to the "root" Phonograph role, which acts
on the behalf of the user in order to facilitate schema updates via the web
interface.
## Permissions Granted via User Roles
### Accessing workspace databases
`GRANT CONNECT ON <database> TO <role>;`
This permission is granted when initially creating the workspace, as well as
when accepting an invitation to a table.
Access to workspaces is controlled via the `CONNECT ON DATABASE` permission.
However, it is unreasonable to query every backing cluster to compute the set of
workspaces to which a user has access, so Phonograph caches workspace-level
"connect" permissions in its own centralized table (`workspace_memberships`).
`workspace_memberships` rows are added whenever the `GRANT CONNECT` command is
run, and are deleted after a `REVOKE CONNECT` command is run.
It is possible that an error occurs after `REVOKE CONNECT` but before the
membership record is deleted. Therefore for authorization purposes, membership
of a workspace is not a guarantee that the user has `CONNECT` privileges, just
that they might. The user's actual ability to connect to the database should
always be confirmed before actually fetching data or metadata from a backing
database.
**Caution!** The Postgres database `CONNECT` privilege has no effect after a
connection has been established, and _it is not re-checked by Postgres when
running the `SET USER` command_. **The Phonograph server is solely responsible
for managing top-level workspace permissions when accessing data via
established, pooled connections.**
### Accessing the `phono` schema
`GRANT USAGE ON <schema> TO <role>;`
This permission is granted when initially creating the workspace, as well as
when accepting an invitation to a table.
## Creating, updating, and deleting columns
`GRANT <table owner role> TO <role>;`
This permission is granted when initially creating the table, as well as when
accepting an invitation to a table, if the invitation includes "owner"
permissions.
**This permission is only used via the web UI and must not be granted to service
credentials, lest users alter table structure in unsupported ways.**
### Reading table data
`GRANT SELECT ON <table> TO <role>;`
This permission is granted when initially creating the table, as well as when
accepting an invitation to the table.
Phonograph uses `SELECT` permissions to infer whether a table should be
accessible to a user via the web UI.
### Inserting rows
`GRANT INSERT (<columns>) ON <table> TO <role>;`
Write-protected columns (`_id`, etc.) are excluded.
This permission is granted when initially creating the table, as well as when
accepting an invitation to the table, if the invitation includes "edit"
permissions.
These permissions must be updated for each relevant user role whenever a column
is added; this is simplified by maintaining a single "writer" role per table.
Note that granting insert or update privileges on specific columns will be
reflected in relation ACLs (that is, in the `pg_class` table) as the
corresponding privilege on the relation overall. In other words, a role listed
as having insert and/or update permissions in a relation's ACL items might not
have insert and/or update permissions to all columns.
### Updating rows
`GRANT UPDATE (<columns>) ON <table> TO <role>;`
Write-protected columns (`_id`, etc.) are excluded.
This permission is granted when initially creating the table, as well as when
accepting an invitation to the table, if the invitation includes "edit"
permissions.
These permissions must be updated for each relevant user role whenever a column
is added; this is simplified by maintaining a single "writer" role per table.
Note that granting insert or update privileges on specific columns will be
reflected in relation ACLs (that is, in the `pg_class` table) as the
corresponding privilege on the relation overall. In other words, a role listed
as having insert and/or update permissions in a relation's ACL items might not
have insert and/or update permissions to all columns.
## Actions Facilitated by Root
- Creating tables
## Service Credentials
Direct user PostgreSQL connections are performed using secondary `LOGIN` roles
created by the user's primary workspace role (where the primary workspace role
is e.g. `usr_{user_id}`). The credentials for these secondary roles are referred
to as "service credentials" or "PostgreSQL credentials". Service credentials are
created and assigned permissions by users in the web UI, and their permissions
are revoked manually in the web UI and/or by cascading `REVOKE` commands
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.

View file

@ -2,8 +2,8 @@ use std::collections::HashSet;
use derive_builder::Builder; use derive_builder::Builder;
use phono_backends::{ use phono_backends::{
client::WorkspaceClient, pg_acl::PgPrivilegeType, pg_class::PgClass, pg_role::RoleTree, client::WorkspaceClient, pg_acl::PgPrivilegeType, pg_class::PgClass, pg_database::PgDatabase,
rolnames::ROLE_PREFIX_USER, pg_role::RoleTree, rolnames::ROLE_PREFIX_USER,
}; };
use sqlx::postgres::types::Oid; use sqlx::postgres::types::Oid;
use tracing::{Instrument, info, info_span}; use tracing::{Instrument, info, info_span};
@ -84,10 +84,6 @@ impl<'a> Accessor<Portal> for PortalAccessor<'a> {
async fn fetch_one(self) -> Result<Portal, AccessError> { async fn fetch_one(self) -> Result<Portal, AccessError> {
let spec = self.build()?; let spec = self.build()?;
async { async {
// FIXME: Do we need to explicitly verify that the actor has
// database `CONNECT` privileges, or is that implicitly given by the
// fact that a workspace client has already been acquired?
let portal = Portal::with_id(spec.id) let portal = Portal::with_id(spec.id)
.fetch_optional(spec.using_app_db) .fetch_optional(spec.using_app_db)
.await? .await?
@ -127,6 +123,24 @@ impl<'a> Accessor<Portal> for PortalAccessor<'a> {
}; };
if let Some(actor_rolname) = actor_rolname { if let Some(actor_rolname) = actor_rolname {
// Verify database CONNECT permissions.
let pg_db = PgDatabase::current()
.fetch_one(spec.using_workspace_client)
.await?;
if !pg_db.datacl.unwrap_or_default().iter().any(|acl| {
// Currently database connect permissions are always granted
// directly, though this may change in the future.
// TODO: Generalize to inherited roles
acl.grantee == actor_rolname
&& acl
.privileges
.iter()
.any(|privilege| privilege.privilege == PgPrivilegeType::Connect)
}) {
info!("actor lacks postgres connect privileges");
return Err(AccessError::NotFound);
}
// Verify ACL permissions. // Verify ACL permissions.
// //
// No need to explicitly check whether // No need to explicitly check whether