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