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}