Skip to main content

kanade_shared/
feature.rs

1//! Page-level **feature** catalog — the single source of truth for
2//! per-account page visibility (see backend `auth::require_features`).
3//!
4//! Each variant maps 1:1 to a navigable SPA page (the sidebar entries in
5//! `web/src/components/Sidebar.tsx`). An account's `allowed_features`
6//! column (`users.allowed_features`, JSON array of these string keys) is
7//! an **allow-list**: `NULL` means "unrestricted — every page", any array
8//! restricts the account to exactly those pages (plus the always-open
9//! commons: the login/self-service routes and the Dashboard landing feed).
10//!
11//! Backend and SPA share these string keys so a page can't be gated on one
12//! side without the other agreeing on its name. The keys match the SPA
13//! route path without the leading slash (`/compliance` → `"compliance"`).
14//!
15//! Enforcement is **hard** (backend `403`), not merely a hidden nav item:
16//! the routes owned by a page are gated by [`Feature`] in
17//! `crate::api::feature_map` on the backend, so a restricted account can't
18//! reach the data by typing the URL or calling the API directly either.
19
20use serde::{Deserialize, Serialize};
21
22/// A gatable SPA page. `serde` (de)serializes each variant as its lowercase
23/// route key (`Compliance` ⇄ `"compliance"`), which is exactly the string
24/// stored in `users.allowed_features` and returned by `/api/auth/me`.
25#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Serialize, Deserialize)]
26#[serde(rename_all = "lowercase")]
27pub enum Feature {
28    Dashboard,
29    Run,
30    Exec,
31    Agents,
32    Inventory,
33    Compliance,
34    Activity,
35    Events,
36    Audit,
37    Logs,
38    Collect,
39    Analytics,
40    Jobs,
41    Schedules,
42    Views,
43    Notifications,
44    Rollout,
45    Apps,
46    Groups,
47    Config,
48    Jetstream,
49    Accounts,
50    Settings,
51    /// #1140 — remote screen view / control. Gated like any other page, but
52    /// the stakes differ: every other feature reveals data the endpoint
53    /// already sent us, while this one reaches into a machine somebody is
54    /// sitting at. Being in the catalog is what lets an operator hold, say,
55    /// Agents without also holding Remote.
56    Remote,
57}
58
59impl Feature {
60    /// The wire/DB key for this feature (matches the SPA route path minus
61    /// the leading slash). Kept in lockstep with the `serde` rename above.
62    pub fn as_str(self) -> &'static str {
63        match self {
64            Feature::Dashboard => "dashboard",
65            Feature::Run => "run",
66            Feature::Exec => "exec",
67            Feature::Agents => "agents",
68            Feature::Inventory => "inventory",
69            Feature::Compliance => "compliance",
70            Feature::Activity => "activity",
71            Feature::Events => "events",
72            Feature::Audit => "audit",
73            Feature::Logs => "logs",
74            Feature::Collect => "collect",
75            Feature::Analytics => "analytics",
76            Feature::Jobs => "jobs",
77            Feature::Schedules => "schedules",
78            Feature::Views => "views",
79            Feature::Notifications => "notifications",
80            Feature::Rollout => "rollout",
81            Feature::Apps => "apps",
82            Feature::Groups => "groups",
83            Feature::Config => "config",
84            Feature::Jetstream => "jetstream",
85            Feature::Accounts => "accounts",
86            Feature::Settings => "settings",
87            Feature::Remote => "remote",
88        }
89    }
90
91    /// Parse a feature key. Unknown keys yield `None` so callers can decide
92    /// whether to reject (create/update validation) or silently drop
93    /// (loading a stored list whose catalog has since shrunk).
94    pub fn parse(s: &str) -> Option<Feature> {
95        Some(match s {
96            "dashboard" => Feature::Dashboard,
97            "run" => Feature::Run,
98            "exec" => Feature::Exec,
99            "agents" => Feature::Agents,
100            "inventory" => Feature::Inventory,
101            "compliance" => Feature::Compliance,
102            "activity" => Feature::Activity,
103            "events" => Feature::Events,
104            "audit" => Feature::Audit,
105            "logs" => Feature::Logs,
106            "collect" => Feature::Collect,
107            "analytics" => Feature::Analytics,
108            "jobs" => Feature::Jobs,
109            "schedules" => Feature::Schedules,
110            "views" => Feature::Views,
111            "notifications" => Feature::Notifications,
112            "rollout" => Feature::Rollout,
113            "apps" => Feature::Apps,
114            "groups" => Feature::Groups,
115            "config" => Feature::Config,
116            "jetstream" => Feature::Jetstream,
117            "accounts" => Feature::Accounts,
118            "settings" => Feature::Settings,
119            "remote" => Feature::Remote,
120            _ => return None,
121        })
122    }
123
124    /// Validate + canonicalize a submitted list of feature keys: reject the
125    /// first unknown key (returning it as the `Err`), de-duplicate, and return
126    /// the surviving keys in catalog order so what's stored is stable. An
127    /// empty input is valid. Shared by the account allow-list editor and the
128    /// permission-group editor so both agree on what a valid list is.
129    pub fn canonicalize(keys: &[String]) -> Result<Vec<String>, String> {
130        let mut set: Vec<Feature> = Vec::new();
131        for k in keys {
132            let f = Feature::parse(k).ok_or_else(|| k.clone())?;
133            if !set.contains(&f) {
134                set.push(f);
135            }
136        }
137        Ok(Feature::ALL
138            .iter()
139            .filter(|f| set.contains(f))
140            .map(|f| f.as_str().to_string())
141            .collect())
142    }
143
144    /// Every feature, in catalog order. Drives the SPA's account editor
145    /// checkbox list and lets the backend validate a submitted allow-list
146    /// against the full known set.
147    pub const ALL: [Feature; 24] = [
148        Feature::Dashboard,
149        Feature::Run,
150        Feature::Exec,
151        Feature::Agents,
152        Feature::Inventory,
153        Feature::Compliance,
154        Feature::Activity,
155        Feature::Events,
156        Feature::Audit,
157        Feature::Logs,
158        Feature::Collect,
159        Feature::Analytics,
160        Feature::Jobs,
161        Feature::Schedules,
162        Feature::Views,
163        Feature::Notifications,
164        Feature::Rollout,
165        Feature::Apps,
166        Feature::Groups,
167        Feature::Config,
168        Feature::Jetstream,
169        Feature::Accounts,
170        Feature::Settings,
171        Feature::Remote,
172    ];
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    #[test]
180    fn key_roundtrips_for_every_variant() {
181        for f in Feature::ALL {
182            assert_eq!(Feature::parse(f.as_str()), Some(f), "roundtrip {f:?}");
183        }
184    }
185
186    #[test]
187    fn all_covers_the_enum() {
188        // A missing entry in ALL would silently drop a feature from
189        // validation + the SPA editor; assert the count matches the array
190        // length so adding a variant without updating ALL fails to compile
191        // (length mismatch) or fails here.
192        assert_eq!(Feature::ALL.len(), 24);
193        // No duplicate keys.
194        let mut keys: Vec<&str> = Feature::ALL.iter().map(|f| f.as_str()).collect();
195        keys.sort_unstable();
196        keys.dedup();
197        assert_eq!(keys.len(), Feature::ALL.len(), "duplicate feature key");
198    }
199
200    #[test]
201    fn serde_uses_lowercase_key() {
202        assert_eq!(
203            serde_json::to_string(&Feature::Compliance).unwrap(),
204            "\"compliance\""
205        );
206        assert_eq!(
207            serde_json::from_str::<Feature>("\"jetstream\"").unwrap(),
208            Feature::Jetstream
209        );
210    }
211
212    #[test]
213    fn unknown_key_is_none() {
214        assert_eq!(Feature::parse("nope"), None);
215        assert_eq!(Feature::parse("Compliance"), None); // case-sensitive
216    }
217
218    #[test]
219    fn canonicalize_validates_dedupes_orders() {
220        // Unknown key → Err(the offending key).
221        assert_eq!(
222            Feature::canonicalize(&["bogus".into()]),
223            Err("bogus".to_string())
224        );
225        // Dedup + catalog order (Inventory precedes Compliance in ALL).
226        assert_eq!(
227            Feature::canonicalize(&["compliance".into(), "inventory".into(), "compliance".into()]),
228            Ok(vec!["inventory".to_string(), "compliance".to_string()])
229        );
230        // Empty is valid.
231        assert_eq!(Feature::canonicalize(&[]), Ok(vec![]));
232    }
233}