Skip to content
Open
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file.

## [Unreleased]

### Added

- Added CA expiry threshold support for filtering out CAs that are about to expire ([#633]).
- New `caExpiryThreshold` field in TrustStore CRD to filter out CAs close to expiry when publishing trust roots.
- New `secrets.stackable.tech/backend.autotls.ca.expiry-threshold` annotation for volume-level CA expiry filtering.
- If no threshold is specified, all CAs (including expired ones) are published for backwards compatibility.

## [25.7.0] - 2025-07-23

## [25.7.0-rc1] - 2025-07-18
Expand Down
4 changes: 4 additions & 0 deletions deploy/helm/secret-operator/crds/crds.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,10 @@ spec:

The requested information is written to a ConfigMap with the same name as the TrustStore.
properties:
caExpiryThreshold:
description: The minimum remaining lifetime that a CA must have to be considered valid for publishing in the TrustStore. If not specified, all CAs (including expired ones) will be published.
nullable: true
type: string
format:
description: The [format](https://docs.stackable.tech/home/nightly/secret-operator/secretclass#format) that the data should be converted into.
enum:
Expand Down
1 change: 1 addition & 0 deletions docs/modules/secret-operator/examples/truststore-tls.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ metadata:
spec:
secretClassName: tls # <2>
format: tls-pem # <3>
caExpiryThreshold: 1h # <4>
1 change: 1 addition & 0 deletions docs/modules/secret-operator/pages/truststore.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ include::example$truststore-tls.yaml[]
<1> Also used to name the created ConfigMap
<2> The name of the xref:secretclass.adoc[]
<3> The requested xref:secretclass.adoc#format[format]
<4> Optional threshold to filter out CAs that are about to expire. If not specified, all CAs (including expired ones) will be included.

This will create a ConfigMap named `truststore-pem` containing a `ca.crt` with the trust root certificates.
It can then either be mounted into a Pod or retrieved and used from outside of Kubernetes.
Expand Down
19 changes: 19 additions & 0 deletions docs/modules/secret-operator/pages/volume.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,25 @@ It can take some hours until all Pods are restarted in a rolling fashion.

The format is documented in xref:concepts:duration.adoc[].

=== `secrets.stackable.tech/backend.autotls.ca.expiry-threshold`

*Required*: false

*Default value*: Not set (all CAs including expired ones are published)

*Backends*: xref:secretclass.adoc#backend-autotls[]

The minimum remaining lifetime that a CA must have to be considered valid.

- A CA is only published if its remaining lifetime is greater than or equal to this threshold.
- A CA may only sign a certificate if it outlives the certificate by at least this threshold.

If not specified, all CAs (including expired ones) will be published. Of course, only the CAs that are still valid will be used to sign certificates.

Use this to avoid publishing almost-expired CA certificates that might expire during pod startup.

The format is documented in xref:concepts:duration.adoc[].

=== `secrets.stackable.tech/backend.autotls.cert.jitter-factor`

*Required*: false
Expand Down
17 changes: 17 additions & 0 deletions rust/operator-binary/src/backend/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,19 @@ pub struct SecretVolumeSelector {
)]
pub autotls_cert_restart_buffer: Duration,

/// The minimum remaining lifetime that a CA must have to be considered valid for
/// publishing and signing certificates.
/// A CA will only be published if it remains valid for at least this duration,
/// and it may only sign if it outlives the issued certificate by at least this duration.
/// If not specified, all CAs (including expired ones) will be published.
/// The format is documented in <https://docs.stackable.tech/home/nightly/concepts/duration>.
#[serde(
rename = "secrets.stackable.tech/backend.autotls.ca.expiry-threshold",
deserialize_with = "SecretVolumeSelector::deserialize_some",
default
)]
pub autotls_ca_expiry_threshold: Option<Duration>,

