Files
cozystack/packages/apps/postgres/tests/username_validation_test.yaml
Alexey Artamonov 2b5877eff9 feat(postgres)!: generate user passwords and read them at runtime
users[].password is gone from the chart, its schema and the API type.
Every application-user password is generated into <release>-credentials
and kept across reconciles by lookup, so an upgraded release keeps the
password it already has and a password left in values is ignored. The
init-job mounts that Secret and applies each password with ALTER ROLE
and a psql variable, so no password is rendered into the init-script
Secret that Helm keeps in release history. While bootstrap is enabled
the Secret is marked credentials-pending, and NOTES say the generated
passwords apply once the init-job runs.

Names reach superuser SQL and the init-script shell, so render-time
guards reject a username, database or extension name outside
[-._a-zA-Z0-9] or past the length PostgreSQL silently truncates: 63
bytes for a role or extension, 54 for a database, whose <db>_readonly
role would otherwise truncate and fail the next run. A username over 63
bytes that the credentials Secret already carries still upgrades.
users.postgres, the CNPG superuser, is rejected in every bootstrap
state. Changing a database's key in values drops the database, so the
reject message and the README give the rename that keeps the data: a
platform administrator renames it and its roles as the CNPG superuser
before the key changes.

The init-job cannot hang its Helm hook: the readiness wait ends on
elapsed time below activeDeadlineSeconds and the Job never retries, so a
purged restore target fails with a diagnostic. The delete-users pass
reads managed users from pg_shdescription instead of parsing the
version-dependent \du+ layout, which on PostgreSQL 16 and later matched
nothing and left a removed user able to log in; it compares names as
CREATE ROLE stores them and reassigns a removed user's objects in every
database before dropping it. A database without roles renders, and
extension names are quoted for hyphenated names and folded to lowercase
so they resolve as they did unquoted. A value that carries its own
surrounding quotes, the only way a hyphenated extension rendered before,
has that pair removed and keeps its case, so such a release still
upgrades. The README reads a password with jq, since jsonpath splits a
dotted key.

Signed-off-by: Alexey Artamonov <aleksei.artamonov@aenix.io>
2026-09-24 18:10:57 +03:00

247 lines
11 KiB
YAML

