Skip to main content

reddb_server/auth/
enforcement_mode.rs

1//! Policy enforcement mode (#712).
2//!
3//! Controls what `AuthStore::check_policy_authz_with_role` does when the
4//! policy evaluator returns [`DefaultDeny`][crate::auth::policies::Decision::DefaultDeny]
5//! — i.e. no statement matched, neither allow nor deny.
6//!
7//! * [`PolicyEnforcementMode::LegacyRbac`] — fall back to the legacy
8//!   role-based decision computed by [`legacy_rbac_decision`]. This is
9//!   the default for existing installs so upgrading does not silently
10//!   tighten access for principals that have not yet been migrated to
11//!   IAM policies.
12//! * [`PolicyEnforcementMode::PolicyOnly`] — surface the `DefaultDeny`
13//!   as a deny. This is the default for fresh bootstraps and the
14//!   long-term posture; the upcoming `MIGRATE POLICY MODE TO
15//!   'policy_only'` SQL (next slice, S5B) flips an existing install
16//!   over after the operator has audited their attached policies.
17//!
18//! The mode is plumbed through [`super::store::AuthStore`] and read on
19//! every policy decision. It is configured by the `red.config.policy.
20//! enforcement_mode` config key — see `runtime::impl_config` for the
21//! write-path validation and the boot-time loader.
22
23use super::action_catalog::{lookup, ActionCategory};
24use super::Role;
25
26/// Config key that selects the enforcement mode.
27pub const ENFORCEMENT_MODE_CONFIG_KEY: &str = "red.config.policy.enforcement_mode";
28
29/// Version at which `policy_only` becomes the only accepted mode and the
30/// `legacy_rbac` fallback is removed. Reported by `SHOW POLICIES` so
31/// operators know how long they have to migrate.
32pub const POLICY_ONLY_HARD_VERSION: &str = "1.0.0";
33
34/// Selects the behaviour of the policy evaluator when no statement
35/// matches the requested `(action, resource)` pair. See the module
36/// docs for the semantics of each variant.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum PolicyEnforcementMode {
39    /// Fall back to the role-based decision when no policy matches.
40    LegacyRbac,
41    /// Treat "no matching policy" as `DefaultDeny`.
42    PolicyOnly,
43}
44
45impl PolicyEnforcementMode {
46    pub fn as_str(self) -> &'static str {
47        match self {
48            Self::LegacyRbac => "legacy_rbac",
49            Self::PolicyOnly => "policy_only",
50        }
51    }
52
53    /// Parse a configuration value. Returns `None` for any string that
54    /// is not exactly one of the two accepted modes — callers turn that
55    /// `None` into the "invalid value" rejection at config-write time.
56    pub fn parse(value: &str) -> Option<Self> {
57        match value {
58            "legacy_rbac" => Some(Self::LegacyRbac),
59            "policy_only" => Some(Self::PolicyOnly),
60            _ => None,
61        }
62    }
63
64    /// Default for a fresh bootstrap (no prior config, no prior users).
65    /// Fresh installs start in the strict posture so they never carry
66    /// the legacy RBAC fallback as accumulated technical debt.
67    pub const fn default_fresh_bootstrap() -> Self {
68        Self::PolicyOnly
69    }
70
71    /// Default for an existing install that has no
72    /// `enforcement_mode` key set. Preserves pre-#712 behaviour so the
73    /// upgrade is non-disruptive; operators move to `policy_only`
74    /// explicitly via the migration command (S5B).
75    pub const fn default_existing_install() -> Self {
76        Self::LegacyRbac
77    }
78}
79
80impl std::fmt::Display for PolicyEnforcementMode {
81    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82        f.write_str(self.as_str())
83    }
84}
85
86/// Computes the legacy role-based decision for a `(role, action)` pair.
87///
88/// This is the function `LegacyRbac` mode falls back to when the
89/// evaluator returns `DefaultDeny`. The mapping is action-category
90/// driven (via [`super::action_catalog`]) so that adding a new action
91/// to the catalog inherits a sensible role floor without touching this
92/// function.
93///
94/// Category → required role floor:
95///
96/// * `Dml` reads (`select`) → `Read`.
97/// * `Dml` writes / `Schema` → `Write`.
98/// * `Ddl`, `Function`, `Mgmt`, `Policy`, `Admin`, `Config`, `Vault`,
99///   `Wildcard`, `Other` → `Admin`.
100/// * `Ai` → `Read` (analytics-facing surface; reserved category today).
101///
102/// Unknown actions (not in the catalog) require `Admin`, matching the
103/// conservative pre-#712 default for verbs the kernel does not
104/// recognise.
105pub fn legacy_rbac_decision(role: Role, action: &str) -> bool {
106    let required = required_role_for_action(action);
107    role >= required
108}
109
110/// Internal: minimum role required to satisfy `action` under the legacy
111/// RBAC posture. Exposed via [`legacy_rbac_decision`].
112fn required_role_for_action(action: &str) -> Role {
113    // The two distinct reads in the DML category. Everything else in
114    // the DML category mutates data and demands Write.
115    if action == "select" {
116        return Role::Read;
117    }
118    match lookup(action) {
119        Some(entry) => match entry.category {
120            ActionCategory::Dml => Role::Write,
121            ActionCategory::Schema => Role::Write,
122            ActionCategory::Ai => Role::Read,
123            ActionCategory::Ddl
124            | ActionCategory::Function
125            | ActionCategory::Mgmt
126            | ActionCategory::Policy
127            | ActionCategory::Admin
128            | ActionCategory::Config
129            | ActionCategory::Vault
130            | ActionCategory::Wildcard
131            | ActionCategory::Other => Role::Admin,
132        },
133        None => Role::Admin,
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    #[test]
142    fn parse_accepts_both_modes() {
143        assert_eq!(
144            PolicyEnforcementMode::parse("legacy_rbac"),
145            Some(PolicyEnforcementMode::LegacyRbac)
146        );
147        assert_eq!(
148            PolicyEnforcementMode::parse("policy_only"),
149            Some(PolicyEnforcementMode::PolicyOnly)
150        );
151    }
152
153    #[test]
154    fn parse_rejects_invalid_values() {
155        for bad in &[
156            "",
157            "rbac",
158            "LEGACY_RBAC",
159            "policy-only",
160            "off",
161            " policy_only",
162        ] {
163            assert!(
164                PolicyEnforcementMode::parse(bad).is_none(),
165                "parse should reject {bad:?}"
166            );
167        }
168    }
169
170    #[test]
171    fn defaults_documented_for_fresh_vs_existing() {
172        // Fresh bootstraps land in the strict posture; existing
173        // installs land in the lenient one so an upgrade does not
174        // accidentally lock anyone out.
175        assert_eq!(
176            PolicyEnforcementMode::default_fresh_bootstrap(),
177            PolicyEnforcementMode::PolicyOnly
178        );
179        assert_eq!(
180            PolicyEnforcementMode::default_existing_install(),
181            PolicyEnforcementMode::LegacyRbac
182        );
183    }
184
185    #[test]
186    fn display_round_trip() {
187        for m in &[
188            PolicyEnforcementMode::LegacyRbac,
189            PolicyEnforcementMode::PolicyOnly,
190        ] {
191            let s = m.to_string();
192            assert_eq!(PolicyEnforcementMode::parse(&s), Some(*m));
193        }
194    }
195
196    #[test]
197    fn legacy_rbac_select_requires_only_read() {
198        assert!(legacy_rbac_decision(Role::Read, "select"));
199        assert!(legacy_rbac_decision(Role::Write, "select"));
200        assert!(legacy_rbac_decision(Role::Admin, "select"));
201    }
202
203    #[test]
204    fn legacy_rbac_dml_write_requires_write() {
205        for action in &["insert", "update", "delete", "truncate", "write"] {
206            assert!(
207                !legacy_rbac_decision(Role::Read, action),
208                "Read must not satisfy {action}",
209            );
210            assert!(
211                legacy_rbac_decision(Role::Write, action),
212                "Write must satisfy {action}",
213            );
214            assert!(
215                legacy_rbac_decision(Role::Admin, action),
216                "Admin must satisfy {action}",
217            );
218        }
219    }
220
221    #[test]
222    fn legacy_rbac_admin_categories_require_admin() {
223        for action in &[
224            "create",
225            "drop",
226            "alter",
227            "grant",
228            "revoke",
229            "policy:put",
230            "admin:bootstrap",
231            "config:write",
232            "vault:read",
233            "*",
234        ] {
235            assert!(
236                !legacy_rbac_decision(Role::Read, action),
237                "Read must not satisfy {action}",
238            );
239            assert!(
240                !legacy_rbac_decision(Role::Write, action),
241                "Write must not satisfy {action}",
242            );
243            assert!(
244                legacy_rbac_decision(Role::Admin, action),
245                "Admin must satisfy {action}",
246            );
247        }
248    }
249
250    #[test]
251    fn legacy_rbac_unknown_action_requires_admin() {
252        // Conservative default: an action verb the catalog does not
253        // know about cannot be granted to non-admins under legacy
254        // RBAC fallback. Operators must add the verb to the catalog
255        // (and ideally to a policy) before non-admin principals can
256        // use it.
257        assert!(!legacy_rbac_decision(Role::Read, "made-up:verb"));
258        assert!(!legacy_rbac_decision(Role::Write, "made-up:verb"));
259        assert!(legacy_rbac_decision(Role::Admin, "made-up:verb"));
260    }
261
262    #[test]
263    fn hard_version_constant_is_well_formed() {
264        // We expose this string in SHOW POLICIES. It must parse as a
265        // dotted semver-style identifier (digits and dots only), with
266        // at least one dot, so client tooling can compare versions.
267        let v = POLICY_ONLY_HARD_VERSION;
268        assert!(v.contains('.'), "hard version must look like x.y[.z]");
269        for ch in v.chars() {
270            assert!(
271                ch.is_ascii_digit() || ch == '.',
272                "hard version must contain only digits and dots, got {ch:?}"
273            );
274        }
275    }
276}