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 9 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
24 changes: 24 additions & 0 deletions config/crd/bases/postgres-operator.crunchydata.com_pgadmins.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1353,6 +1353,30 @@ spec:
- name
type: object
x-kubernetes-map-type: atomic
oauthConfigurations:
description: |-
OauthConfigurations allows the user to reference one or more Secrets
containing OAUTH2 configuration settings for pgAdmin.
Each Secret shall contain a single data key called oauth-config.json
whose value is a JSON object containing the OAUTH2 configuration settings.
More info: https://www.pgadmin.org/docs/pgadmin4/latest/oauth2.html
items:
description: |-
LocalObjectReference contains enough information to let you locate the
referenced object inside the same namespace.
properties:
name:
default: ""
description: |-
Name of the referent.
This field is effectively required, but due to backwards compatibility is
allowed to be empty. Instances of this type with an empty value here are
almost certainly wrong.
More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
type: string
type: object
x-kubernetes-map-type: atomic
type: array
settings:
description: |-
Settings for the pgAdmin server process. Keys should be uppercase and
Expand Down
20 changes: 20 additions & 0 deletions internal/controller/standalone_pgadmin/configmap.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"strings"

corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/types"

"github.com/pkg/errors"

Expand All @@ -27,6 +28,25 @@ import (
// +kubebuilder:rbac:groups="",resources="configmaps",verbs={get}
// +kubebuilder:rbac:groups="",resources="configmaps",verbs={create,delete,patch}

// reconcileOauthConfigSecrets reconciles the Oauth Configuration Secrets for pgAdmin.
func (r *PGAdminReconciler) reconcileOauthConfigSecrets(
ctx context.Context, pgadmin *v1beta1.PGAdmin,
) ([]corev1.Secret, error) {

var secrets []corev1.Secret
for _, secretRef := range pgadmin.Spec.Config.OauthConfigurations {
var secret corev1.Secret
secretKey := types.NamespacedName{Name: secretRef.Name, Namespace: pgadmin.Namespace}

if err := r.Get(ctx, secretKey, &secret); err != nil {
return nil, fmt.Errorf("failed to get Secret %s: %w", secretRef.Name, err)
}
secrets = append(secrets, secret)
}

return secrets, nil
}

// reconcilePGAdminConfigMap writes the ConfigMap for pgAdmin.
func (r *PGAdminReconciler) reconcilePGAdminConfigMap(
ctx context.Context, pgadmin *v1beta1.PGAdmin,
Expand Down
20 changes: 15 additions & 5 deletions internal/controller/standalone_pgadmin/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,12 @@ func (r *PGAdminReconciler) SetupWithManager(mgr ctrl.Manager) error {
return runtime.Requests(r.findPGAdminsForSecret(ctx, client.ObjectKeyFromObject(secret))...)
}),
).
Watches(
&corev1.Secret{},
handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, secret client.Object) []ctrl.Request {
return runtime.Requests(r.findPGAdminsForOauthSecret(ctx, client.ObjectKeyFromObject(secret))...)
}),
).
Complete(r)
}

