Skip to content

Commit 0d8d467

Browse files
committed
fix: Refuse to overwrite foreign objects from TrustStore reconciler
1 parent bc1dbae commit 0d8d467

1 file changed

Lines changed: 165 additions & 0 deletions

File tree

rust/operator-binary/src/truststore_controller.rs

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ use stackable_operator::{
88
k8s_openapi::{
99
ByteString,
1010
api::core::v1::{ConfigMap, Secret},
11+
apimachinery::pkg::apis::meta::v1::OwnerReference,
1112
},
1213
kube::{
1314
Resource,
@@ -220,6 +221,27 @@ pub enum Error {
220221
source: stackable_operator::client::Error,
221222
secret: ObjectRef<Secret>,
222223
},
224+
225+
#[snafu(display(
226+
"failed to look up pre-existing target {object} before applying the TrustStore"
227+
))]
228+
GetExistingTarget {
229+
source: stackable_operator::client::Error,
230+
object: ObjectRef<stackable_operator::kube::api::DynamicObject>,
231+
},
232+
233+
#[snafu(display(
234+
"refusing to overwrite pre-existing {object} that is not owned by this TrustStore"
235+
))]
236+
RefuseToOverwriteForeignTarget {
237+
object: ObjectRef<stackable_operator::kube::api::DynamicObject>,
238+
},
239+
240+
#[snafu(display("TrustStore has no associated UID"))]
241+
NoTrustStoreUid,
242+
243+
#[snafu(display("TrustStore has no associated name"))]
244+
NoTrustStoreName,
223245
}
224246

225247
type Result<T, E = Error> = std::result::Result<T, E>;
@@ -239,10 +261,28 @@ impl ReconcilerError for Error {
239261
Error::BuildOwnerReference { .. } => None,
240262
Error::ApplyTrustStoreConfigMap { config_map, .. } => Some(config_map.clone().erase()),
241263
Error::ApplyTrustStoreSecret { secret, .. } => Some(secret.clone().erase()),
264+
Error::GetExistingTarget { object, .. } => Some(object.clone()),
265+
Error::RefuseToOverwriteForeignTarget { object } => Some(object.clone()),
266+
Error::NoTrustStoreUid => None,
267+
Error::NoTrustStoreName => None,
242268
}
243269
}
244270
}
245271

