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;