Files
cozystack/packages/apps/postgres/tests/user_createrole_test.yaml
Mattia Eleuteri 21fa1fcdb1 feat(postgres): add per-user createRole and bypassRls attributes
Without these, a tenant that needs to manage its own roles has no option
but the postgres superuser, which also hands over filesystem access and
command execution on the instance. Role attributes are not inherited
through role membership, so the generated <db>_admin group role cannot
carry them: they have to be set on the login user itself.

users.<name>.createRole and users.<name>.bypassRls set CREATEROLE and
BYPASSRLS on a managed login user. Both are reconciled on every
init-script run, so setting one back to false emits the NO... form and
revokes the attribute.

createRole is refused on PostgreSQL 15 and older. Before PostgreSQL 16,
CREATEROLE allowed granting membership in any non-superuser role, so a
user holding it could grant itself pg_execute_server_program and reach
command execution on the instance, which is exactly what not exposing
the superuser prevents. PostgreSQL 16 restricted CREATEROLE to roles the
grantee administers. bypassRls carries no such gate: BYPASSRLS only
waives row-level security inside the tenant's own databases, whose owner
already reads every row, so it adds no reach.

The two attributes are needed together, not separately. PostgreSQL only
lets a role create other roles carrying BYPASSRLS if it holds the
attribute itself, so provisioning a privileged service role fails with
"Only roles with the BYPASSRLS attribute may create roles with the
BYPASSRLS attribute" when only createRole is granted.

createRole also sets createrole_self_grant as a role-level default.
PostgreSQL 16 grants a CREATEROLE holder only ADMIN OPTION on the roles
it creates, governed by that parameter, whose default is empty. ADMIN
OPTION allows granting the role onward but not SET ROLE to it, and
creating an object owned by another role requires SET. The attribute
therefore looks functional -- CREATE ROLE succeeds -- while the next
statement a tenant writes, CREATE SCHEMA ... AUTHORIZATION <new role>,
fails with "must be able to SET ROLE". The default is reset when the
attribute is withdrawn.

The init job runs as the superuser, and before this change it trusted
two things a CREATEROLE holder controls for the roles it creates: role
comments and role names. It picked the users and roles to drop by their
comment, and a PostgreSQL 16 CREATEROLE holder may comment every role it
created, then hand it to the chart for deletion. It adopted any role
that already existed under a declared name, so a role created under a
name an administrator declares later stayed under its creator's control.
Neither can be caught from the catalog: creating a role through an
intermediate role and then dropping that role leaves the squatted role
with no member at all, while the objects, settings and default
privileges planted in it stay.

The chart therefore records the roles it manages as members of
"cozystack:managed", a role only the superuser can grant, and the delete
passes act only on those. Its first run marks every role the chart had
commented before, when no role could yet hold CREATEROLE through it.
Once any user has held createRole, recorded as a setting of the postgres
database, the chart refuses to adopt a role it did not mark and fails
with the role's name, rather than hand a squatted role REPLICATION or
the ownership of a database.

Verified against live PostgreSQL instances. On 17 a migration checklist
replayed as the login user, with no superuser: role creation with
attributes, CREATE SCHEMA ... AUTHORIZATION with the requested owner,
extensions in a named schema, per-role search_path and timeouts, RLS
including FORCE ROW LEVEL SECURITY, and SECURITY DEFINER functions all
succeed. On 17 the forged-comment and squatting paths are refused or
ignored, the upgrade from the previous chart marks the roles it already
managed, and a fresh install still adopts the role CNPG bootstraps for
users.app. On 13, 15 and 16 the init script runs and re-runs cleanly.

Assisted-by: LLM
Signed-off-by: Mattia Eleuteri <mattia@hidora.io>
2026-10-06 16:26:59 +03:00

168 lines
5.0 KiB
YAML

