Skip to main content

reddb_server/auth/
action_catalog.rs

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