Expand Down Expand Up @@ -116,15 +122,19 @@ func (r *PGAdminReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct
pgAdmin.Default()

var (
configmap *corev1.ConfigMap
dataVolume *corev1.PersistentVolumeClaim
clusters map[string][]*v1beta1.PostgresCluster
_ *corev1.Service
configmap *corev1.ConfigMap
dataVolume *corev1.PersistentVolumeClaim
clusters map[string][]*v1beta1.PostgresCluster
oauthSecrets []corev1.Secret
_ *corev1.Service
)

if err == nil {
clusters, err = r.getClustersForPGAdmin(ctx, pgAdmin)
}
if err == nil {
oauthSecrets, err = r.reconcileOauthConfigSecrets(ctx, pgAdmin)
}
if err == nil {
configmap, err = r.reconcilePGAdminConfigMap(ctx, pgAdmin, clusters)
}
Expand All @@ -135,7 +145,7 @@ func (r *PGAdminReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct
err = r.reconcilePGAdminService(ctx, pgAdmin)
}
if err == nil {
err = r.reconcilePGAdminStatefulSet(ctx, pgAdmin, configmap, dataVolume)
err = r.reconcilePGAdminStatefulSet(ctx, pgAdmin, configmap, dataVolume, oauthSecrets)
}
if err == nil {
err = r.reconcilePGAdminUsers(ctx, pgAdmin)
Expand Down
39 changes: 37 additions & 2 deletions internal/controller/standalone_pgadmin/pod.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const (
configDatabaseURIPath = "~postgres-operator/config-database-uri"
ldapFilePath = "~postgres-operator/ldap-bind-password"
gunicornConfigFilePath = "~postgres-operator/" + gunicornConfigKey
oauthConfigDir = "~postgres-operator/oauth-config/"

// scriptMountPath is where to mount a temporary directory that is only
// writable during Pod initialization.
Expand All @@ -49,14 +50,15 @@ func pod(
inConfigMap *corev1.ConfigMap,
outPod *corev1.PodSpec,
pgAdminVolume *corev1.PersistentVolumeClaim,
oauthSecrets []corev1.Secret,
) {
// create the projected volume of config maps for use in
// 1. dynamic server discovery
// 2. adding the config variables during pgAdmin startup
configVolume := corev1.Volume{Name: "pgadmin-config"}
configVolume.VolumeSource = corev1.VolumeSource{
Projected: &corev1.ProjectedVolumeSource{
Sources: podConfigFiles(inConfigMap, *inPGAdmin),
Sources: podConfigFiles(inConfigMap, *inPGAdmin, oauthSecrets),
},
}

Expand Down Expand Up @@ -200,7 +202,8 @@ func pod(

// podConfigFiles returns projections of pgAdmin's configuration files to
// include in the configuration volume.
func podConfigFiles(configmap *corev1.ConfigMap, pgadmin v1beta1.PGAdmin) []corev1.VolumeProjection {
func podConfigFiles(configmap *corev1.ConfigMap, pgadmin v1beta1.PGAdmin,
oauthSecrets []corev1.Secret) []corev1.VolumeProjection {

config := append(append([]corev1.VolumeProjection{}, pgadmin.Spec.Config.Files...),
[]corev1.VolumeProjection{
Expand All @@ -227,6 +230,24 @@ func podConfigFiles(configmap *corev1.ConfigMap, pgadmin v1beta1.PGAdmin) []core
},
}...)

if pgadmin.Spec.Config.OauthConfigurations != nil {
for _, secret := range oauthSecrets {
config = append(config, corev1.VolumeProjection{
Secret: &corev1.SecretProjection{
LocalObjectReference: corev1.LocalObjectReference{
Name: secret.Name,
},
Items: []corev1.KeyToPath{
{
Key: "oauth-config",
Path: fmt.Sprintf("%s%s.json", oauthConfigDir, secret.Name),
},
},
},
})
}
}

if pgadmin.Spec.Config.ConfigDatabaseURI != nil {
config = append(config, corev1.VolumeProjection{
Secret: initialize.Pointer(
Expand Down Expand Up @@ -382,6 +403,20 @@ import glob, json, re, os, logging
DEFAULT_BINARY_PATHS = {'pg': sorted([''] + glob.glob('/usr/pgsql-*/bin')).pop()}
with open('` + configMountPath + `/` + configFilePath + `') as _f:
_conf, _data = re.compile(r'[A-Z_0-9]+'), json.load(_f)
folder_path = '` + configMountPath + `/` + oauthConfigDir + `'
if os.path.isdir(folder_path):
for filename in os.listdir(folder_path):
with open(os.path.join(folder_path, filename), "r", encoding="utf-8") as f:
try:
oath = json.load(f)
if oath.get("OAUTH2_NAME") not in [
o.get("OAUTH2_NAME") for o in _data.get("OAUTH2_CONFIG")]:
_data.get("OAUTH2_CONFIG").append(oath)
for o in _data.get("OAUTH2_CONFIG"):
if o.get("OAUTH2_NAME") == oath.get("OAUTH2_NAME"):
o.update(oath)
except Exception as e:
print(f"An unexpected error occurred: {e}")
if type(_data) is dict:
globals().update({k: v for k, v in _data.items() if _conf.fullmatch(k)})
if os.path.isfile('` + ldapPasswordAbsolutePath + `'):
Expand Down
32 changes: 30 additions & 2 deletions internal/controller/standalone_pgadmin/pod_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ func TestPod(t *testing.T) {
testpod := new(corev1.PodSpec)
pvc := new(corev1.PersistentVolumeClaim)

call := func() { pod(pgadmin, config, testpod, pvc) }
call := func() { pod(pgadmin, config, testpod, pvc, nil) }

t.Run("Defaults", func(t *testing.T) {

Expand Down Expand Up @@ -148,6 +148,20 @@ initContainers:
DEFAULT_BINARY_PATHS = {'pg': sorted([''] + glob.glob('/usr/pgsql-*/bin')).pop()}
with open('/etc/pgadmin/conf.d/~postgres-operator/pgadmin-settings.json') as _f:
_conf, _data = re.compile(r'[A-Z_0-9]+'), json.load(_f)
folder_path = '/etc/pgadmin/conf.d/~postgres-operator/oauth-config/'
if os.path.isdir(folder_path):
for filename in os.listdir(folder_path):
with open(os.path.join(folder_path, filename), "r", encoding="utf-8") as f:
try:
oath = json.load(f)
if oath.get("OAUTH2_NAME") not in [
o.get("OAUTH2_NAME") for o in _data.get("OAUTH2_CONFIG")]:
_data.get("OAUTH2_CONFIG").append(oath)
for o in _data.get("OAUTH2_CONFIG"):
if o.get("OAUTH2_NAME") == oath.get("OAUTH2_NAME"):
o.update(oath)
except Exception as e:
print(f"An unexpected error occurred: {e}")
if type(_data) is dict:
globals().update({k: v for k, v in _data.items() if _conf.fullmatch(k)})
if os.path.isfile('/etc/pgadmin/conf.d/~postgres-operator/ldap-bind-password'):
Expand Down Expand Up @@ -367,6 +381,20 @@ initContainers:
DEFAULT_BINARY_PATHS = {'pg': sorted([''] + glob.glob('/usr/pgsql-*/bin')).pop()}
with open('/etc/pgadmin/conf.d/~postgres-operator/pgadmin-settings.json') as _f:
_conf, _data = re.compile(r'[A-Z_0-9]+'), json.load(_f)
folder_path = '/etc/pgadmin/conf.d/~postgres-operator/oauth-config/'
if os.path.isdir(folder_path):
for filename in os.listdir(folder_path):
with open(os.path.join(folder_path, filename), "r", encoding="utf-8") as f:
try:
oath = json.load(f)
if oath.get("OAUTH2_NAME") not in [
o.get("OAUTH2_NAME") for o in _data.get("OAUTH2_CONFIG")]:
_data.get("OAUTH2_CONFIG").append(oath)
for o in _data.get("OAUTH2_CONFIG"):
if o.get("OAUTH2_NAME") == oath.get("OAUTH2_NAME"):
o.update(oath)
except Exception as e:
print(f"An unexpected error occurred: {e}")
if type(_data) is dict:
globals().update({k: v for k, v in _data.items() if _conf.fullmatch(k)})
if os.path.isfile('/etc/pgadmin/conf.d/~postgres-operator/ldap-bind-password'):
Expand Down Expand Up @@ -477,7 +505,7 @@ func TestPodConfigFiles(t *testing.T) {
},
}

projections := podConfigFiles(configmap, pgadmin)
projections := podConfigFiles(configmap, pgadmin, nil)
assert.Assert(t, cmp.MarshalMatches(projections, `
- secret:
name: test-secret
Expand Down
27 changes: 27 additions & 0 deletions internal/controller/standalone_pgadmin/related.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,33 @@ func (r *PGAdminReconciler) findPGAdminsForSecret(
return matching
}

//+kubebuilder:rbac:groups="postgres-operator.crunchydata.com",resources="pgadmins",verbs={list}

// findPGAdminsForOauthSecret returns PGAdmins with an OauthConfiguration referencing a Secret
func (r *PGAdminReconciler) findPGAdminsForOauthSecret(
ctx context.Context, secret client.ObjectKey,
) []*v1beta1.PGAdmin {

var matching []*v1beta1.PGAdmin
var pgadmins v1beta1.PGAdminList

if err := r.Client.List(ctx, &pgadmins, &client.ListOptions{
Namespace: secret.Namespace,
}); err == nil {
for i := range pgadmins.Items {

for j := range pgadmins.Items[i].Spec.Config.OauthConfigurations {
if pgadmins.Items[i].Spec.Config.OauthConfigurations[j].Name == secret.Name {
matching = append(matching, &pgadmins.Items[i])
break
}
}

}
}
return matching
}

//+kubebuilder:rbac:groups="postgres-operator.crunchydata.com",resources="postgresclusters",verbs={get,list}

// getClustersForPGAdmin returns clusters managed by the given pgAdmin
Expand Down
55 changes: 53 additions & 2 deletions internal/controller/standalone_pgadmin/statefulset.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ package standalone_pgadmin

import (
"context"
"crypto/sha256"
"encoding/hex"
"strings"

appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
Expand All @@ -26,8 +29,9 @@ import (
func (r *PGAdminReconciler) reconcilePGAdminStatefulSet(
ctx context.Context, pgadmin *v1beta1.PGAdmin,
configmap *corev1.ConfigMap, dataVolume *corev1.PersistentVolumeClaim,
oauthSecrets []corev1.Secret,
) error {
sts := statefulset(ctx, pgadmin, configmap, dataVolume)
sts := statefulset(ctx, pgadmin, configmap, dataVolume, oauthSecrets)

// Previous versions of PGO used a StatefulSet Pod Management Policy that could leave the Pod
// in a failed state. When we see that it has the wrong policy, we will delete the StatefulSet
Expand Down Expand Up @@ -64,6 +68,7 @@ func statefulset(
pgadmin *v1beta1.PGAdmin,
configmap *corev1.ConfigMap,
dataVolume *corev1.PersistentVolumeClaim,
oauthSecrets []corev1.Secret,
) *appsv1.StatefulSet {
sts := &appsv1.StatefulSet{ObjectMeta: naming.StandalonePGAdmin(pgadmin)}
sts.SetGroupVersionKind(appsv1.SchemeGroupVersion.WithKind("StatefulSet"))
Expand Down Expand Up @@ -119,7 +124,7 @@ func statefulset(

sts.Spec.Template.Spec.SecurityContext = podSecurityContext(ctx)

pod(pgadmin, configmap, &sts.Spec.Template.Spec, dataVolume)
pod(pgadmin, configmap, &sts.Spec.Template.Spec, dataVolume, oauthSecrets)

if feature.Enabled(ctx, feature.OpenTelemetryLogs) {
// Logs for gunicorn and pgadmin write to /var/lib/pgadmin/logs
Expand All @@ -135,5 +140,51 @@ func statefulset(
configmap, &sts.Spec.Template.Spec, volumeMounts, "", []string{}, false)
}

// Determine if a rollout because Secrets and ConfigMaps have changed
checkOauthSecretsChange(oauthSecrets, sts)
checkConfigMapChange(configmap, sts)

return sts
}

func checkOauthSecretsChange(oauthSecrets []corev1.Secret, sts *appsv1.StatefulSet) {
var secretHash, currentHash string
var sb strings.Builder

for _, secret := range oauthSecrets {
hash := sha256.New()
for key, value := range secret.Data {
hash.Write([]byte(key))
hash.Write(value)
}
encoding := hex.EncodeToString(hash.Sum(nil))
sb.WriteString(encoding)
}
secretHash = sb.String()
currentHash = sts.Spec.Template.Annotations["oauthSecretsHash"]

if currentHash != secretHash {
if sts.Spec.Template.Annotations == nil {
sts.Spec.Template.Annotations = map[string]string{}
}
sts.Spec.Template.Annotations["oauthSecretsHash"] = secretHash
}
}

func checkConfigMapChange(configmap *corev1.ConfigMap, sts *appsv1.StatefulSet) {
var secretHash, currentHash string
hash := sha256.New()
for key, value := range configmap.Data {
hash.Write([]byte(key))
hash.Write([]byte(value))
}
secretHash = hex.EncodeToString(hash.Sum(nil))
currentHash = sts.Spec.Template.Annotations["configMapHash"]

if currentHash != secretHash {
if sts.Spec.Template.Annotations == nil {
sts.Spec.Template.Annotations = map[string]string{}
}
sts.Spec.Template.Annotations["configMapHash"] = secretHash
}
}
Loading