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