Skip to main content

bobby_browser_client/
auth.rs

1//! Authentication and capability types for the `/v1` interface.
2
3use serde::{Deserialize, Deserializer, Serialize, Serializer};
4use std::collections::BTreeSet;
5use std::iter::FromIterator;
6use uuid::Uuid;
7
8use crate::InterfaceOperation;
9
10/// Authenticated caller id. Carries no credential material.
11#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
12#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
13#[serde(transparent)]
14pub struct PrincipalId(Uuid);
15
16impl PrincipalId {
17    pub fn from_uuid(value: Uuid) -> Self {
18        Self(value)
19    }
20
21    pub fn as_uuid(&self) -> &Uuid {
22        &self.0
23    }
24}
25
26/// Capability granted by a token or policy.
27#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
28#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
29#[serde(rename_all = "kebab-case")]
30pub enum Capability {
31    #[serde(rename = "session:read")]
32    SessionRead,
33    #[serde(rename = "session:write")]
34    SessionWrite,
35    #[serde(rename = "page:read")]
36    PageRead,
37    #[serde(rename = "page:write")]
38    PageWrite,
39    #[serde(rename = "browser:mutate")]
40    BrowserMutate,
41    #[serde(rename = "network:egress")]
42    NetworkEgress,
43    #[serde(rename = "file:upload")]
44    FileUpload,
45    #[serde(rename = "file:download")]
46    FileDownload,
47    #[serde(rename = "javascript:evaluate")]
48    JavascriptEvaluate,
49    #[serde(rename = "intent:execute")]
50    IntentExecute,
51    #[serde(rename = "vision:assist")]
52    VisionAssist,
53    #[serde(rename = "artifact:read")]
54    ArtifactRead,
55    #[serde(rename = "context:read")]
56    ContextRead,
57    #[serde(rename = "artifact:capture")]
58    ArtifactCapture,
59    #[serde(rename = "recovery:read")]
60    RecoveryRead,
61    #[serde(rename = "recovery:write")]
62    RecoveryWrite,
63    #[serde(rename = "job:submit")]
64    JobSubmit,
65    #[serde(rename = "job:read")]
66    JobRead,
67    #[serde(rename = "job:cancel")]
68    JobCancel,
69    #[serde(rename = "authority:admin")]
70    AuthorityAdmin,
71    #[serde(rename = "browser:fingerprint")]
72    BrowserFingerprint,
73    #[serde(rename = "browser:humanize")]
74    BrowserHumanize,
75}
76
77impl Capability {
78    /// Every capability, for callers that must not drift as variants are added --
79    /// notably the `tools/list` byte-budget gate, which under-measures the connect
80    /// payload if it misses a capability that advertises a tool. `all_is_exhaustive`
81    /// fails to compile when a variant is added without being listed here.
82    pub const ALL: [Self; 22] = [
83        Self::SessionRead,
84        Self::SessionWrite,
85        Self::PageRead,
86        Self::PageWrite,
87        Self::BrowserMutate,
88        Self::NetworkEgress,
89        Self::FileUpload,
90        Self::FileDownload,
91        Self::JavascriptEvaluate,
92        Self::IntentExecute,
93        Self::VisionAssist,
94        Self::ArtifactRead,
95        Self::ContextRead,
96        Self::ArtifactCapture,
97        Self::RecoveryRead,
98        Self::RecoveryWrite,
99        Self::JobSubmit,
100        Self::JobRead,
101        Self::JobCancel,
102        Self::AuthorityAdmin,
103        Self::BrowserFingerprint,
104        Self::BrowserHumanize,
105    ];
106
107    pub const fn as_str(self) -> &'static str {
108        match self {
109            Self::SessionRead => "session:read",
110            Self::SessionWrite => "session:write",
111            Self::PageRead => "page:read",
112            Self::PageWrite => "page:write",
113            Self::BrowserMutate => "browser:mutate",
114            Self::NetworkEgress => "network:egress",
115            Self::FileUpload => "file:upload",
116            Self::FileDownload => "file:download",
117            Self::JavascriptEvaluate => "javascript:evaluate",
118            Self::IntentExecute => "intent:execute",
119            Self::VisionAssist => "vision:assist",
120            Self::ArtifactRead => "artifact:read",
121            Self::ContextRead => "context:read",
122            Self::ArtifactCapture => "artifact:capture",
123            Self::RecoveryRead => "recovery:read",
124            Self::RecoveryWrite => "recovery:write",
125            Self::JobSubmit => "job:submit",
126            Self::JobRead => "job:read",
127            Self::JobCancel => "job:cancel",
128            Self::AuthorityAdmin => "authority:admin",
129            Self::BrowserFingerprint => "browser:fingerprint",
130            Self::BrowserHumanize => "browser:humanize",
131        }
132    }
133}
134
135/// Canonical capability set. Serializes in lexical wire order.
136#[derive(Debug, Clone, Default, PartialEq, Eq)]
137#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
138pub struct CapabilitySet(BTreeSet<Capability>);
139
140impl std::str::FromStr for Capability {
141    type Err = UnknownCapability;
142
143    /// Parses the wire string. Sole parse table: bootstrap files, broker
144    /// startup credentials, and every stdio gateway accept exactly these
145    /// strings. Do not add a per-binary match; they drift and fail closed.
146    fn from_str(value: &str) -> Result<Self, Self::Err> {
147        Ok(match value {
148            "session:read" => Self::SessionRead,
149            "session:write" => Self::SessionWrite,
150            "page:read" => Self::PageRead,
151            "page:write" => Self::PageWrite,
152            "browser:mutate" => Self::BrowserMutate,
153            "network:egress" => Self::NetworkEgress,
154            "file:upload" => Self::FileUpload,
155            "file:download" => Self::FileDownload,
156            "javascript:evaluate" => Self::JavascriptEvaluate,
157            "intent:execute" => Self::IntentExecute,
158            "vision:assist" => Self::VisionAssist,
159            "artifact:read" => Self::ArtifactRead,
160            "context:read" => Self::ContextRead,
161            "artifact:capture" => Self::ArtifactCapture,
162            "recovery:read" => Self::RecoveryRead,
163            "recovery:write" => Self::RecoveryWrite,
164            "job:submit" => Self::JobSubmit,
165            "job:read" => Self::JobRead,
166            "job:cancel" => Self::JobCancel,
167            "authority:admin" => Self::AuthorityAdmin,
168            "browser:fingerprint" => Self::BrowserFingerprint,
169            "browser:humanize" => Self::BrowserHumanize,
170            _ => return Err(UnknownCapability(value.to_owned())),
171        })
172    }
173}
174
175/// A capability wire string no variant claims.
176#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
177#[error("unknown capability: {0}")]
178pub struct UnknownCapability(pub String);
179
180impl CapabilitySet {
181    pub fn new(capabilities: impl IntoIterator<Item = Capability>) -> Self {
182        capabilities.into_iter().collect()
183    }
184
185    pub fn contains(&self, capability: Capability) -> bool {
186        self.0.contains(&capability)
187    }
188
189    pub fn iter(&self) -> impl Iterator<Item = Capability> + '_ {
190        self.0.iter().copied()
191    }
192
193    pub fn allows(&self, operation: InterfaceOperation) -> bool {
194        operation
195            .required()
196            .iter()
197            .all(|capability| self.contains(*capability))
198    }
199}
200
201impl FromIterator<Capability> for CapabilitySet {
202    fn from_iter<T: IntoIterator<Item = Capability>>(iter: T) -> Self {
203        Self(iter.into_iter().collect())
204    }
205}
206
207impl Serialize for CapabilitySet {
208    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
209    where
210        S: Serializer,
211    {
212        let mut capabilities: Vec<_> = self.0.iter().copied().collect();
213        capabilities.sort_by_key(|capability| capability.as_str());
214        capabilities.serialize(serializer)
215    }
216}
217
218impl<'de> Deserialize<'de> for CapabilitySet {
219    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
220    where
221        D: Deserializer<'de>,
222    {
223        let capabilities = Vec::<Capability>::deserialize(deserializer)?;
224        let mut verified = BTreeSet::new();
225        for capability in capabilities {
226            if !verified.insert(capability) {
227                return Err(serde::de::Error::custom("duplicate capability"));
228            }
229        }
230        Ok(Self(verified))
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    #[test]
239    fn authority_admin_serde_round_trip() {
240        let json = serde_json::to_string(&Capability::AuthorityAdmin).unwrap();
241        assert_eq!(json, "\"authority:admin\"");
242        let parsed: Capability = serde_json::from_str(&json).unwrap();
243        assert_eq!(parsed, Capability::AuthorityAdmin);
244    }
245
246    #[test]
247    fn all_is_exhaustive_and_unique() {
248        // The wildcard-free match below stops compiling when a variant is added,
249        // which is the point: a capability missing from ALL silently shrinks what
250        // the byte-budget gate measures.
251        fn listed(capability: Capability) {
252            match capability {
253                Capability::SessionRead
254                | Capability::SessionWrite
255                | Capability::PageRead
256                | Capability::PageWrite
257                | Capability::BrowserMutate
258                | Capability::NetworkEgress
259                | Capability::FileUpload
260                | Capability::FileDownload
261                | Capability::JavascriptEvaluate
262                | Capability::IntentExecute
263                | Capability::VisionAssist
264                | Capability::ArtifactRead
265                | Capability::ContextRead
266                | Capability::ArtifactCapture
267                | Capability::RecoveryRead
268                | Capability::RecoveryWrite
269                | Capability::JobSubmit
270                | Capability::JobRead
271                | Capability::JobCancel
272                | Capability::AuthorityAdmin
273                | Capability::BrowserFingerprint
274                | Capability::BrowserHumanize => {}
275            }
276            assert!(
277                Capability::ALL.contains(&capability),
278                "{capability:?} is missing from Capability::ALL"
279            );
280        }
281
282        for capability in Capability::ALL {
283            listed(capability);
284            assert_eq!(
285                capability.as_str().parse::<Capability>().unwrap(),
286                capability
287            );
288        }
289
290        let unique = Capability::ALL.into_iter().collect::<BTreeSet<_>>();
291        assert_eq!(unique.len(), Capability::ALL.len(), "ALL has duplicates");
292    }
293}