Skip to main content

rightkit_daemon/contract/
method.rs

1use super::caller::{Caller, Scope};
2use crate::error::{ServiceError, ERROR_FORBIDDEN, ERROR_NOT_ALLOWED};
3use crate::record::Allowlist;
4use std::collections::{BTreeMap, BTreeSet};
5
6/// Which transports a method is reachable on.
7#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
8pub enum Transport {
9    /// Local callers only.
10    Local,
11    /// Remote devices only.
12    Remote,
13    /// Both.
14    Both,
15}
16
17/// Policy for one method id: which local apps may call it and which scopes a device needs.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct MethodPolicy {
20    transport: Transport,
21    /// Local apps allowed; `"*"` means any app the table knows.
22    apps: BTreeSet<String>,
23    /// A device needs at least one of these scopes. Empty means no device: a policy that opens
24    /// the method to devices without naming a scope is refused by [`MethodTable::try_method`],
25    /// and if one is built anyway no device passes it.
26    scopes: BTreeSet<Scope>,
27}
28
29impl MethodPolicy {
30    /// Local-only, for the given apps (`"*"` for any).
31    pub fn local<I, S>(apps: I) -> Self
32    where
33        I: IntoIterator<Item = S>,
34        S: Into<String>,
35    {
36        Self {
37            transport: Transport::Local,
38            apps: apps.into_iter().map(Into::into).collect(),
39            scopes: BTreeSet::new(),
40        }
41    }
42
43    /// Remote-only, for devices holding at least one of `scopes`.
44    pub fn remote<I, S>(scopes: I) -> Self
45    where
46        I: IntoIterator<Item = S>,
47        S: Into<Scope>,
48    {
49        Self {
50            transport: Transport::Remote,
51            apps: BTreeSet::new(),
52            scopes: scopes.into_iter().map(Into::into).collect(),
53        }
54    }
55
56    /// Add local apps; the method becomes reachable on both transports if it was remote-only.
57    pub fn and_local<I, S>(mut self, apps: I) -> Self
58    where
59        I: IntoIterator<Item = S>,
60        S: Into<String>,
61    {
62        self.apps.extend(apps.into_iter().map(Into::into));
63        self.transport = match self.transport {
64            Transport::Remote => Transport::Both,
65            t => t,
66        };
67        self
68    }
69
70    /// Add device scopes; the method becomes reachable on both transports if it was local-only.
71    pub fn and_remote<I, S>(mut self, scopes: I) -> Self
72    where
73        I: IntoIterator<Item = S>,
74        S: Into<Scope>,
75    {
76        self.scopes.extend(scopes.into_iter().map(Into::into));
77        self.transport = match self.transport {
78            Transport::Local => Transport::Both,
79            t => t,
80        };
81        self
82    }
83
84    pub fn transport(&self) -> Transport {
85        self.transport
86    }
87
88    fn permits_scopes(&self, held: &BTreeSet<Scope>) -> bool {
89        // An empty scope set admits nobody: "no scope named" must never read as "any device".
90        self.transport != Transport::Local && self.scopes.iter().any(|s| held.contains(s))
91    }
92
93    /// Whether a device may reach the method at all (it names at least one scope).
94    fn device_scopes_named(&self) -> bool {
95        !self.scopes.is_empty()
96    }
97}
98
99/// Why a method id or its policy was refused at table construction.
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub enum TableError {
102    /// The id is empty.
103    EmptyMethod,
104    /// The id holds a glob character (`*`, `?`, `[`, `]`, `{`, `}`), whitespace, a control
105    /// character, or a path/URL delimiter (`/`, `%`, `#`). `*` is the allowlist wildcard for
106    /// apps, never a method.
107    BadMethod(String),
108    /// A policy reachable by devices names no scope (`and_remote([])`, `remote([])`).
109    NoDeviceScopes(String),
110}
111
112impl std::fmt::Display for TableError {
113    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114        match self {
115            TableError::EmptyMethod => f.write_str("method id is empty"),
116            TableError::BadMethod(m) => write!(f, "method id {m:?} is not a plain method name"),
117            TableError::NoDeviceScopes(m) => write!(
118                f,
119                "method {m:?} is reachable by devices but names no scope (an empty scope set admits no device)"
120            ),
121        }
122    }
123}
124
125impl std::error::Error for TableError {}
126
127/// A method id must be a plain name: non-empty, no glob characters, whitespace, control
128/// characters, `/`, `%` or `#`.
129pub fn validate_method_id(id: &str) -> Result<(), TableError> {
130    if id.is_empty() {
131        return Err(TableError::EmptyMethod);
132    }
133    let bad = |c: char| {
134        c.is_whitespace()
135            || c.is_control()
136            || matches!(
137                c,
138                '*' | '?' | '[' | ']' | '{' | '}' | '/' | '%' | '#' | '\\'
139            )
140    };
141    if id.chars().any(bad) {
142        return Err(TableError::BadMethod(id.to_string()));
143    }
144    Ok(())
145}
146
147/// Local-transport authorization against an [`Allowlist`] (the form the host config carries and
148/// `service.json` publishes); same answers as [`MethodTable::authorize`] for a local app.
149pub(crate) fn authorize_allowlist(
150    list: &Allowlist,
151    caller: &Caller,
152    method: &str,
153) -> Result<(), ServiceError> {
154    let ok = match caller {
155        Caller::LocalApp { app, .. } => list.permits(app, method),
156        Caller::Device { .. } => method == "ping",
157    };
158    if ok {
159        Ok(())
160    } else {
161        Err(ServiceError::new(
162            ERROR_NOT_ALLOWED,
163            format!("{} may not call {method}", caller.label()),
164        ))
165    }
166}
167
168/// Every method of a service with its policy: the one source the local allowlist and a remote
169/// route table are both derived from. `ping` is never listed and always allowed.
170#[derive(Debug, Clone, Default, PartialEq, Eq)]
171pub struct MethodTable {
172    methods: BTreeMap<String, MethodPolicy>,
173}
174
175impl MethodTable {
176    pub fn new() -> Self {
177        Self::default()
178    }
179
180    /// Add or replace a method, refusing a bad id or a device-reachable policy with no scope
181    /// ([`TableError`]).
182    pub fn try_method(
183        mut self,
184        id: impl Into<String>,
185        policy: MethodPolicy,
186    ) -> Result<Self, TableError> {
187        let id = id.into();
188        validate_method_id(&id)?;
189        if policy.transport != Transport::Local && !policy.device_scopes_named() {
190            return Err(TableError::NoDeviceScopes(id));
191        }
192        self.methods.insert(id, policy);
193        Ok(self)
194    }
195
196    /// [`try_method`](Self::try_method) for a table built in code: a bad id or an unscoped
197    /// device policy is a programming error and panics at construction, never later at a call.
198    #[track_caller]
199    pub fn method(self, id: impl Into<String>, policy: MethodPolicy) -> Self {
200        match self.try_method(id, policy) {
201            Ok(t) => t,
202            Err(e) => panic!("MethodTable::method: {e}"),
203        }
204    }
205
206    pub fn get(&self, id: &str) -> Option<&MethodPolicy> {
207        self.methods.get(id)
208    }
209
210    pub fn ids(&self) -> impl Iterator<Item = &str> {
211        self.methods.keys().map(String::as_str)
212    }
213
214    /// The local allowlist this table implies: each local-reachable method, per app. A `"*"`
215    /// app entry stays `"*"` and, as in [`Allowlist`], applies only to apps with no entry of
216    /// their own.
217    pub fn allowlist(&self) -> Allowlist {
218        let mut a = Allowlist::new();
219        for (id, p) in &self.methods {
220            if p.transport == Transport::Remote {
221                continue;
222            }
223            for app in &p.apps {
224                a = a.allow(app, [id.clone()]);
225            }
226        }
227        a
228    }
229
230    /// Whether a device may call `method` through `POST /v1/call/{method}`: only a method
231    /// reachable on both transports, by a device holding one of its scopes. Anything else,
232    /// including an unknown method, is `forbidden`, so a device cannot probe which ids exist.
233    pub fn authorize_device_call(&self, caller: &Caller, method: &str) -> Result<(), ServiceError> {
234        let both = self
235            .methods
236            .get(method)
237            .is_some_and(|p| p.transport == Transport::Both);
238        if method != "ping" && !both {
239            return Err(ServiceError::new(
240                ERROR_FORBIDDEN,
241                format!("{} may not call {method}", caller.label()),
242            ));
243        }
244        self.authorize(caller, method)
245    }
246
247    /// Whether `caller` may call `method`. `ping` is always allowed. A local app is refused with
248    /// `request_not_allowed`; a device lacking scope with `forbidden`.
249    pub fn authorize(&self, caller: &Caller, method: &str) -> Result<(), ServiceError> {
250        if method == "ping" {
251            return Ok(());
252        }
253        let policy = self.methods.get(method);
254        match caller {
255            Caller::LocalApp { .. } => authorize_allowlist(&self.allowlist(), caller, method),
256            Caller::Device { id, scopes } => {
257                if policy.is_some_and(|p| p.permits_scopes(scopes)) {
258                    Ok(())
259                } else {
260                    Err(ServiceError::new(
261                        ERROR_FORBIDDEN,
262                        format!("{id} may not call {method}"),
263                    ))
264                }
265            }
266        }
267    }
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273    use crate::peer::PeerIdentity;
274
275    fn table() -> MethodTable {
276        MethodTable::new()
277            .method("echo", MethodPolicy::local(["*"]))
278            .method("both", MethodPolicy::local(["a"]).and_remote(["s1"]))
279            .method("up", MethodPolicy::remote(["s2"]))
280    }
281
282    #[test]
283    fn allowlist_is_derived_and_skips_remote_only() {
284        let a = table().allowlist();
285        assert!(a.permits("any", "echo"));
286        assert!(a.permits("a", "both"));
287        assert!(!a.permits("b", "both"));
288        assert!(!a.permits("a", "up"));
289        assert!(a.permits("b", "ping"));
290    }
291
292    #[test]
293    fn an_empty_scope_set_admits_no_device() {
294        let p = MethodPolicy::remote(Vec::<String>::new());
295        let held: BTreeSet<Scope> = ["s1".to_string(), "any".to_string()].into();
296        assert!(!p.permits_scopes(&held));
297        assert!(!p.permits_scopes(&BTreeSet::new()));
298        let q = MethodPolicy::local(["a"]).and_remote(Vec::<String>::new());
299        assert!(!q.permits_scopes(&held));
300    }
301
302    #[test]
303    fn device_call_needs_both_transports() {
304        let t = table();
305        let dev = Caller::device("dev_1", ["s1", "s2"]);
306        assert!(t.authorize_device_call(&dev, "both").is_ok());
307        for m in ["up", "echo", "missing"] {
308            assert_eq!(
309                t.authorize_device_call(&dev, m).unwrap_err().code,
310                ERROR_FORBIDDEN,
311                "{m}"
312            );
313        }
314        let no_scope = Caller::device("dev_2", ["other"]);
315        assert_eq!(
316            t.authorize_device_call(&no_scope, "both").unwrap_err().code,
317            ERROR_FORBIDDEN
318        );
319    }
320
321    #[test]
322    fn authorize_by_caller() {
323        let t = table();
324        let app = |n: &str| Caller::local_app(n, PeerIdentity::default());
325        assert!(t.authorize(&app("a"), "both").is_ok());
326        assert_eq!(
327            t.authorize(&app("b"), "both").unwrap_err().code,
328            ERROR_NOT_ALLOWED
329        );
330        assert_eq!(
331            t.authorize(&app("a"), "up").unwrap_err().code,
332            ERROR_NOT_ALLOWED
333        );
334        let dev = Caller::device("dev_1", ["s1"]);
335        assert!(t.authorize(&dev, "both").is_ok());
336        assert_eq!(t.authorize(&dev, "up").unwrap_err().code, ERROR_FORBIDDEN);
337        assert_eq!(t.authorize(&dev, "echo").unwrap_err().code, ERROR_FORBIDDEN);
338        assert_eq!(
339            t.authorize(&dev, "missing").unwrap_err().code,
340            ERROR_FORBIDDEN
341        );
342        assert!(t.authorize(&dev, "ping").is_ok());
343    }
344}