Skip to content

Commit 3a61c9f

Browse files
feat: cross ns security (#226)
* feat: restrict cross-ns kubeconfig access * chore: use a separate function * chore: unit tests * chore: use consts * feat: don't expose whether secrets in another namespace exist if permission is not granted
1 parent 6165a5f commit 3a61c9f

File tree

5 files changed

+236
-51
lines changed

5 files changed

+236
-51
lines changed

Cargo.lock

Lines changed: 3 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ thiserror = "^2.0.17"
3131
serde_json_path = "^0.7.2"
3232
tokio-context = "^0.1.3"
3333
tokio-stream = "^0.1.17"
34+
regex = "^1.12.2"
3435

3536
[dev-dependencies]
3637
rstest = "^0.26.1"

src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ pub enum Error {
5454

5555
#[error("Resource {0} of kind {1} not found: {2}")]
5656
ResourceNotFoundError(String, String, kube::Error),
57+
58+
#[error("Referenced Kubeconfig Secret cannot be accessed due to namespace restrictions")]
59+
UnauthorizedKubeconfigAccess(),
5760
}
5861

5962
pub type Result<T, E = Error> = std::result::Result<T, E>;

src/resource_extensions.rs

Lines changed: 226 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,21 @@
11
use std::ops::Deref;
22
use std::sync::Arc;
33

4+
use crate::controller::Context;
5+
use crate::remote_watcher::RemoteWatcherKey;
6+
use crate::resources::{
7+
ClusterRef, ClusterResourceRef, ResourceSync, ALLOWED_NAMESPACES_ANNOTATION,
8+
};
9+
use crate::Error::UnauthorizedKubeconfigAccess;
10+
use crate::{Error, FINALIZER};
411
use k8s_openapi::api::core::v1::Secret;
512
use kube::api::{ApiResource, DynamicObject};
613
use kube::discovery::Scope::*;
714
use kube::runtime::reflector::ObjectRef;
815
use kube::{discovery, Api, Client, Config, ResourceExt};
16+
use regex::Regex;
917
use tracing::debug;
1018

11-
use crate::controller::Context;
12-
use crate::remote_watcher::RemoteWatcherKey;
13-
use crate::resources::{ClusterRef, ClusterResourceRef, ResourceSync};
14-
use crate::{Error, FINALIZER};
15-
1619
macro_rules! rs_watch {
1720
($fn_name:ident, $method:ident) => {
1821
pub async fn $fn_name(&self, ctx: Arc<Context>) {
@@ -98,53 +101,86 @@ async fn cluster_client(
98101
local_ns: &str,
99102
client: Client,
100103
) -> crate::Result<Client> {
101-
let client = match cluster_ref {
102-
None => client,
103-
Some(cluster_ref) => {
104-
let secret_ns = cluster_ref
105-
.kube_config
106-
.secret_ref
107-
.namespace
108-
.as_deref()
109-
.unwrap_or(local_ns);
110-
let secrets: Api<Secret> = Api::namespaced(client, secret_ns);
111-
let secret_ref = &cluster_ref.kube_config.secret_ref;
112-
let sec = secrets.get(&secret_ref.name).await?;
113-
114-
let kube_config = kube::config::Kubeconfig::from_yaml(
115-
std::str::from_utf8(
116-
&sec.data
117-
.unwrap()
118-
.get(&secret_ref.key)
119-
.ok_or_else(|| {
120-
Error::MissingKeyError(
121-
secret_ref.key.clone(),
122-
secret_ref.name.clone(),
123-
secret_ns.to_string(),
124-
)
125-
})?
126-
.0,
127-
)
128-
.map_err(Error::KubeconfigUtf8Error)?,
129-
)?;
130-
let mut config =
131-
Config::from_custom_kubeconfig(kube_config, &Default::default()).await?;
132-
133-
if let Some(ref namespace) = cluster_ref.namespace {
134-
config.default_namespace = namespace.clone();
135-
}
104+
let client =
105+
match cluster_ref {
106+
None => client,
107+
Some(cluster_ref) => {
108+
let secret_ns = cluster_ref
109+
.kube_config
110+
.secret_ref
111+
.namespace
112+
.as_deref()
113+
.unwrap_or(local_ns);
114+
let secrets: Api<Secret> = Api::namespaced(client, secret_ns);
115+
let secret_ref = &cluster_ref.kube_config.secret_ref;
116+
let sec = secrets.get(&secret_ref.name).await.map_err(|e| {
117+
match secret_ns == local_ns {
118+
true => crate::Error::from(e),
119+
false => {
120+
debug!(
121+
"error accessing kubeconfig secret in remote namespace: {}",
122+
e
123+
);
124+
UnauthorizedKubeconfigAccess()
125+
}
126+
}
127+
})?;
136128

137-
debug!(?config.cluster_url, "connecting to remote cluster");
138-
let remote_client = kube::Client::try_from(config)?;
139-
let version = remote_client.apiserver_version().await?;
140-
debug!(?version, "remote cluster version");
129+
if secret_ns != local_ns {
130+
verify_kubeconfig_secret_access(local_ns, &sec)?;
131+
}
141132

142-
remote_client
143-
}
144-
};
133+
let kube_config = kube::config::Kubeconfig::from_yaml(
134+
std::str::from_utf8(
135+
&sec.data
136+
.unwrap()
137+
.get(&secret_ref.key)
138+
.ok_or_else(|| {
139+
Error::MissingKeyError(
140+
secret_ref.key.clone(),
141+
secret_ref.name.clone(),
142+
secret_ns.to_string(),
143+
)
144+
})?
145+
.0,
146+
)
147+
.map_err(Error::KubeconfigUtf8Error)?,
148+
)?;
149+
let mut config =
150+
Config::from_custom_kubeconfig(kube_config, &Default::default()).await?;
151+
152+
if let Some(ref namespace) = cluster_ref.namespace {
153+
config.default_namespace = namespace.clone();
154+
}
155+
156+
debug!(?config.cluster_url, "connecting to remote cluster");
157+
let remote_client = kube::Client::try_from(config)?;
158+
let version = remote_client.apiserver_version().await?;
159+
debug!(?version, "remote cluster version");
160+
161+
remote_client
162+
}
163+
};
145164
Ok(client)
146165
}
147166

167+
fn verify_kubeconfig_secret_access(local_ns: &str, sec: &Secret) -> crate::Result<()> {
168+
let allowed_namespaces = sec
169+
.metadata
170+
.annotations
171+
.as_ref()
172+
.and_then(|annotations| annotations.get(ALLOWED_NAMESPACES_ANNOTATION))
173+
.ok_or(UnauthorizedKubeconfigAccess())?;
174+
let re = Regex::new(allowed_namespaces).map_err(|e| {
175+
debug!("invalid regex in allowed namespaces annotation: {}", e);
176+
UnauthorizedKubeconfigAccess()
177+
})?;
178+
match re.is_match(local_ns) {
179+
true => Ok(()),
180+
false => Err(UnauthorizedKubeconfigAccess()),
181+
}
182+
}
183+
148184
async fn api_for(
149185
cluster_resource_ref: &ClusterResourceRef,
150186
local_ns: &str,
@@ -180,3 +216,146 @@ impl ClusterResourceRef {
180216
api_for(self, local_ns, client).await
181217
}
182218
}
219+
220+
#[cfg(test)]
221+
mod tests {
222+
use super::*;
223+
use futures::future::join_all;
224+
use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta;
225+
use rand::{distr::Alphanumeric, rngs::StdRng, Rng, SeedableRng};
226+
use rstest::rstest;
227+
use std::collections::BTreeMap;
228+
229+
fn secret_with_annotation(value: Option<&str>) -> Secret {
230+
Secret {
231+
metadata: ObjectMeta {
232+
annotations: value.map(|v| {
233+
BTreeMap::from([(ALLOWED_NAMESPACES_ANNOTATION.to_string(), v.to_string())])
234+
}),
235+
..Default::default()
236+
},
237+
..Default::default()
238+
}
239+
}
240+
241+
#[rstest]
242+
fn errs_when_annotation_missing() {
243+
let sec = secret_with_annotation(None);
244+
245+
let res = verify_kubeconfig_secret_access("dev", &sec);
246+
247+
assert!(matches!(res, Err(UnauthorizedKubeconfigAccess())));
248+
}
249+
250+
#[rstest]
251+
fn errs_when_annotation_key_absent() {
252+
let sec = Secret {
253+
metadata: ObjectMeta {
254+
annotations: Some(BTreeMap::from([(
255+
"other-annotation".to_string(),
256+
"^.*$".to_string(),
257+
)])),
258+
..Default::default()
259+
},
260+
..Default::default()
261+
};
262+
263+
let res = verify_kubeconfig_secret_access("ns1", &sec);
264+
265+
assert!(matches!(res, Err(UnauthorizedKubeconfigAccess())));
266+
}
267+
268+
#[rstest]
269+
#[case::unbalanced_paren("(")]
270+
#[case::dangling_escape("\\")]
271+
fn errs_on_invalid_regex(#[case] pattern: &str) {
272+
let sec = secret_with_annotation(Some(pattern));
273+
274+
let res = verify_kubeconfig_secret_access("random", &sec);
275+
276+
assert!(matches!(res, Err(UnauthorizedKubeconfigAccess())));
277+
}
278+
279+
#[rstest]
280+
fn allows_matching_namespace_randomized() {
281+
let pattern = "^ns-[a-z0-9]{4}$";
282+
let sec = secret_with_annotation(Some(pattern));
283+
let mut rng = StdRng::seed_from_u64(42);
284+
285+
for _ in 0..8 {
286+
let tail: String = (0..4)
287+
.map(|_| rng.sample(Alphanumeric) as char)
288+
.map(|c| c.to_ascii_lowercase())
289+
.collect();
290+
let ns = format!("ns-{}", tail);
291+
292+
let res = verify_kubeconfig_secret_access(&ns, &sec);
293+
294+
assert!(res.is_ok(), "{} should match {}", ns, pattern);
295+
}
296+
}
297+
298+
#[rstest]
299+
fn denies_non_matching_namespace_randomized() {
300+
let pattern = "^team-[0-9]{2}$";
301+
let sec = secret_with_annotation(Some(pattern));
302+
let mut rng = StdRng::seed_from_u64(84);
303+
304+
for _ in 0..6 {
305+
let ns = format!("proj-{}", rng.random_range(10_u8..99_u8));
306+
307+
let res = verify_kubeconfig_secret_access(&ns, &sec);
308+
309+
assert!(matches!(res, Err(UnauthorizedKubeconfigAccess())));
310+
}
311+
}
312+
313+
#[tokio::test]
314+
async fn concurrent_checks_are_independent() {
315+
let allow_secret = secret_with_annotation(Some("^ok-[a-z]{2}$"));
316+
let deny_secret = secret_with_annotation(Some("^deny$"));
317+
let mut rng = StdRng::seed_from_u64(7);
318+
319+
let inputs: Vec<_> = (0..6)
320+
.map(|i| {
321+
let expect_ok = i % 2 == 0;
322+
let sec = if expect_ok {
323+
allow_secret.clone()
324+
} else {
325+
deny_secret.clone()
326+
};
327+
let ns = if expect_ok {
328+
let part: String = (0..2)
329+
.map(|_| rng.sample(Alphanumeric) as char)
330+
.map(|c| c.to_ascii_lowercase())
331+
.collect();
332+
format!("ok-{}", part)
333+
} else {
334+
format!("bad-{}", rng.random::<u8>())
335+
};
336+
(ns, expect_ok, sec)
337+
})
338+
.collect();
339+
340+
let handles: Vec<_> = inputs
341+
.into_iter()
342+
.map(|(ns, expect_ok, sec)| {
343+
tokio::spawn(async move {
344+
let res = verify_kubeconfig_secret_access(&ns, &sec);
345+
(expect_ok, res)
346+
})
347+
})
348+
.collect();
349+
350+
let outcomes = join_all(handles).await;
351+
352+
for outcome in outcomes {
353+
let (expect_ok, res) = outcome.expect("task panicked");
354+
if expect_ok {
355+
assert!(res.is_ok(), "expected {:?} to be authorized", res);
356+
} else {
357+
assert!(matches!(res, Err(UnauthorizedKubeconfigAccess())));
358+
}
359+
}
360+
}
361+
}

src/resources.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@ use kube::{
99
use schemars::JsonSchema;
1010
use serde::{Deserialize, Serialize};
1111

12-
static FORCE_DELETE_ANNOTATION: &str = "sinker.influxdata.io/force-delete";
13-
static DISABLE_TARGET_DELETION_ANNOTATION: &str = "sinker.influxdata.io/disable-target-deletion";
12+
pub const FORCE_DELETE_ANNOTATION: &str = "sinker.influxdata.io/force-delete";
13+
pub const DISABLE_TARGET_DELETION_ANNOTATION: &str = "sinker.influxdata.io/disable-target-deletion";
14+
pub const ALLOWED_NAMESPACES_ANNOTATION: &str = "sinker.influxdata.io/allowed-namespaces";
1415

1516
impl ResourceSync {
1617
fn get_boolean_annotation_val(&self, annotation: &str) -> bool {

0 commit comments

Comments
 (0)