suite: per-user CREATEROLE attribute
# users.<name>.createRole grants the CREATEROLE role attribute to a managed login
# user, so a tenant can create and manage its own roles without the postgres
# superuser. Role attributes are not inherited through role membership, so the
# attribute cannot be carried by the generated <db>_admin group role and has to be
# set on the login user itself.
#
# The attribute is reconciled on every run of the init script: flipping createRole
# back to false emits NOCREATEROLE and revokes it.
#
# The guard on PostgreSQL 16 is a security boundary, not a cosmetic check. Before
# PostgreSQL 16, CREATEROLE allowed granting membership in any non-superuser role,
# so a user holding it could grant itself pg_execute_server_program and reach
# command execution on the instance. PostgreSQL 16 restricted CREATEROLE to roles
# the grantee administers, which is what makes the attribute safe to expose.
templates:
- templates/init-script.yaml
release:
name: pg-test
namespace: tenant-test
set:
_cluster:
scheduling: {}
tests:
- it: "user is NOCREATEROLE by default"
set:
users:
app: {}
documentIndex: 1
asserts:
- matchRegex:
path: stringData["init.sh"]
pattern: 'ALTER ROLE "app" WITH PASSWORD .* NOREPLICATION NOCREATEROLE NOBYPASSRLS;'
- it: "createRole=true emits CREATEROLE"
set:
version: v18
users:
app:
createRole: true
documentIndex: 1
asserts:
- matchRegex:
path: stringData["init.sh"]
pattern: 'ALTER ROLE "app" WITH PASSWORD .* NOREPLICATION CREATEROLE NOBYPASSRLS;'
- it: "createRole=false emits NOCREATEROLE so the attribute is revoked"
set:
version: v18
users:
app:
createRole: false
documentIndex: 1
asserts:
- matchRegex:
path: stringData["init.sh"]
pattern: 'ALTER ROLE "app" WITH PASSWORD .* NOCREATEROLE NOBYPASSRLS;'
- it: "createRole composes with replication"
set:
version: v18
users:
app:
replication: true
createRole: true
documentIndex: 1
asserts:
- matchRegex:
path: stringData["init.sh"]
pattern: 'ALTER ROLE "app" WITH PASSWORD .* REPLICATION CREATEROLE NOBYPASSRLS;'
- it: "createRole on PostgreSQL 15 fails fast"
set:
version: v15
users:
app:
createRole: true
asserts:
- failedTemplate:
errorMessage: "SECURITY: users.app.createRole requires PostgreSQL 16 or newer. On PostgreSQL 15 and older, a role holding CREATEROLE can grant itself any non-superuser role, including pg_execute_server_program, which escalates to command execution on the instance."
- it: "createRole on PostgreSQL 13 fails fast"
set:
version: v13
users:
app:
createRole: true
asserts:
- failedTemplate:
errorMessage: "SECURITY: users.app.createRole requires PostgreSQL 16 or newer. On PostgreSQL 15 and older, a role holding CREATEROLE can grant itself any non-superuser role, including pg_execute_server_program, which escalates to command execution on the instance."
- it: "PostgreSQL 15 renders fine as long as createRole is not requested"
set:
version: v15
users:
app: {}
documentIndex: 1
asserts:
- matchRegex:
path: stringData["init.sh"]
pattern: 'ALTER ROLE "app" WITH PASSWORD .* NOCREATEROLE NOBYPASSRLS;'
- it: "bypassRls=true emits BYPASSRLS"
set:
version: v18
users:
app:
bypassRls: true
documentIndex: 1
asserts:
- matchRegex:
path: stringData["init.sh"]
pattern: 'ALTER ROLE "app" WITH PASSWORD .* NOCREATEROLE BYPASSRLS;'
- it: "bypassRls composes with createRole, which is what provisioning a privileged service role needs"
set:
version: v18
users:
app:
createRole: true
bypassRls: true
documentIndex: 1
asserts:
- matchRegex:
path: stringData["init.sh"]
pattern: 'ALTER ROLE "app" WITH PASSWORD .* CREATEROLE BYPASSRLS;'
- it: "bypassRls is allowed on PostgreSQL 15, it carries no escalation path of its own"
set:
version: v15
users:
app:
bypassRls: true
documentIndex: 1
asserts:
- matchRegex:
path: stringData["init.sh"]
pattern: 'ALTER ROLE "app" WITH PASSWORD .* NOCREATEROLE BYPASSRLS;'
- it: "createRole sets createrole_self_grant so the attribute is usable"
set:
version: v18
users:
app:
createRole: true
documentIndex: 1
asserts:
- matchRegex:
path: stringData["init.sh"]
pattern: "ALTER ROLE \"app\" SET createrole_self_grant = 'set, inherit';"
- it: "withdrawing createRole resets createrole_self_grant"
set:
version: v18
users:
app:
createRole: false
documentIndex: 1
asserts:
- matchRegex:
path: stringData["init.sh"]
pattern: 'ALTER ROLE "app" RESET createrole_self_grant;'