Skip to content

Commit 3f173e0

Browse files
authored
Merge branch 'main' into chore/pedantic-clippy-lints
2 parents 34b527b + d4ed55d commit 3f173e0

17 files changed

Lines changed: 993 additions & 28 deletions

File tree

crates/stackable-operator/CHANGELOG.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,20 @@ All notable changes to this project will be documented in this file.
66

77
### Added
88

9-
- Implement `Deref` for `kvp::Key` to be more ergonomic to use ([#1182]).
9+
- Git sync: add support for CAs ([#1154]).
1010
- Add support for specifying a `clientAuthenticationMethod` for OIDC ([#1178]).
1111
This was originally done in [#1158] and had been reverted in [#1170].
12+
- Implement `Deref` for `kvp::Key` to be more ergonomic to use ([#1182]).
13+
14+
### Changed
15+
16+
- BREAKING: Add mandatory `provision_parts` argument to `SecretOperatorVolumeSourceBuilder::new` ([#1165]).
17+
It now forces the caller to make an explicit choice if the public parts are sufficient or if private
18+
(e.g. a certificate for the Pod) parts are needed as well. This is done to avoid accidentally requesting
19+
too much parts. For details see [this issue](https://github.com/stackabletech/issues/issues/547).
20+
21+
Additionally, `SecretClassVolume::to_volume` and `SecretClassVolume::to_ephemeral_volume_source`
22+
also take the same new argument.
1223

1324
### Changed
1425

@@ -25,6 +36,8 @@ All notable changes to this project will be documented in this file.
2536
- BREAKING: Remove unused `add_prefix`, `try_add_prefix`, `set_name`, and `try_set_name` associated
2637
functions from `kvp::Key` to disallow mutable access to inner values ([#1182]).
2738

39+
[#1154]: https://github.com/stackabletech/operator-rs/pull/1154
40+
[#1165]: https://github.com/stackabletech/operator-rs/pull/1165
2841
[#1178]: https://github.com/stackabletech/operator-rs/pull/1178
2942
[#1182]: https://github.com/stackabletech/operator-rs/pull/1182
3043
[#1186]: https://github.com/stackabletech/operator-rs/pull/1186

crates/stackable-operator/crds/DummyCluster.yaml

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,56 @@ spec:
152152
description: 'The git repository URL that will be cloned, for example: `https://github.com/stackabletech/airflow-operator` or `ssh://git@github.com:stackable-airflow/dags.git`.'
153153
format: uri
154154
type: string
155+
tls:
156+
default:
157+
verification:
158+
server:
159+
caCert:
160+
webPki: {}
161+
description: Configure a TLS connection. If not specified it will default to webPki validation.
162+
nullable: true
163+
properties:
164+
verification:
165+
description: The verification method used to verify the certificates of the server and/or the client.
166+
oneOf:
167+
- required:
168+
- none
169+
- required:
170+
- server
171+
properties:
172+
none:
173+
description: Use TLS but don't verify certificates.
174+
type: object
175+
server:
176+
description: Use TLS and a CA certificate to verify the server.
177+
properties:
178+
caCert:
179+
description: CA cert to verify the server.
180+
oneOf:
181+
- required:
182+
- webPki
183+
- required:
184+
- secretClass
185+
properties:
186+
secretClass:
187+
description: |-
188+
Name of the [SecretClass](https://docs.stackable.tech/home/nightly/secret-operator/secretclass) which will provide the CA certificate.
189+
Note that a SecretClass does not need to have a key but can also work with just a CA certificate,
190+
so if you got provided with a CA cert but don't have access to the key you can still use this method.
191+
type: string
192+
webPki:
193+
description: |-
194+
Use TLS and the CA certificates trusted by the common web browsers to verify the server.
195+
This can be useful when you e.g. use public AWS S3 or other public available services.
196+
type: object
197+
type: object
198+
required:
199+
- caCert
200+
type: object
201+
type: object
202+
required:
203+
- verification
204+
type: object
155205
wait:
156206
default: 20s
157207
description: |-

crates/stackable-operator/src/builder/pod/volume.rs

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ use tracing::warn;
1414

1515
use crate::{
1616
builder::meta::ObjectMetaBuilder,
17+
commons::secret_class::SecretClassVolumeProvisionParts,
1718
kvp::{Annotation, AnnotationError, Annotations, LabelError, Labels},
1819
};
1920

@@ -279,17 +280,29 @@ pub struct SecretOperatorVolumeSourceBuilder {
279280
kerberos_service_names: Vec<String>,
280281
tls_pkcs12_password: Option<String>,
281282
auto_tls_cert_lifetime: Option<Duration>,
283+
provision_parts: SecretClassVolumeProvisionParts,
282284
}
283285

284286
impl SecretOperatorVolumeSourceBuilder {
285-
pub fn new(secret_class: impl Into<String>) -> Self {
287+
/// Creates a builder for a secret-operator volume that uses the specified SecretClass to
288+
/// request the specified [`SecretClassVolumeProvisionParts`].
289+
///
290+
/// This function forces the caller to make an explicit choice if the public parts are
291+
/// sufficient or if private (e.g. a certificate for the Pod) parts are needed as well.
292+
/// This is done to avoid accidentally requesting too much parts. For details see
293+
/// [this issue](https://github.com/stackabletech/issues/issues/547).
294+
pub fn new(
295+
secret_class: impl Into<String>,
296+
provision_parts: SecretClassVolumeProvisionParts,
297+
) -> Self {
286298
Self {
287299
secret_class: secret_class.into(),
288300
scopes: Vec::new(),
289301
format: None,
290302
kerberos_service_names: Vec::new(),
291303
tls_pkcs12_password: None,
292304
auto_tls_cert_lifetime: None,
305+
provision_parts,
293306
}
294307
}
295308

@@ -338,8 +351,10 @@ impl SecretOperatorVolumeSourceBuilder {
338351
pub fn build(&self) -> Result<EphemeralVolumeSource, SecretOperatorVolumeSourceBuilderError> {
339352
let mut annotations = Annotations::new();
340353

354+
#[rustfmt::skip]
341355
annotations
342-
.insert(Annotation::secret_class(&self.secret_class).context(ParseAnnotationSnafu)?);
356+
.insert(Annotation::secret_class(&self.secret_class).context(ParseAnnotationSnafu)?)
357+
.insert(Annotation::secret_provision_parts(&self.provision_parts).context(ParseAnnotationSnafu)?);
343358

344359
if !self.scopes.is_empty() {
345360
annotations

crates/stackable-operator/src/commons/secret_class.rs

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,10 @@ impl SecretClassVolume {
3838

3939
pub fn to_ephemeral_volume_source(
4040
&self,
41+
provision_parts: SecretClassVolumeProvisionParts,
4142
) -> Result<EphemeralVolumeSource, SecretClassVolumeError> {
4243
let mut secret_operator_volume_builder =
43-
SecretOperatorVolumeSourceBuilder::new(&self.secret_class);
44+
SecretOperatorVolumeSourceBuilder::new(&self.secret_class, provision_parts);
4445

4546
if let Some(scope) = &self.scope {
4647
if scope.pod {
@@ -62,8 +63,12 @@ impl SecretClassVolume {
6263
.context(SecretOperatorVolumeSnafu)
6364
}
6465

65-
pub fn to_volume(&self, volume_name: &str) -> Result<Volume, SecretClassVolumeError> {
66-
let ephemeral = self.to_ephemeral_volume_source()?;
66+
pub fn to_volume(
67+
&self,
68+
volume_name: &str,
69+
provision_parts: SecretClassVolumeProvisionParts,
70+
) -> Result<Volume, SecretClassVolumeError> {
71+
let ephemeral = self.to_ephemeral_volume_source(provision_parts)?;
6772
Ok(VolumeBuilder::new(volume_name).ephemeral(ephemeral).build())
6873
}
6974
}
@@ -94,14 +99,33 @@ pub struct SecretClassVolumeScope {
9499
pub listener_volumes: Vec<String>,
95100
}
96101

102+
/// What parts of secret material should be provisioned into the requested volume.
103+
//
104+
// There intentionally isn't a global [`Default`] impl, as it is secret-operator's concern what it
105+
// chooses as a default.
106+
// TODO (@Techassi): This to me is a HUGE indicator this lives in the wrong place. All these secret
107+
// volume builders/helpers should be defined as part of a secret-operator library to be as close as
108+
// possible to secret-operator, which is the authoritative source of truth for all of this.
109+
#[derive(Copy, Clone, Debug, PartialEq, Eq, strum::AsRefStr)]
110+
#[strum(serialize_all = "kebab-case")]
111+
pub enum SecretClassVolumeProvisionParts {
112+
/// Only provision public parts, such as the CA certificate (either as PEM or truststore) or
113+
/// `krb5.conf`.
114+
Public,
115+
116+
/// Provision all parts, which includes all [`Public`](Self::Public) ones as well as additional
117+
/// private parts, such as a TLS cert + private key, a keystore or a keytab.
118+
PublicPrivate,
119+
}
120+
97121
#[cfg(test)]
98122
mod tests {
99123
use std::collections::BTreeMap;
100124

101125
use super::*;
102126

103127
#[test]
104-
fn volume_to_csi_volume_source() {
128+
fn volume_to_ephemeral_volume_source() {
105129
let secret_class_volume_source = SecretClassVolume {
106130
secret_class: "myclass".to_string(), // pragma: allowlist secret
107131
scope: Some(SecretClassVolumeScope {
@@ -111,7 +135,8 @@ mod tests {
111135
listener_volumes: vec!["mylistener".to_string()],
112136
}),
113137
}
114-
.to_ephemeral_volume_source()
138+
// Let's assume we need some form of private data (e.g. a certificate or S3 credentials)
139+
.to_ephemeral_volume_source(SecretClassVolumeProvisionParts::PublicPrivate)
115140
.unwrap();
116141

117142
let expected_volume_attributes = BTreeMap::from([
@@ -123,6 +148,10 @@ mod tests {
123148
"secrets.stackable.tech/scope".to_string(),
124149
"pod,service=myservice,listener-volume=mylistener".to_string(),
125150
),
151+
(
152+
"secrets.stackable.tech/provision-parts".to_string(),
153+
"public-private".to_string(),
154+
),
126155
]);
127156

128157
assert_eq!(

crates/stackable-operator/src/commons/tls_verification.rs

Lines changed: 88 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ use crate::{
88
self,
99
pod::{PodBuilder, container::ContainerBuilder, volume::VolumeMountBuilder},
1010
},
11-
commons::secret_class::{SecretClassVolume, SecretClassVolumeError},
11+
commons::secret_class::{
12+
SecretClassVolume, SecretClassVolumeError, SecretClassVolumeProvisionParts,
13+
},
1214
constants::secret::SECRET_BASE_PATH,
1315
};
1416

@@ -26,6 +28,7 @@ pub enum TlsClientDetailsError {
2628
},
2729
}
2830

31+
#[repr(transparent)]
2932
#[derive(
3033
Clone, Debug, Deserialize, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize,
3134
)]
@@ -35,6 +38,40 @@ pub struct TlsClientDetails {
3538
pub tls: Option<Tls>,
3639
}
3740

41+
#[repr(transparent)]
42+
#[derive(
43+
Clone, Debug, Deserialize, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize,
44+
)]
45+
#[serde(rename_all = "camelCase")]
46+
pub struct TlsClientDetailsWithSecureDefaults {
47+
/// Configure a TLS connection. If not specified it will default to webPki validation.
48+
#[serde(default = "default_web_pki_tls")]
49+
pub tls: Option<Tls>,
50+
}
51+
52+
impl std::ops::Deref for TlsClientDetailsWithSecureDefaults {
53+
type Target = TlsClientDetails;
54+
55+
fn deref(&self) -> &TlsClientDetails {
56+
// SAFETY: both types are `#[repr(transparent)]` over `Option<Tls>`, so they share
57+
// the same memory layout and this cast is sound.
58+
//
59+
// This cannot silently break due to struct changes: `#[repr(transparent)]` requires
60+
// exactly one non-zero-sized field, so adding a second real field to either struct
61+
// is a compile error. The only scenario that would NOT be caught at compile time is
62+
// deliberately removing `#[repr(transparent)]` from one of the two structs.
63+
unsafe { &*(self as *const Self as *const TlsClientDetails) }
64+
}
65+
}
66+
67+
fn default_web_pki_tls() -> Option<Tls> {
68+
Some(Tls {
69+
verification: TlsVerification::Server(TlsServerVerification {
70+
ca_cert: CaCert::WebPki {},
71+
}),
72+
})
73+
}
74+
3875
impl TlsClientDetails {
3976
/// This functions adds
4077
///
@@ -72,7 +109,8 @@ impl TlsClientDetails {
72109
let volume_name = format!("{secret_class}-ca-cert");
73110
let secret_class_volume = SecretClassVolume::new(secret_class.clone(), None);
74111
let volume = secret_class_volume
75-
.to_volume(&volume_name)
112+
// We only need the public CA cert
113+
.to_volume(&volume_name, SecretClassVolumeProvisionParts::Public)
76114
.context(SecretClassVolumeSnafu)?;
77115

78116
volumes.push(volume);
@@ -164,3 +202,51 @@ pub enum CaCert {
164202
/// so if you got provided with a CA cert but don't have access to the key you can still use this method.
165203
SecretClass(String),
166204
}
205+
206+
#[cfg(test)]
207+
mod tests {
208+
use super::*;
209+
use crate::utils::yaml_from_str_singleton_map;
210+
211+
#[test]
212+
fn tls_client_details_with_secure_defaults_deserialization() {
213+
// No tls key at all → WebPki default kicks in
214+
let parsed: TlsClientDetailsWithSecureDefaults =
215+
yaml_from_str_singleton_map("{}").expect("failed to deserialize empty input");
216+
assert_eq!(parsed.tls, default_web_pki_tls());
217+
218+
// Explicit null → opt out of TLS entirely
219+
let parsed: TlsClientDetailsWithSecureDefaults =
220+
yaml_from_str_singleton_map("tls: null").expect("failed to deserialize tls: null");
221+
assert_eq!(parsed.tls, None);
222+
223+
// Explicit SecretClass value is preserved as-is
224+
let parsed: TlsClientDetailsWithSecureDefaults = yaml_from_str_singleton_map(
225+
"tls:
226+
verification:
227+
server:
228+
caCert:
229+
secretClass: my-ca",
230+
)
231+
.expect("failed to deserialize secretClass");
232+
assert_eq!(
233+
parsed.tls,
234+
Some(Tls {
235+
verification: TlsVerification::Server(TlsServerVerification {
236+
ca_cert: CaCert::SecretClass("my-ca".to_owned()),
237+
}),
238+
})
239+
);
240+
}
241+
242+
#[test]
243+
#[allow(clippy::explicit_auto_deref)]
244+
fn tls_client_details_with_secure_defaults_deref() {
245+
let secure: TlsClientDetailsWithSecureDefaults =
246+
yaml_from_str_singleton_map("{}").expect("failed to deserialize");
247+
248+
// Deref must not panic and must expose the same tls value
249+
let tls_client_details: &TlsClientDetails = &*secure;
250+
assert_eq!(tls_client_details.tls, secure.tls);
251+
}
252+
}

crates/stackable-operator/src/crd/authentication/ldap/v1alpha1_impl.rs

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@ use crate::{
77
self,
88
pod::{PodBuilder, container::ContainerBuilder, volume::VolumeMountBuilder},
99
},
10-
commons::{secret_class::SecretClassVolumeError, tls_verification::TlsClientDetailsError},
10+
commons::{
11+
secret_class::{SecretClassVolumeError, SecretClassVolumeProvisionParts},
12+
tls_verification::TlsClientDetailsError,
13+
},
1114
constants::secret::SECRET_BASE_PATH,
1215
crd::authentication::ldap::v1alpha1::{AuthenticationProvider, FieldNames},
1316
};
@@ -94,7 +97,8 @@ impl AuthenticationProvider {
9497
let secret_class = &bind_credentials.secret_class;
9598
let volume_name = format!("{secret_class}-bind-credentials");
9699
let volume = bind_credentials
97-
.to_volume(&volume_name)
100+
// We need the private LDAP bind credentials
101+
.to_volume(&volume_name, SecretClassVolumeProvisionParts::PublicPrivate)
98102
.context(BindCredentialsSnafu)?;
99103

100104
volumes.push(volume);
@@ -234,7 +238,10 @@ mod tests {
234238
secret_class: "ldap-ca-cert".to_string(),
235239
scope: None,
236240
}
237-
.to_volume("ldap-ca-cert-ca-cert")
241+
.to_volume(
242+
"ldap-ca-cert-ca-cert",
243+
SecretClassVolumeProvisionParts::Public
244+
)
238245
.unwrap()
239246
]
240247
);
@@ -263,13 +270,19 @@ mod tests {
263270
secret_class: "openldap-bind-credentials".to_string(),
264271
scope: None,
265272
}
266-
.to_volume("openldap-bind-credentials-bind-credentials")
273+
.to_volume(
274+
"openldap-bind-credentials-bind-credentials",
275+
SecretClassVolumeProvisionParts::PublicPrivate
276+
)
267277
.unwrap(),
268278
SecretClassVolume {
269279
secret_class: "ldap-ca-cert".to_string(),
270280
scope: None,
271281
}
272-
.to_volume("ldap-ca-cert-ca-cert")
282+
.to_volume(
283+
"ldap-ca-cert-ca-cert",
284+
SecretClassVolumeProvisionParts::Public
285+
)
273286
.unwrap()
274287
]
275288
);

0 commit comments

Comments
 (0)