suite: init-script rejects a username outside the safe charset
# The username becomes a Kubernetes Secret data key AND is interpolated into the
# init-script shell (pw=$(cat "/credentials/<user>")) and role SQL. A name with a
# shell metacharacter is a command-injection shape; the apiserver would reject it
# later as an invalid Secret key with an opaque error. The chart must reject it at
# render time, naming the offending username, so the metacharacter never reaches
# the script. Reverting the guard in init-script.yaml lets the render succeed,
# so the first test fails.
templates:
- templates/init-script.yaml
release:
name: pg-test
namespace: tenant-test
set:
_cluster:
scheduling: {}
tests:
- it: fails the render for a username carrying a shell metacharacter
set:
users:
'evil";id;echo "x': {}
asserts:
- failedTemplate:
errorMessage: 'invalid username "evil\";id;echo \"x": a username may contain only letters, digits and the characters -._ , must not be "." or start with "..", and must be at most 63 characters (it is a PostgreSQL role name, which CREATE ROLE silently truncates past 63 bytes, and it becomes a Kubernetes Secret key)'
- it: fails the render for a username with a space
set:
users:
'a b': {}
asserts:
- failedTemplate:
errorMessage: 'invalid username "a b": a username may contain only letters, digits and the characters -._ , must not be "." or start with "..", and must be at most 63 characters (it is a PostgreSQL role name, which CREATE ROLE silently truncates past 63 bytes, and it becomes a Kubernetes Secret key)'
# "." and ".." match the charset regex but are rejected by k8s IsConfigMapKey as
# Secret keys, so the guard rejects them explicitly (the length cap is a separate,
# PostgreSQL-driven concern pinned by the 63/64 boundary tests below). These pin
# the guard to the full IsConfigMapKey contract.
- it: fails the render for the reserved name "."
set:
users:
'.': {}
asserts:
- failedTemplate:
errorMessage: 'invalid username ".": a username may contain only letters, digits and the characters -._ , must not be "." or start with "..", and must be at most 63 characters (it is a PostgreSQL role name, which CREATE ROLE silently truncates past 63 bytes, and it becomes a Kubernetes Secret key)'
- it: fails the render for the reserved name ".."
set:
users:
'..': {}
asserts:
- failedTemplate:
errorMessage: 'invalid username "..": a username may contain only letters, digits and the characters -._ , must not be "." or start with "..", and must be at most 63 characters (it is a PostgreSQL role name, which CREATE ROLE silently truncates past 63 bytes, and it becomes a Kubernetes Secret key)'
# IsConfigMapKey rejects ANY name starting with ".." (hasChDirPrefix), not just
# the literal "..", so "..foo" is charset-legal yet cannot install.
- it: fails the render for a name starting with ".."
set:
users:
'..foo': {}
asserts:
- failedTemplate:
errorMessage: 'invalid username "..foo": a username may contain only letters, digits and the characters -._ , must not be "." or start with "..", and must be at most 63 characters (it is a PostgreSQL role name, which CREATE ROLE silently truncates past 63 bytes, and it becomes a Kubernetes Secret key)'
# A username is a PostgreSQL role name: CREATE ROLE silently truncates past 63
# bytes (NAMEDATALEN-1) with a NOTICE, not an error, so a 64-char name would be
# created as its 63-byte prefix and then dropped by the delete pass (catalog
# truncated != declared full name) in the same hook. The cap is 63, not the
# 253-char Secret-key limit. 64 chars fails; 63 renders. Reverting the cap to 253
# lets the 64-char case through.
- it: fails the render for a username of 64 characters (past the 63-byte role limit)
set:
users:
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa': {}
asserts:
- failedTemplate:
errorMessage: 'invalid username "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": a username may contain only letters, digits and the characters -._ , must not be "." or start with "..", and must be at most 63 characters (it is a PostgreSQL role name, which CREATE ROLE silently truncates past 63 bytes, and it becomes a Kubernetes Secret key)'
- it: renders normally for a username at the 63-character boundary
set:
users:
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa': {}
documentSelector:
path: metadata.name
value: pg-test-credentials
asserts:
- matchRegex:
path: stringData["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]
pattern: ^[a-zA-Z0-9]{16}$
- it: renders normally for names using the full safe charset
set:
users:
a-b.c_1: {}
documentSelector:
path: metadata.name
value: pg-test-credentials
asserts:
- matchRegex:
path: stringData["a-b.c_1"]
pattern: ^[a-zA-Z0-9]{16}$
# `postgres` is the CNPG-managed superuser and is forbidden as an app role. The
# rejection must not depend on bootstrap.enabled: while it is true the
# create-users block is gated off, so a restore target carrying users.postgres
# installs, and the restore controller's automatic bootstrap clear
# (disablePostgresAppBootstrap) then wedges the next reconcile against this fail
# — a state the controller records as Succeeded. Rejecting it regardless of
# bootstrap keeps the controller from converging a release into an unrenderable
# one. Moving the guard back inside the `not .Values.bootstrap.enabled` block
# lets the bootstrap case render, so this test fails.
- it: rejects users.postgres even while bootstrap.enabled is true
set:
bootstrap:
enabled: true
users:
postgres:
replication: false
asserts:
- failedTemplate:
errorMessage: '`users.postgres` is forbidden by policy. Use a different username.'
- it: rejects users.postgres on the normal (non-bootstrap) path too
set:
users:
postgres:
replication: false
asserts:
- failedTemplate:
errorMessage: '`users.postgres` is forbidden by policy. Use a different username.'
# The credentials Secret is gated on .Values.users, so a release installed with
# the default `users: {}` has none. Adding its FIRST user is an upgrade whose
# lookup is legitimately empty — indistinguishable at render time from a deleted
# Secret — so postgres must NOT reject an empty-lookup upgrade (that would hard-
# block the common first-user path). It mints instead; the post-upgrade init-job
# applies the password. Re-adding a `not $existingSecret` fail on IsUpgrade turns
# this red — the regression this pins (a userless release could no longer gain a
# user). MariaDB is the opposite case (its Secret always exists via root, and
# root desyncs permanently), so its guard stays; see mariadb secret.yaml.
- it: mints when the first user is added to a userless release on upgrade
release:
name: pg-test
namespace: tenant-test
upgrade: true
set:
users:
app: {}
documentSelector:
path: metadata.name
value: pg-test-credentials
asserts:
- matchRegex:
path: stringData.app
pattern: ^[a-zA-Z0-9]{16}$
# A username of 64+ bytes installed before the 63 cap: its credentials Secret
# key was written, while the role itself was truncated and dropped in the same
# hook. Failing the render on the next upgrade would wedge the whole release
# over a user that never worked, so on upgrade the cap applies only to a user
# the existing credentials Secret does not already carry, i.e. a NEW user.
# Applying the cap on upgrade regardless of the Secret makes the first case
# fail to render; dropping the Secret-key conjunct lets the second render, so
# it fails.
- it: renders on upgrade for a 64-character user the credentials Secret already carries
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:
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa: ZXhpc3RpbmdwYXNzd29yZA==
set:
users:
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa: {}
documentSelector:
path: metadata.name
value: pg-test-credentials
asserts:
- equal:
path: stringData["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]
value: existingpassword
- it: still fails on upgrade for a NEW 64-character user
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:
app: ZXhpc3RpbmdwYXNzd29yZA==
set:
users:
app: {}
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa: {}
asserts:
- failedTemplate:
errorMessage: 'invalid username "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": a username may contain only letters, digits and the characters -._ , must not be "." or start with "..", and must be at most 63 characters (it is a PostgreSQL role name, which CREATE ROLE silently truncates past 63 bytes, and it becomes a Kubernetes Secret key)'
# A fresh install has no release to protect, so the cap holds even when a stale
# credentials Secret left in the namespace happens to carry the key. Dropping
# the ".Release.IsInstall" conjunct lets the render succeed, so this fails.
- it: still fails on install for a 64-character user a stale Secret carries
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:
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa: ZXhpc3RpbmdwYXNzd29yZA==
set:
users:
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa: {}
asserts:
- failedTemplate:
errorMessage: 'invalid username "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": a username may contain only letters, digits and the characters -._ , must not be "." or start with "..", and must be at most 63 characters (it is a PostgreSQL role name, which CREATE ROLE silently truncates past 63 bytes, and it becomes a Kubernetes Secret key)'