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    // -- KV --------------------------------------------------------------
358    ActionEntry {
359        name: "kv:invalidate",
360        category: ActionCategory::Other,
361        lifecycle_state: LifecycleState::Active,
362        gates_description: "invalidate cached KV entries",
363    },
364    ActionEntry {
365        name: "kv:read",
366        category: ActionCategory::Other,
367        lifecycle_state: LifecycleState::Active,
368        gates_description: "read user-managed plain KV entries via $kv.*",
369    },
370    ActionEntry {
371        name: "kv:write",
372        category: ActionCategory::Other,
373        lifecycle_state: LifecycleState::Active,
374        gates_description: "write or delete user-managed plain KV entries",
375    },
376    // -- Admin -----------------------------------------------------------
377    ActionEntry {
378        name: "admin:bootstrap",
379        category: ActionCategory::Admin,
380        lifecycle_state: LifecycleState::Active,
381        gates_description: "execute the bootstrap workflow",
382    },
383    ActionEntry {
384        name: "admin:audit-read",
385        category: ActionCategory::Admin,
386        lifecycle_state: LifecycleState::Active,
387        gates_description: "read the platform audit log",
388    },
389    ActionEntry {
390        name: "admin:reload",
391        category: ActionCategory::Admin,
392        lifecycle_state: LifecycleState::Active,
393        gates_description: "reload runtime configuration",
394    },
395    ActionEntry {
396        name: "admin:lease-promote",
397        category: ActionCategory::Admin,
398        lifecycle_state: LifecycleState::Active,
399        gates_description: "promote a standby instance via lease handoff",
400    },
401    // -- Runtime config --------------------------------------------------
402    ActionEntry {
403        name: "config:read",
404        category: ActionCategory::Config,
405        lifecycle_state: LifecycleState::Active,
406        gates_description: "read runtime configuration values",
407    },
408    ActionEntry {
409        name: "config:write",
410        category: ActionCategory::Config,
411        lifecycle_state: LifecycleState::Active,
412        gates_description: "mutate runtime configuration values",
413    },
414    ActionEntry {
415        name: "config:*",
416        category: ActionCategory::Wildcard,
417        lifecycle_state: LifecycleState::Active,
418        gates_description: "any runtime configuration verb",
419    },
420    // -- Vault -----------------------------------------------------------
421    ActionEntry {
422        name: "vault:read_metadata",
423        category: ActionCategory::Vault,
424        lifecycle_state: LifecycleState::Active,
425        gates_description: "read vault entry metadata (no plaintext)",
426    },
427    ActionEntry {
428        name: "vault:use",
429        category: ActionCategory::Vault,
430        lifecycle_state: LifecycleState::Active,
431        gates_description: "use a secret inside query evaluation without revealing its value",
432    },
433    ActionEntry {
434        name: "vault:reveal",
435        category: ActionCategory::Vault,
436        lifecycle_state: LifecycleState::Active,
437        gates_description: "explicitly reveal vault entry plaintext",
438    },
439    ActionEntry {
440        name: "vault:reveal_history",
441        category: ActionCategory::Vault,
442        lifecycle_state: LifecycleState::Active,
443        gates_description: "explicitly reveal historical vault entry plaintext",
444    },
445    ActionEntry {
446        name: "vault:read",
447        category: ActionCategory::Vault,
448        lifecycle_state: LifecycleState::Active,
449        gates_description: "compatibility alias for vault:reveal",
450    },
451    ActionEntry {
452        name: "vault:write",
453        category: ActionCategory::Vault,
454        lifecycle_state: LifecycleState::Active,
455        gates_description: "write or rotate vault entries",
456    },
457    ActionEntry {
458        name: "secret:read",
459        category: ActionCategory::Vault,
460        lifecycle_state: LifecycleState::Active,
461        gates_description: "compatibility alias for vault:use on default-vault SQL secrets",
462    },
463    ActionEntry {
464        name: "secret:write",
465        category: ActionCategory::Vault,
466        lifecycle_state: LifecycleState::Active,
467        gates_description: "compatibility alias for vault:write on default-vault SQL secrets",
468    },
469    ActionEntry {
470        name: "secret:*",
471        category: ActionCategory::Wildcard,
472        lifecycle_state: LifecycleState::Active,
473        gates_description: "any user-managed SQL vault secret verb",
474    },
475    ActionEntry {
476        name: "vault:unseal",
477        category: ActionCategory::Vault,
478        lifecycle_state: LifecycleState::Active,
479        gates_description: "unseal the vault master key for this session",
480    },
481    // Previous historical reveal capability, retained for existing policies.
482    ActionEntry {
483        name: "vault:unseal_history",
484        category: ActionCategory::Vault,
485        lifecycle_state: LifecycleState::Deprecated {
486            replacement: Some("vault:reveal_history"),
487            since_version: "0.5.0",
488        },
489        gates_description: "compatibility alias for vault:reveal_history",
490    },
491    ActionEntry {
492        name: "vault:purge",
493        category: ActionCategory::Vault,
494        lifecycle_state: LifecycleState::Active,
495        gates_description: "purge (destructively remove) vault entries",
496    },
497    // -- Evidence --------------------------------------------------------
498    ActionEntry {
499        name: "evidence:export",
500        category: ActionCategory::Other,
501        lifecycle_state: LifecycleState::Active,
502        gates_description: "export evidence bundles",
503    },
504    ActionEntry {
505        name: "evidence:*",
506        category: ActionCategory::Wildcard,
507        lifecycle_state: LifecycleState::Active,
508        gates_description: "any evidence-pipeline verb",
509    },
510    // -- Registry --------------------------------------------------------
511    ActionEntry {
512        name: "red.registry:register",
513        category: ActionCategory::Other,
514        lifecycle_state: LifecycleState::Active,
515        gates_description: "register a new managed-config schema",
516    },
517    ActionEntry {
518        name: "red.registry:supersede",
519        category: ActionCategory::Other,
520        lifecycle_state: LifecycleState::Active,
521        gates_description: "supersede an existing managed-config schema",
522    },
523    ActionEntry {
524        name: "red.registry:*",
525        category: ActionCategory::Wildcard,
526        lifecycle_state: LifecycleState::Active,
527        gates_description: "any registry verb",
528    },
529    // -- AI provider gate (S3 / #711) ------------------------------------
530    // The `ai:provider:<token>` namespace lets operators express "role X
531    // cannot use AI provider Y" without denying `insert` on entire
532    // collections. The gate runs at the SQL planner before the AI
533    // credential resolver — see `runtime::ai::provider_gate`. Tokens
534    // mirror `AiProvider::token()` exactly.
535    ActionEntry {
536        name: "ai:provider:openai",
537        category: ActionCategory::Ai,
538        lifecycle_state: LifecycleState::Active,
539        gates_description: "use the OpenAI provider for ASK / AUTO EMBED / SEARCH SIMILAR",
540    },
541    ActionEntry {
542        name: "ai:provider:anthropic",
543        category: ActionCategory::Ai,
544        lifecycle_state: LifecycleState::Active,
545        gates_description: "use the Anthropic provider for ASK / AUTO EMBED / SEARCH SIMILAR",
546    },
547    ActionEntry {
548        name: "ai:provider:groq",
549        category: ActionCategory::Ai,
550        lifecycle_state: LifecycleState::Active,
551        gates_description: "use the Groq provider for ASK / AUTO EMBED / SEARCH SIMILAR",
552    },
553    ActionEntry {
554        name: "ai:provider:openrouter",
555        category: ActionCategory::Ai,
556        lifecycle_state: LifecycleState::Active,
557        gates_description: "use the OpenRouter provider for ASK / AUTO EMBED / SEARCH SIMILAR",
558    },
559    ActionEntry {
560        name: "ai:provider:together",
561        category: ActionCategory::Ai,
562        lifecycle_state: LifecycleState::Active,
563        gates_description: "use the Together provider for ASK / AUTO EMBED / SEARCH SIMILAR",
564    },
565    ActionEntry {
566        name: "ai:provider:venice",
567        category: ActionCategory::Ai,
568        lifecycle_state: LifecycleState::Active,
569        gates_description: "use the Venice provider for ASK / AUTO EMBED / SEARCH SIMILAR",
570    },
571    ActionEntry {
572        name: "ai:provider:ollama",
573        category: ActionCategory::Ai,
574        lifecycle_state: LifecycleState::Active,
575        gates_description: "use the Ollama provider for ASK / AUTO EMBED / SEARCH SIMILAR",
576    },
577    ActionEntry {
578        name: "ai:provider:deepseek",
579        category: ActionCategory::Ai,
580        lifecycle_state: LifecycleState::Active,
581        gates_description: "use the DeepSeek provider for ASK / AUTO EMBED / SEARCH SIMILAR",
582    },
583    ActionEntry {
584        name: "ai:provider:huggingface",
585        category: ActionCategory::Ai,
586        lifecycle_state: LifecycleState::Active,
587        gates_description: "use the HuggingFace provider for ASK / AUTO EMBED / SEARCH SIMILAR",
588    },
589    ActionEntry {
590        name: "ai:provider:local",
591        category: ActionCategory::Ai,
592        lifecycle_state: LifecycleState::Active,
593        gates_description: "use the local (in-process) embedding provider",
594    },
595    ActionEntry {
596        name: "ai:provider:*",
597        category: ActionCategory::Wildcard,
598        lifecycle_state: LifecycleState::Active,
599        gates_description: "use any AI provider (provider-gate wildcard)",
600    },
601    ActionEntry {
602        name: "ai:*",
603        category: ActionCategory::Wildcard,
604        lifecycle_state: LifecycleState::Active,
605        gates_description: "any AI-namespace verb",
606    },
607    // -- Ephemeral notifications (#720 / PRD #718) -----------------------
608    // RedDB-native pub/sub primitive. `notify` gates publish/subscribe
609    // inside the principal's own tenant; `notify:cross-tenant` is the
610    // explicit capability required to address another tenant's channel
611    // or the platform-global namespace. See `crate::notifications`.
612    ActionEntry {
613        name: "notify",
614        category: ActionCategory::Notification,
615        lifecycle_state: LifecycleState::Active,
616        gates_description:
617            "publish to / subscribe to ephemeral notification channels in the principal's own tenant",
618    },
619    ActionEntry {
620        name: "notify:cross-tenant",
621        category: ActionCategory::Notification,
622        lifecycle_state: LifecycleState::Active,
623        gates_description:
624            "address ephemeral notification channels in another tenant or the global namespace",
625    },
626    ActionEntry {
627        name: "notify:*",
628        category: ActionCategory::Wildcard,
629        lifecycle_state: LifecycleState::Active,
630        gates_description: "any ephemeral notification verb",
631    },
632    // -- Durable streams (#721 / PRD #718) -------------------------------
633    // RedDB-native append-only event-log primitive. `stream` gates
634    // append / read / offset save inside the principal's own tenant;
635    // `stream:cross-tenant` is the explicit capability required to
636    // address another tenant's stream or the platform-global
637    // namespace. See `crate::streams`.
638    ActionEntry {
639        name: "stream",
640        category: ActionCategory::Stream,
641        lifecycle_state: LifecycleState::Active,
642        gates_description:
643            "append, read, and offset-save on durable streams in the principal's own tenant",
644    },
645    ActionEntry {
646        name: "stream:cross-tenant",
647        category: ActionCategory::Stream,
648        lifecycle_state: LifecycleState::Active,
649        gates_description:
650            "address durable streams in another tenant or the global namespace",
651    },
652    ActionEntry {
653        name: "stream:*",
654        category: ActionCategory::Wildcard,
655        lifecycle_state: LifecycleState::Active,
656        gates_description: "any durable stream verb",
657    },
658    // -- Queue operations (#755 / PRD #735) ------------------------------
659    // Red UI needs to grant queue toolbar actions independently —
660    // producer, consumer, ack lifecycle, DLQ admin, destructive purge,
661    // and consumer-presence reads each fall under their own verb so
662    // dangerous operations are not over-granted by a single broad
663    // `queue:write`. Wired at the SQL runtime (`check_query_privilege`)
664    // for `QueueCommand` / `QueueSelect` variants.
665    ActionEntry {
666        name: "queue:enqueue",
667        category: ActionCategory::Queue,
668        lifecycle_state: LifecycleState::Active,
669        gates_description: "push / produce a message onto a queue",
670    },
671    ActionEntry {
672        name: "queue:read",
673        category: ActionCategory::Queue,
674        lifecycle_state: LifecycleState::Active,
675        gates_description: "destructive read: pop, group-read, claim",
676    },
677    ActionEntry {
678        name: "queue:peek",
679        category: ActionCategory::Queue,
680        lifecycle_state: LifecycleState::Active,
681        gates_description: "non-destructive read: peek, len, pending, select",
682    },
683    ActionEntry {
684        name: "queue:ack",
685        category: ActionCategory::Queue,
686        lifecycle_state: LifecycleState::Active,
687        gates_description: "acknowledge a delivered queue message",
688    },
689    ActionEntry {
690        name: "queue:nack",
691        category: ActionCategory::Queue,
692        lifecycle_state: LifecycleState::Active,
693        gates_description: "negative-acknowledge / requeue a delivered queue message",
694    },
695    ActionEntry {
696        name: "queue:retry",
697        category: ActionCategory::Queue,
698        lifecycle_state: LifecycleState::Active,
699        gates_description: "override retry policy (e.g. per-failure NACK delay)",
700    },
701    ActionEntry {
702        name: "queue:dlq:move",
703        category: ActionCategory::Queue,
704        lifecycle_state: LifecycleState::Active,
705        gates_description: "move / replay messages between a queue and its DLQ",
706    },
707    ActionEntry {
708        name: "queue:purge",
709        category: ActionCategory::Queue,
710        lifecycle_state: LifecycleState::Active,
711        gates_description: "destructively purge all messages from a queue",
712    },
713    ActionEntry {
714        name: "queue:presence:read",
715        category: ActionCategory::Queue,
716        lifecycle_state: LifecycleState::Active,
717        gates_description: "read consumer presence / heartbeat snapshots",
718    },
719    ActionEntry {
720        name: "queue:*",
721        category: ActionCategory::Wildcard,
722        lifecycle_state: LifecycleState::Active,
723        gates_description: "any queue verb",
724    },
725    // -- Graph operations (#757 / PRD #735) ------------------------------
726    // Red UI needs to grant graph explorer toolbar actions
727    // independently — metadata/property reads, pattern/path traversal,
728    // and analytics algorithm execution each fall under their own verb.
729    // Gated at the SQL runtime (`check_query_privilege`) for
730    // `QueryExpr::Graph` (MATCH), `QueryExpr::Path`, and
731    // `QueryExpr::GraphCommand` variants. The resource is
732    // `graph:<name>` scoped to the current tenant — the runtime today
733    // operates on a singleton graph so the name is `*`, matched by a
734    // `graph:*` policy resource pattern.
735    ActionEntry {
736        name: "graph:read",
737        category: ActionCategory::Graph,
738        lifecycle_state: LifecycleState::Active,
739        gates_description: "read graph node/edge metadata and graph-wide properties",
740    },
741    ActionEntry {
742        name: "graph:traverse",
743        category: ActionCategory::Graph,
744        lifecycle_state: LifecycleState::Active,
745        gates_description: "execute pattern match / neighborhood / path traversal queries",
746    },
747    ActionEntry {
748        name: "graph:algorithm:run",
749        category: ActionCategory::Graph,
750        lifecycle_state: LifecycleState::Active,
751        gates_description: "run a graph analytics algorithm (centrality, community, components, ...)",
752    },
753    ActionEntry {
754        name: "graph:*",
755        category: ActionCategory::Wildcard,
756        lifecycle_state: LifecycleState::Active,
757        gates_description: "any graph verb",
758    },
759    // -- Operational reads (#758 / PRD #735) -----------------------------
760    // Scoped read levels so Red UI's cluster, security, and observability
761    // pages expose only the operational state the current principal is
762    // allowed to inspect. The scope tokens (`self`, `tenant`, `cluster`,
763    // `admin`) deliberately mirror the visibility radius of the read:
764    //   * `ops:read:self`    — single-instance health / lifecycle for the
765    //     principal's own surface (no cross-tenant or cross-node data).
766    //   * `ops:read:tenant`  — tenant-aggregate observability (metrics
767    //     scoped to caller's tenant, not the platform).
768    //   * `ops:read:cluster` — full cluster topology / replication /
769    //     backup / metrics exposition.
770    //   * `ops:admin`        — security-sensitive operational reads
771    //     (audit log, vault posture, IAM-config snapshots).
772    // Wired at the HTTP layer in `crate::server::handlers_ops_policy`.
773    ActionEntry {
774        name: "ops:read:self",
775        category: ActionCategory::Ops,
776        lifecycle_state: LifecycleState::Active,
777        gates_description: "read single-instance health / lifecycle state",
778    },
779    ActionEntry {
780        name: "ops:read:tenant",
781        category: ActionCategory::Ops,
782        lifecycle_state: LifecycleState::Active,
783        gates_description: "read tenant-scoped operational metrics / aggregates",
784    },
785    ActionEntry {
786        name: "ops:read:cluster",
787        category: ActionCategory::Ops,
788        lifecycle_state: LifecycleState::Active,
789        gates_description:
790            "read cluster topology / replication / backup / full metrics exposition",
791    },
792    ActionEntry {
793        name: "ops:admin",
794        category: ActionCategory::Ops,
795        lifecycle_state: LifecycleState::Active,
796        gates_description:
797            "read security-sensitive operational state (audit log, vault posture)",
798    },
799    ActionEntry {
800        name: "ops:*",
801        category: ActionCategory::Wildcard,
802        lifecycle_state: LifecycleState::Active,
803        gates_description: "any operational read verb",
804    },
805    // -- Replication control (#820 / PRD #819) ---------------------------
806    // Dedicated replication capabilities. These are intentionally not
807    // covered by generic data reads: WAL streaming exposes the change set,
808    // and replica acks can move synchronous-commit watermarks.
809    ActionEntry {
810        name: "cluster:replication:stream",
811        category: ActionCategory::Other,
812        lifecycle_state: LifecycleState::Active,
813        gates_description: "stream primary WAL records and replication snapshots to a replica",
814    },
815    ActionEntry {
816        name: "cluster:replication:ack",
817        category: ActionCategory::Other,
818        lifecycle_state: LifecycleState::Active,
819        gates_description: "acknowledge replica LSN progress to the primary",
820    },
821    ActionEntry {
822        name: "cluster:*",
823        category: ActionCategory::Wildcard,
824        lifecycle_state: LifecycleState::Active,
825        gates_description: "any cluster-scoped capability",
826    },
827    // -- Vector operations (#756 / PRD #735) -----------------------------
828    // Red UI needs to grant vector toolbar actions independently —
829    // metadata / data reads, similarity / text / hybrid search,
830    // operational artifact introspection, rebuild / status, and
831    // clustering / admin operations each fall under their own verb so a
832    // single broad `vector:write` cannot over-grant artifact rebuilds
833    // or destructive admin surfaces. Wired at the SQL runtime
834    // (`check_query_privilege`) for `QueryExpr::Vector` and
835    // `QueryExpr::Hybrid` today; the remaining entries (artifact /
836    // admin) are advertised through the catalog for /auth/can probing
837    // and will be enforced as their HTTP / SQL surfaces land.
838    ActionEntry {
839        name: "vector:read",
840        category: ActionCategory::Vector,
841        lifecycle_state: LifecycleState::Active,
842        gates_description: "read vector metadata / data (non-search reads on a vector collection)",
843    },
844    ActionEntry {
845        name: "vector:search",
846        category: ActionCategory::Vector,
847        lifecycle_state: LifecycleState::Active,
848        gates_description: "similarity / text / hybrid search against a vector collection",
849    },
850    ActionEntry {
851        name: "vector:artifact:read",
852        category: ActionCategory::Vector,
853        lifecycle_state: LifecycleState::Active,
854        gates_description: "introspect operational vector index artifacts (pages, status)",
855    },
856    ActionEntry {
857        name: "vector:artifact:rebuild",
858        category: ActionCategory::Vector,
859        lifecycle_state: LifecycleState::Active,
860        gates_description: "rebuild / warmup vector index artifacts",
861    },
862    ActionEntry {
863        name: "vector:admin",
864        category: ActionCategory::Vector,
865        lifecycle_state: LifecycleState::Active,
866        gates_description: "admin operations on a vector collection (clustering, maintenance)",
867    },
868    ActionEntry {
869        name: "vector:*",
870        category: ActionCategory::Wildcard,
871        lifecycle_state: LifecycleState::Active,
872        gates_description: "any vector verb",
873    },
874    // -- Wildcards (kept last for legacy ordering) -----------------------
875    ActionEntry {
876        name: "*",
877        category: ActionCategory::Wildcard,
878        lifecycle_state: LifecycleState::Active,
879        gates_description: "any action (escape hatch — audit usage carefully)",
880    },
881    ActionEntry {
882        name: "admin:*",
883        category: ActionCategory::Wildcard,
884        lifecycle_state: LifecycleState::Active,
885        gates_description: "any admin verb",
886    },
887    ActionEntry {
888        name: "vault:*",
889        category: ActionCategory::Wildcard,
890        lifecycle_state: LifecycleState::Active,
891        gates_description: "any vault verb",
892    },
893    ActionEntry {
894        name: "kv:*",
895        category: ActionCategory::Wildcard,
896        lifecycle_state: LifecycleState::Active,
897        gates_description: "any KV verb",
898    },
899    ActionEntry {
900        name: "policy:*",
901        category: ActionCategory::Wildcard,
902        lifecycle_state: LifecycleState::Active,
903        gates_description: "any policy lifecycle verb",
904    },
905];
906
907/// Returns `true` if `name` is recognised by the catalog and is not in
908/// the `Removed` lifecycle state. `Active` and `Deprecated` entries both
909/// validate.
910pub fn is_valid_action(name: &str) -> bool {
911    ACTIONS
912        .iter()
913        .any(|e| e.name == name && !matches!(e.lifecycle_state, LifecycleState::Removed))
914}
915
916/// Lookup an entry by exact name. Returns `None` for unknown names.
917pub fn lookup(name: &str) -> Option<&'static ActionEntry> {
918    ACTIONS.iter().find(|e| e.name == name)
919}
920
921#[cfg(test)]
922mod tests {
923    use super::*;
924    use std::collections::HashSet;
925
926    /// The pre-catalog allowlist that lived in `auth::policies`. The
927    /// catalog must accept every one of these (modulo any explicit
928    /// `Removed` entries) so existing policies that used to validate
929    /// continue to validate.
930    const HISTORICAL_ALLOWLIST: &[&str] = &[
931        "select",
932        "write",
933        "insert",
934        "update",
935        "delete",
936        "truncate",
937        "references",
938        "execute",
939        "usage",
940        "grant",
941        "revoke",
942        "create",
943        "drop",
944        "alter",
945        "policy:put",
946        "policy:drop",
947        "policy:attach",
948        "policy:detach",
949        "policy:simulate",
950        "kv:invalidate",
951        "kv:read",
952        "kv:write",
953        "admin:bootstrap",
954        "admin:audit-read",
955        "admin:reload",
956        "admin:lease-promote",
957        "config:read",
958        "config:write",
959        "config:*",
960        "vault:read_metadata",
961        "vault:read",
962        "vault:write",
963        "secret:read",
964        "secret:write",
965        "secret:*",
966        "vault:unseal",
967        "vault:unseal_history",
968        "vault:purge",
969        "evidence:export",
970        "evidence:*",
971        "red.registry:register",
972        "red.registry:supersede",
973        "red.registry:*",
974        "*",
975        "admin:*",
976        "vault:*",
977        "kv:*",
978        "policy:*",
979    ];
980
981    #[test]
982    fn no_duplicate_names() {
983        let mut seen = HashSet::new();
984        for entry in ACTIONS {
985            assert!(
986                seen.insert(entry.name),
987                "duplicate action name in catalog: {}",
988                entry.name
989            );
990        }
991    }
992
993    #[test]
994    fn covers_historical_allowlist() {
995        let names: HashSet<&'static str> = ACTIONS.iter().map(|e| e.name).collect();
996        for action in HISTORICAL_ALLOWLIST {
997            assert!(
998                names.contains(action),
999                "catalog missing historically-accepted action: {action}",
1000            );
1001        }
1002    }
1003
1004    #[test]
1005    fn historical_allowlist_still_validates() {
1006        for action in HISTORICAL_ALLOWLIST {
1007            assert!(
1008                is_valid_action(action),
1009                "action {action} was accepted before the catalog and must still validate",
1010            );
1011        }
1012    }
1013
1014    #[test]
1015    fn has_at_least_one_deprecated_entry() {
1016        let count = ACTIONS
1017            .iter()
1018            .filter(|e| matches!(e.lifecycle_state, LifecycleState::Deprecated { .. }))
1019            .count();
1020        assert!(
1021            count >= 1,
1022            "catalog must demonstrate the Deprecated lifecycle state with at least one entry",
1023        );
1024    }
1025
1026    #[test]
1027    fn removed_entries_are_rejected() {
1028        // No `Removed` entries today, but the predicate must enforce the
1029        // rule if/when one is added.
1030        for entry in ACTIONS {
1031            if matches!(entry.lifecycle_state, LifecycleState::Removed) {
1032                assert!(
1033                    !is_valid_action(entry.name),
1034                    "Removed entry {} must not validate",
1035                    entry.name,
1036                );
1037            }
1038        }
1039    }
1040
1041    #[test]
1042    fn lookup_finds_known_entries() {
1043        assert!(lookup("policy:put").is_some());
1044        assert!(lookup("definitely-not-an-action").is_none());
1045    }
1046}