]>
Commit | Line | Data |
---|---|---|
a7a5406c | 1 | use std::collections::{BTreeMap, BTreeSet, HashMap}; |
5c6cdf98 | 2 | use std::io::Write; |
a7a5406c | 3 | use std::path::{Path, PathBuf}; |
bc0d0388 | 4 | use std::str::FromStr; |
a7a5406c | 5 | use std::sync::{Arc, RwLock}; |
5c6cdf98 | 6 | |
f7d4e4b5 | 7 | use anyhow::{bail, Error}; |
5c6cdf98 DM |
8 | |
9 | use lazy_static::lazy_static; | |
10 | ||
bc0d0388 DM |
11 | use ::serde::{Deserialize, Serialize}; |
12 | use serde::de::{value, IntoDeserializer}; | |
13 | ||
bc0d0388 | 14 | use proxmox::api::{api, schema::*}; |
a7a5406c FG |
15 | use proxmox::constnamedbitmap; |
16 | use proxmox::tools::{fs::replace_file, fs::CreateOptions}; | |
5c6cdf98 | 17 | |
a7a5406c | 18 | use crate::api2::types::{Authid, Userid}; |
e7cb4dc5 | 19 | |
5c6cdf98 DM |
20 | // define Privilege bitfield |
21 | ||
fddc8aa4 | 22 | constnamedbitmap! { |
23dc68fd FG |
23 | /// Contains a list of privilege name to privilege value mappings. |
24 | /// | |
25 | /// The names are used when displaying/persisting privileges anywhere, the values are used to | |
26 | /// allow easy matching of privileges as bitflags. | |
1ad9dd08 | 27 | PRIVILEGES: u64 => { |
23dc68fd | 28 | /// Sys.Audit allows knowing about the system and its status |
fddc8aa4 | 29 | PRIV_SYS_AUDIT("Sys.Audit"); |
23dc68fd | 30 | /// Sys.Modify allows modifying system-level configuration |
fddc8aa4 | 31 | PRIV_SYS_MODIFY("Sys.Modify"); |
23dc68fd | 32 | /// Sys.Modify allows to poweroff/reboot/.. the system |
fddc8aa4 | 33 | PRIV_SYS_POWER_MANAGEMENT("Sys.PowerManagement"); |
5c6cdf98 | 34 | |
e4e28018 FG |
35 | /// Datastore.Audit allows knowing about a datastore, |
36 | /// including reading the configuration entry and listing its contents | |
fddc8aa4 | 37 | PRIV_DATASTORE_AUDIT("Datastore.Audit"); |
e4e28018 | 38 | /// Datastore.Allocate allows creating or deleting datastores |
41bfd249 | 39 | PRIV_DATASTORE_ALLOCATE("Datastore.Allocate"); |
e4e28018 | 40 | /// Datastore.Modify allows modifying a datastore and its contents |
fddc8aa4 | 41 | PRIV_DATASTORE_MODIFY("Datastore.Modify"); |
e4e28018 | 42 | /// Datastore.Read allows reading arbitrary backup contents |
fddc8aa4 | 43 | PRIV_DATASTORE_READ("Datastore.Read"); |
e4e28018 | 44 | /// Allows verifying a datastore |
09f6a240 | 45 | PRIV_DATASTORE_VERIFY("Datastore.Verify"); |
54552dda | 46 | |
e4e28018 FG |
47 | /// Datastore.Backup allows Datastore.Read|Verify and creating new snapshots, |
48 | /// but also requires backup ownership | |
fddc8aa4 | 49 | PRIV_DATASTORE_BACKUP("Datastore.Backup"); |
e4e28018 FG |
50 | /// Datastore.Prune allows deleting snapshots, |
51 | /// but also requires backup ownership | |
fddc8aa4 | 52 | PRIV_DATASTORE_PRUNE("Datastore.Prune"); |
5c6cdf98 | 53 | |
23dc68fd | 54 | /// Permissions.Modify allows modifying ACLs |
fddc8aa4 | 55 | PRIV_PERMISSIONS_MODIFY("Permissions.Modify"); |
1ad9dd08 | 56 | |
23dc68fd | 57 | /// Remote.Audit allows reading remote.cfg and sync.cfg entries |
fddc8aa4 | 58 | PRIV_REMOTE_AUDIT("Remote.Audit"); |
23dc68fd | 59 | /// Remote.Modify allows modifying remote.cfg |
fddc8aa4 | 60 | PRIV_REMOTE_MODIFY("Remote.Modify"); |
23dc68fd | 61 | /// Remote.Read allows reading data from a configured `Remote` |
fddc8aa4 | 62 | PRIV_REMOTE_READ("Remote.Read"); |
1c2f842a | 63 | |
23dc68fd | 64 | /// Sys.Console allows access to the system's console |
fddc8aa4 | 65 | PRIV_SYS_CONSOLE("Sys.Console"); |
1ad9dd08 DC |
66 | } |
67 | } | |
4f66423f | 68 | |
05be0984 TL |
69 | /// Admin always has all privileges. It can do everything except a few actions |
70 | /// which are limited to the 'root@pam` superuser | |
0815ec7e | 71 | pub const ROLE_ADMIN: u64 = std::u64::MAX; |
05be0984 | 72 | |
23dc68fd | 73 | /// NoAccess can be used to remove privileges from specific (sub-)paths |
0815ec7e DM |
74 | pub const ROLE_NO_ACCESS: u64 = 0; |
75 | ||
4f727a78 | 76 | #[rustfmt::skip] |
23dc68fd | 77 | /// Audit can view configuration and status information, but not modify it. |
4f727a78 FG |
78 | pub const ROLE_AUDIT: u64 = 0 |
79 | | PRIV_SYS_AUDIT | |
80 | | PRIV_DATASTORE_AUDIT; | |
5c6cdf98 | 81 | |
4f727a78 | 82 | #[rustfmt::skip] |
6f6aa95a | 83 | /// Datastore.Admin can do anything on the datastore. |
4f727a78 FG |
84 | pub const ROLE_DATASTORE_ADMIN: u64 = 0 |
85 | | PRIV_DATASTORE_AUDIT | |
86 | | PRIV_DATASTORE_MODIFY | |
87 | | PRIV_DATASTORE_READ | |
88 | | PRIV_DATASTORE_VERIFY | |
89 | | PRIV_DATASTORE_BACKUP | |
90 | | PRIV_DATASTORE_PRUNE; | |
91 | ||
92 | #[rustfmt::skip] | |
09f6a240 | 93 | /// Datastore.Reader can read/verify datastore content and do restore |
4f727a78 FG |
94 | pub const ROLE_DATASTORE_READER: u64 = 0 |
95 | | PRIV_DATASTORE_AUDIT | |
96 | | PRIV_DATASTORE_VERIFY | |
97 | | PRIV_DATASTORE_READ; | |
6f6aa95a | 98 | |
4f727a78 | 99 | #[rustfmt::skip] |
6f6aa95a | 100 | /// Datastore.Backup can do backup and restore, but no prune. |
4f727a78 FG |
101 | pub const ROLE_DATASTORE_BACKUP: u64 = 0 |
102 | | PRIV_DATASTORE_BACKUP; | |
6f6aa95a | 103 | |
4f727a78 | 104 | #[rustfmt::skip] |
6f6aa95a | 105 | /// Datastore.PowerUser can do backup, restore, and prune. |
4f727a78 FG |
106 | pub const ROLE_DATASTORE_POWERUSER: u64 = 0 |
107 | | PRIV_DATASTORE_PRUNE | |
108 | | PRIV_DATASTORE_BACKUP; | |
d00e1a21 | 109 | |
4f727a78 | 110 | #[rustfmt::skip] |
6f6aa95a | 111 | /// Datastore.Audit can audit the datastore. |
4f727a78 FG |
112 | pub const ROLE_DATASTORE_AUDIT: u64 = 0 |
113 | | PRIV_DATASTORE_AUDIT; | |
9765092e | 114 | |
4f727a78 | 115 | #[rustfmt::skip] |
8247db5b | 116 | /// Remote.Audit can audit the remote |
4f727a78 FG |
117 | pub const ROLE_REMOTE_AUDIT: u64 = 0 |
118 | | PRIV_REMOTE_AUDIT; | |
8247db5b | 119 | |
4f727a78 | 120 | #[rustfmt::skip] |
8247db5b | 121 | /// Remote.Admin can do anything on the remote. |
4f727a78 FG |
122 | pub const ROLE_REMOTE_ADMIN: u64 = 0 |
123 | | PRIV_REMOTE_AUDIT | |
124 | | PRIV_REMOTE_MODIFY | |
125 | | PRIV_REMOTE_READ; | |
8247db5b | 126 | |
4f727a78 | 127 | #[rustfmt::skip] |
8247db5b | 128 | /// Remote.SyncOperator can do read and prune on the remote. |
4f727a78 FG |
129 | pub const ROLE_REMOTE_SYNC_OPERATOR: u64 = 0 |
130 | | PRIV_REMOTE_AUDIT | |
131 | | PRIV_REMOTE_READ; | |
8247db5b | 132 | |
23dc68fd | 133 | /// NoAccess can be used to remove privileges from specific (sub-)paths |
4f727a78 | 134 | pub const ROLE_NAME_NO_ACCESS: &str = "NoAccess"; |
5c6cdf98 | 135 | |
bc0d0388 DM |
136 | #[api()] |
137 | #[repr(u64)] | |
138 | #[derive(Serialize, Deserialize)] | |
23dc68fd FG |
139 | /// Enum representing roles via their [PRIVILEGES] combination. |
140 | /// | |
141 | /// Since privileges are implemented as bitflags, each unique combination of privileges maps to a | |
142 | /// single, unique `u64` value that is used in this enum definition. | |
bc0d0388 DM |
143 | pub enum Role { |
144 | /// Administrator | |
145 | Admin = ROLE_ADMIN, | |
146 | /// Auditor | |
147 | Audit = ROLE_AUDIT, | |
148 | /// Disable Access | |
149 | NoAccess = ROLE_NO_ACCESS, | |
150 | /// Datastore Administrator | |
151 | DatastoreAdmin = ROLE_DATASTORE_ADMIN, | |
152 | /// Datastore Reader (inspect datastore content and do restores) | |
153 | DatastoreReader = ROLE_DATASTORE_READER, | |
154 | /// Datastore Backup (backup and restore owned backups) | |
155 | DatastoreBackup = ROLE_DATASTORE_BACKUP, | |
156 | /// Datastore PowerUser (backup, restore and prune owned backup) | |
157 | DatastorePowerUser = ROLE_DATASTORE_POWERUSER, | |
158 | /// Datastore Auditor | |
159 | DatastoreAudit = ROLE_DATASTORE_AUDIT, | |
160 | /// Remote Auditor | |
161 | RemoteAudit = ROLE_REMOTE_AUDIT, | |
162 | /// Remote Administrator | |
163 | RemoteAdmin = ROLE_REMOTE_ADMIN, | |
164 | /// Syncronisation Opertator | |
165 | RemoteSyncOperator = ROLE_REMOTE_SYNC_OPERATOR, | |
166 | } | |
167 | ||
168 | impl FromStr for Role { | |
169 | type Err = value::Error; | |
170 | ||
171 | fn from_str(s: &str) -> Result<Self, Self::Err> { | |
172 | Self::deserialize(s.into_deserializer()) | |
173 | } | |
174 | } | |
175 | ||
5c6cdf98 | 176 | lazy_static! { |
23dc68fd FG |
177 | /// Map of pre-defined [Roles](Role) to their associated [privileges](PRIVILEGES) combination and |
178 | /// description. | |
3fff55b2 | 179 | pub static ref ROLE_NAMES: HashMap<&'static str, (u64, &'static str)> = { |
5c6cdf98 DM |
180 | let mut map = HashMap::new(); |
181 | ||
bc0d0388 DM |
182 | let list = match Role::API_SCHEMA { |
183 | Schema::String(StringSchema { format: Some(ApiStringFormat::Enum(list)), .. }) => list, | |
184 | _ => unreachable!(), | |
185 | }; | |
186 | ||
187 | for entry in list.iter() { | |
188 | let privs: u64 = Role::from_str(entry.value).unwrap() as u64; | |
189 | map.insert(entry.value, (privs, entry.description)); | |
190 | } | |
8247db5b | 191 | |
5c6cdf98 DM |
192 | map |
193 | }; | |
194 | } | |
195 | ||
23dc68fd | 196 | pub(crate) fn split_acl_path(path: &str) -> Vec<&str> { |
5c6cdf98 DM |
197 | let items = path.split('/'); |
198 | ||
199 | let mut components = vec![]; | |
200 | ||
201 | for name in items { | |
a7a5406c FG |
202 | if name.is_empty() { |
203 | continue; | |
204 | } | |
5c6cdf98 DM |
205 | components.push(name); |
206 | } | |
207 | ||
208 | components | |
209 | } | |
210 | ||
23dc68fd FG |
211 | /// Check whether a given ACL `path` conforms to the expected schema. |
212 | /// | |
213 | /// Currently this just checks for the number of components for various sub-trees. | |
74c08a57 | 214 | pub fn check_acl_path(path: &str) -> Result<(), Error> { |
74c08a57 DM |
215 | let components = split_acl_path(path); |
216 | ||
217 | let components_len = components.len(); | |
218 | ||
a7a5406c FG |
219 | if components_len == 0 { |
220 | return Ok(()); | |
221 | } | |
74c08a57 DM |
222 | match components[0] { |
223 | "access" => { | |
a7a5406c FG |
224 | if components_len == 1 { |
225 | return Ok(()); | |
226 | } | |
74c08a57 DM |
227 | match components[1] { |
228 | "acl" | "users" => { | |
a7a5406c FG |
229 | if components_len == 2 { |
230 | return Ok(()); | |
231 | } | |
74c08a57 | 232 | } |
a7a5406c | 233 | _ => {} |
74c08a57 DM |
234 | } |
235 | } | |
a7a5406c FG |
236 | "datastore" => { |
237 | // /datastore/{store} | |
238 | if components_len <= 2 { | |
239 | return Ok(()); | |
240 | } | |
74c08a57 | 241 | } |
a7a5406c FG |
242 | "remote" => { |
243 | // /remote/{remote}/{store} | |
244 | if components_len <= 3 { | |
245 | return Ok(()); | |
246 | } | |
74c08a57 DM |
247 | } |
248 | "system" => { | |
a7a5406c FG |
249 | if components_len == 1 { |
250 | return Ok(()); | |
251 | } | |
74c08a57 | 252 | match components[1] { |
fa2bdc13 | 253 | "disks" | "log" | "status" | "tasks" | "time" => { |
a7a5406c FG |
254 | if components_len == 2 { |
255 | return Ok(()); | |
256 | } | |
74c08a57 | 257 | } |
a7a5406c FG |
258 | "services" => { |
259 | // /system/services/{service} | |
260 | if components_len <= 3 { | |
261 | return Ok(()); | |
262 | } | |
74c08a57 DM |
263 | } |
264 | "network" => { | |
a7a5406c FG |
265 | if components_len == 2 { |
266 | return Ok(()); | |
267 | } | |
74c08a57 DM |
268 | match components[2] { |
269 | "dns" => { | |
a7a5406c FG |
270 | if components_len == 3 { |
271 | return Ok(()); | |
272 | } | |
74c08a57 | 273 | } |
a7a5406c FG |
274 | "interfaces" => { |
275 | // /system/network/interfaces/{iface} | |
276 | if components_len <= 4 { | |
277 | return Ok(()); | |
278 | } | |
74c08a57 DM |
279 | } |
280 | _ => {} | |
281 | } | |
282 | } | |
283 | _ => {} | |
284 | } | |
285 | } | |
286 | _ => {} | |
287 | } | |
288 | ||
289 | bail!("invalid acl path '{}'.", path); | |
290 | } | |
291 | ||
23dc68fd | 292 | /// Tree representing a parsed acl.cfg |
5c6cdf98 | 293 | pub struct AclTree { |
23dc68fd FG |
294 | /// Root node of the tree. |
295 | /// | |
296 | /// The rest of the tree is available via [find_node()](AclTree::find_node()) or an | |
297 | /// [AclTreeNode]'s [children](AclTreeNode::children) member. | |
ed3e60ae | 298 | pub root: AclTreeNode, |
5c6cdf98 DM |
299 | } |
300 | ||
23dc68fd | 301 | /// Node representing ACLs for a certain ACL path. |
ed3e60ae | 302 | pub struct AclTreeNode { |
23dc68fd FG |
303 | /// [User](crate::config::user::User) or |
304 | /// [Token](crate::config::user::ApiToken) ACLs for this node. | |
e6dc35ac | 305 | pub users: HashMap<Authid, HashMap<String, bool>>, |
23dc68fd | 306 | /// `Group` ACLs for this node (not yet implemented) |
ed3e60ae | 307 | pub groups: HashMap<String, HashMap<String, bool>>, |
23dc68fd | 308 | /// `AclTreeNodes` representing ACL paths directly below the current one. |
ed3e60ae | 309 | pub children: BTreeMap<String, AclTreeNode>, |
5c6cdf98 DM |
310 | } |
311 | ||
312 | impl AclTreeNode { | |
23dc68fd | 313 | /// Creates a new, empty AclTreeNode. |
5c6cdf98 DM |
314 | pub fn new() -> Self { |
315 | Self { | |
316 | users: HashMap::new(), | |
317 | groups: HashMap::new(), | |
a83eab3c | 318 | children: BTreeMap::new(), |
5c6cdf98 DM |
319 | } |
320 | } | |
321 | ||
23dc68fd FG |
322 | /// Returns applicable [Role] and their propagation status for a given |
323 | /// [Authid](crate::api2::types::Authid). | |
324 | /// | |
325 | /// If the `Authid` is a [User](crate::config::user::User) that has no specific `Roles` configured on this node, | |
326 | /// applicable `Group` roles will be returned instead. | |
327 | /// | |
328 | /// If `leaf` is `false`, only those roles where the propagate flag in the ACL is set to `true` | |
329 | /// are returned. Otherwise, all roles will be returned. | |
330 | pub fn extract_roles(&self, auth_id: &Authid, leaf: bool) -> HashMap<String, bool> { | |
331 | let user_roles = self.extract_user_roles(auth_id, leaf); | |
babab85b | 332 | if !user_roles.is_empty() || auth_id.is_token() { |
0815ec7e | 333 | // user privs always override group privs |
a7a5406c | 334 | return user_roles; |
0815ec7e DM |
335 | }; |
336 | ||
23dc68fd | 337 | self.extract_group_roles(auth_id.user(), leaf) |
0815ec7e DM |
338 | } |
339 | ||
23dc68fd | 340 | fn extract_user_roles(&self, auth_id: &Authid, leaf: bool) -> HashMap<String, bool> { |
babab85b | 341 | let mut map = HashMap::new(); |
0815ec7e | 342 | |
e6dc35ac | 343 | let roles = match self.users.get(auth_id) { |
0815ec7e | 344 | Some(m) => m, |
babab85b | 345 | None => return map, |
0815ec7e DM |
346 | }; |
347 | ||
348 | for (role, propagate) in roles { | |
23dc68fd | 349 | if *propagate || leaf { |
8d048af2 | 350 | if role == ROLE_NAME_NO_ACCESS { |
babab85b FG |
351 | // return a map with a single role 'NoAccess' |
352 | let mut map = HashMap::new(); | |
353 | map.insert(role.to_string(), false); | |
354 | return map; | |
0815ec7e | 355 | } |
babab85b | 356 | map.insert(role.to_string(), *propagate); |
0815ec7e DM |
357 | } |
358 | } | |
359 | ||
babab85b | 360 | map |
0815ec7e DM |
361 | } |
362 | ||
23dc68fd | 363 | fn extract_group_roles(&self, _user: &Userid, leaf: bool) -> HashMap<String, bool> { |
babab85b | 364 | let mut map = HashMap::new(); |
0815ec7e DM |
365 | |
366 | for (_group, roles) in &self.groups { | |
367 | let is_member = false; // fixme: check if user is member of the group | |
a7a5406c FG |
368 | if !is_member { |
369 | continue; | |
370 | } | |
0815ec7e DM |
371 | |
372 | for (role, propagate) in roles { | |
23dc68fd | 373 | if *propagate || leaf { |
8d048af2 | 374 | if role == ROLE_NAME_NO_ACCESS { |
babab85b FG |
375 | // return a map with a single role 'NoAccess' |
376 | let mut map = HashMap::new(); | |
377 | map.insert(role.to_string(), false); | |
378 | return map; | |
0815ec7e | 379 | } |
babab85b | 380 | map.insert(role.to_string(), *propagate); |
0815ec7e DM |
381 | } |
382 | } | |
383 | } | |
384 | ||
babab85b | 385 | map |
0815ec7e DM |
386 | } |
387 | ||
23dc68fd | 388 | fn delete_group_role(&mut self, group: &str, role: &str) { |
9765092e DM |
389 | let roles = match self.groups.get_mut(group) { |
390 | Some(r) => r, | |
391 | None => return, | |
392 | }; | |
393 | roles.remove(role); | |
394 | } | |
395 | ||
23dc68fd | 396 | fn delete_user_role(&mut self, auth_id: &Authid, role: &str) { |
e6dc35ac | 397 | let roles = match self.users.get_mut(auth_id) { |
9765092e DM |
398 | Some(r) => r, |
399 | None => return, | |
400 | }; | |
401 | roles.remove(role); | |
402 | } | |
403 | ||
23dc68fd | 404 | fn insert_group_role(&mut self, group: String, role: String, propagate: bool) { |
22a9189e | 405 | let map = self.groups.entry(group).or_insert_with(HashMap::new); |
8d048af2 DM |
406 | if role == ROLE_NAME_NO_ACCESS { |
407 | map.clear(); | |
408 | map.insert(role, propagate); | |
409 | } else { | |
410 | map.remove(ROLE_NAME_NO_ACCESS); | |
411 | map.insert(role, propagate); | |
412 | } | |
5c6cdf98 DM |
413 | } |
414 | ||
23dc68fd | 415 | fn insert_user_role(&mut self, auth_id: Authid, role: String, propagate: bool) { |
22a9189e | 416 | let map = self.users.entry(auth_id).or_insert_with(HashMap::new); |
8d048af2 DM |
417 | if role == ROLE_NAME_NO_ACCESS { |
418 | map.clear(); | |
419 | map.insert(role, propagate); | |
420 | } else { | |
421 | map.remove(ROLE_NAME_NO_ACCESS); | |
422 | map.insert(role, propagate); | |
423 | } | |
5c6cdf98 DM |
424 | } |
425 | } | |
426 | ||
427 | impl AclTree { | |
23dc68fd | 428 | /// Create a new, empty ACL tree with a single, empty root [node](AclTreeNode) |
5c6cdf98 | 429 | pub fn new() -> Self { |
babab85b FG |
430 | Self { |
431 | root: AclTreeNode::new(), | |
432 | } | |
5c6cdf98 DM |
433 | } |
434 | ||
23dc68fd | 435 | /// Iterates over the tree looking for a node matching `path`. |
2882c881 DC |
436 | pub fn find_node(&mut self, path: &str) -> Option<&mut AclTreeNode> { |
437 | let path = split_acl_path(path); | |
438 | return self.get_node(&path); | |
439 | } | |
440 | ||
9765092e DM |
441 | fn get_node(&mut self, path: &[&str]) -> Option<&mut AclTreeNode> { |
442 | let mut node = &mut self.root; | |
443 | for comp in path { | |
444 | node = match node.children.get_mut(*comp) { | |
445 | Some(n) => n, | |
446 | None => return None, | |
447 | }; | |
448 | } | |
449 | Some(node) | |
450 | } | |
451 | ||
5c6cdf98 DM |
452 | fn get_or_insert_node(&mut self, path: &[&str]) -> &mut AclTreeNode { |
453 | let mut node = &mut self.root; | |
454 | for comp in path { | |
a7a5406c FG |
455 | node = node |
456 | .children | |
457 | .entry(String::from(*comp)) | |
22a9189e | 458 | .or_insert_with(AclTreeNode::new); |
5c6cdf98 DM |
459 | } |
460 | node | |
461 | } | |
462 | ||
23dc68fd FG |
463 | /// Deletes the specified `role` from the `group`'s ACL on `path`. |
464 | /// | |
465 | /// Never fails, even if the `path` has no ACLs configured, or the `group`/`role` combination | |
466 | /// does not exist on `path`. | |
9765092e DM |
467 | pub fn delete_group_role(&mut self, path: &str, group: &str, role: &str) { |
468 | let path = split_acl_path(path); | |
469 | let node = match self.get_node(&path) { | |
470 | Some(n) => n, | |
471 | None => return, | |
472 | }; | |
473 | node.delete_group_role(group, role); | |
474 | } | |
475 | ||
23dc68fd FG |
476 | /// Deletes the specified `role` from the `user`'s ACL on `path`. |
477 | /// | |
478 | /// Never fails, even if the `path` has no ACLs configured, or the `user`/`role` combination | |
479 | /// does not exist on `path`. | |
e6dc35ac | 480 | pub fn delete_user_role(&mut self, path: &str, auth_id: &Authid, role: &str) { |
9765092e DM |
481 | let path = split_acl_path(path); |
482 | let node = match self.get_node(&path) { | |
483 | Some(n) => n, | |
484 | None => return, | |
485 | }; | |
e6dc35ac | 486 | node.delete_user_role(auth_id, role); |
9765092e DM |
487 | } |
488 | ||
23dc68fd FG |
489 | /// Inserts the specified `role` into the `group` ACL on `path`. |
490 | /// | |
491 | /// The [AclTreeNode] representing `path` will be created and inserted into the tree if | |
492 | /// necessary. | |
5c6cdf98 DM |
493 | pub fn insert_group_role(&mut self, path: &str, group: &str, role: &str, propagate: bool) { |
494 | let path = split_acl_path(path); | |
495 | let node = self.get_or_insert_node(&path); | |
496 | node.insert_group_role(group.to_string(), role.to_string(), propagate); | |
497 | } | |
498 | ||
23dc68fd FG |
499 | /// Inserts the specified `role` into the `user` ACL on `path`. |
500 | /// | |
501 | /// The [AclTreeNode] representing `path` will be created and inserted into the tree if | |
502 | /// necessary. | |
e6dc35ac | 503 | pub fn insert_user_role(&mut self, path: &str, auth_id: &Authid, role: &str, propagate: bool) { |
5c6cdf98 DM |
504 | let path = split_acl_path(path); |
505 | let node = self.get_or_insert_node(&path); | |
e6dc35ac | 506 | node.insert_user_role(auth_id.to_owned(), role.to_string(), propagate); |
5c6cdf98 DM |
507 | } |
508 | ||
a7a5406c | 509 | fn write_node_config(node: &AclTreeNode, path: &str, w: &mut dyn Write) -> Result<(), Error> { |
5c6cdf98 DM |
510 | let mut role_ug_map0 = HashMap::new(); |
511 | let mut role_ug_map1 = HashMap::new(); | |
512 | ||
e6dc35ac | 513 | for (auth_id, roles) in &node.users { |
5c6cdf98 | 514 | // no need to save, because root is always 'Administrator' |
a7a5406c FG |
515 | if !auth_id.is_token() && auth_id.user() == "root@pam" { |
516 | continue; | |
517 | } | |
5c6cdf98 DM |
518 | for (role, propagate) in roles { |
519 | let role = role.as_str(); | |
e6dc35ac | 520 | let auth_id = auth_id.to_string(); |
5c6cdf98 | 521 | if *propagate { |
a7a5406c FG |
522 | role_ug_map1 |
523 | .entry(role) | |
22a9189e | 524 | .or_insert_with(BTreeSet::new) |
e6dc35ac | 525 | .insert(auth_id); |
5c6cdf98 | 526 | } else { |
a7a5406c FG |
527 | role_ug_map0 |
528 | .entry(role) | |
22a9189e | 529 | .or_insert_with(BTreeSet::new) |
e6dc35ac | 530 | .insert(auth_id); |
5c6cdf98 DM |
531 | } |
532 | } | |
533 | } | |
534 | ||
535 | for (group, roles) in &node.groups { | |
536 | for (role, propagate) in roles { | |
537 | let group = format!("@{}", group); | |
538 | if *propagate { | |
a7a5406c FG |
539 | role_ug_map1 |
540 | .entry(role) | |
22a9189e | 541 | .or_insert_with(BTreeSet::new) |
5c6cdf98 DM |
542 | .insert(group); |
543 | } else { | |
a7a5406c FG |
544 | role_ug_map0 |
545 | .entry(role) | |
22a9189e | 546 | .or_insert_with(BTreeSet::new) |
5c6cdf98 DM |
547 | .insert(group); |
548 | } | |
549 | } | |
550 | } | |
551 | ||
552 | fn group_by_property_list( | |
a83eab3c DM |
553 | item_property_map: &HashMap<&str, BTreeSet<String>>, |
554 | ) -> BTreeMap<String, BTreeSet<String>> { | |
555 | let mut result_map = BTreeMap::new(); | |
5c6cdf98 | 556 | for (item, property_map) in item_property_map { |
a83eab3c | 557 | let item_list = property_map.iter().fold(String::new(), |mut acc, v| { |
a7a5406c FG |
558 | if !acc.is_empty() { |
559 | acc.push(','); | |
560 | } | |
a83eab3c DM |
561 | acc.push_str(v); |
562 | acc | |
563 | }); | |
a7a5406c FG |
564 | result_map |
565 | .entry(item_list) | |
22a9189e | 566 | .or_insert_with(BTreeSet::new) |
5c6cdf98 DM |
567 | .insert(item.to_string()); |
568 | } | |
569 | result_map | |
570 | } | |
571 | ||
a83eab3c DM |
572 | let uglist_role_map0 = group_by_property_list(&role_ug_map0); |
573 | let uglist_role_map1 = group_by_property_list(&role_ug_map1); | |
5c6cdf98 | 574 | |
8d048af2 | 575 | fn role_list(roles: &BTreeSet<String>) -> String { |
a7a5406c FG |
576 | if roles.contains(ROLE_NAME_NO_ACCESS) { |
577 | return String::from(ROLE_NAME_NO_ACCESS); | |
578 | } | |
8d048af2 | 579 | roles.iter().fold(String::new(), |mut acc, v| { |
a7a5406c FG |
580 | if !acc.is_empty() { |
581 | acc.push(','); | |
582 | } | |
a83eab3c DM |
583 | acc.push_str(v); |
584 | acc | |
8d048af2 | 585 | }) |
5c6cdf98 DM |
586 | } |
587 | ||
8d048af2 DM |
588 | for (uglist, roles) in &uglist_role_map0 { |
589 | let role_list = role_list(roles); | |
a7a5406c FG |
590 | writeln!( |
591 | w, | |
592 | "acl:0:{}:{}:{}", | |
593 | if path.is_empty() { "/" } else { path }, | |
594 | uglist, | |
595 | role_list | |
596 | )?; | |
8d048af2 DM |
597 | } |
598 | ||
599 | for (uglist, roles) in &uglist_role_map1 { | |
600 | let role_list = role_list(roles); | |
a7a5406c FG |
601 | writeln!( |
602 | w, | |
603 | "acl:1:{}:{}:{}", | |
604 | if path.is_empty() { "/" } else { path }, | |
605 | uglist, | |
606 | role_list | |
607 | )?; | |
5c6cdf98 DM |
608 | } |
609 | ||
a83eab3c | 610 | for (name, child) in node.children.iter() { |
5c6cdf98 DM |
611 | let child_path = format!("{}/{}", path, name); |
612 | Self::write_node_config(child, &child_path, w)?; | |
613 | } | |
614 | ||
615 | Ok(()) | |
616 | } | |
617 | ||
23dc68fd | 618 | fn write_config(&self, w: &mut dyn Write) -> Result<(), Error> { |
5c6cdf98 DM |
619 | Self::write_node_config(&self.root, "", w) |
620 | } | |
621 | ||
622 | fn parse_acl_line(&mut self, line: &str) -> Result<(), Error> { | |
5c6cdf98 DM |
623 | let items: Vec<&str> = line.split(':').collect(); |
624 | ||
625 | if items.len() != 5 { | |
626 | bail!("wrong number of items."); | |
627 | } | |
628 | ||
629 | if items[0] != "acl" { | |
630 | bail!("line does not start with 'acl'."); | |
631 | } | |
632 | ||
633 | let propagate = if items[1] == "0" { | |
634 | false | |
635 | } else if items[1] == "1" { | |
636 | true | |
637 | } else { | |
638 | bail!("expected '0' or '1' for propagate flag."); | |
639 | }; | |
640 | ||
babab85b FG |
641 | let path_str = items[2]; |
642 | let path = split_acl_path(path_str); | |
5c6cdf98 DM |
643 | let node = self.get_or_insert_node(&path); |
644 | ||
645 | let uglist: Vec<&str> = items[3].split(',').map(|v| v.trim()).collect(); | |
646 | ||
647 | let rolelist: Vec<&str> = items[4].split(',').map(|v| v.trim()).collect(); | |
648 | ||
649 | for user_or_group in &uglist { | |
650 | for role in &rolelist { | |
651 | if !ROLE_NAMES.contains_key(role) { | |
652 | bail!("unknown role '{}'", role); | |
653 | } | |
654 | if user_or_group.starts_with('@') { | |
655 | let group = &user_or_group[1..]; | |
656 | node.insert_group_role(group.to_string(), role.to_string(), propagate); | |
657 | } else { | |
e7cb4dc5 | 658 | node.insert_user_role(user_or_group.parse()?, role.to_string(), propagate); |
5c6cdf98 DM |
659 | } |
660 | } | |
661 | } | |
662 | ||
663 | Ok(()) | |
664 | } | |
665 | ||
a7a5406c | 666 | fn load(filename: &Path) -> Result<(Self, [u8; 32]), Error> { |
5c6cdf98 DM |
667 | let mut tree = Self::new(); |
668 | ||
669 | let raw = match std::fs::read_to_string(filename) { | |
670 | Ok(v) => v, | |
671 | Err(err) => { | |
672 | if err.kind() == std::io::ErrorKind::NotFound { | |
673 | String::new() | |
674 | } else { | |
675 | bail!("unable to read acl config {:?} - {}", filename, err); | |
676 | } | |
677 | } | |
678 | }; | |
679 | ||
680 | let digest = openssl::sha::sha256(raw.as_bytes()); | |
681 | ||
682 | for (linenr, line) in raw.lines().enumerate() { | |
0815ec7e | 683 | let line = line.trim(); |
a7a5406c FG |
684 | if line.is_empty() { |
685 | continue; | |
686 | } | |
5c6cdf98 | 687 | if let Err(err) = tree.parse_acl_line(line) { |
a7a5406c FG |
688 | bail!( |
689 | "unable to parse acl config {:?}, line {} - {}", | |
690 | filename, | |
691 | linenr + 1, | |
692 | err | |
693 | ); | |
5c6cdf98 DM |
694 | } |
695 | } | |
696 | ||
697 | Ok((tree, digest)) | |
698 | } | |
0815ec7e | 699 | |
23dc68fd FG |
700 | #[cfg(test)] |
701 | pub(crate) fn from_raw(raw: &str) -> Result<Self, Error> { | |
0815ec7e DM |
702 | let mut tree = Self::new(); |
703 | for (linenr, line) in raw.lines().enumerate() { | |
704 | let line = line.trim(); | |
a7a5406c FG |
705 | if line.is_empty() { |
706 | continue; | |
707 | } | |
0815ec7e | 708 | if let Err(err) = tree.parse_acl_line(line) { |
a7a5406c FG |
709 | bail!( |
710 | "unable to parse acl config data, line {} - {}", | |
711 | linenr + 1, | |
712 | err | |
713 | ); | |
0815ec7e DM |
714 | } |
715 | } | |
716 | Ok(tree) | |
717 | } | |
718 | ||
23dc68fd FG |
719 | /// Returns a map of role name and propagation status for a given `auth_id` and `path`. |
720 | /// | |
721 | /// This will collect role mappings according to the following algorithm: | |
722 | /// - iterate over all intermediate nodes along `path` and collect roles with `propagate` set | |
723 | /// - get all (propagating and non-propagating) roles for last component of path | |
724 | /// - more specific role maps replace less specific role maps | |
725 | /// -- user/token is more specific than group at each level | |
726 | /// -- roles lower in the tree are more specific than those higher up along the path | |
babab85b | 727 | pub fn roles(&self, auth_id: &Authid, path: &[&str]) -> HashMap<String, bool> { |
0815ec7e | 728 | let mut node = &self.root; |
babab85b | 729 | let mut role_map = node.extract_roles(auth_id, path.is_empty()); |
0815ec7e DM |
730 | |
731 | for (pos, comp) in path.iter().enumerate() { | |
732 | let last_comp = (pos + 1) == path.len(); | |
733 | node = match node.children.get(*comp) { | |
734 | Some(n) => n, | |
babab85b | 735 | None => return role_map, // path not found |
0815ec7e | 736 | }; |
babab85b FG |
737 | |
738 | let new_map = node.extract_roles(auth_id, last_comp); | |
739 | if !new_map.is_empty() { | |
740 | // overwrite previous maptings | |
741 | role_map = new_map; | |
0815ec7e DM |
742 | } |
743 | } | |
744 | ||
babab85b | 745 | role_map |
0815ec7e | 746 | } |
5c6cdf98 DM |
747 | } |
748 | ||
23dc68fd | 749 | /// Filename where [AclTree] is stored. |
5c6cdf98 | 750 | pub const ACL_CFG_FILENAME: &str = "/etc/proxmox-backup/acl.cfg"; |
23dc68fd | 751 | /// Path used to lock the [AclTree] when modifying. |
5c6cdf98 DM |
752 | pub const ACL_CFG_LOCKFILE: &str = "/etc/proxmox-backup/.acl.lck"; |
753 | ||
23dc68fd | 754 | /// Reads the [AclTree] from the [default path](ACL_CFG_FILENAME). |
5c6cdf98 DM |
755 | pub fn config() -> Result<(AclTree, [u8; 32]), Error> { |
756 | let path = PathBuf::from(ACL_CFG_FILENAME); | |
757 | AclTree::load(&path) | |
758 | } | |
759 | ||
23dc68fd FG |
760 | /// Returns a cached [AclTree] or fresh copy read directly from the [default path](ACL_CFG_FILENAME) |
761 | /// | |
762 | /// Since the AclTree is used for every API request's permission check, this caching mechanism | |
763 | /// allows to skip reading and parsing the file again if it is unchanged. | |
5354511f | 764 | pub fn cached_config() -> Result<Arc<AclTree>, Error> { |
5354511f DM |
765 | struct ConfigCache { |
766 | data: Option<Arc<AclTree>>, | |
767 | last_mtime: i64, | |
768 | last_mtime_nsec: i64, | |
769 | } | |
770 | ||
771 | lazy_static! { | |
a7a5406c FG |
772 | static ref CACHED_CONFIG: RwLock<ConfigCache> = RwLock::new(ConfigCache { |
773 | data: None, | |
774 | last_mtime: 0, | |
775 | last_mtime_nsec: 0 | |
776 | }); | |
5354511f DM |
777 | } |
778 | ||
b9f2f761 DM |
779 | let stat = match nix::sys::stat::stat(ACL_CFG_FILENAME) { |
780 | Ok(stat) => Some(stat), | |
781 | Err(nix::Error::Sys(nix::errno::Errno::ENOENT)) => None, | |
782 | Err(err) => bail!("unable to stat '{}' - {}", ACL_CFG_FILENAME, err), | |
783 | }; | |
5354511f | 784 | |
a7a5406c FG |
785 | { |
786 | // limit scope | |
5354511f | 787 | let cache = CACHED_CONFIG.read().unwrap(); |
bd88dc41 DM |
788 | if let Some(ref config) = cache.data { |
789 | if let Some(stat) = stat { | |
a7a5406c FG |
790 | if stat.st_mtime == cache.last_mtime && stat.st_mtime_nsec == cache.last_mtime_nsec |
791 | { | |
bd88dc41 DM |
792 | return Ok(config.clone()); |
793 | } | |
794 | } else if cache.last_mtime == 0 && cache.last_mtime_nsec == 0 { | |
5354511f DM |
795 | return Ok(config.clone()); |
796 | } | |
797 | } | |
798 | } | |
799 | ||
800 | let (config, _digest) = config()?; | |
801 | let config = Arc::new(config); | |
802 | ||
803 | let mut cache = CACHED_CONFIG.write().unwrap(); | |
b9f2f761 DM |
804 | if let Some(stat) = stat { |
805 | cache.last_mtime = stat.st_mtime; | |
806 | cache.last_mtime_nsec = stat.st_mtime_nsec; | |
807 | } | |
5354511f DM |
808 | cache.data = Some(config.clone()); |
809 | ||
810 | Ok(config) | |
811 | } | |
812 | ||
23dc68fd FG |
813 | /// Saves an [AclTree] to the [default path](ACL_CFG_FILENAME), ensuring proper ownership and |
814 | /// file permissions. | |
9765092e | 815 | pub fn save_config(acl: &AclTree) -> Result<(), Error> { |
5c6cdf98 DM |
816 | let mut raw: Vec<u8> = Vec::new(); |
817 | ||
818 | acl.write_config(&mut raw)?; | |
819 | ||
820 | let backup_user = crate::backup::backup_user()?; | |
821 | let mode = nix::sys::stat::Mode::from_bits_truncate(0o0640); | |
822 | // set the correct owner/group/permissions while saving file | |
823 | // owner(rw) = root, group(r)= backup | |
824 | let options = CreateOptions::new() | |
825 | .perm(mode) | |
826 | .owner(nix::unistd::ROOT) | |
827 | .group(backup_user.gid); | |
828 | ||
9765092e | 829 | replace_file(ACL_CFG_FILENAME, &raw, options)?; |
5c6cdf98 DM |
830 | |
831 | Ok(()) | |
832 | } | |
0815ec7e | 833 | |
0815ec7e DM |
834 | #[cfg(test)] |
835 | mod test { | |
0815ec7e | 836 | use super::AclTree; |
a7a5406c | 837 | use anyhow::Error; |
0815ec7e | 838 | |
e6dc35ac | 839 | use crate::api2::types::Authid; |
e7cb4dc5 | 840 | |
a7a5406c | 841 | fn check_roles(tree: &AclTree, auth_id: &Authid, path: &str, expected_roles: &str) { |
0815ec7e | 842 | let path_vec = super::split_acl_path(path); |
a7a5406c FG |
843 | let mut roles = tree |
844 | .roles(auth_id, &path_vec) | |
845 | .iter() | |
846 | .map(|(v, _)| v.clone()) | |
847 | .collect::<Vec<String>>(); | |
0815ec7e DM |
848 | roles.sort(); |
849 | let roles = roles.join(","); | |
850 | ||
a7a5406c FG |
851 | assert_eq!( |
852 | roles, expected_roles, | |
853 | "\nat check_roles for '{}' on '{}'", | |
854 | auth_id, path | |
855 | ); | |
0815ec7e DM |
856 | } |
857 | ||
858 | #[test] | |
e7cb4dc5 | 859 | fn test_acl_line_compression() { |
e7cb4dc5 WB |
860 | let tree = AclTree::from_raw( |
861 | "\ | |
862 | acl:0:/store/store2:user1@pbs:Admin\n\ | |
863 | acl:0:/store/store2:user2@pbs:Admin\n\ | |
864 | acl:0:/store/store2:user1@pbs:DatastoreBackup\n\ | |
865 | acl:0:/store/store2:user2@pbs:DatastoreBackup\n\ | |
866 | ", | |
867 | ) | |
868 | .expect("failed to parse acl tree"); | |
0815ec7e DM |
869 | |
870 | let mut raw: Vec<u8> = Vec::new(); | |
a7a5406c FG |
871 | tree.write_config(&mut raw) |
872 | .expect("failed to write acl tree"); | |
e7cb4dc5 | 873 | let raw = std::str::from_utf8(&raw).expect("acl tree is not valid utf8"); |
0815ec7e | 874 | |
a7a5406c FG |
875 | assert_eq!( |
876 | raw, | |
877 | "acl:0:/store/store2:user1@pbs,user2@pbs:Admin,DatastoreBackup\n" | |
878 | ); | |
0815ec7e DM |
879 | } |
880 | ||
881 | #[test] | |
882 | fn test_roles_1() -> Result<(), Error> { | |
a7a5406c FG |
883 | let tree = AclTree::from_raw( |
884 | r###" | |
0815ec7e | 885 | acl:1:/storage:user1@pbs:Admin |
bc0d0388 DM |
886 | acl:1:/storage/store1:user1@pbs:DatastoreBackup |
887 | acl:1:/storage/store2:user2@pbs:DatastoreBackup | |
a7a5406c FG |
888 | "###, |
889 | )?; | |
e6dc35ac | 890 | let user1: Authid = "user1@pbs".parse()?; |
e7cb4dc5 WB |
891 | check_roles(&tree, &user1, "/", ""); |
892 | check_roles(&tree, &user1, "/storage", "Admin"); | |
893 | check_roles(&tree, &user1, "/storage/store1", "DatastoreBackup"); | |
894 | check_roles(&tree, &user1, "/storage/store2", "Admin"); | |
895 | ||
e6dc35ac | 896 | let user2: Authid = "user2@pbs".parse()?; |
e7cb4dc5 WB |
897 | check_roles(&tree, &user2, "/", ""); |
898 | check_roles(&tree, &user2, "/storage", ""); | |
899 | check_roles(&tree, &user2, "/storage/store1", ""); | |
900 | check_roles(&tree, &user2, "/storage/store2", "DatastoreBackup"); | |
0815ec7e DM |
901 | |
902 | Ok(()) | |
903 | } | |
904 | ||
905 | #[test] | |
906 | fn test_role_no_access() -> Result<(), Error> { | |
a7a5406c FG |
907 | let tree = AclTree::from_raw( |
908 | r###" | |
0815ec7e DM |
909 | acl:1:/:user1@pbs:Admin |
910 | acl:1:/storage:user1@pbs:NoAccess | |
bc0d0388 | 911 | acl:1:/storage/store1:user1@pbs:DatastoreBackup |
a7a5406c FG |
912 | "###, |
913 | )?; | |
e6dc35ac | 914 | let user1: Authid = "user1@pbs".parse()?; |
e7cb4dc5 WB |
915 | check_roles(&tree, &user1, "/", "Admin"); |
916 | check_roles(&tree, &user1, "/storage", "NoAccess"); | |
917 | check_roles(&tree, &user1, "/storage/store1", "DatastoreBackup"); | |
918 | check_roles(&tree, &user1, "/storage/store2", "NoAccess"); | |
919 | check_roles(&tree, &user1, "/system", "Admin"); | |
0815ec7e | 920 | |
a7a5406c FG |
921 | let tree = AclTree::from_raw( |
922 | r###" | |
0815ec7e DM |
923 | acl:1:/:user1@pbs:Admin |
924 | acl:0:/storage:user1@pbs:NoAccess | |
bc0d0388 | 925 | acl:1:/storage/store1:user1@pbs:DatastoreBackup |
a7a5406c FG |
926 | "###, |
927 | )?; | |
e7cb4dc5 WB |
928 | check_roles(&tree, &user1, "/", "Admin"); |
929 | check_roles(&tree, &user1, "/storage", "NoAccess"); | |
930 | check_roles(&tree, &user1, "/storage/store1", "DatastoreBackup"); | |
931 | check_roles(&tree, &user1, "/storage/store2", "Admin"); | |
932 | check_roles(&tree, &user1, "/system", "Admin"); | |
0815ec7e DM |
933 | |
934 | Ok(()) | |
935 | } | |
8d048af2 DM |
936 | |
937 | #[test] | |
938 | fn test_role_add_delete() -> Result<(), Error> { | |
8d048af2 DM |
939 | let mut tree = AclTree::new(); |
940 | ||
e6dc35ac | 941 | let user1: Authid = "user1@pbs".parse()?; |
8d048af2 | 942 | |
e7cb4dc5 WB |
943 | tree.insert_user_role("/", &user1, "Admin", true); |
944 | tree.insert_user_role("/", &user1, "Audit", true); | |
8d048af2 | 945 | |
e7cb4dc5 WB |
946 | check_roles(&tree, &user1, "/", "Admin,Audit"); |
947 | ||
948 | tree.insert_user_role("/", &user1, "NoAccess", true); | |
949 | check_roles(&tree, &user1, "/", "NoAccess"); | |
8d048af2 DM |
950 | |
951 | let mut raw: Vec<u8> = Vec::new(); | |
952 | tree.write_config(&mut raw)?; | |
953 | let raw = std::str::from_utf8(&raw)?; | |
954 | ||
955 | assert_eq!(raw, "acl:1:/:user1@pbs:NoAccess\n"); | |
956 | ||
957 | Ok(()) | |
958 | } | |
959 | ||
960 | #[test] | |
961 | fn test_no_access_overwrite() -> Result<(), Error> { | |
8d048af2 DM |
962 | let mut tree = AclTree::new(); |
963 | ||
e6dc35ac | 964 | let user1: Authid = "user1@pbs".parse()?; |
e7cb4dc5 WB |
965 | |
966 | tree.insert_user_role("/storage", &user1, "NoAccess", true); | |
8d048af2 | 967 | |
e7cb4dc5 | 968 | check_roles(&tree, &user1, "/storage", "NoAccess"); |
8d048af2 | 969 | |
e7cb4dc5 WB |
970 | tree.insert_user_role("/storage", &user1, "Admin", true); |
971 | tree.insert_user_role("/storage", &user1, "Audit", true); | |
8d048af2 | 972 | |
e7cb4dc5 | 973 | check_roles(&tree, &user1, "/storage", "Admin,Audit"); |
8d048af2 | 974 | |
e7cb4dc5 | 975 | tree.insert_user_role("/storage", &user1, "NoAccess", true); |
8d048af2 | 976 | |
e7cb4dc5 | 977 | check_roles(&tree, &user1, "/storage", "NoAccess"); |
8d048af2 DM |
978 | |
979 | Ok(()) | |
980 | } | |
0815ec7e | 981 | } |