Files
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

146 lines
7.0 KiB
YAML

suite: init-script removes users dropped from values
# The delete-users pass revokes a user that left .Values.users. Reading the managed
# users by parsing `\du+` and matching the description in column 4 breaks on
# PostgreSQL 16 and later, which dropped the "Member of" column, so the description
# sits in column 3 and nothing matches: a removed user keeps its LOGIN and old
# password. Query the catalog instead, as the database and role passes do.
templates:
- templates/init-script.yaml
release:
name: pg-test
namespace: tenant-test
set:
_cluster:
scheduling: {}
tests:
- it: reads the managed users from the catalog, not from the \du+ layout
set:
users:
app: {}
documentSelector:
path: metadata.name
value: pg-test-init-script
asserts:
- matchRegex:
path: stringData["init.sh"]
pattern: "MANAGED_USERS=\\$\\(psql -v ON_ERROR_STOP=1 -t -A -c \"SELECT r.rolname FROM pg_roles r JOIN pg_shdescription s ON s.objoid = r.oid AND s.classoid = 'pg_authid'::regclass WHERE s.description = 'user managed by helm' AND r.oid IN \\(SELECT m.member FROM pg_auth_members m JOIN pg_roles g ON g.oid = m.roleid WHERE g.rolname = 'cozystack:managed'\\)\"\\)"
- notMatchRegex:
path: stringData["init.sh"]
pattern: 'du\+'
# CREATE ROLE keeps only the first 63 bytes of a longer name, so the catalog holds
# a long user under its truncated name. Comparing it against the declared full
# name would call it undeclared and drop it on every run, so the declared names
# are compared truncated the same way.
- it: compares declared users truncated to 63 bytes
release:
name: pg-test
namespace: tenant-test
upgrade: true
kubernetesProvider:
scheme:
"v1/Secret":
gvr: {version: v1, resource: secrets}
namespaced: true
objects:
- apiVersion: v1
kind: Secret
metadata:
name: pg-test-credentials
namespace: tenant-test
data:
uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu: ZXhpc3RpbmdwYXNzd29yZA==
set:
users:
app: {}
uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu: {}
documentSelector:
path: metadata.name
value: pg-test-init-script
asserts:
- matchRegex:
path: stringData["init.sh"]
pattern: 'DEFINED_USERS="app uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu"'
# A removed user may own objects in any database, and REASSIGN OWNED / DROP OWNED
# act only on the database they run in; run in the postgres database alone, DROP
# USER then fails on the objects elsewhere and wedges every later init-job run.
# Run them in every connectable database before dropping the user.
- it: reassigns and drops a removed user's objects in every database
set:
users:
app: {}
documentSelector:
path: metadata.name
value: pg-test-init-script
asserts:
- matchRegex:
path: stringData["init.sh"]
pattern: "ALL_DBS=\\$\\(psql -v ON_ERROR_STOP=1 -t -A -c \"SELECT datname FROM pg_database WHERE datallowconn AND NOT datistemplate\"\\)"
- matchRegex:
path: stringData["init.sh"]
pattern: 'for db in \$ALL_DBS; do\npsql -v ON_ERROR_STOP=1 --echo-queries --dbname "\$db" -v user="\$user" <<\\EOT\nREASSIGN OWNED BY :"user" TO "cozystack:orphaned";\nDROP OWNED BY :"user";\nEOT\ndone\npsql -v ON_ERROR_STOP=1 --echo-queries -v user="\$user" <<\\EOT\nDROP USER :"user";\nEOT\n'
# What a tenant role created must not become superuser-owned when the role is
# dropped, so its objects go to an inert role instead of postgres.
- it: hands a dropped role's objects to an unprivileged role, not the superuser
set:
users:
app: {}
documentSelector:
path: metadata.name
value: pg-test-init-script
asserts:
- matchRegex:
path: stringData["init.sh"]
pattern: 'SELECT ''CREATE ROLE "cozystack:orphaned" NOLOGIN NOINHERIT''\nWHERE NOT EXISTS \(SELECT FROM pg_catalog\.pg_roles WHERE rolname = ''cozystack:orphaned''\)\\gexec'
- matchRegex:
path: stringData["init.sh"]
pattern: 'ALTER ROLE "cozystack:orphaned" NOLOGIN NOINHERIT NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS;'
- matchRegex:
path: stringData["init.sh"]
pattern: 'REASSIGN OWNED BY :"role" TO "cozystack:orphaned";'
- notMatchRegex:
path: stringData["init.sh"]
pattern: 'REASSIGN OWNED BY .* TO postgres'
# Every dropped role's objects share the one receiving role, so it must hold no
# memberships in either direction, and functions it ends up owning must run with
# the caller's rights rather than its own.
- it: keeps the receiving role free of memberships and its functions invoker-rights
set:
users:
app: {}
documentSelector:
path: metadata.name
value: pg-test-init-script
asserts:
- matchRegex:
path: stringData["init.sh"]
pattern: 'SELECT format\(''REVOKE %I FROM %I'', r\.rolname, m\.rolname\) FROM .* WHERE ''cozystack:orphaned'' IN \(r\.rolname, m\.rolname\)\\gexec\nALTER ROLE "cozystack:orphaned"'
- matchRegex:
path: stringData["init.sh"]
pattern: 'for db in \$ALL_DBS; do\npsql -v ON_ERROR_STOP=1 --echo-queries --dbname "\$db" <<\\EOT\nSELECT format\(''ALTER %s %s SECURITY INVOKER'', .*\) FROM pg_proc p WHERE p\.proowner = ''"cozystack:orphaned"''::regrole AND p\.prosecdef\\gexec\nSELECT format\(''ALTER VIEW %s SET \(security_invoker = true\)'', c\.oid::regclass\) FROM pg_class c WHERE c\.relowner = ''"cozystack:orphaned"''::regrole AND c\.relkind = ''v'' AND current_setting\(''server_version_num''\)::int >= 150000 .*\\gexec\nEOT\ndone'
# An orphaned role's name comes from the catalog, so it reaches SQL only as a
# psql variable in a quoted heredoc, never spliced in by the shell.
- it: passes an orphaned role's name to SQL as a psql variable
set:
users:
app: {}
documentSelector:
path: metadata.name
value: pg-test-init-script
asserts:
- matchRegex:
path: stringData["init.sh"]
pattern: 'DELETE_ROLES=\$\(.*\)\nALL_DBS=\$\(psql -v ON_ERROR_STOP=1 -t -A -c "SELECT datname FROM pg_database WHERE datallowconn AND NOT datistemplate"\)\n\necho "roles to delete: \$DELETE_ROLES"\nfor role in \$DELETE_ROLES; do\npsql -v ON_ERROR_STOP=1 --echo-queries -v role="\$role" <<\\EOT\nSELECT format\(''REVOKE %I FROM %I'', r\.rolname, m\.rolname\) FROM .* WHERE r\.rolname = :''role''\\gexec\nEOT\nfor db in \$ALL_DBS; do\npsql -v ON_ERROR_STOP=1 --echo-queries --dbname "\$db" -v role="\$role" <<\\EOT\nREASSIGN OWNED BY :"role" TO "cozystack:orphaned";\nDROP OWNED BY :"role";\nEOT\ndone\npsql -v ON_ERROR_STOP=1 --echo-queries -v role="\$role" <<\\EOT\nDROP ROLE IF EXISTS :"role";\nEOT'
- notMatchRegex:
path: stringData["init.sh"]
pattern: '[A-Z] "\$role"|''\$role'''