权限、能力、作用域
主线位置:调用主线上
on_message里那道 ACL 检查查的是一张表,这张表由本页的三种东西拼成:插件和应用声明的权限、项目里写的能力、两者都可以携带的作用域。本页只讲数据结构和写法;它们怎么在编译期展开成表见编译期解析,运行时怎么查见运行时检查。
flowchart TB pfile["权限文件 toml json"] --> perm["Permission"] pfile --> pset["PermissionSet"] pfile --> dflt["default 权限集"] perm --> cmds["commands allow deny"] perm --> scope["scope allow deny"] manifest["Manifest 每插件一份"] --> perm manifest --> pset manifest --> dflt cap["Capability"] -->|"按标识符引用"| manifest cap --> who["windows webviews"] cap --> where["local 和 remote.urls"] cap --> ext["扩展作用域"]
源码都在 tauri-utils 的 acl/ 目录:mod.rs(权限、作用域、执行上下文)、capability.rs(能力)、identifier.rs(标识符)、manifest.rs(权限文件和清单)、value.rs(作用域里的值)。本页的实验 crate 是 sec-acl:两个本地插件 crate tauri-plugin-files、tauri-plugin-notes,一个写在应用里的插件 inline,三个应用命令,两份能力文件;跑在 MockRuntime 上(tauri::test),依赖 tauri =2.12.5、tauri-build =2.7.4,平台 M2 Max、macOS 27、rustc 1.99.0。
权限:一组命令,加上可选的作用域
Permission 是最小的授权单位:
pub struct Permission {
/// The version of the permission.
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<NonZeroU64>,
/// A unique identifier for the permission.
pub identifier: String,
/// Human-readable description of what the permission does.
// ...
pub description: Option<String>,
/// Allowed or denied commands when using this permission.
#[serde(default)]
pub commands: Commands,
/// Allowed or denied scoped when using this permission.
#[serde(default, skip_serializing_if = "Scopes::is_empty")]
pub scope: Scopes,
/// Target platforms this permission applies. By default all platforms are affected by this permission.
#[serde(skip_serializing_if = "Option::is_none")]
pub platforms: Option<Vec<Target>>,
}
Commands 是两张命令名列表 allow 和 deny,文档注释写明两边冲突时拒绝优先(「Denied command, which takes priority」)。Scopes 也是 allow / deny 两张表,元素是 Value:一个和 JSON 同构的枚举(Null、Bool、Number、String、List、Map),之所以不直接用 serde_json::Value,是因为权限文件也可以写成 TOML,两种格式要能反序列化到同一个类型。
作用域的内容 tauri 不解释。它只负责把值原样交给命令,命令自己决定 { path = "/docs/*" } 是什么意思,这一点下面「作用域落到命令上」再看。
sec-acl 的应用权限放在项目的 permissions/docs.toml:
[[permission]]
identifier = "allow-open-docs"
description = "open_doc under /docs, never /docs/private"
commands.allow = ["open_doc"]
[[permission.scope.allow]]
path = "/docs/*"
[[permission.scope.deny]]
path = "/docs/private/*"
插件 files 的权限放在 permissions/files/scoped.toml,第二条没有 commands:
[[permission]]
identifier = "allow-read-tmp"
description = "read under /tmp"
commands.allow = ["read"]
[[permission.scope.allow]]
path = "/tmp/*"
[[permission]]
identifier = "global-deny-secret"
description = "plugin-wide: never anything with secret in it"
[[permission.scope.deny]]
path = "*secret*"
没有命令、只有作用域的权限是全局作用域:它不放行任何命令,只给整个插件(或整个应用)的所有命令附加一份作用域。这是解析时按 commands.allow 和 commands.deny 是否都为空来区分的,见编译期解析。
除了手写的,还有自动生成的:AppManifest::commands 和 InlinedPlugin::commands 列出的每个命令都会得到一对 allow-<cmd> / deny-<cmd>(下划线换成连字符),生成过程在 tauri-build。sec-acl 的 build.rs:
fn main() {
// `files` and `notes` live in their own crates (tauri-plugin-*), but their permissions
// are declared here as inline plugins, so they don't need a build script or `links`.
let attributes = tauri_build::Attributes::new()
.app_manifest(tauri_build::AppManifest::new().commands(&["greet", "wipe", "open_doc"]))
.plugin("files", tauri_build::InlinedPlugin::new().commands(&["read"]))
.plugin("notes", tauri_build::InlinedPlugin::new().commands(&["read"]))
.plugin("inline", tauri_build::InlinedPlugin::new().commands(&["read"]));
tauri_build::try_build(attributes).expect("failed to run tauri-build");
}
权限文件、权限集和 default
一个权限文件反序列化成 PermissionFile,可以同时有三样:[default]、若干 [[set]]、若干 [[permission]]。同一个插件(或应用)的所有权限文件由 Manifest::new 合成一份 Manifest:
| 字段 | 来自 | 合并规则 |
|---|---|---|
default_permission | [default] | 后读到的文件覆盖先读到的(replace),标识符固定为 "default" |
permission_sets | [[set]] | 按标识符插入 BTreeMap,同名覆盖 |
permissions | [[permission]] | 同上 |
global_scope_schema | 插件 build script 传来的 JSON schema | 只给编辑器补全用 |
PermissionSet 只有标识符、描述和一列权限名,本身不带命令也不带作用域。列表里的名字可以是本清单的权限、本清单的另一个权限集,也可以带前缀引用别的插件(源码注释的例子是 dialog:default 里包含 fs:default),展开是递归的。default 就是一个名字固定的权限集,能力里写 core:default、fs:default 引用的就是它;插件没有 [default] 时,写 <插件>:default 在解析阶段展开成空,但在 tauri-build 的校验阶段 default 永远算存在,所以不会报错,只是什么也没授权。
能力:把权限交给谁
Capability 是项目里 capabilities/*.json(或 toml、json5)的内容,也可以内联在 tauri.conf.json 的 app > security > capabilities 里:
| 字段 | 默认 | 含义 |
|---|---|---|
identifier | 必填 | 能力名,重复会在构建时报错 |
windows | 空 | 窗口标签的 glob 模式;匹配上的窗口里所有 webview 都得到这份能力 |
webviews | 空 | webview 标签的 glob 模式,多 webview 窗口里做细分 |
local | true | 对本地页面(tauri://localhost 等)生效 |
remote.urls | 无 | 对哪些远程 URL 生效,URLPattern 语法 |
permissions | 必填 | 权限标识符,或「标识符 + 追加的作用域」 |
platforms | 无(全部) | 只在这些平台上生效 |
windows 和 webviews 是「或」的关系:webview 标签匹配 webviews 里任一模式,或者它所在窗口的标签匹配 windows 里任一模式,就算命中。两者都为空的能力不命中任何东西。用 WebviewWindowBuilder 建的普通窗口,窗口和 webview 是同一个标签,写 windows 就够了。
permissions 的元素是 PermissionEntry:一个字符串,或者一个对象 { "identifier": ..., "allow": [...], "deny": [...] }。后者在引用权限的同时追加作用域,适合插件给出「允许读文件」、具体读哪里由应用决定的情形。sec-acl 的两份能力:
{
"identifier": "main",
"windows": ["main"],
"permissions": [
"allow-greet",
"allow-wipe",
"allow-open-docs",
"global-app",
"files:allow-read-tmp",
{ "identifier": "files:allow-read", "allow": [{ "path": "/home/*" }] },
"files:global-deny-secret",
"notes:allow-read",
"notes:global-allow-notes",
"inline:allow-read"
]
}
{
"identifier": "audit",
"windows": ["audit"],
"permissions": ["deny-wipe"]
}
audit 这份只拒绝不允许,它对 main 窗口有没有影响,是运行时检查那一页的动脑筋题。
问: 权限已经写明了允许哪些命令,为什么还要再套一层能力?
答: 权限由写命令的人定义,能力由用应用的人定义,两者知道的事情不一样。插件作者只知道「read 这个命令可以按路径限制」,不知道应用有几个窗口、哪个窗口加载了不可信的内容。能力把「哪些权限」和「给谁、在什么来源下」绑在一起,同一个插件的权限可以给主窗口、不给设置窗口。这也是能力能追加作用域、而权限文件不知道能力存在的原因。
remote.urls 用的是 URLPattern
remote.urls 里的每一项由 RemoteUrlPattern::from_str 解析成 urlpattern crate 的 UrlPattern,解析前补三处通配:
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
let mut init = urlpattern::UrlPatternInit::parse_constructor_string::<regex::Regex>(s, None)?;
if init.search.as_ref().map(|p| p.is_empty()).unwrap_or(true) {
init.search.replace("*".to_string());
}
if init.hash.as_ref().map(|p| p.is_empty()).unwrap_or(true) {
init.hash.replace("*".to_string());
}
if init
.pathname
.as_ref()
.map(|p| p.is_empty() || p == "/")
.unwrap_or(true)
{
init.pathname.replace("*".to_string());
}
let pattern = urlpattern::UrlPattern::parse(init, Default::default())?;
Ok(Self(Arc::new(pattern), s.to_string()))
}
所以 https://example.com 和 https://example.com/ 都等于「这个源下的任意路径、任意查询串和片段」。源码里的测试给了几个边界:http://* 匹配任意主机;http://*.tauri.app 匹配 api.tauri.app 但不匹配 tauri.app 本身;*://localhost 匹配任意 scheme。能力里写带路径的模式(文档例子是 https://mydomain.dev/api/*)在实际的 webview 里会遇到一个问题:运行时拿来匹配的 URL 取自请求的 Origin 头,没有路径,详见运行时检查。
2.0 的 beta 期间这个字段改了三次。#8898(2.0.0-beta.3)把 remote 从一列 domains 改成一列 urls(CHANGELOG:「Changed the capability remote configuration to take a list of urls instead of domains for more flexibility.」),那时按 glob 匹配;#9116(首次发布于 tauri 2.0.0-beta.10)改成 URLPattern(「The ACL configuration for remote URLs now uses the URLPattern standard instead of glob patterns.」);紧接着 #9133(2.0.0-beta.11)补上了上面那三段 replace("*"),CHANGELOG 原文是「Fixes capability remote domain not allowing subpaths, query parameters and hash when those values are empty.」:URLPattern 的标准行为是「没写的部分必须为空」,于是只写了域名的模式一个带路径的页面都匹配不上。
标识符的规则
能力里引用权限用的是 Identifier:一个字符串,外加最后一个分隔符 : 的位置。get_prefix 是冒号之前(清单名),get_base 是冒号之后(权限名);没有冒号时前缀为空,解析时按应用清单 __app-acl__ 查。TryFrom<String> 的检查:
| 规则 | 错误 |
|---|---|
不能以 tauri-plugin- 开头 | identifiers cannot start with tauri-plugin- |
| 不能为空、不能超过 116 字节(插件名上限 64 − 13,加冒号,加 64) | Empty / Humongous |
第一个字节必须是 ASCII 字母或数字;之后可以有 -,但 - 不能紧挨 :、不能在末尾 | InvalidFormat / TrailingHyphen |
只能有一个 :,以 core: 开头的除外 | MultipleSeparators |
: 后面不能为空 | PrefixWithoutBase |
core: 的例外是为了 core:window:allow-close 这种三段式:tauri 自带的九个插件的清单名本身就是 core:window,分隔符记在最后一个冒号上,前缀就是 core:window。错误信息说标识符「can only include lowercase ASCII」,代码里实际用的是 is_ascii_alphanumeric,大写字母也能过,源码测试里 preFix:base 就是合法的。另外 Identifier::set_prefix 是一个公开方法,函数体只有 todo!()。
作用域落到命令上
命令想看作用域,就在参数里要一个 CommandScope<T> 或 GlobalScope<T>。T 要实现 ScopeObject,任何 DeserializeOwned 类型都自动实现了它(把 Value 转成 serde_json::Value 再反序列化);再实现 ScopeObjectMatch,就能用 CommandScope::matches。tauri-plugin-files 里的作用域类型:
/// One scope entry, as written in the permission files: `{ path = "/docs/*" }`.
#[derive(Debug, Deserialize)]
pub struct Entry {
pub path: String,
}
impl ScopeObjectMatch for Entry {
type Input = str;
fn matches(&self, input: &str) -> bool {
glob::Pattern::new(&self.path).map(|p| p.matches(input)).unwrap_or(false)
}
}
CommandScope::matches 的规则是:命中任何一条 deny 就是 false;allow 为空时是 true,否则至少命中一条 allow。「allow 为空就放行」写在它的文档里,并建议需要至少一条 allow 的命令先自己检查 allows().is_empty()。应用命令 open_doc 用它判断:
#[tauri::command]
fn open_doc(path: String, scope: CommandScope<Entry>) -> Result<String, String> {
let verdict = scope.matches(&path);
let s = format!(
"allow={:?} deny={:?} matches({path})={verdict}",
paths(scope.allows()),
paths(scope.denies())
);
if verdict { Ok(s) } else { Err(s) }
}
用 tauri::test 在 main 窗口里发三次请求:
[main @ tauri://localhost] open_doc -> Ok("allow=[\"/docs/*\"] deny=[\"/docs/private/*\"] matches(/docs/a.txt)=true")
[main @ tauri://localhost] open_doc -> Err:
allow=["/docs/*"] deny=["/docs/private/*"] matches(/docs/private/key.txt)=false
[main @ tauri://localhost] open_doc -> Err:
allow=["/docs/*"] deny=["/docs/private/*"] matches(/etc/passwd)=false
插件命令 read 同时要了两种作用域。files:allow-read-tmp 带的 /tmp/* 和能力里追加给 files:allow-read 的 /home/* 都进了 CommandScope,files:global-deny-secret 进了 GlobalScope:
[main @ tauri://localhost] plugin:files|read -> Ok("command allow=[\"/tmp/*\", \"/home/*\"] deny=[]; global allow=[] deny=[\"*secret*\"]; ok=true")
[main @ tauri://localhost] plugin:files|read -> Ok("command allow=[\"/tmp/*\", \"/home/*\"] deny=[]; global allow=[] deny=[\"*secret*\"]; ok=false")
第二行的路径是 /tmp/secret.txt:命令作用域允许,全局作用域拒绝。注意 ok 是命令自己算的,tauri 只把两份列表交过来,GlobalScope 连 matches 方法都没有。作用域是给命令的建议,不是 tauri 执行的检查:一个命令不声明 CommandScope 参数,能力里写的作用域就完全不起作用。这和命令级的允许 / 拒绝不同,后者在命令被调用之前由 on_message 强制执行。
作用域按什么取、取自哪份能力、缓存在哪,以及同一个实验里 notes 和 inline 两个插件拿到了不属于自己的全局作用域,见运行时检查。
小结
Permission= 允许 / 拒绝的命令名 + 可选的作用域 + 可选的平台;没有命令只有作用域的权限是整个插件(或应用)的全局作用域。PermissionSet只是权限名的列表,可以跨插件、可以嵌套;default是名字固定的权限集。每个插件的权限文件合成一份Manifest。Capability把权限交给匹配windows或webviews模式的 webview,在local和 / 或remote.urls来源下生效,可以给引用的权限追加作用域。- 标识符最多一个冒号,
core:开头的例外;remote.urls是 URLPattern,没写路径、查询串、片段的部分自动变成*。 - 作用域的值 tauri 不解释,命令通过
CommandScope<T>/GlobalScope<T>拿到反序列化后的列表,自己决定怎么用。