Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit e19f3fe

Browse files
philrhurstcbandy
andauthoredMar 12, 2025··
Pgadmin oauth secrets (#4123)
* preliminary work on OAUTH2 configuration Secrets with pgAdmin * update description * update comment * add logic to configSystem script * check OAuth Secrets and ConfigMap for changes and schedule rollout when needed * update test for new Python logic handling OAuth Secret JSON files * update test * updated typo in code comments * updated comments for clarification * rebase * updated description in CRD * Change oauth2 to mount rather than load secrets * FIXUP: dots-only filenames --------- Co-authored-by: Chris Bandy <chris.bandy@crunchydata.com>
1 parent 345d90f commit e19f3fe

File tree

7 files changed

+234
-8
lines changed

7 files changed

+234
-8
lines changed
 

‎config/crd/bases/postgres-operator.crunchydata.com_pgadmins.yaml

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1320,7 +1320,7 @@ spec:
13201320
type: array
13211321
gunicorn:
13221322
description: |-
1323-
Settings for the gunicorn server.
1323+
Settings for the Gunicorn server.
13241324
More info: https://docs.gunicorn.org/en/latest/settings.html
13251325
type: object
13261326
x-kubernetes-preserve-unknown-fields: true
@@ -1353,12 +1353,61 @@ spec:
13531353
- name
13541354
type: object
13551355
x-kubernetes-map-type: atomic
1356+
oauthConfigurations:
1357+
description: |-
1358+
Secrets for the `OAUTH2_CONFIG` setting. If there are `OAUTH2_CONFIG` values
1359+
in the settings field, they will be combined with the values loaded here.
1360+
More info: https://www.pgadmin.org/docs/pgadmin4/latest/oauth2.html
1361+
items:
1362+
properties:
1363+
name:
1364+
description: The OAUTH2_NAME of this configuration.
1365+
maxLength: 20
1366+
minLength: 1
1367+
pattern: ^[A-Za-z0-9]+$
1368+
type: string
1369+
secret:
1370+
description: A Secret containing the settings of one OAuth2
1371+
provider as a JSON object.
1372+
properties:
1373+
key:
1374+
description: Name of the data field within the Secret.
1375+
maxLength: 253
1376+
minLength: 1
1377+
pattern: ^[-._a-zA-Z0-9]+$
1378+
type: string
1379+
x-kubernetes-validations:
1380+
- message: cannot be "." or start with ".."
1381+
rule: self != "." && !self.startsWith("..")
1382+
name:
1383+
description: Name of the Secret.
1384+
maxLength: 253
1385+
minLength: 1
1386+
pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?([.][a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
1387+
type: string
1388+
required:
1389+
- key
1390+
- name
1391+
type: object
1392+
x-kubernetes-map-type: atomic
1393+
required:
1394+
- name
1395+
- secret
1396+
type: object
1397+
x-kubernetes-map-type: atomic
1398+
maxItems: 10
1399+
minItems: 1
1400+
type: array
1401+
x-kubernetes-list-map-keys:
1402+
- name
1403+
x-kubernetes-list-type: map
13561404
settings:
13571405
description: |-
13581406
Settings for the pgAdmin server process. Keys should be uppercase and
13591407
values must be constants.
13601408
More info: https://www.pgadmin.org/docs/pgadmin4/latest/config_py.html
13611409
type: object
1410+
x-kubernetes-map-type: granular
13621411
x-kubernetes-preserve-unknown-fields: true
13631412
type: object
13641413
dataVolumeClaimSpec:

‎internal/controller/standalone_pgadmin/pod.go

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ const (
2828
configDatabaseURIPath = "~postgres-operator/config-database-uri"
2929
ldapFilePath = "~postgres-operator/ldap-bind-password"
3030
gunicornConfigFilePath = "~postgres-operator/" + gunicornConfigKey
31+
oauthConfigDir = "~postgres-operator/oauth-config"
32+
oauthAbsolutePath = configMountPath + "/" + oauthConfigDir
3133

3234
// scriptMountPath is where to mount a temporary directory that is only
3335
// writable during Pod initialization.
@@ -212,6 +214,17 @@ func podConfigFiles(configmap *corev1.ConfigMap, pgadmin v1beta1.PGAdmin) []core
212214
},
213215
}...)
214216

217+
for i, oauth := range pgadmin.Spec.Config.OAuthConfigurations {
218+
// Safely encode the OAUTH2_NAME in the file name. Prepend the index so
219+
// the files can be loaded in the order they are defined in the spec.
220+
mountPath := fmt.Sprintf(
221+
"%s/%02d-%s.json", oauthConfigDir, i, shell.CleanFileName(oauth.Name),
222+
)
223+
config = append(config, corev1.VolumeProjection{
224+
Secret: initialize.Pointer(oauth.Secret.AsProjection(mountPath)),
225+
})
226+
}
227+
215228
if pgadmin.Spec.Config.ConfigDatabaseURI != nil {
216229
config = append(config, corev1.VolumeProjection{
217230
Secret: initialize.Pointer(
@@ -311,15 +324,17 @@ loadServerCommand
311324
// descriptor and uses the timeout of the builtin `read` to wait. That same
312325
// descriptor gets closed and reopened to use the builtin `[ -nt` to check mtimes.
313326
// - https://unix.stackexchange.com/a/407383
314-
// In order to get gunicorn to reload the logging config
315-
// we need to send a KILL rather than a HUP signal.
327+
//
328+
// Gunicorn needs a SIGTERM rather than SIGHUP to reload its logging config.
329+
// This also causes pgAdmin to restart when its configuration changes.
316330
// - https://github.com/benoitc/gunicorn/issues/3353
331+
//
317332
// Right now the config file is on the same configMap as the cluster file
318333
// so if the mtime changes for any of those files, it will change for all.
319334
var reloadScript = `
320335
exec {fd}<> <(:||:)
321336
while read -r -t 5 -u "${fd}" ||:; do
322-
if [[ "${cluster_file}" -nt "/proc/self/fd/${fd}" ]] && loadServerCommand && kill -KILL $(head -1 ${PGADMIN4_PIDFILE?});
337+
if [[ "${cluster_file}" -nt "/proc/self/fd/${fd}" ]] && loadServerCommand && kill -TERM $(head -1 ${PGADMIN4_PIDFILE?});
323338
then
324339
exec {fd}>&- && exec {fd}<> <(:||:)
325340
stat --format='Loaded shared servers dated %y' "${cluster_file}"
@@ -375,12 +390,31 @@ with open('` + configMountPath + `/` + configFilePath + `') as _f:
375390
_conf, _data = re.compile(r'[A-Z_0-9]+'), json.load(_f)
376391
if type(_data) is dict:
377392
globals().update({k: v for k, v in _data.items() if _conf.fullmatch(k)})
393+
if 'OAUTH2_CONFIG' in globals() and type(OAUTH2_CONFIG) is list:
394+
OAUTH2_CONFIG = [_conf for _conf in OAUTH2_CONFIG if type(_conf) is dict and 'OAUTH2_NAME' in _conf]
395+
for _f in reversed(glob.glob('` + oauthAbsolutePath + `/[0-9][0-9]-*.json')):
396+
if 'OAUTH2_CONFIG' not in globals() or type(OAUTH2_CONFIG) is not list:
397+
OAUTH2_CONFIG = []
398+
try:
399+
with open(_f) as _f:
400+
_data, _name = json.load(_f), os.path.basename(_f.name)[3:-5]
401+
_data, _next = { 'OAUTH2_NAME': _name } | _data, []
402+
for _conf in OAUTH2_CONFIG:
403+
if _data['OAUTH2_NAME'] == _conf.get('OAUTH2_NAME'):
404+
_data = _conf | _data
405+
else:
406+
_next.append(_conf)
407+
OAUTH2_CONFIG = [_data] + _next
408+
del _next
409+
except:
410+
pass
378411
if os.path.isfile('` + ldapPasswordAbsolutePath + `'):
379412
with open('` + ldapPasswordAbsolutePath + `') as _f:
380413
LDAP_BIND_PASSWORD = _f.read()
381414
if os.path.isfile('` + configDatabaseURIPathAbsolutePath + `'):
382415
with open('` + configDatabaseURIPathAbsolutePath + `') as _f:
383416
CONFIG_DATABASE_URI = _f.read()
417+
del _conf, _data, _f
384418
`
385419

386420
// Gunicorn reads from the `/etc/pgadmin/gunicorn_config.py` file during startup

‎internal/controller/standalone_pgadmin/pod_test.go

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ containers:
7575
7676
exec {fd}<> <(:||:)
7777
while read -r -t 5 -u "${fd}" ||:; do
78-
if [[ "${cluster_file}" -nt "/proc/self/fd/${fd}" ]] && loadServerCommand && kill -KILL $(head -1 ${PGADMIN4_PIDFILE?});
78+
if [[ "${cluster_file}" -nt "/proc/self/fd/${fd}" ]] && loadServerCommand && kill -TERM $(head -1 ${PGADMIN4_PIDFILE?});
7979
then
8080
exec {fd}>&- && exec {fd}<> <(:||:)
8181
stat --format='Loaded shared servers dated %y' "${cluster_file}"
@@ -149,12 +149,31 @@ initContainers:
149149
_conf, _data = re.compile(r'[A-Z_0-9]+'), json.load(_f)
150150
if type(_data) is dict:
151151
globals().update({k: v for k, v in _data.items() if _conf.fullmatch(k)})
152+
if 'OAUTH2_CONFIG' in globals() and type(OAUTH2_CONFIG) is list:
153+
OAUTH2_CONFIG = [_conf for _conf in OAUTH2_CONFIG if type(_conf) is dict and 'OAUTH2_NAME' in _conf]
154+
for _f in reversed(glob.glob('/etc/pgadmin/conf.d/~postgres-operator/oauth-config/[0-9][0-9]-*.json')):
155+
if 'OAUTH2_CONFIG' not in globals() or type(OAUTH2_CONFIG) is not list:
156+
OAUTH2_CONFIG = []
157+
try:
158+
with open(_f) as _f:
159+
_data, _name = json.load(_f), os.path.basename(_f.name)[3:-5]
160+
_data, _next = { 'OAUTH2_NAME': _name } | _data, []
161+
for _conf in OAUTH2_CONFIG:
162+
if _data['OAUTH2_NAME'] == _conf.get('OAUTH2_NAME'):
163+
_data = _conf | _data
164+
else:
165+
_next.append(_conf)
166+
OAUTH2_CONFIG = [_data] + _next
167+
del _next
168+
except:
169+
pass
152170
if os.path.isfile('/etc/pgadmin/conf.d/~postgres-operator/ldap-bind-password'):
153171
with open('/etc/pgadmin/conf.d/~postgres-operator/ldap-bind-password') as _f:
154172
LDAP_BIND_PASSWORD = _f.read()
155173
if os.path.isfile('/etc/pgadmin/conf.d/~postgres-operator/config-database-uri'):
156174
with open('/etc/pgadmin/conf.d/~postgres-operator/config-database-uri') as _f:
157175
CONFIG_DATABASE_URI = _f.read()
176+
del _conf, _data, _f
158177
- |
159178
import json, re, gunicorn
160179
gunicorn.SERVER_SOFTWARE = 'Python'
@@ -257,7 +276,7 @@ containers:
257276
258277
exec {fd}<> <(:||:)
259278
while read -r -t 5 -u "${fd}" ||:; do
260-
if [[ "${cluster_file}" -nt "/proc/self/fd/${fd}" ]] && loadServerCommand && kill -KILL $(head -1 ${PGADMIN4_PIDFILE?});
279+
if [[ "${cluster_file}" -nt "/proc/self/fd/${fd}" ]] && loadServerCommand && kill -TERM $(head -1 ${PGADMIN4_PIDFILE?});
261280
then
262281
exec {fd}>&- && exec {fd}<> <(:||:)
263282
stat --format='Loaded shared servers dated %y' "${cluster_file}"
@@ -335,12 +354,31 @@ initContainers:
335354
_conf, _data = re.compile(r'[A-Z_0-9]+'), json.load(_f)
336355
if type(_data) is dict:
337356
globals().update({k: v for k, v in _data.items() if _conf.fullmatch(k)})
357+
if 'OAUTH2_CONFIG' in globals() and type(OAUTH2_CONFIG) is list:
358+
OAUTH2_CONFIG = [_conf for _conf in OAUTH2_CONFIG if type(_conf) is dict and 'OAUTH2_NAME' in _conf]
359+
for _f in reversed(glob.glob('/etc/pgadmin/conf.d/~postgres-operator/oauth-config/[0-9][0-9]-*.json')):
360+
if 'OAUTH2_CONFIG' not in globals() or type(OAUTH2_CONFIG) is not list:
361+
OAUTH2_CONFIG = []
362+
try:
363+
with open(_f) as _f:
364+
_data, _name = json.load(_f), os.path.basename(_f.name)[3:-5]
365+
_data, _next = { 'OAUTH2_NAME': _name } | _data, []
366+
for _conf in OAUTH2_CONFIG:
367+
if _data['OAUTH2_NAME'] == _conf.get('OAUTH2_NAME'):
368+
_data = _conf | _data
369+
else:
370+
_next.append(_conf)
371+
OAUTH2_CONFIG = [_data] + _next
372+
del _next
373+
except:
374+
pass
338375
if os.path.isfile('/etc/pgadmin/conf.d/~postgres-operator/ldap-bind-password'):
339376
with open('/etc/pgadmin/conf.d/~postgres-operator/ldap-bind-password') as _f:
340377
LDAP_BIND_PASSWORD = _f.read()
341378
if os.path.isfile('/etc/pgadmin/conf.d/~postgres-operator/config-database-uri'):
342379
with open('/etc/pgadmin/conf.d/~postgres-operator/config-database-uri') as _f:
343380
CONFIG_DATABASE_URI = _f.read()
381+
del _conf, _data, _f
344382
- |
345383
import json, re, gunicorn
346384
gunicorn.SERVER_SOFTWARE = 'Python'

‎internal/shell/paths.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,23 @@ import (
1414
"strings"
1515
)
1616

17+
// CleanFileName returns the suffix of path after its last slash U+002F.
18+
// This is similar to "basename" except this returns empty string when:
19+
// - The final character of path is slash U+002F, or
20+
// - The result would be "." or ".."
21+
//
22+
// See:
23+
// - https://pubs.opengroup.org/onlinepubs/9799919799/utilities/basename.html
24+
func CleanFileName(path string) string {
25+
if i := strings.LastIndexByte(path, '/'); i >= 0 {
26+
path = path[i+1:]
27+
}
28+
if path != "." && path != ".." {
29+
return path
30+
}
31+
return ""
32+
}
33+
1734
// MakeDirectories returns a list of POSIX shell commands that ensure each path
1835
// exists. It creates every directory leading to path from (but not including)
1936
// base and sets their permissions to exactly perms, regardless of umask.

‎internal/shell/paths_test.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,36 @@ import (
1717
"github.com/crunchydata/postgres-operator/internal/testing/require"
1818
)
1919

20+
func TestCleanFileName(t *testing.T) {
21+
t.Parallel()
22+
23+
t.Run("Empty", func(t *testing.T) {
24+
assert.Equal(t, CleanFileName(""), "")
25+
})
26+
27+
t.Run("Dots", func(t *testing.T) {
28+
assert.Equal(t, CleanFileName("."), "")
29+
assert.Equal(t, CleanFileName(".."), "")
30+
assert.Equal(t, CleanFileName("..."), "...")
31+
assert.Equal(t, CleanFileName("././/.././../."), "")
32+
assert.Equal(t, CleanFileName("././/.././../.."), "")
33+
assert.Equal(t, CleanFileName("././/.././../../x.j"), "x.j")
34+
})
35+
36+
t.Run("Directories", func(t *testing.T) {
37+
assert.Equal(t, CleanFileName("/"), "")
38+
assert.Equal(t, CleanFileName("//"), "")
39+
assert.Equal(t, CleanFileName("asdf/"), "")
40+
assert.Equal(t, CleanFileName("asdf//12.3"), "12.3")
41+
assert.Equal(t, CleanFileName("//////"), "")
42+
assert.Equal(t, CleanFileName("//////gg"), "gg")
43+
})
44+
45+
t.Run("NoSeparators", func(t *testing.T) {
46+
assert.Equal(t, CleanFileName("asdf12.3.ssgg"), "asdf12.3.ssgg")
47+
})
48+
}
49+
2050
func TestMakeDirectories(t *testing.T) {
2151
t.Parallel()
2252

‎pkg/apis/postgres-operator.crunchydata.com/v1beta1/standalone_pgadmin_types.go

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ type StandalonePGAdminConfiguration struct {
2121
// +optional
2222
ConfigDatabaseURI *OptionalSecretKeyRef `json:"configDatabaseURI,omitempty"`
2323

24-
// Settings for the gunicorn server.
24+
// Settings for the Gunicorn server.
2525
// More info: https://docs.gunicorn.org/en/latest/settings.html
2626
// +optional
2727
// +kubebuilder:pruning:PreserveUnknownFields
@@ -37,11 +37,46 @@ type StandalonePGAdminConfiguration struct {
3737
// Settings for the pgAdmin server process. Keys should be uppercase and
3838
// values must be constants.
3939
// More info: https://www.pgadmin.org/docs/pgadmin4/latest/config_py.html
40-
// +optional
40+
// ---
4141
// +kubebuilder:pruning:PreserveUnknownFields
4242
// +kubebuilder:validation:Schemaless
4343
// +kubebuilder:validation:Type=object
44+
//
45+
// +mapType=granular
46+
// +optional
4447
Settings SchemalessObject `json:"settings,omitempty"`
48+
49+
// Secrets for the `OAUTH2_CONFIG` setting. If there are `OAUTH2_CONFIG` values
50+
// in the settings field, they will be combined with the values loaded here.
51+
// More info: https://www.pgadmin.org/docs/pgadmin4/latest/oauth2.html
52+
// ---
53+
// The controller expects this number to be no more than two digits.
54+
// +kubebuilder:validation:MinItems=1
55+
// +kubebuilder:validation:MaxItems=10
56+
//
57+
// +listType=map
58+
// +listMapKey=name
59+
// +optional
60+
OAuthConfigurations []PGAdminOAuthConfig `json:"oauthConfigurations,omitempty"`
61+
}
62+
63+
// +structType=atomic
64+
type PGAdminOAuthConfig struct {
65+
// The OAUTH2_NAME of this configuration.
66+
// ---
67+
// This goes into a filename, so let's keep it short and simple.
68+
// The Secret is allowed to contain OAUTH2_NAME and deviate from this.
69+
// +kubebuilder:validation:Pattern=`^[A-Za-z0-9]+$`
70+
//
71+
// +kubebuilder:validation:MinLength=1
72+
// +kubebuilder:validation:MaxLength=20
73+
// +required
74+
Name string `json:"name"`
75+
76+
// A Secret containing the settings of one OAuth2 provider as a JSON object.
77+
// ---
78+
// +required
79+
Secret SecretKeyRef `json:"secret"`
4580
}
4681

4782
// PGAdminSpec defines the desired state of PGAdmin

‎pkg/apis/postgres-operator.crunchydata.com/v1beta1/zz_generated.deepcopy.go

Lines changed: 23 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)
Please sign in to comment.