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    /// User lifecycle verbs (`user:create`, `user:delete`, ...).
49    User,
50    /// Admin verbs (`admin:bootstrap`, ...).
51    Admin,
52    /// Runtime config verbs (`config:read`, ...).
53    Config,
54    /// Vault verbs (`vault:read`, ...).
55    Vault,
56    /// Wildcard / namespace-wildcard entries (`*`, `admin:*`).
57    Wildcard,
58    /// AI / analytics-facing actions (none today; reserved).
59    Ai,
60    /// Ephemeral notification verbs (`notify`, `notify:cross-tenant`).
61    /// Gates the pub/sub primitive defined in `crate::notifications`.
62    Notification,
63    /// Durable stream verbs (`stream`, `stream:cross-tenant`). Gates
64    /// the append-only event-log primitive defined in
65    /// `crate::streams`.
66    Stream,
67    /// Queue verbs (`queue:enqueue`, `queue:read`, ...). Gates the
68    /// producer / consumer / ack-lifecycle / DLQ / destructive-admin
69    /// surface so Red UI can grant toolbar actions independently.
70    Queue,
71    /// Graph verbs (`graph:read`, `graph:traverse`,
72    /// `graph:algorithm:run`). Gates the graph explorer's read /
73    /// traversal / analytics surface so Red UI can grant the algorithm
74    /// runner independently of plain metadata reads or pattern matching.
75    Graph,
76    /// Operational read verbs (`ops:read:self`, `ops:read:tenant`,
77    /// `ops:read:cluster`, `ops:admin`). Scoped read levels for the
78    /// admin / metrics / cluster / security HTTP surfaces so Red UI
79    /// can expose tenant-aggregate observability without leaking
80    /// cluster topology, collection names, or per-tenant load to
81    /// principals that should not see them. See `crate::server::
82    /// handlers_ops_policy`.
83    Ops,
84    /// Vector verbs (`vector:read`, `vector:search`,
85    /// `vector:artifact:read`, ...). Gates vector metadata reads,
86    /// similarity / text / hybrid search, operational artifact
87    /// introspection, and rebuild / admin operations so Red UI can
88    /// grant toolbar actions independently.
89    Vector,
90    /// Catch-all for actions that don't fit a tighter category yet
91    /// (`evidence:export`, `red.registry:register`, `kv:invalidate`).
92    Other,
93}
94
95impl ActionCategory {
96    /// Stable lowercase identifier used by the SQL virtual table and
97    /// the `GET /admin/policies/actions` HTTP surface. Operators read
98    /// these strings, so they are part of the public contract.
99    pub fn as_str(&self) -> &'static str {
100        match self {
101            ActionCategory::Dml => "dml",
102            ActionCategory::Ddl => "ddl",
103            ActionCategory::Schema => "schema",
104            ActionCategory::Function => "function",
105            ActionCategory::Mgmt => "mgmt",
106            ActionCategory::Policy => "policy",
107            ActionCategory::User => "user",
108            ActionCategory::Admin => "admin",
109            ActionCategory::Config => "config",
110            ActionCategory::Vault => "vault",
111            ActionCategory::Wildcard => "wildcard",
112            ActionCategory::Ai => "ai",
113            ActionCategory::Notification => "notification",
114            ActionCategory::Stream => "stream",
115            ActionCategory::Queue => "queue",
116            ActionCategory::Graph => "graph",
117            ActionCategory::Ops => "ops",
118            ActionCategory::Vector => "vector",
119            ActionCategory::Other => "other",
120        }
121    }
122}
123
124/// Lifecycle state for a catalog entry.
125#[derive(Debug, Clone, PartialEq, Eq)]
126pub enum LifecycleState {
127    /// Currently the canonical name for this capability.
128    Active,
129    /// Still accepted by validation, but a newer name is preferred.
130    Deprecated {
131        /// Recommended replacement action verb, if one exists.
132        replacement: Option<&'static str>,
133        /// Version at which the action was deprecated.
134        since_version: &'static str,
135    },
136    /// No longer accepted. Kept in the catalog so the linter can produce
137    /// a targeted "removed in version X" diagnostic instead of a generic
138    /// "unknown action" error.
139    Removed,
140}
141
142/// One entry in the action catalog.
143#[derive(Debug, Clone)]
144pub struct ActionEntry {
145    pub name: &'static str,
146    pub category: ActionCategory,
147    pub lifecycle_state: LifecycleState,
148    pub gates_description: &'static str,
149}
150
151/// Canonical action catalog. Order matters: the control-capabilities
152/// virtual table emits rows in this order, so tests that assert
153/// row-order parity with the prior hand-rolled slice depend on it.
154///
155/// To add a new action: append (or insert) an entry here. To deprecate
156/// one: change its `lifecycle_state` to `Deprecated { … }` — do not
157/// delete the row. To remove one: change it to `Removed` (and only
158/// delete after a release cycle).
159pub const ACTIONS: &[ActionEntry] = &[
160    ActionEntry {
161        name: "cache:read",
162        category: ActionCategory::Other,
163        lifecycle_state: LifecycleState::Active,
164        gates_description: "read or probe a native Blob Cache namespace",
165    },
166    ActionEntry {
167        name: "cache:write",
168        category: ActionCategory::Other,
169        lifecycle_state: LifecycleState::Active,
170        gates_description: "write values to a native Blob Cache namespace",
171    },
172    ActionEntry {
173        name: "cache:invalidate",
174        category: ActionCategory::Other,
175        lifecycle_state: LifecycleState::Active,
176        gates_description: "invalidate or flush a native Blob Cache namespace",
177    },
178    // -- DML / DDL / privilege management --------------------------------
179    ActionEntry {
180        name: "select",
181        category: ActionCategory::Dml,
182        lifecycle_state: LifecycleState::Active,
183        gates_description: "read rows from a collection",
184    },
185    ActionEntry {
186        name: "write",
187        category: ActionCategory::Dml,
188        lifecycle_state: LifecycleState::Active,
189        gates_description: "any mutating DML (insert/update/delete)",
190    },
191    ActionEntry {
192        name: "insert",
193        category: ActionCategory::Dml,
194        lifecycle_state: LifecycleState::Active,
195        gates_description: "insert rows into a collection",
196    },
197    ActionEntry {
198        name: "update",
199        category: ActionCategory::Dml,
200        lifecycle_state: LifecycleState::Active,
201        gates_description: "update rows in a collection",
202    },
203    ActionEntry {
204        name: "delete",
205        category: ActionCategory::Dml,
206        lifecycle_state: LifecycleState::Active,
207        gates_description: "delete rows from a collection",
208    },
209    ActionEntry {
210        name: "truncate",
211        category: ActionCategory::Dml,
212        lifecycle_state: LifecycleState::Active,
213        gates_description: "truncate a collection",
214    },
215    ActionEntry {
216        name: "references",
217        category: ActionCategory::Schema,
218        lifecycle_state: LifecycleState::Active,
219        gates_description: "declare a foreign key referencing a table",
220    },
221    ActionEntry {
222        name: "execute",
223        category: ActionCategory::Function,
224        lifecycle_state: LifecycleState::Active,
225        gates_description: "execute a stored function",
226    },
227    ActionEntry {
228        name: "usage",
229        category: ActionCategory::Schema,
230        lifecycle_state: LifecycleState::Active,
231        gates_description: "use a schema namespace",
232    },
233    ActionEntry {
234        name: "grant",
235        category: ActionCategory::Mgmt,
236        lifecycle_state: LifecycleState::Active,
237        gates_description: "grant privileges to another principal",
238    },
239    ActionEntry {
240        name: "revoke",
241        category: ActionCategory::Mgmt,
242        lifecycle_state: LifecycleState::Active,
243        gates_description: "revoke privileges from another principal",
244    },
245    ActionEntry {
246        name: "create",
247        category: ActionCategory::Ddl,
248        lifecycle_state: LifecycleState::Active,
249        gates_description: "create a database object",
250    },
251    ActionEntry {
252        name: "drop",
253        category: ActionCategory::Ddl,
254        lifecycle_state: LifecycleState::Active,
255        gates_description: "drop a database object",
256    },
257    ActionEntry {
258        name: "alter",
259        category: ActionCategory::Ddl,
260        lifecycle_state: LifecycleState::Active,
261        gates_description: "alter a database object",
262    },
263    // Hybrid DDL fallback verbs (#753). The specific `create`/`drop`/
264    // `alter` entries above remain the preferred targets for fine-grained
265    // policies; `schema:write` covers grouped DDL that does not have an
266    // obvious per-collection target (foreign tables, migration steps),
267    // and `schema:admin` covers namespace-level operations (CREATE
268    // SCHEMA, CREATE SERVER). Both surface stable action names through
269    // `red.policy.actions` so Red UI can gate toolbar visibility without
270    // waiting on a perfect DDL taxonomy.
271    ActionEntry {
272        name: "schema:write",
273        category: ActionCategory::Schema,
274        lifecycle_state: LifecycleState::Active,
275        gates_description: "grouped DDL on the current schema namespace (foreign table, migration)",
276    },
277    ActionEntry {
278        name: "schema:admin",
279        category: ActionCategory::Admin,
280        lifecycle_state: LifecycleState::Active,
281        gates_description: "namespace-level DDL (CREATE SCHEMA, CREATE SERVER)",
282    },
283    // -- Policy lifecycle ------------------------------------------------
284    ActionEntry {
285        name: "policy:put",
286        category: ActionCategory::Policy,
287        lifecycle_state: LifecycleState::Active,
288        gates_description: "create or update a managed policy document",
289    },
290    ActionEntry {
291        name: "policy:drop",
292        category: ActionCategory::Policy,
293        lifecycle_state: LifecycleState::Active,
294        gates_description: "delete a managed policy document",
295    },
296    ActionEntry {
297        name: "policy:attach",
298        category: ActionCategory::Policy,
299        lifecycle_state: LifecycleState::Active,
300        gates_description: "attach a policy to a principal",
301    },
302    ActionEntry {
303        name: "policy:detach",
304        category: ActionCategory::Policy,
305        lifecycle_state: LifecycleState::Active,
306        gates_description: "detach a policy from a principal",
307    },
308    ActionEntry {
309        name: "policy:simulate",
310        category: ActionCategory::Policy,
311        lifecycle_state: LifecycleState::Active,
312        gates_description: "run the policy simulator",
313    },
314    // -- User lifecycle --------------------------------------------------
315    ActionEntry {
316        name: "user:create",
317        category: ActionCategory::User,
318        lifecycle_state: LifecycleState::Active,
319        gates_description: "create an auth user",
320    },
321    ActionEntry {
322        name: "user:update",
323        category: ActionCategory::User,
324        lifecycle_state: LifecycleState::Active,
325        gates_description: "update non-credential user metadata",
326    },
327    ActionEntry {
328        name: "user:disable",
329        category: ActionCategory::User,
330        lifecycle_state: LifecycleState::Active,
331        gates_description: "disable an auth user",
332    },
333    ActionEntry {
334        name: "user:delete",
335        category: ActionCategory::User,
336        lifecycle_state: LifecycleState::Active,
337        gates_description: "delete an auth user and revoke their sessions/API keys",
338    },
339    ActionEntry {
340        name: "user:password:change",
341        category: ActionCategory::User,
342        lifecycle_state: LifecycleState::Active,
343        gates_description: "change an auth user's password",
344    },
345    ActionEntry {
346        name: "user:role:update",
347        category: ActionCategory::User,
348        lifecycle_state: LifecycleState::Active,
349        gates_description: "change an auth user's role",
350    },
351    ActionEntry {
352        name: "user:*",
353        category: ActionCategory::Wildcard,
354        lifecycle_state: LifecycleState::Active,
355        gates_description: "any user lifecycle verb",
356    },
357    ActionEntry {
358        name: "tenant:assume",
359        category: ActionCategory::Admin,
360        lifecycle_state: LifecycleState::Active,
361        gates_description: "assume or clear a tenant routing scope",
362    },
363    ActionEntry {
364        name: "identity:assume",
365        category: ActionCategory::Admin,
366        lifecycle_state: LifecycleState::Active,
367        gates_description: "project another user or role into statement scope",
368    },
369    ActionEntry {
370        name: "metrics:cross-tenant",
371        category: ActionCategory::Admin,
372        lifecycle_state: LifecycleState::Active,
373        gates_description: "read or write Prometheus data for another tenant",
374    },
375    // -- KV --------------------------------------------------------------
376    ActionEntry {
377        name: "kv:invalidate",
378        category: ActionCategory::Other,
379        lifecycle_state: LifecycleState::Active,
380        gates_description: "invalidate cached KV entries",
381    },
382    ActionEntry {
383        name: "kv:read",
384        category: ActionCategory::Other,
385        lifecycle_state: LifecycleState::Active,
386        gates_description: "read user-managed plain KV entries via $kv.*",
387    },
388    ActionEntry {
389        name: "kv:write",
390        category: ActionCategory::Other,
391        lifecycle_state: LifecycleState::Active,
392        gates_description: "write or delete user-managed plain KV entries",
393    },
394    // -- Admin -----------------------------------------------------------
395    ActionEntry {
396        name: "admin:bootstrap",
397        category: ActionCategory::Admin,
398        lifecycle_state: LifecycleState::Active,
399        gates_description: "execute the bootstrap workflow",
400    },
401    ActionEntry {
402        name: "admin:audit-read",
403        category: ActionCategory::Admin,
404        lifecycle_state: LifecycleState::Active,
405        gates_description: "read the platform audit log",
406    },
407    ActionEntry {
408        name: "admin:reload",
409        category: ActionCategory::Admin,
410        lifecycle_state: LifecycleState::Active,
411        gates_description: "reload runtime configuration",
412    },
413    ActionEntry {
414        name: "admin:lease-promote",
415        category: ActionCategory::Admin,
416        lifecycle_state: LifecycleState::Active,
417        gates_description: "promote a standby instance via lease handoff",
418    },
419    // -- Runtime config --------------------------------------------------
420    ActionEntry {
421        name: "config:read",
422        category: ActionCategory::Config,
423        lifecycle_state: LifecycleState::Active,
424        gates_description: "read runtime configuration values",
425    },
426    ActionEntry {
427        name: "config:write",
428        category: ActionCategory::Config,
429        lifecycle_state: LifecycleState::Active,
430        gates_description: "mutate runtime configuration values",
431    },
432    ActionEntry {
433        name: "config:*",
434        category: ActionCategory::Wildcard,
435        lifecycle_state: LifecycleState::Active,
436        gates_description: "any runtime configuration verb",
437    },
438    // -- Vault -----------------------------------------------------------
439    ActionEntry {
440        name: "vault:read_metadata",
441        category: ActionCategory::Vault,
442        lifecycle_state: LifecycleState::Active,
443        gates_description: "read vault entry metadata (no plaintext)",
444    },
445    ActionEntry {
446        name: "vault:use",
447        category: ActionCategory::Vault,
448        lifecycle_state: LifecycleState::Active,
449        gates_description: "use a secret inside query evaluation without revealing its value",
450    },
451    ActionEntry {
452        name: "vault:reveal",
453        category: ActionCategory::Vault,
454        lifecycle_state: LifecycleState::Active,
455        gates_description: "explicitly reveal vault entry plaintext",
456    },
457    ActionEntry {
458        name: "vault:reveal_history",
459        category: ActionCategory::Vault,
460        lifecycle_state: LifecycleState::Active,
461        gates_description: "explicitly reveal historical vault entry plaintext",
462    },
463    ActionEntry {
464        name: "vault:read",
465        category: ActionCategory::Vault,
466        lifecycle_state: LifecycleState::Active,
467        gates_description: "compatibility alias for vault:reveal",
468    },
469    ActionEntry {
470        name: "vault:write",
471        category: ActionCategory::Vault,
472        lifecycle_state: LifecycleState::Active,
473        gates_description: "write or rotate vault entries",
474    },
475    ActionEntry {
476        name: "secret:read",
477        category: ActionCategory::Vault,
478        lifecycle_state: LifecycleState::Active,
479        gates_description: "compatibility alias for vault:use on default-vault SQL secrets",
480    },
481    ActionEntry {
482        name: "secret:write",
483        category: ActionCategory::Vault,
484        lifecycle_state: LifecycleState::Active,
485        gates_description: "compatibility alias for vault:write on default-vault SQL secrets",
486    },
487    ActionEntry {
488        name: "secret:*",
489        category: ActionCategory::Wildcard,
490        lifecycle_state: LifecycleState::Active,
491        gates_description: "any user-managed SQL vault secret verb",
492    },
493    ActionEntry {
494        name: "vault:unseal",
495        category: ActionCategory::Vault,
496        lifecycle_state: LifecycleState::Active,
497        gates_description: "unseal the vault master key for this session",
498    },
499    // Previous historical reveal capability, retained for existing policies.
500    ActionEntry {
501        name: "vault:unseal_history",
502        category: ActionCategory::Vault,
503        lifecycle_state: LifecycleState::Deprecated {
504            replacement: Some("vault:reveal_history"),
505            since_version: "0.5.0",
506        },
507        gates_description: "compatibility alias for vault:reveal_history",
508    },
509    ActionEntry {
510        name: "vault:purge",
511        category: ActionCategory::Vault,
512        lifecycle_state: LifecycleState::Active,
513        gates_description: "purge (destructively remove) vault entries",
514    },
515    // -- Evidence --------------------------------------------------------
516    ActionEntry {
517        name: "evidence:export",
518        category: ActionCategory::Other,
519        lifecycle_state: LifecycleState::Active,
520        gates_description: "export evidence bundles",
521    },
522    ActionEntry {
523        name: "evidence:*",
524        category: ActionCategory::Wildcard,
525        lifecycle_state: LifecycleState::Active,
526        gates_description: "any evidence-pipeline verb",
527    },
528    // -- Registry --------------------------------------------------------
529    ActionEntry {
530        name: "red.registry:register",
531        category: ActionCategory::Other,
532        lifecycle_state: LifecycleState::Active,
533        gates_description: "register a new managed-config schema",
534    },
535    ActionEntry {
536        name: "red.registry:supersede",
537        category: ActionCategory::Other,
538        lifecycle_state: LifecycleState::Active,
539        gates_description: "supersede an existing managed-config schema",
540    },
541    ActionEntry {
542        name: "red.registry:*",
543        category: ActionCategory::Wildcard,
544        lifecycle_state: LifecycleState::Active,
545        gates_description: "any registry verb",
546    },
547    // -- AI provider gate (S3 / #711) ------------------------------------
548    // The `ai:provider:<token>` namespace lets operators express "role X
549    // cannot use AI provider Y" without denying `insert` on entire
550    // collections. The gate runs at the SQL planner before the AI
551    // credential resolver — see `runtime::ai::provider_gate`. Tokens
552    // mirror `AiProvider::token()` exactly.
553    ActionEntry {
554        name: "ai:provider:openai",
555        category: ActionCategory::Ai,
556        lifecycle_state: LifecycleState::Active,
557        gates_description: "use the OpenAI provider for ASK / AUTO EMBED / SEARCH SIMILAR",
558    },
559    ActionEntry {
560        name: "ai:provider:anthropic",
561        category: ActionCategory::Ai,
562        lifecycle_state: LifecycleState::Active,
563        gates_description: "use the Anthropic provider for ASK / AUTO EMBED / SEARCH SIMILAR",
564    },
565    ActionEntry {
566        name: "ai:provider:groq",
567        category: ActionCategory::Ai,
568        lifecycle_state: LifecycleState::Active,
569        gates_description: "use the Groq provider for ASK / AUTO EMBED / SEARCH SIMILAR",
570    },
571    ActionEntry {
572        name: "ai:provider:openrouter",
573        category: ActionCategory::Ai,
574        lifecycle_state: LifecycleState::Active,
575        gates_description: "use the OpenRouter provider for ASK / AUTO EMBED / SEARCH SIMILAR",
576    },
577    ActionEntry {
578        name: "ai:provider:together",
579        category: ActionCategory::Ai,
580        lifecycle_state: LifecycleState::Active,
581        gates_description: "use the Together provider for ASK / AUTO EMBED / SEARCH SIMILAR",
582    },
583    ActionEntry {
584        name: "ai:provider:venice",
585        category: ActionCategory::Ai,
586        lifecycle_state: LifecycleState::Active,
587        gates_description: "use the Venice provider for ASK / AUTO EMBED / SEARCH SIMILAR",
588    },
589    ActionEntry {
590        name: "ai:provider:ollama",
591        category: ActionCategory::Ai,
592        lifecycle_state: LifecycleState::Active,
593        gates_description: "use the Ollama provider for ASK / AUTO EMBED / SEARCH SIMILAR",
594    },
595    ActionEntry {
596        name: "ai:provider:deepseek",
597        category: ActionCategory::Ai,
598        lifecycle_state: LifecycleState::Active,
599        gates_description: "use the DeepSeek provider for ASK / AUTO EMBED / SEARCH SIMILAR",
600    },
601    ActionEntry {
602        name: "ai:provider:huggingface",
603        category: ActionCategory::Ai,
604        lifecycle_state: LifecycleState::Active,
605        gates_description: "use the HuggingFace provider for ASK / AUTO EMBED / SEARCH SIMILAR",
606    },
607    ActionEntry {
608        name: "ai:provider:local",
609        category: ActionCategory::Ai,
610        lifecycle_state: LifecycleState::Active,
611        gates_description: "use the local (in-process) embedding provider",
612    },
613    ActionEntry {
614        name: "ai:provider:*",
615        category: ActionCategory::Wildcard,
616        lifecycle_state: LifecycleState::Active,
617        gates_description: "use any AI provider (provider-gate wildcard)",
618    },
619    ActionEntry {
620        name: "ai:*",
621        category: ActionCategory::Wildcard,
622        lifecycle_state: LifecycleState::Active,
623        gates_description: "any AI-namespace verb",
624    },
625    // -- Ephemeral notifications (#720 / PRD #718) -----------------------
626    // RedDB-native pub/sub primitive. `notify` gates publish/subscribe
627    // inside the principal's own tenant; `notify:cross-tenant` is the
628    // explicit capability required to address another tenant's channel
629    // or the platform-global namespace. See `crate::notifications`.
630    ActionEntry {
631        name: "notify",
632        category: ActionCategory::Notification,
633        lifecycle_state: LifecycleState::Active,
634        gates_description:
635            "publish to / subscribe to ephemeral notification channels in the principal's own tenant",
636    },
637    ActionEntry {
638        name: "notify:cross-tenant",
639        category: ActionCategory::Notification,
640        lifecycle_state: LifecycleState::Active,
641        gates_description:
642            "address ephemeral notification channels in another tenant or the global namespace",
643    },
644    ActionEntry {
645        name: "notify:*",
646        category: ActionCategory::Wildcard,
647        lifecycle_state: LifecycleState::Active,
648        gates_description: "any ephemeral notification verb",
649    },
650    // -- Durable streams (#721 / PRD #718) -------------------------------
651    // RedDB-native append-only event-log primitive. `stream` gates
652    // append / read / offset save inside the principal's own tenant;
653    // `stream:cross-tenant` is the explicit capability required to
654    // address another tenant's stream or the platform-global
655    // namespace. See `crate::streams`.
656    ActionEntry {
657        name: "stream",
658        category: ActionCategory::Stream,
659        lifecycle_state: LifecycleState::Active,
660        gates_description:
661            "append, read, and offset-save on durable streams in the principal's own tenant",
662    },
663    ActionEntry {
664        name: "stream:cross-tenant",
665        category: ActionCategory::Stream,
666        lifecycle_state: LifecycleState::Active,
667        gates_description:
668            "address durable streams in another tenant or the global namespace",
669    },
670    ActionEntry {
671        name: "stream:*",
672        category: ActionCategory::Wildcard,
673        lifecycle_state: LifecycleState::Active,
674        gates_description: "any durable stream verb",
675    },
676    // -- Queue operations (#755 / PRD #735) ------------------------------
677    // Red UI needs to grant queue toolbar actions independently —
678    // producer, consumer, ack lifecycle, DLQ admin, destructive purge,
679    // and consumer-presence reads each fall under their own verb so
680    // dangerous operations are not over-granted by a single broad
681    // `queue:write`. Wired at the SQL runtime (`check_query_privilege`)
682    // for `QueueCommand` / `QueueSelect` variants.
683    ActionEntry {
684        name: "queue:enqueue",
685        category: ActionCategory::Queue,
686        lifecycle_state: LifecycleState::Active,
687        gates_description: "push / produce a message onto a queue",
688    },
689    ActionEntry {
690        name: "queue:read",
691        category: ActionCategory::Queue,
692        lifecycle_state: LifecycleState::Active,
693        gates_description: "destructive read: pop, group-read, claim",
694    },
695    ActionEntry {
696        name: "queue:peek",
697        category: ActionCategory::Queue,
698        lifecycle_state: LifecycleState::Active,
699        gates_description: "non-destructive read: peek, len, pending, select",
700    },
701    ActionEntry {
702        name: "queue:ack",
703        category: ActionCategory::Queue,
704        lifecycle_state: LifecycleState::Active,
705        gates_description: "acknowledge a delivered queue message",
706    },
707    ActionEntry {
708        name: "queue:nack",
709        category: ActionCategory::Queue,
710        lifecycle_state: LifecycleState::Active,
711        gates_description: "negative-acknowledge / requeue a delivered queue message",
712    },
713    ActionEntry {
714        name: "queue:retry",
715        category: ActionCategory::Queue,
716        lifecycle_state: LifecycleState::Active,
717        gates_description: "override retry policy (e.g. per-failure NACK delay)",
718    },
719    ActionEntry {
720        name: "queue:dlq:move",
721        category: ActionCategory::Queue,
722        lifecycle_state: LifecycleState::Active,
723        gates_description: "move / replay messages between a queue and its DLQ",
724    },
725    ActionEntry {
726        name: "queue:purge",
727        category: ActionCategory::Queue,
728        lifecycle_state: LifecycleState::Active,
729        gates_description: "destructively purge all messages from a queue",
730    },
731    ActionEntry {
732        name: "queue:presence:read",
733        category: ActionCategory::Queue,
734        lifecycle_state: LifecycleState::Active,
735        gates_description: "read consumer presence / heartbeat snapshots",
736    },
737    ActionEntry {
738        name: "queue:*",
739        category: ActionCategory::Wildcard,
740        lifecycle_state: LifecycleState::Active,
741        gates_description: "any queue verb",
742    },
743    // -- Graph operations (#757 / PRD #735) ------------------------------
744    // Red UI needs to grant graph explorer toolbar actions
745    // independently — metadata/property reads, pattern/path traversal,
746    // and analytics algorithm execution each fall under their own verb.
747    // Gated at the SQL runtime (`check_query_privilege`) for
748    // `QueryExpr::Graph` (MATCH), `QueryExpr::Path`, and
749    // `QueryExpr::GraphCommand` variants. The resource is
750    // `graph:<name>` scoped to the current tenant — the runtime today
751    // operates on a singleton graph so the name is `*`, matched by a
752    // `graph:*` policy resource pattern.
753    ActionEntry {
754        name: "graph:read",
755        category: ActionCategory::Graph,
756        lifecycle_state: LifecycleState::Active,
757        gates_description: "read graph node/edge metadata and graph-wide properties",
758    },
759    ActionEntry {
760        name: "graph:traverse",
761        category: ActionCategory::Graph,
762        lifecycle_state: LifecycleState::Active,
763        gates_description: "execute pattern match / neighborhood / path traversal queries",
764    },
765    ActionEntry {
766        name: "graph:algorithm:run",
767        category: ActionCategory::Graph,
768        lifecycle_state: LifecycleState::Active,
769        gates_description: "run a graph analytics algorithm (centrality, community, components, ...)",
770    },
771    ActionEntry {
772        name: "graph:*",
773        category: ActionCategory::Wildcard,
774        lifecycle_state: LifecycleState::Active,
775        gates_description: "any graph verb",
776    },
777    // -- Operational reads (#758 / PRD #735) -----------------------------
778    // Scoped read levels so Red UI's cluster, security, and observability
779    // pages expose only the operational state the current principal is
780    // allowed to inspect. The scope tokens (`self`, `tenant`, `cluster`,
781    // `admin`) deliberately mirror the visibility radius of the read:
782    //   * `ops:read:self`    — single-instance health / lifecycle for the
783    //     principal's own surface (no cross-tenant or cross-node data).
784    //   * `ops:read:tenant`  — tenant-aggregate observability (metrics
785    //     scoped to caller's tenant, not the platform).
786    //   * `ops:read:cluster` — full cluster topology / replication /
787    //     backup / metrics exposition.
788    //   * `ops:admin`        — security-sensitive operational reads
789    //     (audit log, vault posture, IAM-config snapshots).
790    // Wired at the HTTP layer in `crate::server::handlers_ops_policy`.
791    ActionEntry {
792        name: "ops:read:self",
793        category: ActionCategory::Ops,
794        lifecycle_state: LifecycleState::Active,
795        gates_description: "read single-instance health / lifecycle state",
796    },
797    ActionEntry {
798        name: "ops:read:tenant",
799        category: ActionCategory::Ops,
800        lifecycle_state: LifecycleState::Active,
801        gates_description: "read tenant-scoped operational metrics / aggregates",
802    },
803    ActionEntry {
804        name: "ops:read:cluster",
805        category: ActionCategory::Ops,
806        lifecycle_state: LifecycleState::Active,
807        gates_description:
808            "read cluster topology / replication / backup / full metrics exposition",
809    },
810    ActionEntry {
811        name: "ops:admin",
812        category: ActionCategory::Ops,
813        lifecycle_state: LifecycleState::Active,
814        gates_description:
815            "read security-sensitive operational state (audit log, vault posture)",
816    },
817    ActionEntry {
818        name: "ops:*",
819        category: ActionCategory::Wildcard,
820        lifecycle_state: LifecycleState::Active,
821        gates_description: "any operational read verb",
822    },
823    // -- Replication control (#820 / PRD #819) ---------------------------
824    // Dedicated replication capabilities. These are intentionally not
825    // covered by generic data reads: WAL streaming exposes the change set,
826    // and replica acks can move synchronous-commit watermarks.
827    ActionEntry {
828        name: "cluster:replication:stream",
829        category: ActionCategory::Other,
830        lifecycle_state: LifecycleState::Active,
831        gates_description: "stream primary WAL records and replication snapshots to a replica",
832    },
833    ActionEntry {
834        name: "cluster:replication:ack",
835        category: ActionCategory::Other,
836        lifecycle_state: LifecycleState::Active,
837        gates_description: "acknowledge replica LSN progress to the primary",
838    },
839    ActionEntry {
840        name: "cluster:*",
841        category: ActionCategory::Wildcard,
842        lifecycle_state: LifecycleState::Active,
843        gates_description: "any cluster-scoped capability",
844    },
845    // -- Vector operations (#756 / PRD #735) -----------------------------
846    // Red UI needs to grant vector toolbar actions independently —
847    // metadata / data reads, similarity / text / hybrid search,
848    // operational artifact introspection, rebuild / status, and
849    // clustering / admin operations each fall under their own verb so a
850    // single broad `vector:write` cannot over-grant artifact rebuilds
851    // or destructive admin surfaces. Wired at the SQL runtime
852    // (`check_query_privilege`) for `QueryExpr::Vector` and
853    // `QueryExpr::Hybrid` today; the remaining entries (artifact /
854    // admin) are advertised through the catalog for /auth/can probing
855    // and will be enforced as their HTTP / SQL surfaces land.
856    ActionEntry {
857        name: "vector:read",
858        category: ActionCategory::Vector,
859        lifecycle_state: LifecycleState::Active,
860        gates_description: "read vector metadata / data (non-search reads on a vector collection)",
861    },
862    ActionEntry {
863        name: "vector:search",
864        category: ActionCategory::Vector,
865        lifecycle_state: LifecycleState::Active,
866        gates_description: "similarity / text / hybrid search against a vector collection",
867    },
868    ActionEntry {
869        name: "vector:artifact:read",
870        category: ActionCategory::Vector,
871        lifecycle_state: LifecycleState::Active,
872        gates_description: "introspect operational vector index artifacts (pages, status)",
873    },
874    ActionEntry {
875        name: "vector:artifact:rebuild",
876        category: ActionCategory::Vector,
877        lifecycle_state: LifecycleState::Active,
878        gates_description: "rebuild / warmup vector index artifacts",
879    },
880    ActionEntry {
881        name: "vector:admin",
882        category: ActionCategory::Vector,
883        lifecycle_state: LifecycleState::Active,
884        gates_description: "admin operations on a vector collection (clustering, maintenance)",
885    },
886    ActionEntry {
887        name: "vector:*",
888        category: ActionCategory::Wildcard,
889        lifecycle_state: LifecycleState::Active,
890        gates_description: "any vector verb",
891    },
892    // -- Wildcards (kept last for legacy ordering) -----------------------
893    ActionEntry {
894        name: "*",
895        category: ActionCategory::Wildcard,
896        lifecycle_state: LifecycleState::Active,
897        gates_description: "any action (escape hatch — audit usage carefully)",
898    },
899    ActionEntry {
900        name: "admin:*",
901        category: ActionCategory::Wildcard,
902        lifecycle_state: LifecycleState::Active,
903        gates_description: "any admin verb",
904    },
905    ActionEntry {
906        name: "vault:*",
907        category: ActionCategory::Wildcard,
908        lifecycle_state: LifecycleState::Active,
909        gates_description: "any vault verb",
910    },
911    ActionEntry {
912        name: "kv:*",
913        category: ActionCategory::Wildcard,
914        lifecycle_state: LifecycleState::Active,
915        gates_description: "any KV verb",
916    },
917    ActionEntry {
918        name: "policy:*",
919        category: ActionCategory::Wildcard,
920        lifecycle_state: LifecycleState::Active,
921        gates_description: "any policy lifecycle verb",
922    },
923];
924
925/// Returns `true` if `name` is recognised by the catalog and is not in
926/// the `Removed` lifecycle state. `Active` and `Deprecated` entries both
927/// validate.
928pub fn is_valid_action(name: &str) -> bool {
929    ACTIONS
930        .iter()
931        .any(|e| e.name == name && !matches!(e.lifecycle_state, LifecycleState::Removed))
932}
933
934/// Lookup an entry by exact name. Returns `None` for unknown names.
935pub fn lookup(name: &str) -> Option<&'static ActionEntry> {
936    ACTIONS.iter().find(|e| e.name == name)
937}
938
939#[cfg(test)]
940mod tests {
941    use super::*;
942    use std::collections::HashSet;
943
944    /// The pre-catalog allowlist that lived in `auth::policies`. The
945    /// catalog must accept every one of these (modulo any explicit
946    /// `Removed` entries) so existing policies that used to validate
947    /// continue to validate.
948    const HISTORICAL_ALLOWLIST: &[&str] = &[
949        "select",
950        "write",
951        "insert",
952        "update",
953        "delete",
954        "truncate",
955        "references",
956        "execute",
957        "usage",
958        "grant",
959        "revoke",
960        "create",
961        "drop",
962        "alter",
963        "policy:put",
964        "policy:drop",
965        "policy:attach",
966        "policy:detach",
967        "policy:simulate",
968        "kv:invalidate",
969        "kv:read",
970        "kv:write",
971        "admin:bootstrap",
972        "admin:audit-read",
973        "admin:reload",
974        "admin:lease-promote",
975        "config:read",
976        "config:write",
977        "config:*",
978        "vault:read_metadata",
979        "vault:read",
980        "vault:write",
981        "secret:read",
982        "secret:write",
983        "secret:*",
984        "vault:unseal",
985        "vault:unseal_history",
986        "vault:purge",
987        "evidence:export",
988        "evidence:*",
989        "red.registry:register",
990        "red.registry:supersede",
991        "red.registry:*",
992        "*",
993        "admin:*",
994        "vault:*",
995        "kv:*",
996        "policy:*",
997    ];
998
999    #[test]
1000    fn no_duplicate_names() {
1001        let mut seen = HashSet::new();
1002        for entry in ACTIONS {
1003            assert!(
1004                seen.insert(entry.name),
1005                "duplicate action name in catalog: {}",
1006                entry.name
1007            );
1008        }
1009    }
1010
1011    #[test]
1012    fn covers_historical_allowlist() {
1013        let names: HashSet<&'static str> = ACTIONS.iter().map(|e| e.name).collect();
1014        for action in HISTORICAL_ALLOWLIST {
1015            assert!(
1016                names.contains(action),
1017                "catalog missing historically-accepted action: {action}",
1018            );
1019        }
1020    }
1021
1022    #[test]
1023    fn historical_allowlist_still_validates() {
1024        for action in HISTORICAL_ALLOWLIST {
1025            assert!(
1026                is_valid_action(action),
1027                "action {action} was accepted before the catalog and must still validate",
1028            );
1029        }
1030    }
1031
1032    #[test]
1033    fn has_at_least_one_deprecated_entry() {
1034        let count = ACTIONS
1035            .iter()
1036            .filter(|e| matches!(e.lifecycle_state, LifecycleState::Deprecated { .. }))
1037            .count();
1038        assert!(
1039            count >= 1,
1040            "catalog must demonstrate the Deprecated lifecycle state with at least one entry",
1041        );
1042    }
1043
1044    #[test]
1045    fn removed_entries_are_rejected() {
1046        // No `Removed` entries today, but the predicate must enforce the
1047        // rule if/when one is added.
1048        for entry in ACTIONS {
1049            if matches!(entry.lifecycle_state, LifecycleState::Removed) {
1050                assert!(
1051                    !is_valid_action(entry.name),
1052                    "Removed entry {} must not validate",
1053                    entry.name,
1054                );
1055            }
1056        }
1057    }
1058
1059    #[test]
1060    fn lookup_finds_known_entries() {
1061        assert!(lookup("policy:put").is_some());
1062        assert!(lookup("definitely-not-an-action").is_none());
1063    }
1064}