diff --git a/CHANGELOG.md b/CHANGELOG.md index 278a397b..888c47e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ All notable changes to this project will be documented in this file. - Made RSA key length configurable for certificates issued by cert-manager ([#528]). - Kerberos principal backends now also provision principals for IP address, not just DNS hostnames ([#552]). - OLM deployment helper ([#546]). +- Allow the specification of additional trust roots in autoTls SecretClasses ([#573]). ### Changed @@ -38,6 +39,7 @@ All notable changes to this project will be documented in this file. [#566]: https://github.com/stackabletech/secret-operator/pull/566 [#569]: https://github.com/stackabletech/secret-operator/pull/569 [#571]: https://github.com/stackabletech/secret-operator/pull/571 +[#573]: https://github.com/stackabletech/secret-operator/pull/573 ## [24.11.1] - 2025-01-10 diff --git a/deploy/helm/secret-operator/crds/crds.yaml b/deploy/helm/secret-operator/crds/crds.yaml index e9957745..5909c60b 100644 --- a/deploy/helm/secret-operator/crds/crds.yaml +++ b/deploy/helm/secret-operator/crds/crds.yaml @@ -42,6 +42,44 @@ spec: A new certificate and key pair will be generated and signed for each Pod, keys or certificates are never reused. properties: + additionalTrustRoots: + default: [] + description: Additional trust roots which are added to the provided `ca.crt` file. + items: + oneOf: + - required: + - configMap + - required: + - secret + properties: + configMap: + description: 'Reference (name and namespace) to a Kubernetes ConfigMap object where additional certificates are stored. The extensions of the keys denote its contents: A key suffixed with `.crt` contains a stack of base64 encoded DER certificates, a key suffixed with `.der` contains a binary DER certificate.' + properties: + name: + description: Name of the ConfigMap being referred to. + type: string + namespace: + description: Namespace of the ConfigMap being referred to. + type: string + required: + - name + - namespace + type: object + secret: + description: 'Reference (name and namespace) to a Kubernetes Secret object where additional certificates are stored. The extensions of the keys denote its contents: A key suffixed with `.crt` contains a stack of base64 encoded DER certificates, a key suffixed with `.der` contains a binary DER certificate.' + properties: + name: + description: Name of the Secret being referred to. + type: string + namespace: + description: Namespace of the Secret being referred to. + type: string + required: + - name + - namespace + type: object + type: object + type: array ca: description: Configures the certificate authority used to issue Pod certificates. properties: diff --git a/deploy/helm/secret-operator/templates/roles.yaml b/deploy/helm/secret-operator/templates/roles.yaml index 2ebb1b02..b6e36c36 100644 --- a/deploy/helm/secret-operator/templates/roles.yaml +++ b/deploy/helm/secret-operator/templates/roles.yaml @@ -69,6 +69,7 @@ rules: - apiGroups: - "" resources: + - configmaps - nodes - persistentvolumeclaims verbs: diff --git a/docs/modules/secret-operator/pages/secretclass.adoc b/docs/modules/secret-operator/pages/secretclass.adoc index 0b20bbb7..172d4841 100644 --- a/docs/modules/secret-operator/pages/secretclass.adoc +++ b/docs/modules/secret-operator/pages/secretclass.adoc @@ -113,6 +113,10 @@ spec: keyGeneration: rsa: length: 4096 + additionalTrustRoots: + - secret: + name: trust-roots + namespace: default maxCertificateLifetime: 15d # optional ---- @@ -125,6 +129,12 @@ spec: `autoTls.ca.keyGeneration`:: Configures how keys should be generated. `autoTls.ca.keyGeneration.rsa`:: Declares that keys should be generated using the RSA algorithm. `autoTls.ca.keyGeneration.rsa.length`:: The amount of bits used for generating the RSA key pair. Currently, `2048`, `3072` and `4096` are supported. Defaults to `2048` bits. +`additionalTrustRoots`:: Configures additional trust roots which are added to the CA files or truststores in the provisioned volume mounts. +`additionalTrustRoots.secret`:: + Reference (`name` and `namespace`) to a K8s `Secret` object where the trusted certificates are stored. + The extension of a key defines its content: + * `.crt` denotes a stack of PEM (base64-encoded DER) certificates. + * `.der` denotes a a binary DER certificates. `autoTls.maxCertificateLifetime`:: Maximum lifetime the created certificates are allowed to have. In case consumers request a longer lifetime than allowed by this setting, the lifetime will be the minimum of both. [#backend-certmanager] diff --git a/rust/crd-utils/src/lib.rs b/rust/crd-utils/src/lib.rs index cc6b962b..90cffabb 100644 --- a/rust/crd-utils/src/lib.rs +++ b/rust/crd-utils/src/lib.rs @@ -4,11 +4,47 @@ use std::fmt::Display; use serde::{Deserialize, Serialize}; use stackable_operator::{ - k8s_openapi::api::core::v1::Secret, - kube::runtime::reflector::ObjectRef, + k8s_openapi::api::core::v1::{ConfigMap, Secret}, + kube::{api::DynamicObject, runtime::reflector::ObjectRef}, schemars::{self, JsonSchema}, }; +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct ConfigMapReference { + /// Namespace of the ConfigMap being referred to. + pub namespace: String, + /// Name of the ConfigMap being referred to. + pub name: String, +} + +// Use ObjectRef for logging/errors +impl Display for ConfigMapReference { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + ObjectRef::::from(self).fmt(f) + } +} +impl From for ObjectRef { + fn from(val: ConfigMapReference) -> Self { + ObjectRef::::from(&val) + } +} +impl From<&ConfigMapReference> for ObjectRef { + fn from(val: &ConfigMapReference) -> Self { + ObjectRef::::new(&val.name).within(&val.namespace) + } +} +impl From for ObjectRef { + fn from(val: ConfigMapReference) -> Self { + ObjectRef::::from(&val).erase() + } +} +impl From<&ConfigMapReference> for ObjectRef { + fn from(val: &ConfigMapReference) -> Self { + ObjectRef::::from(val).erase() + } +} + // Redefine SecretReference instead of reusing k8s-openapi's, in order to make name/namespace mandatory. #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] #[serde(rename_all = "camelCase")] @@ -35,3 +71,13 @@ impl From<&SecretReference> for ObjectRef { ObjectRef::::new(&val.name).within(&val.namespace) } } +impl From for ObjectRef { + fn from(val: SecretReference) -> Self { + ObjectRef::::from(&val).erase() + } +} +impl From<&SecretReference> for ObjectRef { + fn from(val: &SecretReference) -> Self { + ObjectRef::::from(val).erase() + } +} diff --git a/rust/operator-binary/src/backend/dynamic.rs b/rust/operator-binary/src/backend/dynamic.rs index dd1e6b68..865f66ed 100644 --- a/rust/operator-binary/src/backend/dynamic.rs +++ b/rust/operator-binary/src/backend/dynamic.rs @@ -118,11 +118,13 @@ pub async fn from_class( } crd::SecretClassBackend::AutoTls(crd::AutoTlsBackend { ca, + additional_trust_roots, max_certificate_lifetime, }) => from( super::TlsGenerate::get_or_create_k8s_certificate( client, &ca, + &additional_trust_roots, max_certificate_lifetime, ) .await?, diff --git a/rust/operator-binary/src/backend/tls/ca.rs b/rust/operator-binary/src/backend/tls/ca.rs index 442d0925..5104479b 100644 --- a/rust/operator-binary/src/backend/tls/ca.rs +++ b/rust/operator-binary/src/backend/tls/ca.rs @@ -1,6 +1,6 @@ //! Dynamically provisions and picks Certificate Authorities. -use std::{collections::BTreeMap, fmt::Display}; +use std::{collections::BTreeMap, ffi::OsStr, fmt::Display, path::Path}; use openssl::{ asn1::{Asn1Integer, Asn1Time}, @@ -17,24 +17,27 @@ use openssl::{ }; use snafu::{OptionExt, ResultExt, Snafu}; use stackable_operator::{ - k8s_openapi::{api::core::v1::Secret, ByteString}, + k8s_openapi::{ + api::core::v1::{ConfigMap, Secret}, + ByteString, + }, kube::{ self, api::{ entry::{self, Entry}, - PostParams, + DynamicObject, PostParams, }, runtime::reflector::ObjectRef, }, time::Duration, }; -use stackable_secret_operator_crd_utils::SecretReference; +use stackable_secret_operator_crd_utils::{ConfigMapReference, SecretReference}; use time::OffsetDateTime; use tracing::{info, info_span, warn}; use crate::{ backend::SecretBackendError, - crd::CertificateKeyGeneration, + crd::{AdditionalTrustRoot, CertificateKeyGeneration}, utils::{asn1time_to_offsetdatetime, Asn1TimeParseError, Unloggable}, }; @@ -55,8 +58,14 @@ pub enum Error { #[snafu(display("failed to generate certificate key"))] GenerateKey { source: openssl::error::ErrorStack }, - #[snafu(display("failed to load CA {secret}"))] - FindCa { + #[snafu(display("failed to load {config_map}"))] + FindConfigMap { + source: kube::Error, + config_map: ObjectRef, + }, + + #[snafu(display("failed to load {secret}"))] + FindSecret { source: kube::Error, secret: ObjectRef, }, @@ -70,11 +79,17 @@ pub enum Error { secret: ObjectRef, }, - #[snafu(display("failed to load certificate from key {key:?} of {secret}"))] + #[snafu(display("failed to load certificate from key {key:?} of {object}"))] LoadCertificate { source: openssl::error::ErrorStack, key: String, - secret: ObjectRef, + object: ObjectRef, + }, + + #[snafu(display("unsupported certificate format in key {key:?} of {object}; supported extensions: .crt, .der"))] + UnsupportedCertificateFormat { + key: String, + object: ObjectRef, }, #[snafu(display("failed to parse CA lifetime from key {key:?} of {secret}"))] @@ -106,9 +121,11 @@ impl SecretBackendError for Error { match self { Error::GenerateKey { .. } => tonic::Code::Internal, Error::MissingCertificate { .. } => tonic::Code::FailedPrecondition, - Error::FindCa { .. } => tonic::Code::Unavailable, + Error::FindConfigMap { .. } => tonic::Code::Unavailable, + Error::FindSecret { .. } => tonic::Code::Unavailable, Error::CaNotFoundAndGenDisabled { .. } => tonic::Code::FailedPrecondition, Error::LoadCertificate { .. } => tonic::Code::FailedPrecondition, + Error::UnsupportedCertificateFormat { .. } => tonic::Code::InvalidArgument, Error::ParseLifetime { .. } => tonic::Code::FailedPrecondition, Error::BuildCertificate { .. } => tonic::Code::FailedPrecondition, Error::SerializeCertificate { .. } => tonic::Code::FailedPrecondition, @@ -261,7 +278,7 @@ impl CertificateAuthority { ) .with_context(|_| LoadCertificateSnafu { key: key_certificate, - secret: secret_ref, + object: secret_ref, })?; let private_key = PKey::private_key_from_pem( &secret_data @@ -274,7 +291,7 @@ impl CertificateAuthority { ) .with_context(|_| LoadCertificateSnafu { key: key_private_key, - secret: secret_ref, + object: secret_ref, })?; Ok(CertificateAuthority { not_after: asn1time_to_offsetdatetime(certificate.not_after()).with_context(|_| { @@ -293,12 +310,14 @@ impl CertificateAuthority { #[derive(Debug)] pub struct Manager { certificate_authorities: Vec, + additional_trusted_certificates: Vec, } impl Manager { pub async fn load_or_create( client: &stackable_operator::client::Client, secret_ref: &SecretReference, + additional_trust_roots: &[AdditionalTrustRoot], config: &Config, ) -> Result { // Use entry API rather than apply so that we crash and retry on conflicts (to avoid creating spurious certs that we throw away immediately) @@ -306,7 +325,7 @@ impl Manager { let ca_secret = secrets_api .entry(&secret_ref.name) .await - .with_context(|_| FindCaSnafu { secret: secret_ref })?; + .with_context(|_| FindSecretSnafu { secret: secret_ref })?; let mut update_ca_secret = false; let mut certificate_authorities = match &ca_secret { Entry::Occupied(ca_secret) => { @@ -441,8 +460,122 @@ impl Manager { return SaveRequestedButForbiddenSnafu.fail(); } } + + let mut additional_trusted_certificates = vec![]; + for entry in additional_trust_roots { + let certs = match entry { + AdditionalTrustRoot::ConfigMap(config_map) => { + Self::read_certificates_from_config_map(client, config_map).await? + } + AdditionalTrustRoot::Secret(secret) => { + Self::read_certificates_from_secret(client, secret).await? + } + }; + additional_trusted_certificates.extend(certs); + } + Ok(Self { certificate_authorities, + additional_trusted_certificates, + }) + } + + /// Read certificates from the given ConfigMap + /// + /// The keys are assumed to be filenames and their extensions denote the expected format of the + /// certificate. + async fn read_certificates_from_config_map( + client: &stackable_operator::client::Client, + config_map_ref: &ConfigMapReference, + ) -> Result> { + let mut certificates = vec![]; + + let config_map_api = &client.get_api::(&config_map_ref.namespace); + let config_map = config_map_api + .get(&config_map_ref.name) + .await + .with_context(|_| FindConfigMapSnafu { + config_map: config_map_ref, + })?; + + let config_map_data = config_map.data.unwrap_or_default(); + let config_map_binary_data = config_map.binary_data.unwrap_or_default(); + let data = config_map_data + .iter() + .map(|(key, value)| (key, value.as_bytes())) + .chain( + config_map_binary_data + .iter() + .map(|(key, ByteString(value))| (key, value.as_ref())), + ); + for (key, value) in data { + let certs = Self::deserialize_certificate(key, value, config_map_ref.into())?; + info!( + ?certs, + %config_map_ref, + %key, + "adding certificates from additional trust root", + ); + certificates.extend(certs); + } + + Ok(certificates) + } + + /// Read certificates from the given Secret + /// + /// The keys are assumed to be filenames and their extensions denote the expected format of the + /// certificate. + async fn read_certificates_from_secret( + client: &stackable_operator::client::Client, + secret_ref: &SecretReference, + ) -> Result> { + let mut certificates = vec![]; + + let secrets_api = &client.get_api::(&secret_ref.namespace); + let secret = secrets_api + .get(&secret_ref.name) + .await + .with_context(|_| FindSecretSnafu { secret: secret_ref })?; + + let secret_data = secret.data.unwrap_or_default(); + for (key, ByteString(value)) in &secret_data { + let certs = Self::deserialize_certificate(key, value, secret_ref.into())?; + info!( + ?certs, + %secret_ref, + %key, + "adding certificates from additional trust root", + ); + certificates.extend(certs); + } + + Ok(certificates) + } + + /// Deserialize a certificate from the given value. The format is determined by the extension + /// of the key. + fn deserialize_certificate( + key: &str, + value: &[u8], + object_ref: ObjectRef, + ) -> Result> { + let extension = Path::new(key).extension().and_then(OsStr::to_str); + + match extension { + Some("crt") => X509::stack_from_pem(value), + Some("der") => X509::from_der(value).map(|cert| vec![cert]), + _ => { + return UnsupportedCertificateFormatSnafu { + key, + object: object_ref, + } + .fail(); + } + } + .context(LoadCertificateSnafu { + key, + object: object_ref, }) } @@ -467,5 +600,6 @@ impl Manager { self.certificate_authorities .iter() .map(|ca| &ca.certificate) + .chain(&self.additional_trusted_certificates) } } diff --git a/rust/operator-binary/src/backend/tls/mod.rs b/rust/operator-binary/src/backend/tls/mod.rs index a223659e..79fb1da8 100644 --- a/rust/operator-binary/src/backend/tls/mod.rs +++ b/rust/operator-binary/src/backend/tls/mod.rs @@ -33,7 +33,7 @@ use super::{ ScopeAddressesError, SecretBackend, SecretBackendError, SecretContents, }; use crate::{ - crd::{self, CertificateKeyGeneration}, + crd::{self, AdditionalTrustRoot, CertificateKeyGeneration}, format::{well_known, SecretData, WellKnownSecretData}, utils::iterator_try_concat_bytes, }; @@ -149,12 +149,14 @@ impl TlsGenerate { ca_certificate_lifetime, key_generation, }: &crd::AutoTlsCa, + additional_trust_roots: &[AdditionalTrustRoot], max_cert_lifetime: Duration, ) -> Result { Ok(Self { ca_manager: ca::Manager::load_or_create( client, ca_secret, + additional_trust_roots, &ca::Config { manage_ca: *auto_generate_ca, ca_certificate_lifetime: *ca_certificate_lifetime, diff --git a/rust/operator-binary/src/crd.rs b/rust/operator-binary/src/crd.rs index bb388746..2395ee29 100644 --- a/rust/operator-binary/src/crd.rs +++ b/rust/operator-binary/src/crd.rs @@ -8,7 +8,7 @@ use stackable_operator::{ schemars::{self, schema::Schema, JsonSchema}, time::Duration, }; -use stackable_secret_operator_crd_utils::SecretReference; +use stackable_secret_operator_crd_utils::{ConfigMapReference, SecretReference}; use crate::backend; @@ -89,6 +89,10 @@ pub struct AutoTlsBackend { /// Configures the certificate authority used to issue Pod certificates. pub ca: AutoTlsCa, + /// Additional trust roots which are added to the provided `ca.crt` file. + #[serde(default)] + pub additional_trust_roots: Vec, + /// Maximum lifetime the created certificates are allowed to have. /// In case consumers request a longer lifetime than allowed by this setting, /// the lifetime will be the minimum of both, so this setting takes precedence. @@ -137,6 +141,24 @@ impl AutoTlsCa { } } +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub enum AdditionalTrustRoot { + /// Reference (name and namespace) to a Kubernetes ConfigMap object where additional + /// certificates are stored. + /// The extensions of the keys denote its contents: A key suffixed with `.crt` contains a stack + /// of base64 encoded DER certificates, a key suffixed with `.der` contains a binary DER + /// certificate. + ConfigMap(ConfigMapReference), + + /// Reference (name and namespace) to a Kubernetes Secret object where additional certificates + /// are stored. + /// The extensions of the keys denote its contents: A key suffixed with `.crt` contains a stack + /// of base64 encoded DER certificates, a key suffixed with `.der` contains a binary DER + /// certificate. + Secret(SecretReference), +} + #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] #[serde(rename_all = "camelCase")] pub enum CertificateKeyGeneration { @@ -417,6 +439,7 @@ mod test { length: CertificateKeyGeneration::RSA_KEY_LENGTH_3072 } }, + additional_trust_roots: vec![], max_certificate_lifetime: DEFAULT_MAX_CERT_LIFETIME, }) } @@ -436,6 +459,13 @@ mod test { namespace: default autoGenerate: true caCertificateLifetime: 100d + additionalTrustRoots: + - configMap: + name: tls-root-ca-config-map + namespace: default + - secret: + name: tls-root-ca-secret + namespace: default maxCertificateLifetime: 31d "#; let deserializer = serde_yaml::Deserializer::from_str(input); @@ -454,6 +484,16 @@ mod test { ca_certificate_lifetime: Duration::from_days_unchecked(100), key_generation: CertificateKeyGeneration::default() }, + additional_trust_roots: vec![ + AdditionalTrustRoot::ConfigMap(ConfigMapReference { + name: "tls-root-ca-config-map".to_string(), + namespace: "default".to_string(), + }), + AdditionalTrustRoot::Secret(SecretReference { + name: "tls-root-ca-secret".to_string(), + namespace: "default".to_string(), + }) + ], max_certificate_lifetime: Duration::from_days_unchecked(31), }) } diff --git a/tests/templates/kuttl/tls/02-rbac.yaml.j2 b/tests/templates/kuttl/tls/02-rbac.yaml.j2 index 9cbf0351..403819a6 100644 --- a/tests/templates/kuttl/tls/02-rbac.yaml.j2 +++ b/tests/templates/kuttl/tls/02-rbac.yaml.j2 @@ -4,6 +4,13 @@ apiVersion: rbac.authorization.k8s.io/v1 metadata: name: use-integration-tests-scc rules: + - apiGroups: + - "" + resources: + - configmaps + - secrets + verbs: + - create {% if test_scenario['values']['openshift'] == "true" %} - apiGroups: ["security.openshift.io"] resources: ["securitycontextconstraints"] diff --git a/tests/templates/kuttl/tls/03-create-trust-roots.yaml b/tests/templates/kuttl/tls/03-create-trust-roots.yaml new file mode 100644 index 00000000..cc5e2ae3 --- /dev/null +++ b/tests/templates/kuttl/tls/03-create-trust-roots.yaml @@ -0,0 +1,59 @@ +--- +apiVersion: batch/v1 +kind: Job +metadata: + name: create-trust-roots +spec: + template: + spec: + containers: + - name: create-trust-roots + image: oci.stackable.tech/sdp/testing-tools:0.2.0-stackable0.0.0-dev + command: + - bash + args: + - -c + - | + set -euo pipefail + + function create_cert { + outform=$1 + cn=$2 + + openssl req \ + -x509 \ + -nodes \ + -outform "$outform" \ + -subj "/CN=$cn" + } + + # .crt files with one PEM certificate + create_cert PEM "cert 1" > cert1.crt + create_cert PEM "cert 2" > cert2.crt + + # .crt files with multiple PEM certificates + create_cert PEM "cert 3a" > cert3.crt + create_cert PEM "cert 3b" >> cert3.crt + create_cert PEM "cert 4a" > cert4.crt + create_cert PEM "cert 4b" >> cert4.crt + + # .der files with one DER certificate + create_cert DER "cert 5" > cert5.der + create_cert DER "cert 6" > cert6.der + + kubectl create configmap trust-roots \ + --from-file=cert1.crt \ + --from-file=cert3.crt \ + --from-file=cert5.der + + kubectl create secret generic trust-roots \ + --from-file=cert2.crt \ + --from-file=cert4.crt \ + --from-file=cert6.der + securityContext: + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + restartPolicy: Never + terminationGracePeriodSeconds: 0 + serviceAccount: integration-tests-sa diff --git a/tests/templates/kuttl/tls/consumer.yaml b/tests/templates/kuttl/tls/consumer.yaml index dcd78cef..d00d4863 100644 --- a/tests/templates/kuttl/tls/consumer.yaml +++ b/tests/templates/kuttl/tls/consumer.yaml @@ -40,6 +40,25 @@ spec: cat /stackable/tls-3d/tls.crt | openssl x509 -noout -text | grep "DNS:my-tls-service.$NAMESPACE.svc.cluster.local" cat /stackable/tls-42h/tls.crt | openssl x509 -noout -text | grep "DNS:my-tls-service.$NAMESPACE.svc.cluster.local" + + + function assert_trusted_roots_contain { + subject=$1 + + while openssl x509 -subject -noout; do :; done \ + < /stackable/tls-3d/ca.crt \ + | grep --line-regexp "subject=CN *= *$subject" + } + + assert_trusted_roots_contain "secret-operator self-signed" + assert_trusted_roots_contain "cert 1" + assert_trusted_roots_contain "cert 2" + assert_trusted_roots_contain "cert 3a" + assert_trusted_roots_contain "cert 3b" + assert_trusted_roots_contain "cert 4a" + assert_trusted_roots_contain "cert 4b" + assert_trusted_roots_contain "cert 5" + assert_trusted_roots_contain "cert 6" volumeMounts: - mountPath: /stackable/tls-3d name: tls-3d diff --git a/tests/templates/kuttl/tls/secretclass.yaml.j2 b/tests/templates/kuttl/tls/secretclass.yaml.j2 index d7f2ad4f..b84e2bb9 100644 --- a/tests/templates/kuttl/tls/secretclass.yaml.j2 +++ b/tests/templates/kuttl/tls/secretclass.yaml.j2 @@ -15,6 +15,13 @@ spec: keyGeneration: rsa: length: {{ test_scenario['values']['rsa-key-length'] }} + additionalTrustRoots: + - configMap: + name: trust-roots + namespace: $NAMESPACE + - secret: + name: trust-roots + namespace: $NAMESPACE --- apiVersion: secrets.stackable.tech/v1alpha1 kind: SecretClass