diff --git a/charts/postgres-operator/crds/postgresqls.yaml b/charts/postgres-operator/crds/postgresqls.yaml index 515c0072..49627603 100644 --- a/charts/postgres-operator/crds/postgresqls.yaml +++ b/charts/postgres-operator/crds/postgresqls.yaml @@ -694,6 +694,19 @@ spec: type: string recoveryEventType: type: string + tde: + nullable: true + properties: + enable: + type: boolean + keybits: + type: integer + format: int32 + default: 128 + enum: + - 128 + - 192 + - 256 teamId: type: string tls: diff --git a/docs/hugo/content/en/crd/crd-postgresql.md b/docs/hugo/content/en/crd/crd-postgresql.md index 062f20b6..92325e82 100644 --- a/docs/hugo/content/en/crd/crd-postgresql.md +++ b/docs/hugo/content/en/crd/crd-postgresql.md @@ -57,7 +57,7 @@ weight: 331 | spiloRunAsUser | int | false | Sets the user ID which should be used in the container to run the process. This must be set to run the container without root. | | [standby](#standby) | map | false | Enables the creation of a standby cluster at the time of the creation of a new cluster | | [streams](#streams) | array | false | Enables change data capture streams for defined database tables | -| [tde](#tde) | map | false | Enables the activation of TDE if a new cluster is created | +| [tde](#tde) | map | false | Enables TDE and allows you to define options such as key bits. | | teamId | string | true | name of the team the cluster belongs to. Will be removed soon | | [tls](#tls) | map | false | Custom TLS certificate | | [tolerations](#tolerations) | array | false | a list of tolerations that apply to the cluster pods. Each element of that list is a dictionary with the following fields: @@ -256,9 +256,10 @@ key, operator, value, effect and tolerationSeconds | #### tde -| Name | Type | required | Description | -| ------------------------------ |:-------:| ---------:| ------------------:| -| enable | boolean | true | enable TDE during initDB | +| Name | Type | default | Description | +| ------------------------------ |:-------:| ---------:| -------------------------:| +| enable | boolean | false | enable TDE during initDB | +| keybits | integer | 256 | used to specify the key length in bits. Accepted values are 128, 192 and 256 (default). More Informations: [PGEE-Documentation](https://repository.cybertec.at/doc/18ee/encryption.html) | {{< back >}} diff --git a/docs/hugo/content/en/tde/_index.md b/docs/hugo/content/en/tde/_index.md index ce8a20e1..0d3767a4 100644 --- a/docs/hugo/content/en/tde/_index.md +++ b/docs/hugo/content/en/tde/_index.md @@ -27,7 +27,7 @@ Further information on TDE and PGEE can be found here: [CYBERTEC TDE](https://ww ## Securing clusters with TDE -The CYBERTEC pg operator, together with Patroni, takes over the setup and administration of the TDE functionality in conjunction with the cost-effective PGEE containers +The CYBERTEC-pg-operator, together with Patroni, takes over the setup and administration of the TDE functionality in conjunction with the cost-effective PGEE containers ### Preconditions - CYBERTEC-pgee-container @@ -36,7 +36,7 @@ The CYBERTEC pg operator, together with Patroni, takes over the setup and admini ### Deploy a TDE-Cluster Setting up a TDE cluster is basically the same as setting up a conventional cluster. -The only difference is the defined Postgres. container and the object TDE.enabled: true, which instructs the operator to initialise the database with the TDE functionality. +The only difference is the defined Postgres-container and the object TDE.enabled: true, which instructs the operator to initialise the database with the TDE functionality. ```yaml apiVersion: cpo.opensource.cybertec.at/v1 @@ -58,20 +58,65 @@ spec: memory: 500Mi tde: enable: true + keybits: 256 teamId: acid volume: size: 5Gi ``` - `dockerImage` - Must contain a PostgreSQL image of the pgee container suite - `tde.enabled`- initialises the DB with TDE +- `tde.keybits`- Defines keylength in bits. Possible Values: 128,192,256 Default: 256 {{< hint type=important >}} Please note that the activation of TDE is only possible when creating new clusters. Subsequent activation is not possible. {{< /hint >}} +### Key-Management + +For TDE, we or the operator must work with the required encryption key. The key is transferred to the Postgres containers using a secret. There are two basic options for the necessary key management. + +#### Automatic key generation (default) +If no existing secret is provided, the operator automatically generates a cryptographically secure key when creating a new cluster. This is stored in the secret in the cluster's namespace. + +It is important to note for key management that TDE allows you to choose from the available key lengths (128 bit, 192 bit, 256 bit). By default, the operator chooses 256 bit. The keybits parameter allows you to adjust this if desired. See [CRD](crd/crd-postgresql). +Further information on TDE can also be found in the [PGEE-Documentation](https://repository.cybertec.at/doc/18ee/encryption.html) + +#### Use of your own keys (Bring Your Own Key) +You have the option of defining your own encryption key before the cluster is created. +To do this, create a secret with the desired key in advance. +When starting, the operator checks whether a corresponding secret already exists and uses this instead of a newly generated key. +Use case: This enables the integration of external secret store solutions (e.g. HashiCorp Vault) to stream or synchronise keys directly into the Kubernetes secret. + +{{< hint type=important >}} If you provide your own key, you must ensure that the length of the key (in bytes) matches the configured keybits exactly. +| keybits | Keylength (Byte) | Notes | +| -------- |:----------------:| --------------:| +| 128 | 16 Byte | | +| 192 | 24 Byte | | +| 256 | 32 Byte | (Default) | + +A discrepancy between the configured bit length and the actual byte length of the provided key will result in errors when starting the database. +{{< /hint >}} + +The secret name follows the following fixed naming convention: [CLUSTERNAME]-tde + +```yaml +kind: Secret +apiVersion: v1 +metadata: + name: [CLUSTERNAME]-tde +stringData: + key: [TDE-KEY] +type: Opaque +``` + ### Check TDE-Status ```sh + +sh-5.1$ /usr/pgsql-17/bin/pg_controldata | grep -i "Encryption" +Data encryption: on +Encryption key length: 128 + [postgres@tde-cluster-1-0 ~]$ psql -psql (17.4 EE 1.4.1) +psql (18.1 EE 1.5.0, server 17.7 EE 1.4.4) ____ ____ _____ _____ | _ \ / ___| ____| ____| | |_) | | _| _| | _| diff --git a/manifests/postgresql.crd.yaml b/manifests/postgresql.crd.yaml index 955d31dc..4370bba4 100644 --- a/manifests/postgresql.crd.yaml +++ b/manifests/postgresql.crd.yaml @@ -692,6 +692,20 @@ spec: type: string recoveryEventType: type: string + tde: + nullable: true + properties: + enable: + type: boolean + keybits: + type: integer + format: int32 + default: 128 + enum: + - 128 + - 192 + - 256 + type: object teamId: type: string tls: diff --git a/pkg/apis/cpo.opensource.cybertec.at/v1/crds.go b/pkg/apis/cpo.opensource.cybertec.at/v1/crds.go index e035058c..bf7a3934 100644 --- a/pkg/apis/cpo.opensource.cybertec.at/v1/crds.go +++ b/pkg/apis/cpo.opensource.cybertec.at/v1/crds.go @@ -1444,6 +1444,18 @@ var PostgresCRDResourceValidation = apiextv1.CustomResourceValidation{ "enable": { Type: "boolean", }, + "keybits": { + Type: "integer", + Format: "int32", + Enum: []apiextv1.JSON{ + {Raw: []byte("128")}, + {Raw: []byte("192")}, + {Raw: []byte("256")}, + }, + Default: &apiextv1.JSON{ + Raw: []byte("128"), + }, + }, }, }, "monitor": { diff --git a/pkg/apis/cpo.opensource.cybertec.at/v1/postgresql_type.go b/pkg/apis/cpo.opensource.cybertec.at/v1/postgresql_type.go index 21908897..3662ed57 100644 --- a/pkg/apis/cpo.opensource.cybertec.at/v1/postgresql_type.go +++ b/pkg/apis/cpo.opensource.cybertec.at/v1/postgresql_type.go @@ -316,7 +316,8 @@ type Configuration struct { } type TDE struct { - Enable bool `json:"enable"` + Enable bool `json:"enable"` + Keybits *int32 `json:"keybits"` } // Monitoring Sidecar defines a container to be run in the same pod as the Postgres container. diff --git a/pkg/apis/cpo.opensource.cybertec.at/v1/zz_generated.deepcopy.go b/pkg/apis/cpo.opensource.cybertec.at/v1/zz_generated.deepcopy.go index 001cf58e..61b6327b 100644 --- a/pkg/apis/cpo.opensource.cybertec.at/v1/zz_generated.deepcopy.go +++ b/pkg/apis/cpo.opensource.cybertec.at/v1/zz_generated.deepcopy.go @@ -1077,7 +1077,7 @@ func (in *PostgresSpec) DeepCopyInto(out *PostgresSpec) { if in.TDE != nil { in, out := &in.TDE, &out.TDE *out = new(TDE) - **out = **in + (*in).DeepCopyInto(*out) } if in.Monitoring != nil { in, out := &in.Monitoring, &out.Monitoring @@ -1625,6 +1625,11 @@ func (in *StreamTable) DeepCopy() *StreamTable { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *TDE) DeepCopyInto(out *TDE) { *out = *in + if in.Keybits != nil { + in, out := &in.Keybits, &out.Keybits + *out = new(int32) + **out = **in + } return } diff --git a/pkg/cluster/k8sres.go b/pkg/cluster/k8sres.go index 8b516893..77726142 100644 --- a/pkg/cluster/k8sres.go +++ b/pkg/cluster/k8sres.go @@ -83,6 +83,11 @@ type spiloConfiguration struct { Bootstrap pgBootstrap `json:"bootstrap"` } +type TDEConfig struct { + Enabled bool + KeyBits string +} + func (c *Cluster) statefulSetName() string { return c.Name } @@ -327,7 +332,7 @@ func (c *Cluster) generateResourceRequirements( return &result, nil } -func generateSpiloJSONConfiguration(pg *cpov1.PostgresqlParam, patroni *cpov1.Patroni, opConfig *config.Config, enableTDE bool, logger *logrus.Entry) (string, error) { +func generateSpiloJSONConfiguration(pg *cpov1.PostgresqlParam, patroni *cpov1.Patroni, opConfig *config.Config, tdeOptions TDEConfig, logger *logrus.Entry) (string, error) { config := spiloConfiguration{} config.Bootstrap = pgBootstrap{} @@ -350,8 +355,9 @@ func generateSpiloJSONConfiguration(pg *cpov1.PostgresqlParam, patroni *cpov1.Pa map[string]string{"auth-local": "trust"}} } - if enableTDE { + if tdeOptions.Enabled { config.Bootstrap.Initdb = append(config.Bootstrap.Initdb, map[string]string{"encryption-key-command": "/tmp/tde.sh"}) + config.Bootstrap.Initdb = append(config.Bootstrap.Initdb, map[string]string{"key-bits": tdeOptions.KeyBits}) } initdbOptionNames := []string{} @@ -878,17 +884,16 @@ func (c *Cluster) generatePodTemplate( podSpec.PriorityClassName = priorityClassName } - if c.Postgresql.Spec.Monitoring != nil { - addEmptyDirVolume(&podSpec, "exporter-tmp", "postgres-exporter", "/tmp") - } - - if c.OpConfig.ReadOnlyRootFilesystem != nil && *c.OpConfig.ReadOnlyRootFilesystem && !isRepoHost { + if c.OpConfig.ReadOnlyRootFilesystem != nil && *c.OpConfig.ReadOnlyRootFilesystem && spiloContainer.Name == "postgres" { addRunVolume(&podSpec, "postgres-run", "postgres", "/run") addEmptyDirVolume(&podSpec, "postgres-tmp", "postgres", "/tmp") + if c.Postgresql.Spec.Monitoring != nil { + addEmptyDirVolume(&podSpec, "exporter-tmp", "postgres-exporter", "/tmp") + } } - if c.OpConfig.ReadOnlyRootFilesystem != nil && *c.OpConfig.ReadOnlyRootFilesystem && isRepoHost { - addEmptyDirVolume(&podSpec, "pgbackrest-tmp", "pgbackrest", "/tmp") + if c.OpConfig.ReadOnlyRootFilesystem != nil && *c.OpConfig.ReadOnlyRootFilesystem && strings.Contains(spiloContainer.Name, "pgbackrest") { + addEmptyDirVolume(&podSpec, "pgbackrest-tmp", spiloContainer.Name, "/tmp") } if sharePgSocketWithSidecars != nil && *sharePgSocketWithSidecars { @@ -1393,11 +1398,20 @@ func (c *Cluster) generateStatefulSet(spec *cpov1.PostgresSpec) (*appsv1.Statefu } } - enableTDE := false + // Keybits must be converted to strings for initdb processing. + tdeOptions := TDEConfig{Enabled: false} if spec.TDE != nil && spec.TDE.Enable { - enableTDE = true + tdeOptions.Enabled = true + tdeOptions.KeyBits = strconv.Itoa(256) + ptr := c.Postgresql.Spec.TDE.Keybits + if ptr != nil { + val := *ptr + if val == 128 || val == 192 { + tdeOptions.KeyBits = fmt.Sprintf("%d", val) + } + } } - spiloConfiguration, err := generateSpiloJSONConfiguration(&spec.PostgresqlParam, &spec.Patroni, &c.OpConfig, enableTDE, c.logger) + spiloConfiguration, err := generateSpiloJSONConfiguration(&spec.PostgresqlParam, &spec.Patroni, &c.OpConfig, tdeOptions, c.logger) if err != nil { return nil, fmt.Errorf("could not generate Spilo JSON configuration: %v", err) } diff --git a/pkg/cluster/k8sres_test.go b/pkg/cluster/k8sres_test.go index 40834086..4748f1a3 100644 --- a/pkg/cluster/k8sres_test.go +++ b/pkg/cluster/k8sres_test.go @@ -65,26 +65,30 @@ func TestGenerateSpiloJSONConfiguration(t *testing.T) { }, k8sutil.KubernetesClient{}, cpov1.Postgresql{}, logger, eventRecorder) tests := []struct { - subtest string - pgParam *cpov1.PostgresqlParam - patroni *cpov1.Patroni - opConfig *config.Config - result string + subtest string + pgParam *cpov1.PostgresqlParam + patroni *cpov1.Patroni + opConfig *config.Config + tdeConfig TDEConfig + result string }{ { subtest: "Patroni default configuration", - pgParam: &cpov1.PostgresqlParam{PgVersion: "15"}, + pgParam: &cpov1.PostgresqlParam{PgVersion: "17"}, patroni: &cpov1.Patroni{}, opConfig: &config.Config{ Auth: config.Auth{ PamRoleName: "humans", }, }, - result: `{"postgresql":{"bin_dir":"/usr/lib/postgresql/15/bin"},"bootstrap":{"initdb":[{"auth-host":"scram-sha-256"},{"auth-local":"trust"}],"users":{"humans":{"password":"","options":["CREATEDB","NOLOGIN"]}},"dcs":{}}}`, + tdeConfig: TDEConfig{ + Enabled: false, + }, + result: `{"postgresql":{"bin_dir":"/usr/lib/postgresql/17/bin"},"bootstrap":{"initdb":[{"auth-host":"scram-sha-256"},{"auth-local":"trust"}],"users":{"humans":{"password":"","options":["CREATEDB","NOLOGIN"]}},"dcs":{}}}`, }, { subtest: "Patroni configured", - pgParam: &cpov1.PostgresqlParam{PgVersion: "15"}, + pgParam: &cpov1.PostgresqlParam{PgVersion: "17"}, patroni: &cpov1.Patroni{ InitDB: map[string]string{ "encoding": "UTF8", @@ -107,11 +111,14 @@ func TestGenerateSpiloJSONConfiguration(t *testing.T) { PamRoleName: "humans", }, }, - result: `{"postgresql":{"bin_dir":"/usr/lib/postgresql/15/bin","pg_hba":["hostssl all all 0.0.0.0/0 scram-sha-256","host all all 0.0.0.0/0 scram-sha-256"]},"bootstrap":{"initdb":[{"auth-host":"scram-sha-256"},{"auth-local":"trust"},"data-checksums",{"encoding":"UTF8"},{"locale":"en_US.UTF-8"}],"users":{"humans":{"password":"","options":["CREATEDB","NOLOGIN"]}},"dcs":{"ttl":30,"loop_wait":10,"retry_timeout":10,"maximum_lag_on_failover":33554432,"synchronous_mode":true,"synchronous_mode_strict":true,"synchronous_node_count":1,"slots":{"permanent_logical_1":{"database":"foo","plugin":"pgoutput","type":"logical"}},"failsafe_mode":true}}}`, + tdeConfig: TDEConfig{ + Enabled: false, + }, + result: `{"postgresql":{"bin_dir":"/usr/lib/postgresql/17/bin","pg_hba":["hostssl all all 0.0.0.0/0 scram-sha-256","host all all 0.0.0.0/0 scram-sha-256"]},"bootstrap":{"initdb":[{"auth-host":"scram-sha-256"},{"auth-local":"trust"},"data-checksums",{"encoding":"UTF8"},{"locale":"en_US.UTF-8"}],"users":{"humans":{"password":"","options":["CREATEDB","NOLOGIN"]}},"dcs":{"ttl":30,"loop_wait":10,"retry_timeout":10,"maximum_lag_on_failover":33554432,"synchronous_mode":true,"synchronous_mode_strict":true,"synchronous_node_count":1,"slots":{"permanent_logical_1":{"database":"foo","plugin":"pgoutput","type":"logical"}},"failsafe_mode":true}}}`, }, { subtest: "Patroni failsafe_mode configured globally", - pgParam: &cpov1.PostgresqlParam{PgVersion: "15"}, + pgParam: &cpov1.PostgresqlParam{PgVersion: "17"}, patroni: &cpov1.Patroni{}, opConfig: &config.Config{ Auth: config.Auth{ @@ -119,11 +126,14 @@ func TestGenerateSpiloJSONConfiguration(t *testing.T) { }, EnablePatroniFailsafeMode: util.True(), }, - result: `{"postgresql":{"bin_dir":"/usr/lib/postgresql/15/bin"},"bootstrap":{"initdb":[{"auth-host":"scram-sha-256"},{"auth-local":"trust"}],"users":{"humans":{"password":"","options":["CREATEDB","NOLOGIN"]}},"dcs":{"failsafe_mode":true}}}`, + tdeConfig: TDEConfig{ + Enabled: false, + }, + result: `{"postgresql":{"bin_dir":"/usr/lib/postgresql/17/bin"},"bootstrap":{"initdb":[{"auth-host":"scram-sha-256"},{"auth-local":"trust"}],"users":{"humans":{"password":"","options":["CREATEDB","NOLOGIN"]}},"dcs":{"failsafe_mode":true}}}`, }, { subtest: "Patroni failsafe_mode configured globally, disabled for cluster", - pgParam: &cpov1.PostgresqlParam{PgVersion: "15"}, + pgParam: &cpov1.PostgresqlParam{PgVersion: "17"}, patroni: &cpov1.Patroni{ FailsafeMode: util.False(), }, @@ -133,11 +143,14 @@ func TestGenerateSpiloJSONConfiguration(t *testing.T) { }, EnablePatroniFailsafeMode: util.True(), }, - result: `{"postgresql":{"bin_dir":"/usr/lib/postgresql/15/bin"},"bootstrap":{"initdb":[{"auth-host":"scram-sha-256"},{"auth-local":"trust"}],"users":{"humans":{"password":"","options":["CREATEDB","NOLOGIN"]}},"dcs":{"failsafe_mode":false}}}`, + tdeConfig: TDEConfig{ + Enabled: false, + }, + result: `{"postgresql":{"bin_dir":"/usr/lib/postgresql/17/bin"},"bootstrap":{"initdb":[{"auth-host":"scram-sha-256"},{"auth-local":"trust"}],"users":{"humans":{"password":"","options":["CREATEDB","NOLOGIN"]}},"dcs":{"failsafe_mode":false}}}`, }, { subtest: "Patroni failsafe_mode disabled globally, configured for cluster", - pgParam: &cpov1.PostgresqlParam{PgVersion: "15"}, + pgParam: &cpov1.PostgresqlParam{PgVersion: "17"}, patroni: &cpov1.Patroni{ FailsafeMode: util.True(), }, @@ -147,12 +160,30 @@ func TestGenerateSpiloJSONConfiguration(t *testing.T) { }, EnablePatroniFailsafeMode: util.False(), }, - result: `{"postgresql":{"bin_dir":"/usr/lib/postgresql/15/bin"},"bootstrap":{"initdb":[{"auth-host":"scram-sha-256"},{"auth-local":"trust"}],"users":{"humans":{"password":"","options":["CREATEDB","NOLOGIN"]}},"dcs":{"failsafe_mode":true}}}`, + tdeConfig: TDEConfig{ + Enabled: false, + }, + result: `{"postgresql":{"bin_dir":"/usr/lib/postgresql/17/bin"},"bootstrap":{"initdb":[{"auth-host":"scram-sha-256"},{"auth-local":"trust"}],"users":{"humans":{"password":"","options":["CREATEDB","NOLOGIN"]}},"dcs":{"failsafe_mode":true}}}`, + }, + { + subtest: "TDE enabled with 256 bits", + pgParam: &cpov1.PostgresqlParam{PgVersion: "17"}, + patroni: &cpov1.Patroni{}, + opConfig: &config.Config{ + Auth: config.Auth{ + PamRoleName: "humans", + }, + }, + tdeConfig: TDEConfig{ + Enabled: true, + KeyBits: "256", + }, + result: `{"postgresql":{"bin_dir":"/usr/lib/postgresql/17/bin"},"bootstrap":{"initdb":[{"auth-host":"scram-sha-256"},{"auth-local":"trust"},{"encryption-key-command": "/tmp/tde.sh"},{"key-bits":"256"}],"users":{"humans":{"password":"","options":["CREATEDB","NOLOGIN"]}},"dcs":{}}}`, }, } for _, tt := range tests { cluster.OpConfig = *tt.opConfig - result, err := generateSpiloJSONConfiguration(tt.pgParam, tt.patroni, tt.opConfig, false, logger) + result, err := generateSpiloJSONConfiguration(tt.pgParam, tt.patroni, tt.opConfig, tt.tdeConfig, logger) if err != nil { t.Errorf("Unexpected error: %v", err) } diff --git a/pkg/cluster/sync.go b/pkg/cluster/sync.go index 0da424ad..d91669c2 100644 --- a/pkg/cluster/sync.go +++ b/pkg/cluster/sync.go @@ -1684,7 +1684,17 @@ func (c *Cluster) syncPgbackrestJob(forceRemove bool) error { func (c *Cluster) createTDESecret() error { c.logger.Info("creating TDE secret") c.setProcessName("creating TDE secret") - generatedKey := make([]byte, 16) + + var bits int32 = 128 + ptr := c.Postgresql.Spec.TDE.Keybits + if ptr != nil { + val := *ptr + if val == 256 || val == 192 { + bits = val + } + } + + generatedKey := make([]byte, (bits / 8)) rand.Read(generatedKey) generatedSecret := v1.Secret{ @@ -1698,11 +1708,17 @@ func (c *Cluster) createTDESecret() error { }, } secret, err := c.KubeClient.Secrets(generatedSecret.Namespace).Create(context.TODO(), &generatedSecret, metav1.CreateOptions{}) + if err == nil { c.Secrets[secret.UID] = secret c.logger.Debugf("created new secret %s, namespace: %s, uid: %s", util.NameFromMeta(secret.ObjectMeta), generatedSecret.Namespace, secret.UID) } else { - return fmt.Errorf("could not create secret for TDE %s: in namespace %s: %v", util.NameFromMeta(secret.ObjectMeta), generatedSecret.Namespace, err) + + if k8sutil.ResourceAlreadyExists(err) { + c.logger.Warningf("TDE secret already exists, skip key generation and use existing one.") + } else { + return fmt.Errorf("could not create secret for TDE %s: in namespace %s: %v", generatedSecret.Name, generatedSecret.Namespace, err) + } } return nil