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 watched namespaces, or an empty slice when cluster-wide.
84 #[must_use]
85 pub fn namespaces(&self) -> &[String] {
86 match self {
87 Self::All => &[],
88 Self::Namespaces(ns) => ns,
89 }
90 }
91}
92
93#[cfg(test)]
94#[path = "namespace_scope_tests.rs"]
95mod namespace_scope_tests;