diff --git a/README.md b/README.md index ea24912..46cdb41 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,22 @@ configuration to automatically manage the development environment: ## 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 @@ -44,136 +59,9 @@ Examples of LLM-assisted changes in practice: - Replacing SVG icons with similar webfont icons from a different icon pack. (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 TO ;` - -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 TO ;` - -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 TO ;` - -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
TO ;` - -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 () ON
TO ;` - -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 () ON
TO ;` - -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 -[^1]: - Barring historical pedantry, "Postgres" and "PostgreSQL" are essentially +[^1]: Barring historical pedantry, "Postgres" and "PostgreSQL" are essentially synonymous and are often used interchangeably. As a matter of convention throughout Phonograph docs, "Postgres" is largely used to refer to the database software, while "PostgreSQL" is typically used to refer to the diff --git a/docs/auth.md b/docs/auth.md new file mode 100644 index 0000000..2a2d9c7 --- /dev/null +++ b/docs/auth.md @@ -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 TO ;` + +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 TO ;` + +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
TO ;` + +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
TO ;` + +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 () ON
TO ;` + +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 () ON
TO ;` + +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. diff --git a/phono-models/src/accessors/portal.rs b/phono-models/src/accessors/portal.rs index 38951a2..dbd1c4a 100644 --- a/phono-models/src/accessors/portal.rs +++ b/phono-models/src/accessors/portal.rs @@ -2,8 +2,8 @@ use std::collections::HashSet; use derive_builder::Builder; use phono_backends::{ - client::WorkspaceClient, pg_acl::PgPrivilegeType, pg_class::PgClass, pg_role::RoleTree, - rolnames::ROLE_PREFIX_USER, + client::WorkspaceClient, pg_acl::PgPrivilegeType, pg_class::PgClass, pg_database::PgDatabase, + pg_role::RoleTree, rolnames::ROLE_PREFIX_USER, }; use sqlx::postgres::types::Oid; use tracing::{Instrument, info, info_span}; @@ -84,10 +84,6 @@ impl<'a> Accessor for PortalAccessor<'a> { async fn fetch_one(self) -> Result { let spec = self.build()?; 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) .fetch_optional(spec.using_app_db) .await? @@ -127,6 +123,24 @@ impl<'a> Accessor for PortalAccessor<'a> { }; 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. // // No need to explicitly check whether