Skip to main content

reddb_server/auth/
action_catalog.rs

1//! Action catalog — the single source of truth for policy action names.
2//!
3//! Historically two hand-rolled slices duplicated the list of recognised
4//! policy actions: `ACTION_ALLOWLIST` in [`crate::auth::policies`] (used to
5//! validate policy documents) and `ACTIONS` in
6//! [`crate::runtime::red_schema`] (used to populate the
7//! `red.control_capabilities` virtual table). Drift between the two was a
8//! latent bug — a typo in one but not the other meant either an action
9//! advertised through the catalog could not be put into a policy, or a
10//! policy could grant an action that the catalog never advertised.
11//!
12//! This module consolidates the list into a single static slice. Both
13//! consumers now read from [`ACTIONS`]. Each entry carries:
14//!
15//! * `name` — the action verb (e.g. `policy:put`, `*`, `admin:*`).
16//! * `category` — coarse grouping ([`ActionCategory`]).
17//! * `lifecycle_state` — [`LifecycleState::Active`],
18//!   [`LifecycleState::Deprecated`] (with a `replacement` and
19//!   `since_version`), or [`LifecycleState::Removed`].
20//! * `gates_description` — short human-readable note about what the action
21//!   gates. Used by the (forthcoming) `red.policy.actions` virtual table.
22//!
23//! Lifecycle semantics:
24//! * `Active` and `Deprecated` entries are both accepted by policy
25//!   validation. Deprecated entries will (in the linter slice) produce a
26//!   diagnostic with the `replacement` hint, but they still validate.
27//! * `Removed` entries are rejected by validation. Keeping them in the
28//!   catalog (rather than just deleting them) lets the linter produce a
29//!   "this action was removed in version X, use Y instead" diagnostic
30//!   rather than a generic "unknown action" error.
31
32/// Coarse category for an action verb. Used by the (forthcoming) admin
33/// virtual table; the policy evaluator does not consult it.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum ActionCategory {
36    /// Data-manipulation verbs (`select`, `insert`, `update`, ...).
37    Dml,
38    /// Data-definition verbs (`create`, `drop`, `alter`).
39    Ddl,
40    /// Schema-level grants (`references`, `usage`).
41    Schema,
42    /// Stored function execution.
43    Function,
44    /// Privilege-management verbs (`grant`, `revoke`).
45    Mgmt,
46    /// Policy lifecycle verbs (`policy:put`, ...).
47    Policy,
48    /// Admin verbs (`admin:bootstrap`, ...).
49    Admin,
50    /// Runtime config verbs (`config:read`, ...).
51    Config,
52    /// Vault verbs (`vault:read`, ...).
53    Vault,
54    /// Wildcard / namespace-wildcard entries (`*`, `admin:*`).
55    Wildcard,
56    /// AI / analytics-facing actions (none today; reserved).
57    Ai,
58    /// Catch-all for actions that don't fit a tighter category yet
59    /// (`evidence:export`, `red.registry:register`, `kv:invalidate`).
60    Other,
61}
62
63/// Lifecycle state for a catalog entry.
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub enum LifecycleState {
66    /// Currently the canonical name for this capability.
67    Active,
68    /// Still accepted by validation, but a newer name is preferred.
69    Deprecated {
70        /// Recommended replacement action verb, if one exists.
71        replacement: Option<&'static str>,
72        /// Version at which the action was deprecated.
73        since_version: &'static str,
74    },
75    /// No longer accepted. Kept in the catalog so the linter can produce
76    /// a targeted "removed in version X" diagnostic instead of a generic
77    /// "unknown action" error.
78    Removed,
79}
80
81/// One entry in the action catalog.
82#[derive(Debug, Clone)]
83pub struct ActionEntry {
84    pub name: &'static str,
85    pub category: ActionCategory,
86    pub lifecycle_state: LifecycleState,
87    pub gates_description: &'static str,
88}
89
90/// Canonical action catalog. Order matters: the control-capabilities
91/// virtual table emits rows in this order, so tests that assert
92/// row-order parity with the prior hand-rolled slice depend on it.
93///
94/// To add a new action: append (or insert) an entry here. To deprecate
95/// one: change its `lifecycle_state` to `Deprecated { … }` — do not
96/// delete the row. To remove one: change it to `Removed` (and only
97/// delete after a release cycle).
98pub const ACTIONS: &[ActionEntry] = &[
99    // -- DML / DDL / privilege management --------------------------------
100    ActionEntry {
101        name: "select",
102        category: ActionCategory::Dml,
103        lifecycle_state: LifecycleState::Active,
104        gates_description: "read rows from a collection",
105    },
106    ActionEntry {
107        name: "write",
108        category: ActionCategory::Dml,
109        lifecycle_state: LifecycleState::Active,
110        gates_description: "any mutating DML (insert/update/delete)",
111    },
112    ActionEntry {
113        name: "insert",
114        category: ActionCategory::Dml,
115        lifecycle_state: LifecycleState::Active,
116        gates_description: "insert rows into a collection",
117    },
118    ActionEntry {
119        name: "update",
120        category: ActionCategory::Dml,
121        lifecycle_state: LifecycleState::Active,
122        gates_description: "update rows in a collection",
123    },
124    ActionEntry {
125        name: "delete",
126        category: ActionCategory::Dml,
127        lifecycle_state: LifecycleState::Active,
128        gates_description: "delete rows from a collection",
129    },
130    ActionEntry {
131        name: "truncate",
132        category: ActionCategory::Dml,
133        lifecycle_state: LifecycleState::Active,
134        gates_description: "truncate a collection",
135    },
136    ActionEntry {
137        name: "references",
138        category: ActionCategory::Schema,
139        lifecycle_state: LifecycleState::Active,
140        gates_description: "declare a foreign key referencing a table",
141    },
142    ActionEntry {
143        name: "execute",
144        category: ActionCategory::Function,
145        lifecycle_state: LifecycleState::Active,
146        gates_description: "execute a stored function",
147    },
148    ActionEntry {
149        name: "usage",
150        category: ActionCategory::Schema,
151        lifecycle_state: LifecycleState::Active,
152        gates_description: "use a schema namespace",
153    },
154    ActionEntry {
155        name: "grant",
156        category: ActionCategory::Mgmt,
157        lifecycle_state: LifecycleState::Active,
158        gates_description: "grant privileges to another principal",
159    },
160    ActionEntry {
161        name: "revoke",
162        category: ActionCategory::Mgmt,
163        lifecycle_state: LifecycleState::Active,
164        gates_description: "revoke privileges from another principal",
165    },
166    ActionEntry {
167        name: "create",
168        category: ActionCategory::Ddl,
169        lifecycle_state: LifecycleState::Active,
170        gates_description: "create a database object",
171    },
172    ActionEntry {
173        name: "drop",
174        category: ActionCategory::Ddl,
175        lifecycle_state: LifecycleState::Active,
176        gates_description: "drop a database object",
177    },
178    ActionEntry {
179        name: "alter",
180        category: ActionCategory::Ddl,
181        lifecycle_state: LifecycleState::Active,
182        gates_description: "alter a database object",
183    },
184    // -- Policy lifecycle ------------------------------------------------
185    ActionEntry {
186        name: "policy:put",
187        category: ActionCategory::Policy,
188        lifecycle_state: LifecycleState::Active,
189        gates_description: "create or update a managed policy document",
190    },
191    ActionEntry {
192        name: "policy:drop",
193        category: ActionCategory::Policy,
194        lifecycle_state: LifecycleState::Active,
195        gates_description: "delete a managed policy document",
196    },
197    ActionEntry {
198        name: "policy:attach",
199        category: ActionCategory::Policy,
200        lifecycle_state: LifecycleState::Active,
201        gates_description: "attach a policy to a principal",
202    },
203    ActionEntry {
204        name: "policy:detach",
205        category: ActionCategory::Policy,
206        lifecycle_state: LifecycleState::Active,
207        gates_description: "detach a policy from a principal",
208    },
209    ActionEntry {
210        name: "policy:simulate",
211        category: ActionCategory::Policy,
212        lifecycle_state: LifecycleState::Active,
213        gates_description: "run the policy simulator",
214    },
215    // -- KV --------------------------------------------------------------
216    ActionEntry {
217        name: "kv:invalidate",
218        category: ActionCategory::Other,
219        lifecycle_state: LifecycleState::Active,
220        gates_description: "invalidate cached KV entries",
221    },
222    // -- Admin -----------------------------------------------------------
223    ActionEntry {
224        name: "admin:bootstrap",
225        category: ActionCategory::Admin,
226        lifecycle_state: LifecycleState::Active,
227        gates_description: "execute the bootstrap workflow",
228    },
229    ActionEntry {
230        name: "admin:audit-read",
231        category: ActionCategory::Admin,
232        lifecycle_state: LifecycleState::Active,
233        gates_description: "read the platform audit log",
234    },
235    ActionEntry {
236        name: "admin:reload",
237        category: ActionCategory::Admin,
238        lifecycle_state: LifecycleState::Active,
239        gates_description: "reload runtime configuration",
240    },
241    ActionEntry {
242        name: "admin:lease-promote",
243        category: ActionCategory::Admin,
244        lifecycle_state: LifecycleState::Active,
245        gates_description: "promote a standby instance via lease handoff",
246    },
247    // -- Runtime config --------------------------------------------------
248    ActionEntry {
249        name: "config:read",
250        category: ActionCategory::Config,
251        lifecycle_state: LifecycleState::Active,
252        gates_description: "read runtime configuration values",
253    },
254    ActionEntry {
255        name: "config:write",
256        category: ActionCategory::Config,
257        lifecycle_state: LifecycleState::Active,
258        gates_description: "mutate runtime configuration values",
259    },
260    ActionEntry {
261        name: "config:*",
262        category: ActionCategory::Wildcard,
263        lifecycle_state: LifecycleState::Active,
264        gates_description: "any runtime configuration verb",
265    },
266    // -- Vault -----------------------------------------------------------
267    ActionEntry {
268        name: "vault:read_metadata",
269        category: ActionCategory::Vault,
270        lifecycle_state: LifecycleState::Active,
271        gates_description: "read vault entry metadata (no plaintext)",
272    },
273    ActionEntry {
274        name: "vault:read",
275        category: ActionCategory::Vault,
276        lifecycle_state: LifecycleState::Active,
277        gates_description: "reveal vault entry plaintext",
278    },
279    ActionEntry {
280        name: "vault:write",
281        category: ActionCategory::Vault,
282        lifecycle_state: LifecycleState::Active,
283        gates_description: "write or rotate vault entries",
284    },
285    ActionEntry {
286        name: "vault:unseal",
287        category: ActionCategory::Vault,
288        lifecycle_state: LifecycleState::Active,
289        gates_description: "unseal the vault master key for this session",
290    },
291    // Deprecated: `vault:unseal_history` was the previous name for
292    // reading the audit trail of unseal events. The capability is now
293    // surfaced through `vault:read_metadata` on the unseal-events
294    // resource, so the dedicated verb is retained for back-compat but
295    // policy authors should migrate.
296    ActionEntry {
297        name: "vault:unseal_history",
298        category: ActionCategory::Vault,
299        lifecycle_state: LifecycleState::Deprecated {
300            replacement: Some("vault:read_metadata"),
301            since_version: "0.5.0",
302        },
303        gates_description: "read the vault unseal-event audit trail",
304    },
305    ActionEntry {
306        name: "vault:purge",
307        category: ActionCategory::Vault,
308        lifecycle_state: LifecycleState::Active,
309        gates_description: "purge (destructively remove) vault entries",
310    },
311    // -- Evidence --------------------------------------------------------
312    ActionEntry {
313        name: "evidence:export",
314        category: ActionCategory::Other,
315        lifecycle_state: LifecycleState::Active,
316        gates_description: "export evidence bundles",
317    },
318    ActionEntry {
319        name: "evidence:*",
320        category: ActionCategory::Wildcard,
321        lifecycle_state: LifecycleState::Active,
322        gates_description: "any evidence-pipeline verb",
323    },
324    // -- Registry --------------------------------------------------------
325    ActionEntry {
326        name: "red.registry:register",
327        category: ActionCategory::Other,
328        lifecycle_state: LifecycleState::Active,
329        gates_description: "register a new managed-config schema",
330    },
331    ActionEntry {
332        name: "red.registry:supersede",
333        category: ActionCategory::Other,
334        lifecycle_state: LifecycleState::Active,
335        gates_description: "supersede an existing managed-config schema",
336    },
337    ActionEntry {
338        name: "red.registry:*",
339        category: ActionCategory::Wildcard,
340        lifecycle_state: LifecycleState::Active,
341        gates_description: "any registry verb",
342    },
343    // -- Wildcards (kept last for legacy ordering) -----------------------
344    ActionEntry {
345        name: "*",
346        category: ActionCategory::Wildcard,
347        lifecycle_state: LifecycleState::Active,
348        gates_description: "any action (escape hatch — audit usage carefully)",
349    },
350    ActionEntry {
351        name: "admin:*",
352        category: ActionCategory::Wildcard,
353        lifecycle_state: LifecycleState::Active,
354        gates_description: "any admin verb",
355    },
356    ActionEntry {
357        name: "vault:*",
358        category: ActionCategory::Wildcard,
359        lifecycle_state: LifecycleState::Active,
360        gates_description: "any vault verb",
361    },
362    ActionEntry {
363        name: "kv:*",
364        category: ActionCategory::Wildcard,
365        lifecycle_state: LifecycleState::Active,
366        gates_description: "any KV verb",
367    },
368    ActionEntry {
369        name: "policy:*",
370        category: ActionCategory::Wildcard,
371        lifecycle_state: LifecycleState::Active,
372        gates_description: "any policy lifecycle verb",
373    },
374];
375
376/// Returns `true` if `name` is recognised by the catalog and is not in
377/// the `Removed` lifecycle state. `Active` and `Deprecated` entries both
378/// validate.
379pub fn is_valid_action(name: &str) -> bool {
380    ACTIONS
381        .iter()
382        .any(|e| e.name == name && !matches!(e.lifecycle_state, LifecycleState::Removed))
383}
384
385/// Lookup an entry by exact name. Returns `None` for unknown names.
386pub fn lookup(name: &str) -> Option<&'static ActionEntry> {
387    ACTIONS.iter().find(|e| e.name == name)
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393    use std::collections::HashSet;
394
395    /// The pre-catalog allowlist that lived in `auth::policies`. The
396    /// catalog must accept every one of these (modulo any explicit
397    /// `Removed` entries) so existing policies that used to validate
398    /// continue to validate.
399    const HISTORICAL_ALLOWLIST: &[&str] = &[
400        "select",
401        "write",
402        "insert",
403        "update",
404        "delete",
405        "truncate",
406        "references",
407        "execute",
408        "usage",
409        "grant",
410        "revoke",
411        "create",
412        "drop",
413        "alter",
414        "policy:put",
415        "policy:drop",
416        "policy:attach",
417        "policy:detach",
418        "policy:simulate",
419        "kv:invalidate",
420        "admin:bootstrap",
421        "admin:audit-read",
422        "admin:reload",
423        "admin:lease-promote",
424        "config:read",
425        "config:write",
426        "config:*",
427        "vault:read_metadata",
428        "vault:read",
429        "vault:write",
430        "vault:unseal",
431        "vault:unseal_history",
432        "vault:purge",
433        "evidence:export",
434        "evidence:*",
435        "red.registry:register",
436        "red.registry:supersede",
437        "red.registry:*",
438        "*",
439        "admin:*",
440        "vault:*",
441        "kv:*",
442        "policy:*",
443    ];
444
445    #[test]
446    fn no_duplicate_names() {
447        let mut seen = HashSet::new();
448        for entry in ACTIONS {
449            assert!(
450                seen.insert(entry.name),
451                "duplicate action name in catalog: {}",
452                entry.name
453            );
454        }
455    }
456
457    #[test]
458    fn covers_historical_allowlist() {
459        let names: HashSet<&'static str> = ACTIONS.iter().map(|e| e.name).collect();
460        for action in HISTORICAL_ALLOWLIST {
461            assert!(
462                names.contains(action),
463                "catalog missing historically-accepted action: {action}",
464            );
465        }
466    }
467
468    #[test]
469    fn historical_allowlist_still_validates() {
470        for action in HISTORICAL_ALLOWLIST {
471            assert!(
472                is_valid_action(action),
473                "action {action} was accepted before the catalog and must still validate",
474            );
475        }
476    }
477
478    #[test]
479    fn has_at_least_one_deprecated_entry() {
480        let count = ACTIONS
481            .iter()
482            .filter(|e| matches!(e.lifecycle_state, LifecycleState::Deprecated { .. }))
483            .count();
484        assert!(
485            count >= 1,
486            "catalog must demonstrate the Deprecated lifecycle state with at least one entry",
487        );
488    }
489
490    #[test]
491    fn removed_entries_are_rejected() {
492        // No `Removed` entries today, but the predicate must enforce the
493        // rule if/when one is added.
494        for entry in ACTIONS {
495            if matches!(entry.lifecycle_state, LifecycleState::Removed) {
496                assert!(
497                    !is_valid_action(entry.name),
498                    "Removed entry {} must not validate",
499                    entry.name,
500                );
501            }
502        }
503    }
504
505    #[test]
506    fn lookup_finds_known_entries() {
507        assert!(lookup("policy:put").is_some());
508        assert!(lookup("definitely-not-an-action").is_none());
509    }
510}