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 || action == "queue:peek"
117 || action == "queue:presence:read"
118 || action == "vector:read"
119 || action == "vector:search"
120 || action == "vector:artifact:read"
121 {
122 return Role::Read;
123 }
124 match lookup(action) {
125 Some(entry) => match entry.category {
126 ActionCategory::Dml => Role::Write,
127 ActionCategory::Schema => Role::Write,
128 ActionCategory::Ai => Role::Read,
129 ActionCategory::Notification => Role::Write,
130 ActionCategory::Stream => Role::Write,
131 ActionCategory::Queue => Role::Write,
132 ActionCategory::Graph => Role::Read,
133 // Operational reads default to `Role::Read` under legacy RBAC —
134 // the HTTP surface is already gated by the `RED_ADMIN_TOKEN`
135 // env path or by the role middleware's read-gate, so
136 // demanding `Admin` here would lock out existing dashboards
137 // on installs that haven't adopted IAM. The fine-grained
138 // scope is enforced by `check_ops_http_policy` when IAM is
139 // active.
140 ActionCategory::Ops => Role::Read,
141 ActionCategory::Vector => Role::Write,
142 ActionCategory::Ddl
143 | ActionCategory::Function
144 | ActionCategory::Mgmt
145 | ActionCategory::Policy
146 | ActionCategory::User
147 | ActionCategory::Admin
148 | ActionCategory::Config
149 | ActionCategory::Vault
150 | ActionCategory::Wildcard
151 | ActionCategory::Other => Role::Admin,
152 },
153 None => Role::Admin,
154 }
155}
156
157#[cfg(test)]
158mod tests {
159 use super::*;
160
161 #[test]
162 fn parse_accepts_both_modes() {
163 assert_eq!(
164 PolicyEnforcementMode::parse("legacy_rbac"),
165 Some(PolicyEnforcementMode::LegacyRbac)
166 );
167 assert_eq!(
168 PolicyEnforcementMode::parse("policy_only"),
169 Some(PolicyEnforcementMode::PolicyOnly)
170 );
171 }
172
173 #[test]
174 fn parse_rejects_invalid_values() {
175 for bad in &[
176 "",
177 "rbac",
178 "LEGACY_RBAC",
179 "policy-only",
180 "off",
181 " policy_only",
182 ] {
183 assert!(
184 PolicyEnforcementMode::parse(bad).is_none(),
185 "parse should reject {bad:?}"
186 );
187 }
188 }
189
190 #[test]
191 fn defaults_documented_for_fresh_vs_existing() {
192 // Fresh bootstraps land in the strict posture; existing
193 // installs land in the lenient one so an upgrade does not
194 // accidentally lock anyone out.
195 assert_eq!(
196 PolicyEnforcementMode::default_fresh_bootstrap(),
197 PolicyEnforcementMode::PolicyOnly
198 );
199 assert_eq!(
200 PolicyEnforcementMode::default_existing_install(),
201 PolicyEnforcementMode::LegacyRbac
202 );
203 }
204
205 #[test]
206 fn display_round_trip() {
207 for m in &[
208 PolicyEnforcementMode::LegacyRbac,
209 PolicyEnforcementMode::PolicyOnly,
210 ] {
211 let s = m.to_string();
212 assert_eq!(PolicyEnforcementMode::parse(&s), Some(*m));
213 }
214 }
215
216 #[test]
217 fn legacy_rbac_select_requires_only_read() {
218 assert!(legacy_rbac_decision(Role::Read, "select"));
219 assert!(legacy_rbac_decision(Role::Write, "select"));
220 assert!(legacy_rbac_decision(Role::Admin, "select"));
221 }
222
223 #[test]
224 fn legacy_rbac_dml_write_requires_write() {
225 for action in &["insert", "update", "delete", "truncate", "write"] {
226 assert!(
227 !legacy_rbac_decision(Role::Read, action),
228 "Read must not satisfy {action}",
229 );
230 assert!(
231 legacy_rbac_decision(Role::Write, action),
232 "Write must satisfy {action}",
233 );
234 assert!(
235 legacy_rbac_decision(Role::Admin, action),
236 "Admin must satisfy {action}",
237 );
238 }
239 }
240
241 #[test]
242 fn legacy_rbac_admin_categories_require_admin() {
243 for action in &[
244 "create",
245 "drop",
246 "alter",
247 "grant",
248 "revoke",
249 "policy:put",
250 "admin:bootstrap",
251 "config:write",
252 "vault:read",
253 "*",
254 ] {
255 assert!(
256 !legacy_rbac_decision(Role::Read, action),
257 "Read must not satisfy {action}",
258 );
259 assert!(
260 !legacy_rbac_decision(Role::Write, action),
261 "Write must not satisfy {action}",
262 );
263 assert!(
264 legacy_rbac_decision(Role::Admin, action),
265 "Admin must satisfy {action}",
266 );
267 }
268 }
269
270 #[test]
271 fn legacy_rbac_unknown_action_requires_admin() {
272 // Conservative default: an action verb the catalog does not
273 // know about cannot be granted to non-admins under legacy
274 // RBAC fallback. Operators must add the verb to the catalog
275 // (and ideally to a policy) before non-admin principals can
276 // use it.
277 assert!(!legacy_rbac_decision(Role::Read, "made-up:verb"));
278 assert!(!legacy_rbac_decision(Role::Write, "made-up:verb"));
279 assert!(legacy_rbac_decision(Role::Admin, "made-up:verb"));
280 }
281
282 #[test]
283 fn hard_version_constant_is_well_formed() {
284 // We expose this string in SHOW POLICIES. It must parse as a
285 // dotted semver-style identifier (digits and dots only), with
286 // at least one dot, so client tooling can compare versions.
287 let v = POLICY_ONLY_HARD_VERSION;
288 assert!(v.contains('.'), "hard version must look like x.y[.z]");
289 for ch in v.chars() {
290 assert!(
291 ch.is_ascii_digit() || ch == '.',
292 "hard version must contain only digits and dots, got {ch:?}"
293 );
294 }
295 }
296}