tauri_utils/acl/
capability.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//! End-user abstraction for selecting permissions a window has access to.
6
7use std::{path::Path, str::FromStr};
8
9use crate::{acl::Identifier, platform::Target};
10use serde::{
11  de::{Error, IntoDeserializer},
12  Deserialize, Deserializer, Serialize,
13};
14use serde_untagged::UntaggedEnumVisitor;
15
16use super::Scopes;
17
18/// An entry for a permission value in a [`Capability`] can be either a raw permission [`Identifier`]
19/// or an object that references a permission and extends its scope.
20#[derive(Debug, Clone, PartialEq, Serialize)]
21#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
22#[serde(untagged)]
23pub enum PermissionEntry {
24  /// Reference a permission or permission set by identifier.
25  PermissionRef(Identifier),
26  /// Reference a permission or permission set by identifier and extends its scope.
27  ExtendedPermission {
28    /// Identifier of the permission or permission set.
29    identifier: Identifier,
30    /// Scope to append to the existing permission scope.
31    #[serde(default, flatten)]
32    scope: Scopes,
33  },
34}
35
36impl PermissionEntry {
37  /// The identifier of the permission referenced in this entry.
38  pub fn identifier(&self) -> &Identifier {
39    match self {
40      Self::PermissionRef(identifier) => identifier,
41      Self::ExtendedPermission {
42        identifier,
43        scope: _,
44      } => identifier,
45    }
46  }
47}
48
49impl<'de> Deserialize<'de> for PermissionEntry {
50  fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
51  where
52    D: Deserializer<'de>,
53  {
54    #[derive(Deserialize)]
55    struct ExtendedPermissionStruct {
56      identifier: Identifier,
57      #[serde(default, flatten)]
58      scope: Scopes,
59    }
60
61    UntaggedEnumVisitor::new()
62      .string(|string| {
63        let de = string.into_deserializer();
64        Identifier::deserialize(de).map(Self::PermissionRef)
65      })
66      .map(|map| {
67        let ext_perm = map.deserialize::<ExtendedPermissionStruct>()?;
68        Ok(Self::ExtendedPermission {
69          identifier: ext_perm.identifier,
70          scope: ext_perm.scope,
71        })
72      })
73      .deserialize(deserializer)
74  }
75}
76
77/// A grouping and boundary mechanism developers can use to isolate access to the IPC layer.
78///
79/// It controls application windows' and webviews' fine grained access
80/// to the Tauri core, application, or plugin commands.
81/// If a webview or its window is not matching any capability then it has no access to the IPC layer at all.
82///
83/// This can be done to create groups of windows, based on their required system access, which can reduce
84/// impact of frontend vulnerabilities in less privileged windows.
85/// Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`.
86/// A Window can have none, one, or multiple associated capabilities.
87///
88/// ## Example
89///
90/// ```json
91/// {
92///   "identifier": "main-user-files-write",
93///   "description": "This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programatic access to files selected by the user.",
94///   "windows": [
95///     "main"
96///   ],
97///   "permissions": [
98///     "core:default",
99///     "dialog:open",
100///     {
101///       "identifier": "fs:allow-write-text-file",
102///       "allow": [{ "path": "$HOME/test.txt" }]
103///     },
104///   ],
105///   "platforms": ["macOS","windows"]
106/// }
107/// ```
108#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
109#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
110pub struct Capability {
111  /// Identifier of the capability.
112  ///
113  /// ## Example
114  ///
115  /// `main-user-files-write`
116  ///
117  pub identifier: String,
118  /// Description of what the capability is intended to allow on associated windows.
119  ///
120  /// It should contain a description of what the grouped permissions should allow.
121  ///
122  /// ## Example
123  ///
124  /// This capability allows the `main` window access to `filesystem` write related
125  /// commands and `dialog` commands to enable programatic access to files selected by the user.
126  #[serde(default)]
127  pub description: String,
128  /// Configure remote URLs that can use the capability permissions.
129  ///
130  /// This setting is optional and defaults to not being set, as our
131  /// default use case is that the content is served from our local application.
132  ///
133  /// :::caution
134  /// Make sure you understand the security implications of providing remote
135  /// sources with local system access.
136  /// :::
137  ///
138  /// ## Example
139  ///
140  /// ```json
141  /// {
142  ///   "urls": ["https://*.mydomain.dev"]
143  /// }
144  /// ```
145  #[serde(default, skip_serializing_if = "Option::is_none")]
146  pub remote: Option<CapabilityRemote>,
147  /// Whether this capability is enabled for local app URLs or not. Defaults to `true`.
148  #[serde(default = "default_capability_local")]
149  pub local: bool,
150  /// List of windows that are affected by this capability. Can be a glob pattern.
151  ///
152  /// If a window label matches any of the patterns in this list,
153  /// the capability will be enabled on all the webviews of that window,
154  /// regardless of the value of [`Self::webviews`].
155  ///
156  /// On multiwebview windows, prefer specifying [`Self::webviews`] and omitting [`Self::windows`]
157  /// for a fine grained access control.
158  ///
159  /// ## Example
160  ///
161  /// `["main"]`
162  #[serde(default, skip_serializing_if = "Vec::is_empty")]
163  pub windows: Vec<String>,
164  /// List of webviews that are affected by this capability. Can be a glob pattern.
165  ///
166  /// The capability will be enabled on all the webviews
167  /// whose label matches any of the patterns in this list,
168  /// regardless of whether the webview's window label matches a pattern in [`Self::windows`].
169  ///
170  /// ## Example
171  ///
172  /// `["sub-webview-one", "sub-webview-two"]`
173  #[serde(default, skip_serializing_if = "Vec::is_empty")]
174  pub webviews: Vec<String>,
175  /// List of permissions attached to this capability.
176  ///
177  /// Must include the plugin name as prefix in the form of `${plugin-name}:${permission-name}`.
178  /// For commands directly implemented in the application itself only `${permission-name}`
179  /// is required.
180  ///
181  /// ## Example
182  ///
183  /// ```json
184  /// [
185  ///   "core:default",
186  ///   "shell:allow-open",
187  ///   "dialog:open",
188  ///   {
189  ///     "identifier": "fs:allow-write-text-file",
190  ///     "allow": [{ "path": "$HOME/test.txt" }]
191  ///   }
192  /// ]
193  /// ```
194  #[cfg_attr(feature = "schema", schemars(schema_with = "unique_permission"))]
195  pub permissions: Vec<PermissionEntry>,
196  /// Limit which target platforms this capability applies to.
197  ///
198  /// By default all platforms are targeted.
199  ///
200  /// ## Example
201  ///
202  /// `["macOS","windows"]`
203  #[serde(skip_serializing_if = "Option::is_none")]
204  pub platforms: Option<Vec<Target>>,
205}
206
207impl Capability {
208  /// Whether this capability should be active based on the platform target or not.
209  pub fn is_active(&self, target: &Target) -> bool {
210    self
211      .platforms
212      .as_ref()
213      .map(|platforms| platforms.contains(target))
214      .unwrap_or(true)
215  }
216}
217
218#[cfg(feature = "schema")]
219fn unique_permission(gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema {
220  use schemars::schema;
221  schema::SchemaObject {
222    instance_type: Some(schema::InstanceType::Array.into()),
223    array: Some(Box::new(schema::ArrayValidation {
224      unique_items: Some(true),
225      items: Some(gen.subschema_for::<PermissionEntry>().into()),
226      ..Default::default()
227    })),
228    ..Default::default()
229  }
230  .into()
231}
232
233fn default_capability_local() -> bool {
234  true
235}
236
237/// Configuration for remote URLs that are associated with the capability.
238#[derive(Debug, Default, Clone, Serialize, Deserialize, Eq, PartialEq, PartialOrd, Ord, Hash)]
239#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
240#[serde(rename_all = "camelCase")]
241pub struct CapabilityRemote {
242  /// Remote domains this capability refers to using the [URLPattern standard](https://urlpattern.spec.whatwg.org/).
243  ///
244  /// ## Examples
245  ///
246  /// - "https://*.mydomain.dev": allows subdomains of mydomain.dev
247  /// - "https://mydomain.dev/api/*": allows any subpath of mydomain.dev/api
248  pub urls: Vec<String>,
249}
250
251/// Capability formats accepted in a capability file.
252#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
253#[cfg_attr(feature = "schema", schemars(untagged))]
254#[cfg_attr(test, derive(Debug, PartialEq))]
255pub enum CapabilityFile {
256  /// A single capability.
257  Capability(Capability),
258  /// A list of capabilities.
259  List(Vec<Capability>),
260  /// A list of capabilities.
261  NamedList {
262    /// The list of capabilities.
263    capabilities: Vec<Capability>,
264  },
265}
266
267impl CapabilityFile {
268  /// Load the given capability file.
269  pub fn load<P: AsRef<Path>>(path: P) -> Result<Self, super::Error> {
270    let path = path.as_ref();
271    let capability_file =
272      std::fs::read_to_string(path).map_err(|e| super::Error::ReadFile(e, path.into()))?;
273    let ext = path.extension().unwrap().to_string_lossy().to_string();
274    let file: Self = match ext.as_str() {
275      "toml" => toml::from_str(&capability_file)?,
276      "json" => serde_json::from_str(&capability_file)?,
277      #[cfg(feature = "config-json5")]
278      "json5" => json5::from_str(&capability_file)?,
279      _ => return Err(super::Error::UnknownCapabilityFormat(ext)),
280    };
281    Ok(file)
282  }
283}
284
285impl<'de> Deserialize<'de> for CapabilityFile {
286  fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
287  where
288    D: Deserializer<'de>,
289  {
290    UntaggedEnumVisitor::new()
291      .seq(|seq| seq.deserialize::<Vec<Capability>>().map(Self::List))
292      .map(|map| {
293        #[derive(Deserialize)]
294        struct CapabilityNamedList {
295          capabilities: Vec<Capability>,
296        }
297
298        let value: serde_json::Map<String, serde_json::Value> = map.deserialize()?;
299        if value.contains_key("capabilities") {
300          serde_json::from_value::<CapabilityNamedList>(value.into())
301            .map(|named| Self::NamedList {
302              capabilities: named.capabilities,
303            })
304            .map_err(|e| serde_untagged::de::Error::custom(e.to_string()))
305        } else {
306          serde_json::from_value::<Capability>(value.into())
307            .map(Self::Capability)
308            .map_err(|e| serde_untagged::de::Error::custom(e.to_string()))
309        }
310      })
311      .deserialize(deserializer)
312  }
313}
314
315impl FromStr for CapabilityFile {
316  type Err = super::Error;
317
318  fn from_str(s: &str) -> Result<Self, Self::Err> {
319    serde_json::from_str(s)
320      .or_else(|_| toml::from_str(s))
321      .map_err(Into::into)
322  }
323}
324
325#[cfg(feature = "build")]
326mod build {
327  use std::convert::identity;
328
329  use proc_macro2::TokenStream;
330  use quote::{quote, ToTokens, TokenStreamExt};
331
332  use super::*;
333  use crate::{literal_struct, tokens::*};
334
335  impl ToTokens for CapabilityRemote {
336    fn to_tokens(&self, tokens: &mut TokenStream) {
337      let urls = vec_lit(&self.urls, str_lit);
338      literal_struct!(
339        tokens,
340        ::tauri::utils::acl::capability::CapabilityRemote,
341        urls
342      );
343    }
344  }
345
346  impl ToTokens for PermissionEntry {
347    fn to_tokens(&self, tokens: &mut TokenStream) {
348      let prefix = quote! { ::tauri::utils::acl::capability::PermissionEntry };
349
350      tokens.append_all(match self {
351        Self::PermissionRef(id) => {
352          quote! { #prefix::PermissionRef(#id) }
353        }
354        Self::ExtendedPermission { identifier, scope } => {
355          quote! { #prefix::ExtendedPermission {
356            identifier: #identifier,
357            scope: #scope
358          } }
359        }
360      });
361    }
362  }
363
364  impl ToTokens for Capability {
365    fn to_tokens(&self, tokens: &mut TokenStream) {
366      let identifier = str_lit(&self.identifier);
367      let description = str_lit(&self.description);
368      let remote = opt_lit(self.remote.as_ref());
369      let local = self.local;
370      let windows = vec_lit(&self.windows, str_lit);
371      let webviews = vec_lit(&self.webviews, str_lit);
372      let permissions = vec_lit(&self.permissions, identity);
373      let platforms = opt_vec_lit(self.platforms.as_ref(), identity);
374
375      literal_struct!(
376        tokens,
377        ::tauri::utils::acl::capability::Capability,
378        identifier,
379        description,
380        remote,
381        local,
382        windows,
383        webviews,
384        permissions,
385        platforms
386      );
387    }
388  }
389}
390
391#[cfg(test)]
392mod tests {
393  use crate::acl::{Identifier, Scopes};
394
395  use super::{Capability, CapabilityFile, PermissionEntry};
396
397  #[test]
398  fn permission_entry_de() {
399    let identifier = Identifier::try_from("plugin:perm".to_string()).unwrap();
400    let identifier_json = serde_json::to_string(&identifier).unwrap();
401    assert_eq!(
402      serde_json::from_str::<PermissionEntry>(&identifier_json).unwrap(),
403      PermissionEntry::PermissionRef(identifier.clone())
404    );
405
406    assert_eq!(
407      serde_json::from_value::<PermissionEntry>(serde_json::json!({
408        "identifier": identifier,
409        "allow": [],
410        "deny": null
411      }))
412      .unwrap(),
413      PermissionEntry::ExtendedPermission {
414        identifier,
415        scope: Scopes {
416          allow: Some(vec![]),
417          deny: None
418        }
419      }
420    );
421  }
422
423  #[test]
424  fn capability_file_de() {
425    let capability = Capability {
426      identifier: "test".into(),
427      description: "".into(),
428      remote: None,
429      local: true,
430      windows: vec![],
431      webviews: vec![],
432      permissions: vec![],
433      platforms: None,
434    };
435    let capability_json = serde_json::to_string(&capability).unwrap();
436
437    assert_eq!(
438      serde_json::from_str::<CapabilityFile>(&capability_json).unwrap(),
439      CapabilityFile::Capability(capability.clone())
440    );
441
442    assert_eq!(
443      serde_json::from_str::<CapabilityFile>(&format!("[{capability_json}]")).unwrap(),
444      CapabilityFile::List(vec![capability.clone()])
445    );
446
447    assert_eq!(
448      serde_json::from_str::<CapabilityFile>(&format!(
449        "{{ \"capabilities\": [{capability_json}] }}"
450      ))
451      .unwrap(),
452      CapabilityFile::NamedList {
453        capabilities: vec![capability.clone()]
454      }
455    );
456  }
457}