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