Skip to main content

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