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