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}
52
53impl Feature {
54    /// The wire/DB key for this feature (matches the SPA route path minus
55    /// the leading slash). Kept in lockstep with the `serde` rename above.
56    pub fn as_str(self) -> &'static str {
57        match self {
58            Feature::Dashboard => "dashboard",
59            Feature::Run => "run",
60            Feature::Exec => "exec",
61            Feature::Agents => "agents",
62            Feature::Inventory => "inventory",
63            Feature::Compliance => "compliance",
64            Feature::Activity => "activity",
65            Feature::Events => "events",
66            Feature::Audit => "audit",
67            Feature::Logs => "logs",
68            Feature::Collect => "collect",
69            Feature::Analytics => "analytics",
70            Feature::Jobs => "jobs",
71            Feature::Schedules => "schedules",
72            Feature::Views => "views",
73            Feature::Notifications => "notifications",
74            Feature::Rollout => "rollout",
75            Feature::Apps => "apps",
76            Feature::Groups => "groups",
77            Feature::Config => "config",
78            Feature::Jetstream => "jetstream",
79            Feature::Accounts => "accounts",
80            Feature::Settings => "settings",
81        }
82    }
83
84    /// Parse a feature key. Unknown keys yield `None` so callers can decide
85    /// whether to reject (create/update validation) or silently drop
86    /// (loading a stored list whose catalog has since shrunk).
87    pub fn parse(s: &str) -> Option<Feature> {
88        Some(match s {
89            "dashboard" => Feature::Dashboard,
90            "run" => Feature::Run,
91            "exec" => Feature::Exec,
92            "agents" => Feature::Agents,
93            "inventory" => Feature::Inventory,
94            "compliance" => Feature::Compliance,
95            "activity" => Feature::Activity,
96            "events" => Feature::Events,
97            "audit" => Feature::Audit,
98            "logs" => Feature::Logs,
99            "collect" => Feature::Collect,
100            "analytics" => Feature::Analytics,
101            "jobs" => Feature::Jobs,
102            "schedules" => Feature::Schedules,
103            "views" => Feature::Views,
104            "notifications" => Feature::Notifications,
105            "rollout" => Feature::Rollout,
106            "apps" => Feature::Apps,
107            "groups" => Feature::Groups,
108            "config" => Feature::Config,
109            "jetstream" => Feature::Jetstream,
110            "accounts" => Feature::Accounts,
111            "settings" => Feature::Settings,
112            _ => return None,
113        })
114    }
115
116    /// Validate + canonicalize a submitted list of feature keys: reject the
117    /// first unknown key (returning it as the `Err`), de-duplicate, and return
118    /// the surviving keys in catalog order so what's stored is stable. An
119    /// empty input is valid. Shared by the account allow-list editor and the
120    /// permission-group editor so both agree on what a valid list is.
121    pub fn canonicalize(keys: &[String]) -> Result<Vec<String>, String> {
122        let mut set: Vec<Feature> = Vec::new();
123        for k in keys {
124            let f = Feature::parse(k).ok_or_else(|| k.clone())?;
125            if !set.contains(&f) {
126                set.push(f);
127            }
128        }
129        Ok(Feature::ALL
130            .iter()
131            .filter(|f| set.contains(f))
132            .map(|f| f.as_str().to_string())
133            .collect())
134    }
135
136    /// Every feature, in catalog order. Drives the SPA's account editor
137    /// checkbox list and lets the backend validate a submitted allow-list
138    /// against the full known set.
139    pub const ALL: [Feature; 23] = [
140        Feature::Dashboard,
141        Feature::Run,
142        Feature::Exec,
143        Feature::Agents,
144        Feature::Inventory,
145        Feature::Compliance,
146        Feature::Activity,
147        Feature::Events,
148        Feature::Audit,
149        Feature::Logs,
150        Feature::Collect,
151        Feature::Analytics,
152        Feature::Jobs,
153        Feature::Schedules,
154        Feature::Views,
155        Feature::Notifications,
156        Feature::Rollout,
157        Feature::Apps,
158        Feature::Groups,
159        Feature::Config,
160        Feature::Jetstream,
161        Feature::Accounts,
162        Feature::Settings,
163    ];
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    #[test]
171    fn key_roundtrips_for_every_variant() {
172        for f in Feature::ALL {
173            assert_eq!(Feature::parse(f.as_str()), Some(f), "roundtrip {f:?}");
174        }
175    }
176
177    #[test]
178    fn all_covers_the_enum() {
179        // A missing entry in ALL would silently drop a feature from
180        // validation + the SPA editor; assert the count matches the array
181        // length so adding a variant without updating ALL fails to compile
182        // (length mismatch) or fails here.
183        assert_eq!(Feature::ALL.len(), 23);
184        // No duplicate keys.
185        let mut keys: Vec<&str> = Feature::ALL.iter().map(|f| f.as_str()).collect();
186        keys.sort_unstable();
187        keys.dedup();
188        assert_eq!(keys.len(), Feature::ALL.len(), "duplicate feature key");
189    }
190
191    #[test]
192    fn serde_uses_lowercase_key() {
193        assert_eq!(
194            serde_json::to_string(&Feature::Compliance).unwrap(),
195            "\"compliance\""
196        );
197        assert_eq!(
198            serde_json::from_str::<Feature>("\"jetstream\"").unwrap(),
199            Feature::Jetstream
200        );
201    }
202
203    #[test]
204    fn unknown_key_is_none() {
205        assert_eq!(Feature::parse("nope"), None);
206        assert_eq!(Feature::parse("Compliance"), None); // case-sensitive
207    }
208
209    #[test]
210    fn canonicalize_validates_dedupes_orders() {
211        // Unknown key → Err(the offending key).
212        assert_eq!(
213            Feature::canonicalize(&["bogus".into()]),
214            Err("bogus".to_string())
215        );
216        // Dedup + catalog order (Inventory precedes Compliance in ALL).
217        assert_eq!(
218            Feature::canonicalize(&["compliance".into(), "inventory".into(), "compliance".into()]),
219            Ok(vec!["inventory".to_string(), "compliance".to_string()])
220        );
221        // Empty is valid.
222        assert_eq!(Feature::canonicalize(&[]), Ok(vec![]));
223    }
224}