tauri_utils/acl/
resolved.rs

1// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
2// SPDX-License-Identifier: Apache-2.0
3// SPDX-License-Identifier: MIT
4
5//! Resolved ACL for runtime usage.
6
7use std::{collections::BTreeMap, fmt};
8
9use crate::platform::Target;
10
11use super::{
12  capability::{Capability, PermissionEntry},
13  has_app_manifest,
14  manifest::Manifest,
15  Commands, Error, ExecutionContext, Identifier, Permission, PermissionSet, Scopes, Value,
16  APP_ACL_KEY,
17};
18
19/// A key for a scope, used to link a [`ResolvedCommand#structfield.scope`] to the store [`Resolved#structfield.scopes`].
20pub type ScopeKey = u64;
21
22/// Metadata for what referenced a [`ResolvedCommand`].
23#[cfg(debug_assertions)]
24#[derive(Default, Clone, PartialEq, Eq)]
25pub struct ResolvedCommandReference {
26  /// Identifier of the capability.
27  pub capability: String,
28  /// Identifier of the permission.
29  pub permission: String,
30}
31
32/// A resolved command permission.
33#[derive(Default, Clone, PartialEq, Eq)]
34pub struct ResolvedCommand {
35  /// The execution context of this command.
36  pub context: ExecutionContext,
37  /// The capability/permission that referenced this command.
38  #[cfg(debug_assertions)]
39  pub referenced_by: ResolvedCommandReference,
40  /// The list of window label patterns that was resolved for this command.
41  pub windows: Vec<glob::Pattern>,
42  /// The list of webview label patterns that was resolved for this command.
43  pub webviews: Vec<glob::Pattern>,
44  /// The reference of the scope that is associated with this command. See [`Resolved#structfield.command_scopes`].
45  pub scope_id: Option<ScopeKey>,
46}
47
48impl fmt::Debug for ResolvedCommand {
49  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50    f.debug_struct("ResolvedCommand")
51      .field("context", &self.context)
52      .field("windows", &self.windows)
53      .field("webviews", &self.webviews)
54      .field("scope_id", &self.scope_id)
55      .finish()
56  }
57}
58
59/// A resolved scope. Merges all scopes defined for a single command.
60#[derive(Debug, Default, Clone)]
61pub struct ResolvedScope {
62  /// Allows something on the command.
63  pub allow: Vec<Value>,
64  /// Denies something on the command.
65  pub deny: Vec<Value>,
66}
67
68/// Resolved access control list.
69#[derive(Debug, Default)]
70pub struct Resolved {
71  /// If we should check the ACL for the app commands
72  pub has_app_acl: bool,
73  /// The commands that are allowed. Map each command with its context to a [`ResolvedCommand`].
74  pub allowed_commands: BTreeMap<String, Vec<ResolvedCommand>>,
75  /// The commands that are denied. Map each command with its context to a [`ResolvedCommand`].
76  pub denied_commands: BTreeMap<String, Vec<ResolvedCommand>>,
77  /// The store of scopes referenced by a [`ResolvedCommand`].
78  pub command_scope: BTreeMap<ScopeKey, ResolvedScope>,
79  /// The global scope.
80  pub global_scope: BTreeMap<String, ResolvedScope>,
81}
82
83impl Resolved {
84  /// Resolves the ACL for the given plugin permissions and app capabilities.
85  pub fn resolve(
86    acl: &BTreeMap<String, Manifest>,
87    mut capabilities: BTreeMap<String, Capability>,
88    target: Target,
89  ) -> Result<Self, Error> {
90    let mut allowed_commands = BTreeMap::new();
91    let mut denied_commands = BTreeMap::new();
92
93    let mut current_scope_id = 0;
94    let mut command_scope = BTreeMap::new();
95    let mut global_scope: BTreeMap<String, Vec<Scopes>> = BTreeMap::new();
96
97    // resolve commands
98    for capability in capabilities.values_mut().filter(|c| c.is_active(&target)) {
99      with_resolved_permissions(
100        capability,
101        acl,
102        target,
103        |ResolvedPermission {
104           key,
105           commands,
106           scope,
107           #[cfg_attr(not(debug_assertions), allow(unused))]
108           permission_name,
109         }| {
110          if commands.allow.is_empty() && commands.deny.is_empty() {
111            // global scope
112            global_scope.entry(key.to_string()).or_default().push(scope);
113          } else {
114            let scope_id = if scope.allow.is_some() || scope.deny.is_some() {
115              current_scope_id += 1;
116              command_scope.insert(
117                current_scope_id,
118                ResolvedScope {
119                  allow: scope.allow.unwrap_or_default(),
120                  deny: scope.deny.unwrap_or_default(),
121                },
122              );
123              Some(current_scope_id)
124            } else {
125              None
126            };
127
128            for allowed_command in &commands.allow {
129              resolve_command(
130                &mut allowed_commands,
131                if key == APP_ACL_KEY {
132                  allowed_command.to_string()
133                } else if let Some(core_plugin_name) = key.strip_prefix("core:") {
134                  format!("plugin:{core_plugin_name}|{allowed_command}")
135                } else {
136                  format!("plugin:{key}|{allowed_command}")
137                },
138                capability,
139                scope_id,
140                #[cfg(debug_assertions)]
141                permission_name.to_string(),
142              )?;
143            }
144
145            for denied_command in &commands.deny {
146              resolve_command(
147                &mut denied_commands,
148                if key == APP_ACL_KEY {
149                  denied_command.to_string()
150                } else if let Some(core_plugin_name) = key.strip_prefix("core:") {
151                  format!("plugin:{core_plugin_name}|{denied_command}")
152                } else {
153                  format!("plugin:{key}|{denied_command}")
154                },
155                capability,
156                scope_id,
157                #[cfg(debug_assertions)]
158                permission_name.to_string(),
159              )?;
160            }
161          }
162
163          Ok(())
164        },
165      )?;
166    }
167
168    let global_scope = global_scope
169      .into_iter()
170      .map(|(key, scopes)| {
171        let mut resolved_scope = ResolvedScope {
172          allow: Vec::new(),
173          deny: Vec::new(),
174        };
175        for scope in scopes {
176          if let Some(allow) = scope.allow {
177            resolved_scope.allow.extend(allow);
178          }
179          if let Some(deny) = scope.deny {
180            resolved_scope.deny.extend(deny);
181          }
182        }
183        (key, resolved_scope)
184      })
185      .collect();
186
187    let resolved = Self {
188      has_app_acl: has_app_manifest(acl),
189      allowed_commands,
190      denied_commands,
191      command_scope,
192      global_scope,
193    };
194
195    Ok(resolved)
196  }
197}
198
199fn parse_glob_patterns(mut raw: Vec<String>) -> Result<Vec<glob::Pattern>, Error> {
200  raw.sort();
201
202  let mut patterns = Vec::new();
203  for pattern in raw {
204    patterns.push(glob::Pattern::new(&pattern)?);
205  }
206
207  Ok(patterns)
208}
209
210fn resolve_command(
211  commands: &mut BTreeMap<String, Vec<ResolvedCommand>>,
212  command: String,
213  capability: &Capability,
214  scope_id: Option<ScopeKey>,
215  #[cfg(debug_assertions)] referenced_by_permission_identifier: String,
216) -> Result<(), Error> {
217  let mut contexts = Vec::new();
218  if capability.local {
219    contexts.push(ExecutionContext::Local);
220  }
221  if let Some(remote) = &capability.remote {
222    contexts.extend(remote.urls.iter().map(|url| {
223      ExecutionContext::Remote {
224        url: url
225          .parse()
226          .unwrap_or_else(|e| panic!("invalid URL pattern for remote URL {url}: {e}")),
227      }
228    }));
229  }
230
231  for context in contexts {
232    let resolved_list = commands.entry(command.clone()).or_default();
233
234    resolved_list.push(ResolvedCommand {
235      context,
236      #[cfg(debug_assertions)]
237      referenced_by: ResolvedCommandReference {
238        capability: capability.identifier.clone(),
239        permission: referenced_by_permission_identifier.clone(),
240      },
241      windows: parse_glob_patterns(capability.windows.clone())?,
242      webviews: parse_glob_patterns(capability.webviews.clone())?,
243      scope_id,
244    });
245  }
246
247  Ok(())
248}
249
250struct ResolvedPermission<'a> {
251  key: &'a str,
252  permission_name: &'a str,
253  commands: Commands,
254  scope: Scopes,
255}
256
257/// Iterate over permissions in a capability, resolving permission sets if necessary
258/// to produce a [`ResolvedPermission`] and calling the provided callback with it.
259fn with_resolved_permissions<F: FnMut(ResolvedPermission<'_>) -> Result<(), Error>>(
260  capability: &Capability,
261  acl: &BTreeMap<String, Manifest>,
262  target: Target,
263  mut f: F,
264) -> Result<(), Error> {
265  for permission_entry in &capability.permissions {
266    let permission_id = permission_entry.identifier();
267
268    let permissions = get_permissions(permission_id, acl)?
269      .into_iter()
270      .filter(|p| p.permission.is_active(&target));
271
272    for TraversedPermission {
273      key,
274      permission_name,
275      permission,
276    } in permissions
277    {
278      let mut resolved_scope = Scopes::default();
279      let mut commands = Commands::default();
280
281      if let PermissionEntry::ExtendedPermission {
282        identifier: _,
283        scope,
284      } = permission_entry
285      {
286        if let Some(allow) = scope.allow.clone() {
287          resolved_scope
288            .allow
289            .get_or_insert_with(Default::default)
290            .extend(allow);
291        }
292        if let Some(deny) = scope.deny.clone() {
293          resolved_scope
294            .deny
295            .get_or_insert_with(Default::default)
296            .extend(deny);
297        }
298      }
299
300      if let Some(allow) = permission.scope.allow.clone() {
301        resolved_scope
302          .allow
303          .get_or_insert_with(Default::default)
304          .extend(allow);
305      }
306      if let Some(deny) = permission.scope.deny.clone() {
307        resolved_scope
308          .deny
309          .get_or_insert_with(Default::default)
310          .extend(deny);
311      }
312
313      commands.allow.extend(permission.commands.allow.clone());
314      commands.deny.extend(permission.commands.deny.clone());
315
316      f(ResolvedPermission {
317        key: &key,
318        permission_name: &permission_name,
319        commands,
320        scope: resolved_scope,
321      })?;
322    }
323  }
324
325  Ok(())
326}
327
328/// Traversed permission
329#[derive(Debug)]
330pub struct TraversedPermission<'a> {
331  /// Plugin name without the tauri-plugin- prefix
332  pub key: String,
333  /// Permission's name
334  pub permission_name: String,
335  /// Permission details
336  pub permission: &'a Permission,
337}
338
339/// Expand a permissions id based on the ACL to get the associated permissions (e.g. expand some-plugin:default)
340pub fn get_permissions<'a>(
341  permission_id: &Identifier,
342  acl: &'a BTreeMap<String, Manifest>,
343) -> Result<Vec<TraversedPermission<'a>>, Error> {
344  let key = permission_id.get_prefix().unwrap_or(APP_ACL_KEY);
345  let permission_name = permission_id.get_base();
346
347  let manifest = acl.get(key).ok_or_else(|| Error::UnknownManifest {
348    key: display_perm_key(key).to_string(),
349    available: acl.keys().cloned().collect::<Vec<_>>().join(", "),
350  })?;
351
352  if permission_name == "default" {
353    manifest
354      .default_permission
355      .as_ref()
356      .map(|default| get_permission_set_permissions(permission_id, acl, manifest, default))
357      .unwrap_or_else(|| Ok(Default::default()))
358  } else if let Some(set) = manifest.permission_sets.get(permission_name) {
359    get_permission_set_permissions(permission_id, acl, manifest, set)
360  } else if let Some(permission) = manifest.permissions.get(permission_name) {
361    Ok(vec![TraversedPermission {
362      key: key.to_string(),
363      permission_name: permission_name.to_string(),
364      permission,
365    }])
366  } else {
367    Err(Error::UnknownPermission {
368      key: display_perm_key(key).to_string(),
369      permission: permission_name.to_string(),
370    })
371  }
372}
373
374// get the permissions from a permission set
375fn get_permission_set_permissions<'a>(
376  permission_id: &Identifier,
377  acl: &'a BTreeMap<String, Manifest>,
378  manifest: &'a Manifest,
379  set: &'a PermissionSet,
380) -> Result<Vec<TraversedPermission<'a>>, Error> {
381  let key = permission_id.get_prefix().unwrap_or(APP_ACL_KEY);
382
383  let mut permissions = Vec::new();
384
385  for perm in &set.permissions {
386    // a set could include permissions from other plugins
387    // for example `dialog:default`, could include `fs:default`
388    // in this case `perm = "fs:default"` which is not a permission
389    // in the dialog manifest so we check if `perm` still have a prefix (i.e `fs:`)
390    // and if so, we resolve this prefix from `acl` first before proceeding
391    let id = Identifier::try_from(perm.clone()).expect("invalid identifier in permission set?");
392    let (manifest, permission_id, key, permission_name) =
393      if let Some((new_key, manifest)) = id.get_prefix().and_then(|k| acl.get(k).map(|m| (k, m))) {
394        (manifest, &id, new_key, id.get_base())
395      } else {
396        (manifest, permission_id, key, perm.as_str())
397      };
398
399    if permission_name == "default" {
400      permissions.extend(
401        manifest
402          .default_permission
403          .as_ref()
404          .map(|default| get_permission_set_permissions(permission_id, acl, manifest, default))
405          .transpose()?
406          .unwrap_or_default(),
407      );
408    } else if let Some(permission) = manifest.permissions.get(permission_name) {
409      permissions.push(TraversedPermission {
410        key: key.to_string(),
411        permission_name: permission_name.to_string(),
412        permission,
413      });
414    } else if let Some(permission_set) = manifest.permission_sets.get(permission_name) {
415      permissions.extend(get_permission_set_permissions(
416        permission_id,
417        acl,
418        manifest,
419        permission_set,
420      )?);
421    } else {
422      return Err(Error::SetPermissionNotFound {
423        permission: permission_name.to_string(),
424        set: set.identifier.clone(),
425      });
426    }
427  }
428
429  Ok(permissions)
430}
431
432#[inline]
433fn display_perm_key(prefix: &str) -> &str {
434  if prefix == APP_ACL_KEY {
435    "app manifest"
436  } else {
437    prefix
438  }
439}
440
441#[cfg(feature = "build")]
442mod build {
443  use proc_macro2::TokenStream;
444  use quote::{quote, ToTokens, TokenStreamExt};
445  use std::convert::identity;
446
447  use super::*;
448  use crate::{literal_struct, tokens::*};
449
450  #[cfg(debug_assertions)]
451  impl ToTokens for ResolvedCommandReference {
452    fn to_tokens(&self, tokens: &mut TokenStream) {
453      let capability = str_lit(&self.capability);
454      let permission = str_lit(&self.permission);
455      literal_struct!(
456        tokens,
457        ::tauri::utils::acl::resolved::ResolvedCommandReference,
458        capability,
459        permission
460      )
461    }
462  }
463
464  impl ToTokens for ResolvedCommand {
465    fn to_tokens(&self, tokens: &mut TokenStream) {
466      #[cfg(debug_assertions)]
467      let referenced_by = &self.referenced_by;
468
469      let context = &self.context;
470
471      let windows = vec_lit(&self.windows, |window| {
472        let w = window.as_str();
473        quote!(#w.parse().unwrap())
474      });
475      let webviews = vec_lit(&self.webviews, |window| {
476        let w = window.as_str();
477        quote!(#w.parse().unwrap())
478      });
479      let scope_id = opt_lit(self.scope_id.as_ref());
480
481      #[cfg(debug_assertions)]
482      {
483        literal_struct!(
484          tokens,
485          ::tauri::utils::acl::resolved::ResolvedCommand,
486          context,
487          referenced_by,
488          windows,
489          webviews,
490          scope_id
491        )
492      }
493      #[cfg(not(debug_assertions))]
494      literal_struct!(
495        tokens,
496        ::tauri::utils::acl::resolved::ResolvedCommand,
497        context,
498        windows,
499        webviews,
500        scope_id
501      )
502    }
503  }
504
505  impl ToTokens for ResolvedScope {
506    fn to_tokens(&self, tokens: &mut TokenStream) {
507      let allow = vec_lit(&self.allow, identity);
508      let deny = vec_lit(&self.deny, identity);
509      literal_struct!(
510        tokens,
511        ::tauri::utils::acl::resolved::ResolvedScope,
512        allow,
513        deny
514      )
515    }
516  }
517
518  impl ToTokens for Resolved {
519    fn to_tokens(&self, tokens: &mut TokenStream) {
520      let has_app_acl = self.has_app_acl;
521
522      let allowed_commands = map_lit(
523        quote! { ::std::collections::BTreeMap },
524        &self.allowed_commands,
525        str_lit,
526        |v| vec_lit(v, identity),
527      );
528
529      let denied_commands = map_lit(
530        quote! { ::std::collections::BTreeMap },
531        &self.denied_commands,
532        str_lit,
533        |v| vec_lit(v, identity),
534      );
535
536      let command_scope = map_lit(
537        quote! { ::std::collections::BTreeMap },
538        &self.command_scope,
539        identity,
540        identity,
541      );
542
543      let global_scope = map_lit(
544        quote! { ::std::collections::BTreeMap },
545        &self.global_scope,
546        str_lit,
547        identity,
548      );
549
550      literal_struct!(
551        tokens,
552        ::tauri::utils::acl::resolved::Resolved,
553        has_app_acl,
554        allowed_commands,
555        denied_commands,
556        command_scope,
557        global_scope
558      )
559    }
560  }
561}
562
563#[cfg(test)]
564mod tests {
565
566  use super::{get_permissions, Identifier, Manifest, Permission, PermissionSet};
567
568  fn manifest<const P: usize, const S: usize>(
569    name: &str,
570    permissions: [&str; P],
571    default_set: Option<&[&str]>,
572    sets: [(&str, &[&str]); S],
573  ) -> (String, Manifest) {
574    (
575      name.to_string(),
576      Manifest {
577        default_permission: default_set.map(|perms| PermissionSet {
578          identifier: "default".to_string(),
579          description: "default set".to_string(),
580          permissions: perms.iter().map(|s| s.to_string()).collect(),
581        }),
582        permissions: permissions
583          .iter()
584          .map(|p| {
585            (
586              p.to_string(),
587              Permission {
588                identifier: p.to_string(),
589                ..Default::default()
590              },
591            )
592          })
593          .collect(),
594        permission_sets: sets
595          .iter()
596          .map(|(s, perms)| {
597            (
598              s.to_string(),
599              PermissionSet {
600                identifier: s.to_string(),
601                description: format!("{s} set"),
602                permissions: perms.iter().map(|s| s.to_string()).collect(),
603              },
604            )
605          })
606          .collect(),
607        ..Default::default()
608      },
609    )
610  }
611
612  fn id(id: &str) -> Identifier {
613    Identifier::try_from(id.to_string()).unwrap()
614  }
615
616  #[test]
617  fn resolves_permissions_from_other_plugins() {
618    let acl = [
619      manifest(
620        "fs",
621        ["read", "write", "rm", "exist"],
622        Some(&["read", "exist"]),
623        [],
624      ),
625      manifest(
626        "http",
627        ["fetch", "fetch-cancel"],
628        None,
629        [("fetch-with-cancel", &["fetch", "fetch-cancel"])],
630      ),
631      manifest(
632        "dialog",
633        ["open", "save"],
634        None,
635        [(
636          "extra",
637          &[
638            "save",
639            "fs:default",
640            "fs:write",
641            "http:default",
642            "http:fetch-with-cancel",
643          ],
644        )],
645      ),
646    ]
647    .into();
648
649    let permissions = get_permissions(&id("fs:default"), &acl).unwrap();
650    assert_eq!(permissions.len(), 2);
651    assert_eq!(permissions[0].key, "fs");
652    assert_eq!(permissions[0].permission_name, "read");
653    assert_eq!(permissions[1].key, "fs");
654    assert_eq!(permissions[1].permission_name, "exist");
655
656    let permissions = get_permissions(&id("fs:rm"), &acl).unwrap();
657    assert_eq!(permissions.len(), 1);
658    assert_eq!(permissions[0].key, "fs");
659    assert_eq!(permissions[0].permission_name, "rm");
660
661    let permissions = get_permissions(&id("http:fetch-with-cancel"), &acl).unwrap();
662    assert_eq!(permissions.len(), 2);
663    assert_eq!(permissions[0].key, "http");
664    assert_eq!(permissions[0].permission_name, "fetch");
665    assert_eq!(permissions[1].key, "http");
666    assert_eq!(permissions[1].permission_name, "fetch-cancel");
667
668    let permissions = get_permissions(&id("dialog:extra"), &acl).unwrap();
669    assert_eq!(permissions.len(), 6);
670    assert_eq!(permissions[0].key, "dialog");
671    assert_eq!(permissions[0].permission_name, "save");
672    assert_eq!(permissions[1].key, "fs");
673    assert_eq!(permissions[1].permission_name, "read");
674    assert_eq!(permissions[2].key, "fs");
675    assert_eq!(permissions[2].permission_name, "exist");
676    assert_eq!(permissions[3].key, "fs");
677    assert_eq!(permissions[3].permission_name, "write");
678    assert_eq!(permissions[4].key, "http");
679    assert_eq!(permissions[4].permission_name, "fetch");
680    assert_eq!(permissions[5].key, "http");
681    assert_eq!(permissions[5].permission_name, "fetch-cancel");
682  }
683}