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