bindy/
namespace_scope.rs

1// Copyright (c) 2025 Erick Bourgeois, firestoned
2// SPDX-License-Identifier: MIT
3
4//! Operator namespace scoping.
5//!
6//! Controls the set of namespaces the operator watches and manages, driven by
7//! the `BINDY_WATCH_NAMESPACES` environment variable. This is the foundation
8//! of the least-privilege deployment model that closes audit findings C2
9//! (operator can create workloads cluster-wide) and H3 (operator can read all
10//! Secrets cluster-wide): when the scope is restricted to specific namespaces,
11//! the operator builds its watches with `Api::namespaced` and only needs
12//! per-namespace RBAC (RoleBindings) instead of a cluster-wide
13//! ClusterRoleBinding.
14//!
15//! The default is [`NamespaceScope::All`] (cluster-wide) so existing single
16//! cluster-wide installs keep working unchanged.
17
18use std::collections::HashSet;
19
20/// Environment variable naming the namespaces the operator should watch.
21///
22/// A comma-separated list (e.g. `bindy-system,tenant-a,tenant-b`). Unset or
23/// empty means watch every namespace cluster-wide.
24pub const WATCH_NAMESPACES_ENV: &str = "BINDY_WATCH_NAMESPACES";
25
26/// The set of namespaces the operator watches and manages.
27#[derive(Clone, Debug, PartialEq, Eq)]
28pub enum NamespaceScope {
29    /// Watch every namespace cluster-wide. Requires cluster-wide RBAC
30    /// (ClusterRole + ClusterRoleBinding). This is the default.
31    All,
32    /// Watch only the listed namespaces. Requires only per-namespace RBAC
33    /// (Role + RoleBinding in each namespace). Never empty.
34    Namespaces(Vec<String>),
35}
36
37impl NamespaceScope {
38    /// Parse a [`WATCH_NAMESPACES_ENV`] value into a scope.
39    ///
40    /// - `None`, empty, or whitespace-only → [`NamespaceScope::All`]
41    ///   (backward compatible: the operator keeps watching cluster-wide).
42    /// - A comma-separated list → [`NamespaceScope::Namespaces`] with each
43    ///   entry trimmed, empty entries dropped, and duplicates removed while
44    ///   preserving first-seen order. If every entry is empty it falls back to
45    ///   [`NamespaceScope::All`].
46    #[must_use]
47    pub fn parse(raw: Option<&str>) -> Self {
48        let Some(raw) = raw else {
49            return Self::All;
50        };
51
52        let mut seen = HashSet::new();
53        let mut namespaces = Vec::new();
54        for entry in raw.split(',') {
55            let ns = entry.trim();
56            if ns.is_empty() {
57                continue;
58            }
59            if seen.insert(ns.to_string()) {
60                namespaces.push(ns.to_string());
61            }
62        }
63
64        if namespaces.is_empty() {
65            Self::All
66        } else {
67            Self::Namespaces(namespaces)
68        }
69    }
70
71    /// Load the scope from the [`WATCH_NAMESPACES_ENV`] environment variable.
72    #[must_use]
73    pub fn from_env() -> Self {
74        Self::parse(std::env::var(WATCH_NAMESPACES_ENV).ok().as_deref())
75    }
76
77    /// Whether the operator watches cluster-wide.
78    #[must_use]
79    pub fn is_all(&self) -> bool {
80        matches!(self, Self::All)
81    }
82
83    /// The namespace targets to build `Api` handles from, one per watch.
84    ///
85    /// `None` means "cluster-wide" (build with `Api::all`); `Some(ns)` means
86    /// "this namespace only" (build with `Api::namespaced`). The result is
87    /// **never empty** — an empty target list would silently disable every
88    /// watch, leaving the operator healthy but reconciling nothing.
89    ///
90    /// [`NamespaceScope::All`] deliberately yields exactly one `None` target so
91    /// the default deployment keeps its current single-watch-per-kind shape,
92    /// byte-for-byte. All the fan-out risk is confined to the opt-in scoped mode.
93    #[must_use]
94    pub fn api_targets(&self) -> Vec<Option<&str>> {
95        match self {
96            Self::All => vec![None],
97            Self::Namespaces(ns) => ns.iter().map(|n| Some(n.as_str())).collect(),
98        }
99    }
100
101    /// The watched namespaces, or an empty slice when cluster-wide.
102    #[must_use]
103    pub fn namespaces(&self) -> &[String] {
104        match self {
105            Self::All => &[],
106            Self::Namespaces(ns) => ns,
107        }
108    }
109}
110
111/// Build an `Api` for a single namespace target.
112///
113/// `None` means cluster-wide (`Api::all`); `Some(ns)` means that namespace only
114/// (`Api::namespaced`). Pair with [`NamespaceScope::api_targets`].
115pub fn scoped_namespaced_api<K>(client: &kube::Client, target: Option<&str>) -> kube::Api<K>
116where
117    K: kube::Resource<Scope = kube::core::NamespaceResourceScope>,
118    K::DynamicType: Default,
119{
120    match target {
121        None => kube::Api::all(client.clone()),
122        Some(ns) => kube::Api::namespaced(client.clone(), ns),
123    }
124}
125
126#[cfg(test)]
127#[path = "namespace_scope_tests.rs"]
128mod namespace_scope_tests;