Skip to main content

origin_manifest/
profile.rs

1use serde::{Deserialize, Serialize};
2
3/// A named security profile (ADR-0007, §20).
4///
5/// Profiles exist so that granting a window its permissions is a *decision between
6/// named options*, not a free-form list somebody copies from another project and
7/// widens by one line at a time.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
9#[serde(rename_all = "kebab-case")]
10pub enum SecurityProfile {
11    /// Reads application state and receives events. Nothing else.
12    ReadonlyDashboard,
13
14    /// The default for a main window: state, events, and clipboard for copyable data.
15    StandardDashboard,
16
17    /// Manages accounts and credentials. Credential handling itself happens in Rust —
18    /// this profile does not grant the frontend access to secrets.
19    AccountSettings,
20
21    /// A workspace window that may read files under user-confirmed roots and execute
22    /// programs listed in the process allowlist.
23    ///
24    /// This is the narrowest useful grant for a window that touches the local file
25    /// system — explicitly *not* `fs:default` / `shell:default`, and every permission
26    /// maps to a platform contract (ADR-0007).
27    LocalWorkspace,
28}
29
30impl SecurityProfile {
31    /// The Tauri permissions this profile grants.
32    ///
33    /// Listed explicitly rather than pulling in a plugin's `default` set: a plugin
34    /// default grows when the plugin is updated, silently widening every window that
35    /// used it.
36    pub fn permissions(self) -> &'static [&'static str] {
37        match self {
38            Self::ReadonlyDashboard => &[
39                "core:default",
40                "core:event:allow-listen",
41                "core:event:allow-unlisten",
42            ],
43            Self::StandardDashboard => &[
44                "core:default",
45                "core:event:allow-listen",
46                "core:event:allow-unlisten",
47            ],
48            Self::AccountSettings => &[
49                "core:default",
50                "core:event:allow-listen",
51                "core:event:allow-unlisten",
52                "core:window:allow-close",
53            ],
54            Self::LocalWorkspace => &[
55                "core:default",
56                "core:event:allow-listen",
57                "core:event:allow-unlisten",
58            ],
59        }
60    }
61
62    pub fn identifier(self) -> &'static str {
63        match self {
64            Self::ReadonlyDashboard => "readonly-dashboard",
65            Self::StandardDashboard => "standard-dashboard",
66            Self::AccountSettings => "account-settings",
67            Self::LocalWorkspace => "local-workspace",
68        }
69    }
70
71    pub fn description(self) -> &'static str {
72        match self {
73            Self::ReadonlyDashboard => {
74                "Reads application state and receives platform events. No filesystem, \
75                 no shell, no process execution."
76            }
77            Self::StandardDashboard => {
78                "Main window: reads application state and receives platform events. No \
79                 filesystem, no shell, no process execution."
80            }
81            Self::AccountSettings => {
82                "Settings window: manages accounts through commands. Credentials never \
83                 reach the frontend."
84            }
85            Self::LocalWorkspace => {
86                "Workspace window: access to workspace files and allowlisted processes \
87                 via Origin commands. No direct Tauri fs or shell plugin access."
88            }
89        }
90    }
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96
97    #[test]
98    fn no_profile_grants_fs_shell_or_process() {
99        let profiles = [
100            SecurityProfile::ReadonlyDashboard,
101            SecurityProfile::StandardDashboard,
102            SecurityProfile::AccountSettings,
103            SecurityProfile::LocalWorkspace,
104        ];
105
106        for profile in profiles {
107            for permission in profile.permissions() {
108                assert!(
109                    !permission.starts_with("fs:")
110                        && !permission.starts_with("shell:")
111                        && !permission.starts_with("process:"),
112                    "{} grants {permission}",
113                    profile.identifier()
114                );
115            }
116        }
117    }
118
119    #[test]
120    fn all_profiles_have_unique_identifiers() {
121        let profiles = [
122            SecurityProfile::ReadonlyDashboard,
123            SecurityProfile::StandardDashboard,
124            SecurityProfile::AccountSettings,
125            SecurityProfile::LocalWorkspace,
126        ];
127
128        let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
129        for profile in profiles {
130            let id = profile.identifier();
131            assert!(seen.insert(id), "duplicate identifier: {id}");
132            assert!(
133                !profile.description().is_empty(),
134                "{id}: description must not be empty"
135            );
136        }
137    }
138
139    #[test]
140    fn local_workspace_round_trips_through_the_manifest_format() {
141        let parsed: SecurityProfile = toml::from_str("value = \"local-workspace\"")
142            .map(|table: toml::Table| table["value"].clone())
143            .map(|value| value.try_into().unwrap())
144            .unwrap();
145
146        assert_eq!(parsed, SecurityProfile::LocalWorkspace);
147        assert_eq!(parsed.identifier(), "local-workspace");
148    }
149
150    #[test]
151    fn profiles_round_trip_through_the_manifest_format() {
152        let parsed: SecurityProfile = toml::from_str("value = \"account-settings\"")
153            .map(|table: toml::Table| table["value"].clone())
154            .map(|value| value.try_into().unwrap())
155            .unwrap();
156
157        assert_eq!(parsed, SecurityProfile::AccountSettings);
158        assert_eq!(parsed.identifier(), "account-settings");
159    }
160}