/// The part of the certificate's lifetime that may be removed for jittering.
/// Must be within 0.0 and 1.0.
#[serde(
Expand All @@ -144,6 +157,10 @@ pub struct SecretVolumeSelector {
pub struct TrustSelector {
/// The name of the [`TrustStore`]'s `Namespace`.
pub namespace: String,
/// Optional CA expiry threshold for filtering out CAs that are about to expire.
/// If specified, only CAs that are valid for at least this duration will be published.
/// If not specified, all CAs (including expired ones) will be published.
pub ca_expiry_threshold: Option<Duration>,
}

/// Internal parameters of [`SecretVolumeSelector`] managed by secret-operator itself.
Expand Down
30 changes: 26 additions & 4 deletions rust/operator-binary/src/backend/tls/ca.rs
Original file line number Diff line number Diff line change
Expand Up @@ -614,27 +614,49 @@ impl Manager {
}

/// Get an appropriate [`CertificateAuthority`] for signing a given certificate.
///
/// The selected CA must outlive the certificate by at least `min_remaining_lifetime`.
/// If `min_remaining_lifetime` is None, any CA that outlives the certificate will be acceptable.
pub fn find_certificate_authority_for_signing(
&self,
valid_until_at_least: OffsetDateTime,
min_remaining_lifetime: Option<Duration>,
) -> Result<&CertificateAuthority, GetCaError> {
use get_ca_error::*;

let cutoff = match min_remaining_lifetime {
Some(min_lifetime) => valid_until_at_least + min_lifetime,
None => valid_until_at_least,
};

self.certificate_authorities
.iter()
.filter(|ca| ca.not_after > valid_until_at_least)
.filter(|ca| ca.not_after > cutoff)
// pick the oldest valid CA, since it will be trusted by the most peers
.min_by_key(|ca| ca.not_after)
.with_context(|| NoCaLivesLongEnoughSnafu {
cutoff: valid_until_at_least,
cutoff,
secret: self.source_secret.clone(),
})
}

/// Get all active trust root certificates.
pub fn trust_roots(&self) -> impl IntoIterator<Item = &X509> + '_ {
/// If `min_remaining_lifetime` is specified, only CAs that will stay valid for at least that long
/// will be returned. Otherwise all CAs (including expired ones) will be returned.
pub fn trust_roots(&self, min_remaining_lifetime: Option<Duration>) -> Vec<&X509> {
let cutoff =
min_remaining_lifetime.map(|min_lifetime| OffsetDateTime::now_utc() + min_lifetime);

self.certificate_authorities
.iter()
.filter(|ca| cutoff.is_none_or(|cutoff| ca.not_after >= cutoff))
.map(|ca| &ca.certificate)
.chain(&self.additional_trusted_certificates)
.chain(self.additional_trusted_certificates.iter().filter(|cert| {
cutoff.is_none_or(|cutoff| {
crate::utils::asn1time_to_offsetdatetime(cert.not_after())
.is_ok_and(|not_after| not_after >= cutoff)
})
}))
.collect()
}
}
31 changes: 19 additions & 12 deletions rust/operator-binary/src/backend/tls/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@ impl SecretBackend for TlsGenerate {
// Extract and convert consumer input from the Volume annotations.
let cert_lifetime = selector.autotls_cert_lifetime;
let cert_restart_buffer = selector.autotls_cert_restart_buffer;
let ca_expiry_threshold = selector.autotls_ca_expiry_threshold;

// We need to check that the cert lifetime it is not longer than allowed,
// by capping it to the maximum configured at the SecretClass.
Expand Down Expand Up @@ -284,7 +285,7 @@ impl SecretBackend for TlsGenerate {
}
let ca = self
.ca_manager
.find_certificate_authority_for_signing(not_after)
.find_certificate_authority_for_signing(not_after, ca_expiry_threshold)
.context(PickCaSnafu)?;
let pod_cert = X509Builder::new()
.and_then(|mut x509| {
Expand Down Expand Up @@ -347,10 +348,13 @@ impl SecretBackend for TlsGenerate {
SecretContents::new(SecretData::WellKnown(WellKnownSecretData::TlsPem(
well_known::TlsPem {
ca_pem: iterator_try_concat_bytes(
self.ca_manager.trust_roots().into_iter().map(|ca| {
ca.to_pem()
.context(SerializeCertificateSnafu { tpe: CertType::Ca })
}),
self.ca_manager
.trust_roots(ca_expiry_threshold)
.into_iter()
.map(|ca| {
ca.to_pem()
.context(SerializeCertificateSnafu { tpe: CertType::Ca })
}),
)?,
certificate_pem: Some(
pod_cert
Expand All @@ -372,16 +376,19 @@ impl SecretBackend for TlsGenerate {

async fn get_trust_data(
&self,
_selector: &super::TrustSelector,
selector: &super::TrustSelector,
) -> Result<SecretContents, Self::Error> {
Ok(SecretContents::new(SecretData::WellKnown(
WellKnownSecretData::TlsPem(well_known::TlsPem {
ca_pem: iterator_try_concat_bytes(self.ca_manager.trust_roots().into_iter().map(
|ca| {
ca.to_pem()
.context(SerializeCertificateSnafu { tpe: CertType::Ca })
},
))?,
ca_pem: iterator_try_concat_bytes(
self.ca_manager
.trust_roots(selector.ca_expiry_threshold)
.into_iter()
.map(|ca| {
ca.to_pem()
.context(SerializeCertificateSnafu { tpe: CertType::Ca })
}),
)?,
certificate_pem: None,
key_pem: None,
}),
Expand Down
6 changes: 6 additions & 0 deletions rust/operator-binary/src/crd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,12 @@ pub struct TrustStoreSpec {

/// The [format](DOCS_BASE_URL_PLACEHOLDER/secret-operator/secretclass#format) that the data should be converted into.
pub format: Option<SecretFormat>,

/// The minimum remaining lifetime that a CA must have to be considered valid for
/// publishing in the TrustStore.
/// If not specified, all CAs (including expired ones) will be published.
#[serde(default)]
pub ca_expiry_threshold: Option<Duration>,
}

#[cfg(test)]
Expand Down
1 change: 1 addition & 0 deletions rust/operator-binary/src/truststore_controller.rs
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,7 @@ async fn reconcile(
.namespace
.clone()
.context(NoTrustStoreNamespaceSnafu)?,
ca_expiry_threshold: truststore.spec.ca_expiry_threshold,
};
let trust_data = backend
.get_trust_data(&selector)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{% if test_scenario['values']['openshift'] == 'true' %}
# see https://github.com/stackabletech/issues/issues/566
---
apiVersion: kuttl.dev/v1beta1
kind: TestStep
commands:
- script: kubectl patch namespace $NAMESPACE -p '{"metadata":{"labels":{"pod-security.kubernetes.io/enforce":"privileged"}}}'
timeout: 120
{% endif %}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
apiVersion: kuttl.dev/v1beta1
kind: TestStep
commands:
- script: envsubst '$NAMESPACE' < 01_secretclass.yaml | kubectl --namespace=$NAMESPACE apply -f -
28 changes: 28 additions & 0 deletions tests/templates/kuttl/tls-ca-expiry-threshold/01_secretclass.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
apiVersion: secrets.stackable.tech/v1alpha1
kind: SecretClass
metadata:
name: tls-short-ca
spec:
backend:
autoTls:
ca:
secret:
name: secret-provisioner-tls-ca-short
namespace: $NAMESPACE
autoGenerate: true
caCertificateLifetime: 5m
---
apiVersion: secrets.stackable.tech/v1alpha1
kind: SecretClass
metadata:
name: tls-normal-ca
spec:
backend:
autoTls:
ca:
secret:
name: secret-provisioner-tls-ca-normal
namespace: $NAMESPACE
autoGenerate: true
caCertificateLifetime: 365d
38 changes: 38 additions & 0 deletions tests/templates/kuttl/tls-ca-expiry-threshold/02-rbac.yaml.j2
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
kind: Role
apiVersion: rbac.authorization.k8s.io/v1
metadata:
name: use-integration-tests-scc
rules:
- apiGroups:
- ""
resources:
- configmaps
- secrets
verbs:
- create
- get
- list
{% if test_scenario['values']['openshift'] == "true" %}
- apiGroups: ["security.openshift.io"]
resources: ["securitycontextconstraints"]
resourceNames: ["privileged"]
verbs: ["use"]
{% endif %}
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: integration-tests-sa
---
kind: RoleBinding
apiVersion: rbac.authorization.k8s.io/v1
metadata:
name: use-integration-tests-scc
subjects:
- kind: ServiceAccount
name: integration-tests-sa
roleRef:
kind: Role
name: use-integration-tests-scc
apiGroup: rbac.authorization.k8s.io
29 changes: 29 additions & 0 deletions tests/templates/kuttl/tls-ca-expiry-threshold/03-truststore.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
# TrustStore without caExpiryThreshold - should include all CAs even if expired
apiVersion: secrets.stackable.tech/v1alpha1
kind: TrustStore
metadata:
name: truststore-all-cas
spec:
secretClassName: tls-short-ca
format: tls-pem
---
# TrustStore with caExpiryThreshold - should filter out CAs close to expiry
apiVersion: secrets.stackable.tech/v1alpha1
kind: TrustStore
metadata:
name: truststore-filtered-cas
spec:
secretClassName: tls-short-ca
format: tls-pem
caExpiryThreshold: 1h
---
# TrustStore with normal CA lifetime for comparison
apiVersion: secrets.stackable.tech/v1alpha1
kind: TrustStore
metadata:
name: truststore-normal-cas
spec:
secretClassName: tls-normal-ca
format: tls-pem
caExpiryThreshold: 1d
19 changes: 19 additions & 0 deletions tests/templates/kuttl/tls-ca-expiry-threshold/04-assert.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
apiVersion: kuttl.dev/v1beta1
kind: TestAssert
timeout: 30
---
apiVersion: v1
kind: ConfigMap
metadata:
name: truststore-all-cas
---
apiVersion: v1
kind: ConfigMap
metadata:
name: truststore-filtered-cas
---
apiVersion: v1
kind: ConfigMap
metadata:
name: truststore-normal-cas
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
apiVersion: kuttl.dev/v1beta1
kind: TestStep
commands:
- script: sleep 310
timeout: 320
Loading
Loading