272+
/// Returns `true` if `existing_owners` contain a controller `OwnerReference` that points to the
273+
/// TrustStore with the given UID. Used to ensure we only overwrite output ConfigMaps and Secrets
274+
/// that we previously created ourselves; refusing otherwise prevents the TrustStore primitive
275+
/// from being abused to clobber foreign same-named objects (e.g. `kube-root-ca.crt`) via the
276+
/// operator's elevated cluster-wide write permissions.
277+
fn is_owned_by_truststore(existing_owners: &[OwnerReference], truststore_uid: &str) -> bool {
278+
existing_owners.iter().any(|owner| {
279+
owner.controller == Some(true)
280+
&& owner.kind == "TrustStore"
281+
&& owner.api_version.starts_with("secrets.stackable.tech/")
282+
&& owner.uid == truststore_uid
283+
})
284+
}
285+
246286
struct Ctx {
247287
client: stackable_operator::client::Client,
248288
}
@@ -311,6 +351,18 @@ async fn reconcile(
311351
.context(BuildOwnerReferenceSnafu)?
312352
.build();
313353

354+
let truststore_name = truststore
355+
.metadata
356+
.name
357+
.as_deref()
358+
.context(NoTrustStoreNameSnafu)?;
359+
let truststore_namespace = selector.namespace.as_str();
360+
let truststore_uid = truststore
361+
.metadata
362+
.uid
363+
.as_deref()
364+
.context(NoTrustStoreUidSnafu)?;
365+
314366
match truststore.spec.target_kind {
315367
v1alpha1::TrustStoreOutputType::ConfigMap => {
316368
let trust_cm = ConfigMap {
@@ -319,6 +371,22 @@ async fn reconcile(
319371
binary_data: Some(binary_data),
320372
..Default::default()
321373
};
374+
let existing = ctx
375+
.client
376+
.get_opt::<ConfigMap>(truststore_name, truststore_namespace)
377+
.await
378+
.with_context(|_| GetExistingTargetSnafu {
379+
object: ObjectRef::from_obj(&trust_cm).erase(),
380+
})?;
381+
if let Some(existing) = existing {
382+
let owners = existing.metadata.owner_references.as_deref().unwrap_or(&[]);
383+
if !is_owned_by_truststore(owners, truststore_uid) {
384+
return RefuseToOverwriteForeignTargetSnafu {
385+
object: ObjectRef::from_obj(&trust_cm).erase(),
386+
}
387+
.fail();
388+
}
389+
}
322390
ctx.client
323391
.apply_patch(CONTROLLER_NAME, &trust_cm, &trust_cm)
324392
.await
@@ -333,6 +401,22 @@ async fn reconcile(
333401
data: Some(binary_data),
334402
..Default::default()
335403
};
404+
let existing = ctx
405+
.client
406+
.get_opt::<Secret>(truststore_name, truststore_namespace)
407+
.await
408+
.with_context(|_| GetExistingTargetSnafu {
409+
object: ObjectRef::from_obj(&trust_secret).erase(),
410+
})?;
411+
if let Some(existing) = existing {
412+
let owners = existing.metadata.owner_references.as_deref().unwrap_or(&[]);
413+
if !is_owned_by_truststore(owners, truststore_uid) {
414+
return RefuseToOverwriteForeignTargetSnafu {
415+
object: ObjectRef::from_obj(&trust_secret).erase(),
416+
}
417+
.fail();
418+
}
419+
}
336420
ctx.client
337421
.apply_patch(CONTROLLER_NAME, &trust_secret, &trust_secret)
338422
.await
@@ -352,3 +436,84 @@ fn error_policy(
352436
) -> controller::Action {
353437
controller::Action::requeue(Duration::from_secs(5))
354438
}
439+
440+
#[cfg(test)]
441+
mod tests {
442+
use super::*;
443+
444+
fn owner_ref(
445+
api_version: &str,
446+
kind: &str,
447+
uid: &str,
448+
controller: Option<bool>,
449+
) -> OwnerReference {
450+
OwnerReference {
451+
api_version: api_version.to_string(),
452+
kind: kind.to_string(),
453+
name: "some-name".to_string(),
454+
uid: uid.to_string(),
455+
controller,
456+
block_owner_deletion: None,
457+
}
458+
}
459+
460+
#[test]
461+
fn empty_owner_refs_are_rejected() {
462+
assert!(!is_owned_by_truststore(&[], "my-uid"));
463+
}
464+
465+
#[test]
466+
fn matching_controller_owner_ref_is_accepted() {
467+
let owners = [owner_ref(
468+
"secrets.stackable.tech/v1alpha1",
469+
"TrustStore",
470+
"my-uid",
471+
Some(true),
472+
)];
473+
assert!(is_owned_by_truststore(&owners, "my-uid"));
474+
}
475+
476+
#[test]
477+
fn non_controller_owner_ref_is_rejected() {
478+
let owners = [owner_ref(
479+
"secrets.stackable.tech/v1alpha1",
480+
"TrustStore",
481+
"my-uid",
482+
Some(false),
483+
)];
484+
assert!(!is_owned_by_truststore(&owners, "my-uid"));
485+
}
486+
487+
#[test]
488+
fn different_uid_is_rejected() {
489+
let owners = [owner_ref(
490+
"secrets.stackable.tech/v1alpha1",
491+
"TrustStore",
492+
"other-uid",
493+
Some(true),
494+
)];
495+
assert!(!is_owned_by_truststore(&owners, "my-uid"));
496+
}
497+
498+
#[test]
499+
fn different_kind_is_rejected() {
500+
let owners = [owner_ref("v1", "ConfigMap", "my-uid", Some(true))];
501+
assert!(!is_owned_by_truststore(&owners, "my-uid"));
502+
}
503+
504+
#[test]
505+
fn foreign_api_group_is_rejected() {
506+
let owners = [owner_ref(
507+
"evil.example.com/v1",
508+
"TrustStore",
509+
"my-uid",
510+
Some(true),
511+
)];
512+
assert!(!is_owned_by_truststore(&owners, "my-uid"));
513+
}
514+
515+
#[test]
516+
fn kube_root_ca_style_no_owner_is_rejected() {
517+
assert!(!is_owned_by_truststore(&[], "my-uid"));
518+
}
519+
}

0 commit comments

Comments
 (0)