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 = "file:upload")]
42    FileUpload,
43    #[serde(rename = "file:download")]
44    FileDownload,
45    #[serde(rename = "javascript:evaluate")]
46    JavascriptEvaluate,
47    #[serde(rename = "intent:execute")]
48    IntentExecute,
49    #[serde(rename = "vision:assist")]
50    VisionAssist,
51    #[serde(rename = "artifact:read")]
52    ArtifactRead,
53    #[serde(rename = "artifact:capture")]
54    ArtifactCapture,
55    #[serde(rename = "recovery:read")]
56    RecoveryRead,
57    #[serde(rename = "recovery:write")]
58    RecoveryWrite,
59    #[serde(rename = "job:submit")]
60    JobSubmit,
61    #[serde(rename = "job:read")]
62    JobRead,
63    #[serde(rename = "job:cancel")]
64    JobCancel,
65    #[serde(rename = "authority:admin")]
66    AuthorityAdmin,
67    #[serde(rename = "browser:fingerprint")]
68    BrowserFingerprint,
69    #[serde(rename = "browser:humanize")]
70    BrowserHumanize,
71}
72
73impl Capability {
74    pub const fn as_str(self) -> &'static str {
75        match self {
76            Self::SessionRead => "session:read",
77            Self::SessionWrite => "session:write",
78            Self::PageRead => "page:read",
79            Self::PageWrite => "page:write",
80            Self::BrowserMutate => "browser:mutate",
81            Self::FileUpload => "file:upload",
82            Self::FileDownload => "file:download",
83            Self::JavascriptEvaluate => "javascript:evaluate",
84            Self::IntentExecute => "intent:execute",
85            Self::VisionAssist => "vision:assist",
86            Self::ArtifactRead => "artifact:read",
87            Self::ArtifactCapture => "artifact:capture",
88            Self::RecoveryRead => "recovery:read",
89            Self::RecoveryWrite => "recovery:write",
90            Self::JobSubmit => "job:submit",
91            Self::JobRead => "job:read",
92            Self::JobCancel => "job:cancel",
93            Self::AuthorityAdmin => "authority:admin",
94            Self::BrowserFingerprint => "browser:fingerprint",
95            Self::BrowserHumanize => "browser:humanize",
96        }
97    }
98}
99
100/// Canonical capability set. Serializes in lexical wire order.
101#[derive(Debug, Clone, Default, PartialEq, Eq)]
102#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
103pub struct CapabilitySet(BTreeSet<Capability>);
104
105impl std::str::FromStr for Capability {
106    type Err = UnknownCapability;
107
108    /// Parses the wire string. This is the ONE parse table: bootstrap files,
109    /// broker startup credentials, and every stdio gateway all accept exactly
110    /// these strings, so a new capability is accepted everywhere the day its
111    /// variant lands. The previous shape — a hand-maintained match per binary
112    /// — drifted twice in a week (a gateway rejected `job:*`, then bootstrap
113    /// rejected `browser:*`), both times failing closed against a credential
114    /// `bobby init` itself had written.
115    fn from_str(value: &str) -> Result<Self, Self::Err> {
116        Ok(match value {
117            "session:read" => Self::SessionRead,
118            "session:write" => Self::SessionWrite,
119            "page:read" => Self::PageRead,
120            "page:write" => Self::PageWrite,
121            "browser:mutate" => Self::BrowserMutate,
122            "file:upload" => Self::FileUpload,
123            "file:download" => Self::FileDownload,
124            "javascript:evaluate" => Self::JavascriptEvaluate,
125            "intent:execute" => Self::IntentExecute,
126            "vision:assist" => Self::VisionAssist,
127            "artifact:read" => Self::ArtifactRead,
128            "artifact:capture" => Self::ArtifactCapture,
129            "recovery:read" => Self::RecoveryRead,
130            "recovery:write" => Self::RecoveryWrite,
131            "job:submit" => Self::JobSubmit,
132            "job:read" => Self::JobRead,
133            "job:cancel" => Self::JobCancel,
134            "authority:admin" => Self::AuthorityAdmin,
135            "browser:fingerprint" => Self::BrowserFingerprint,
136            "browser:humanize" => Self::BrowserHumanize,
137            _ => return Err(UnknownCapability(value.to_owned())),
138        })
139    }
140}
141
142/// A capability wire string no variant claims.
143#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
144#[error("unknown capability: {0}")]
145pub struct UnknownCapability(pub String);
146
147impl CapabilitySet {
148    pub fn new(capabilities: impl IntoIterator<Item = Capability>) -> Self {
149        capabilities.into_iter().collect()
150    }
151
152    pub fn contains(&self, capability: Capability) -> bool {
153        self.0.contains(&capability)
154    }
155
156    pub fn allows(&self, operation: InterfaceOperation) -> bool {
157        operation
158            .required()
159            .iter()
160            .all(|capability| self.contains(*capability))
161    }
162}
163
164impl FromIterator<Capability> for CapabilitySet {
165    fn from_iter<T: IntoIterator<Item = Capability>>(iter: T) -> Self {
166        Self(iter.into_iter().collect())
167    }
168}
169
170impl Serialize for CapabilitySet {
171    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
172    where
173        S: Serializer,
174    {
175        let mut capabilities: Vec<_> = self.0.iter().copied().collect();
176        capabilities.sort_by_key(|capability| capability.as_str());
177        capabilities.serialize(serializer)
178    }
179}
180
181impl<'de> Deserialize<'de> for CapabilitySet {
182    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
183    where
184        D: Deserializer<'de>,
185    {
186        let capabilities = Vec::<Capability>::deserialize(deserializer)?;
187        let mut verified = BTreeSet::new();
188        for capability in capabilities {
189            if !verified.insert(capability) {
190                return Err(serde::de::Error::custom("duplicate capability"));
191            }
192        }
193        Ok(Self(verified))
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    #[test]
202    fn authority_admin_serde_round_trip() {
203        let json = serde_json::to_string(&Capability::AuthorityAdmin).unwrap();
204        assert_eq!(json, "\"authority:admin\"");
205        let parsed: Capability = serde_json::from_str(&json).unwrap();
206        assert_eq!(parsed, Capability::AuthorityAdmin);
207    }
208}