Skip to content

Pgadmin oauth secrets #4123

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 16 commits into from
Mar 12, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 50 additions & 1 deletion config/crd/bases/postgres-operator.crunchydata.com_pgadmins.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1320,7 +1320,7 @@ spec:
type: array
gunicorn:
description: |-
Settings for the gunicorn server.
Settings for the Gunicorn server.
More info: https://docs.gunicorn.org/en/latest/settings.html
type: object
x-kubernetes-preserve-unknown-fields: true
Expand Down Expand Up @@ -1353,12 +1353,61 @@ spec:
- name
type: object
x-kubernetes-map-type: atomic
oauthConfigurations:
description: |-
Secrets for the `OAUTH2_CONFIG` setting. If there are `OAUTH2_CONFIG` values
in the settings field, they will be combined with the values loaded here.
More info: https://www.pgadmin.org/docs/pgadmin4/latest/oauth2.html
items:
properties:
name:
description: The OAUTH2_NAME of this configuration.
maxLength: 20
minLength: 1
pattern: ^[A-Za-z0-9]+$
type: string
secret:
description: A Secret containing the settings of one OAuth2
provider as a JSON object.
properties:
key:
description: Name of the data field within the Secret.
maxLength: 253
minLength: 1
pattern: ^[-._a-zA-Z0-9]+$
type: string
x-kubernetes-validations:
- message: cannot be "." or start with ".."
rule: self != "." && !self.startsWith("..")
name:
description: Name of the Secret.
maxLength: 253
minLength: 1
pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?([.][a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
type: string
required:
- key
- name
type: object
x-kubernetes-map-type: atomic
required:
- name
- secret
type: object
x-kubernetes-map-type: atomic
maxItems: 10
minItems: 1
type: array
x-kubernetes-list-map-keys:
- name
x-kubernetes-list-type: map
settings:
description: |-
Settings for the pgAdmin server process. Keys should be uppercase and
values must be constants.
More info: https://www.pgadmin.org/docs/pgadmin4/latest/config_py.html
type: object
x-kubernetes-map-type: granular
x-kubernetes-preserve-unknown-fields: true
type: object
dataVolumeClaimSpec:
Expand Down
40 changes: 37 additions & 3 deletions internal/controller/standalone_pgadmin/pod.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ const (
configDatabaseURIPath = "~postgres-operator/config-database-uri"
ldapFilePath = "~postgres-operator/ldap-bind-password"
gunicornConfigFilePath = "~postgres-operator/" + gunicornConfigKey
oauthConfigDir = "~postgres-operator/oauth-config"
oauthAbsolutePath = configMountPath + "/" + oauthConfigDir

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

for i, oauth := range pgadmin.Spec.Config.OAuthConfigurations {
// Safely encode the OAUTH2_NAME in the file name. Prepend the index so
// the files can be loaded in the order they are defined in the spec.
mountPath := fmt.Sprintf(
"%s/%02d-%s.json", oauthConfigDir, i, shell.CleanFileName(oauth.Name),
)
config = append(config, corev1.VolumeProjection{
Secret: initialize.Pointer(oauth.Secret.AsProjection(mountPath)),
})
}

if pgadmin.Spec.Config.ConfigDatabaseURI != nil {
config = append(config, corev1.VolumeProjection{
Secret: initialize.Pointer(
Expand Down Expand Up @@ -311,15 +324,17 @@ loadServerCommand
// descriptor and uses the timeout of the builtin `read` to wait. That same
// descriptor gets closed and reopened to use the builtin `[ -nt` to check mtimes.
// - https://unix.stackexchange.com/a/407383
// In order to get gunicorn to reload the logging config
// we need to send a KILL rather than a HUP signal.
//
// Gunicorn needs a SIGTERM rather than SIGHUP to reload its logging config.
// This also causes pgAdmin to restart when its configuration changes.
// - https://github.com/benoitc/gunicorn/issues/3353
//
// Right now the config file is on the same configMap as the cluster file
// so if the mtime changes for any of those files, it will change for all.
var reloadScript = `
exec {fd}<> <(:||:)
while read -r -t 5 -u "${fd}" ||:; do
if [[ "${cluster_file}" -nt "/proc/self/fd/${fd}" ]] && loadServerCommand && kill -KILL $(head -1 ${PGADMIN4_PIDFILE?});
if [[ "${cluster_file}" -nt "/proc/self/fd/${fd}" ]] && loadServerCommand && kill -TERM $(head -1 ${PGADMIN4_PIDFILE?});
then
exec {fd}>&- && exec {fd}<> <(:||:)
stat --format='Loaded shared servers dated %y' "${cluster_file}"
Expand Down Expand Up @@ -375,12 +390,31 @@ with open('` + configMountPath + `/` + configFilePath + `') as _f:
_conf, _data = re.compile(r'[A-Z_0-9]+'), json.load(_f)
if type(_data) is dict:
globals().update({k: v for k, v in _data.items() if _conf.fullmatch(k)})
if 'OAUTH2_CONFIG' in globals() and type(OAUTH2_CONFIG) is list:
OAUTH2_CONFIG = [_conf for _conf in OAUTH2_CONFIG if type(_conf) is dict and 'OAUTH2_NAME' in _conf]
for _f in reversed(glob.glob('` + oauthAbsolutePath + `/[0-9][0-9]-*.json')):
if 'OAUTH2_CONFIG' not in globals() or type(OAUTH2_CONFIG) is not list:
OAUTH2_CONFIG = []
try:
with open(_f) as _f:
_data, _name = json.load(_f), os.path.basename(_f.name)[3:-5]
_data, _next = { 'OAUTH2_NAME': _name } | _data, []
for _conf in OAUTH2_CONFIG:
if _data['OAUTH2_NAME'] == _conf.get('OAUTH2_NAME'):
_data = _conf | _data
else:
_next.append(_conf)
OAUTH2_CONFIG = [_data] + _next
del _next
except:
pass
if os.path.isfile('` + ldapPasswordAbsolutePath + `'):
with open('` + ldapPasswordAbsolutePath + `') as _f:
LDAP_BIND_PASSWORD = _f.read()
if os.path.isfile('` + configDatabaseURIPathAbsolutePath + `'):
with open('` + configDatabaseURIPathAbsolutePath + `') as _f:
CONFIG_DATABASE_URI = _f.read()
del _conf, _data, _f
`

// Gunicorn reads from the `/etc/pgadmin/gunicorn_config.py` file during startup
Expand Down
42 changes: 40 additions & 2 deletions internal/controller/standalone_pgadmin/pod_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ containers:

exec {fd}<> <(:||:)
while read -r -t 5 -u "${fd}" ||:; do
if [[ "${cluster_file}" -nt "/proc/self/fd/${fd}" ]] && loadServerCommand && kill -KILL $(head -1 ${PGADMIN4_PIDFILE?});
if [[ "${cluster_file}" -nt "/proc/self/fd/${fd}" ]] && loadServerCommand && kill -TERM $(head -1 ${PGADMIN4_PIDFILE?});
then
exec {fd}>&- && exec {fd}<> <(:||:)
stat --format='Loaded shared servers dated %y' "${cluster_file}"
Expand Down Expand Up @@ -148,12 +148,31 @@ initContainers:
_conf, _data = re.compile(r'[A-Z_0-9]+'), json.load(_f)
if type(_data) is dict:
globals().update({k: v for k, v in _data.items() if _conf.fullmatch(k)})
if 'OAUTH2_CONFIG' in globals() and type(OAUTH2_CONFIG) is list:
OAUTH2_CONFIG = [_conf for _conf in OAUTH2_CONFIG if type(_conf) is dict and 'OAUTH2_NAME' in _conf]
for _f in reversed(glob.glob('/etc/pgadmin/conf.d/~postgres-operator/oauth-config/[0-9][0-9]-*.json')):
if 'OAUTH2_CONFIG' not in globals() or type(OAUTH2_CONFIG) is not list:
OAUTH2_CONFIG = []
try:
with open(_f) as _f:
_data, _name = json.load(_f), os.path.basename(_f.name)[3:-5]
_data, _next = { 'OAUTH2_NAME': _name } | _data, []
for _conf in OAUTH2_CONFIG:
if _data['OAUTH2_NAME'] == _conf.get('OAUTH2_NAME'):
_data = _conf | _data
else:
_next.append(_conf)
OAUTH2_CONFIG = [_data] + _next
del _next
except:
pass
if os.path.isfile('/etc/pgadmin/conf.d/~postgres-operator/ldap-bind-password'):
with open('/etc/pgadmin/conf.d/~postgres-operator/ldap-bind-password') as _f:
LDAP_BIND_PASSWORD = _f.read()
if os.path.isfile('/etc/pgadmin/conf.d/~postgres-operator/config-database-uri'):
with open('/etc/pgadmin/conf.d/~postgres-operator/config-database-uri') as _f:
CONFIG_DATABASE_URI = _f.read()
del _conf, _data, _f
- |
import json, re, gunicorn
gunicorn.SERVER_SOFTWARE = 'Python'
Expand Down Expand Up @@ -260,7 +279,7 @@ containers:

exec {fd}<> <(:||:)
while read -r -t 5 -u "${fd}" ||:; do
if [[ "${cluster_file}" -nt "/proc/self/fd/${fd}" ]] && loadServerCommand && kill -KILL $(head -1 ${PGADMIN4_PIDFILE?});
if [[ "${cluster_file}" -nt "/proc/self/fd/${fd}" ]] && loadServerCommand && kill -TERM $(head -1 ${PGADMIN4_PIDFILE?});
then
exec {fd}>&- && exec {fd}<> <(:||:)
stat --format='Loaded shared servers dated %y' "${cluster_file}"
Expand Down Expand Up @@ -338,12 +357,31 @@ initContainers:
_conf, _data = re.compile(r'[A-Z_0-9]+'), json.load(_f)
if type(_data) is dict:
globals().update({k: v for k, v in _data.items() if _conf.fullmatch(k)})
if 'OAUTH2_CONFIG' in globals() and type(OAUTH2_CONFIG) is list:
OAUTH2_CONFIG = [_conf for _conf in OAUTH2_CONFIG if type(_conf) is dict and 'OAUTH2_NAME' in _conf]
for _f in reversed(glob.glob('/etc/pgadmin/conf.d/~postgres-operator/oauth-config/[0-9][0-9]-*.json')):
if 'OAUTH2_CONFIG' not in globals() or type(OAUTH2_CONFIG) is not list:
OAUTH2_CONFIG = []
try:
with open(_f) as _f:
_data, _name = json.load(_f), os.path.basename(_f.name)[3:-5]
_data, _next = { 'OAUTH2_NAME': _name } | _data, []
for _conf in OAUTH2_CONFIG:
if _data['OAUTH2_NAME'] == _conf.get('OAUTH2_NAME'):
_data = _conf | _data
else:
_next.append(_conf)
OAUTH2_CONFIG = [_data] + _next
del _next
except:
pass
if os.path.isfile('/etc/pgadmin/conf.d/~postgres-operator/ldap-bind-password'):
with open('/etc/pgadmin/conf.d/~postgres-operator/ldap-bind-password') as _f:
LDAP_BIND_PASSWORD = _f.read()
if os.path.isfile('/etc/pgadmin/conf.d/~postgres-operator/config-database-uri'):
with open('/etc/pgadmin/conf.d/~postgres-operator/config-database-uri') as _f:
CONFIG_DATABASE_URI = _f.read()
del _conf, _data, _f
- |
import json, re, gunicorn
gunicorn.SERVER_SOFTWARE = 'Python'
Expand Down
17 changes: 17 additions & 0 deletions internal/shell/paths.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,23 @@ import (
"strings"
)

// CleanFileName returns the suffix of path after its last slash U+002F.
// This is similar to "basename" except this returns empty string when:
// - The final character of path is slash U+002F, or
// - The result would be "." or ".."
//
// See:
// - https://pubs.opengroup.org/onlinepubs/9799919799/utilities/basename.html
func CleanFileName(path string) string {
if i := strings.LastIndexByte(path, '/'); i >= 0 {
path = path[i+1:]
}
if path != "." && path != ".." {
return path
}
return ""
}

// MakeDirectories returns a list of POSIX shell commands that ensure each path
// exists. It creates every directory leading to path from (but not including)
// base and sets their permissions to exactly perms, regardless of umask.
Expand Down
30 changes: 30 additions & 0 deletions internal/shell/paths_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,36 @@ import (
"github.com/crunchydata/postgres-operator/internal/testing/require"
)

func TestCleanFileName(t *testing.T) {
t.Parallel()

t.Run("Empty", func(t *testing.T) {
assert.Equal(t, CleanFileName(""), "")
})

t.Run("Dots", func(t *testing.T) {
assert.Equal(t, CleanFileName("."), "")
assert.Equal(t, CleanFileName(".."), "")
assert.Equal(t, CleanFileName("..."), "...")
assert.Equal(t, CleanFileName("././/.././../."), "")
assert.Equal(t, CleanFileName("././/.././../.."), "")
assert.Equal(t, CleanFileName("././/.././../../x.j"), "x.j")
})

t.Run("Directories", func(t *testing.T) {
assert.Equal(t, CleanFileName("/"), "")
assert.Equal(t, CleanFileName("//"), "")
assert.Equal(t, CleanFileName("asdf/"), "")
assert.Equal(t, CleanFileName("asdf//12.3"), "12.3")
assert.Equal(t, CleanFileName("//////"), "")
assert.Equal(t, CleanFileName("//////gg"), "gg")
})

t.Run("NoSeparators", func(t *testing.T) {
assert.Equal(t, CleanFileName("asdf12.3.ssgg"), "asdf12.3.ssgg")
})
}

func TestMakeDirectories(t *testing.T) {
t.Parallel()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ type StandalonePGAdminConfiguration struct {
// +optional
ConfigDatabaseURI *OptionalSecretKeyRef `json:"configDatabaseURI,omitempty"`

// Settings for the gunicorn server.
// Settings for the Gunicorn server.
// More info: https://docs.gunicorn.org/en/latest/settings.html
// +optional
// +kubebuilder:pruning:PreserveUnknownFields
Expand All @@ -37,11 +37,46 @@ type StandalonePGAdminConfiguration struct {
// Settings for the pgAdmin server process. Keys should be uppercase and
// values must be constants.
// More info: https://www.pgadmin.org/docs/pgadmin4/latest/config_py.html
// +optional
// ---
// +kubebuilder:pruning:PreserveUnknownFields
// +kubebuilder:validation:Schemaless
// +kubebuilder:validation:Type=object
//
// +mapType=granular
// +optional
Settings SchemalessObject `json:"settings,omitempty"`

// Secrets for the `OAUTH2_CONFIG` setting. If there are `OAUTH2_CONFIG` values
// in the settings field, they will be combined with the values loaded here.
// More info: https://www.pgadmin.org/docs/pgadmin4/latest/oauth2.html
// ---
// The controller expects this number to be no more than two digits.
// +kubebuilder:validation:MinItems=1
// +kubebuilder:validation:MaxItems=10
//
// +listType=map
// +listMapKey=name
// +optional
OAuthConfigurations []PGAdminOAuthConfig `json:"oauthConfigurations,omitempty"`
}

// +structType=atomic
type PGAdminOAuthConfig struct {
// The OAUTH2_NAME of this configuration.
// ---
// This goes into a filename, so let's keep it short and simple.
// The Secret is allowed to contain OAUTH2_NAME and deviate from this.
// +kubebuilder:validation:Pattern=`^[A-Za-z0-9]+$`
//
// +kubebuilder:validation:MinLength=1
// +kubebuilder:validation:MaxLength=20
// +required
Name string `json:"name"`

// A Secret containing the settings of one OAuth2 provider as a JSON object.
// ---
// +required
Secret SecretKeyRef `json:"secret"`
}

// PGAdminSpec defines the desired state of PGAdmin
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.