Files
cozystack/packages/apps/postgres/tests/delete_users_test.yaml
Timofei Larkin 2fac379457 feat(postgres)!: mint tenant passwords through the credentials API
The chart generated every user's password at render time into
<release>-credentials, which tenants could read again at any time and
which Helm history kept for MaxHistory revisions, so a deployment whose
policy requires a credential shown once could not run PostgreSQL here.

The accounts move under CloudNativePG's managed roles, each pointing at
the account Secret the Cozystack API seeds, so the operator creates the
role with the SCRAM verifier the Secret holds and applies a changed
Secret live, and a password set inside PostgreSQL is reverted. Every
role is rendered as the init-script created it, because the operator
reverts an attribute that differs, revokes a parent role missing from
inRoles and clears a comment the spec leaves unset, while the
init-script still finds the roles to drop by that comment and by
membership in the marker. The init-script stops creating, passwording
and altering accounts, where it would be a second writer, and keeps the
two things the operator has no field for: the marker membership
narrowed to a record, and createrole_self_grant.

The refusal to adopt a role the chart did not mark moves into a
pre-upgrade hook, because the operator adopts a managed role within
seconds of the spec reaching it and the post-upgrade init-job would
find it adopted already. The definition declares the credentials block,
so the API seeds, serves and mints for the kind, and no longer selects
the credentials Secret, which the chart no longer renders or grants.

BREAKING CHANGE: PostgreSQL passwords are no longer readable from
<release>-credentials. A password is issued once, by a mint on the
account's Credential in core.cozystack.io, and replaced by the next
mint; deleting a Postgres application whose release is suspended is
refused. The conversion needs a platform variant with migrations
enabled.

Assisted-by: LLM
Signed-off-by: Timofei Larkin <lllamnyp@gmail.com>
2026-10-27 16:23:21 +01:00

129 lines
6.5 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
set:
users:
app: {}
uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu: {}
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'''