Skip to main content

navcore_signalk_client/
access.rs

1//! Being let in.
2//!
3//! With security switched on, a device asks for a token and a human
4//! approves it once:
5//!
6//! 1. `POST /signalk/v1/access/requests` with a client id, a description
7//!    and the permissions wanted. Answers `202` with a request id.
8//! 2. `GET /signalk/v1/requests/<id>` -- `PENDING` until somebody decides.
9//! 3. The administrator approves it in the server's own interface.
10//! 4. The same GET then answers `COMPLETED` and carries the token.
11//!
12//! The token goes in an `Authorization: Bearer` header afterwards and is
13//! good for as long as the administrator said. Storing it is the
14//! application's business, not this crate's.
15//!
16//! # Whether a token is needed to read at all
17//!
18//! With security on, the REST interface refuses an unauthenticated
19//! request with `401`. The data stream is governed separately, by the
20//! server's `allowReadonly` setting: it decides whether a device can
21//! draw a chart before anybody has approved it. Publishing routes
22//! always needs a token either way.
23//!
24//! When `allowReadonly` is off, an unauthenticated client still
25//! connects and still receives the greeting, complete with the
26//! vessel's identifier, then receives nothing further -- indistinguishable
27//! on the wire from a boat whose instruments are switched off. A client
28//! with no token should report that itself rather than wait to be
29//! told, since it never will be.
30
31use std::time::Duration;
32
33use crate::{ClientError, Trust};
34
35/// How long to wait for the server to answer one call.
36const TIMEOUT: Duration = Duration::from_secs(10);
37
38/// What the mariner is asking to be allowed to do.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum Permissions {
41    /// Enough to draw the chart: everything the server publishes.
42    ReadOnly,
43    /// Also enough to publish routes and to steer the course API.
44    ReadWrite,
45}
46
47impl Permissions {
48    fn as_str(self) -> &'static str {
49        match self {
50            Self::ReadOnly => "readonly",
51            Self::ReadWrite => "readwrite",
52        }
53    }
54}
55
56/// A request that is now in front of whoever administers the server.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct PendingRequest {
59    /// What to ask about later.
60    pub request_id: String,
61}
62
63/// What became of asking for access.
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub enum Requested {
66    /// A fresh request now sits in front of the administrator.
67    New(PendingRequest),
68    /// The server already holds an unanswered request under this
69    /// `clientId`. Not a failure of this call -- the device's place in the
70    /// queue was never lost, only this particular attempt to make a second
71    /// one. There is no `PendingRequest` to poll here: the server's answer
72    /// carries no usable id for it (see the module doc), so this is a state
73    /// to display, not a request to check on.
74    AlreadyPending,
75}
76
77/// Where a request has got to.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub enum Access {
80    /// Nobody has decided yet.
81    Pending,
82    /// Approved, with the token to use from now on.
83    Approved {
84        /// The bearer token.
85        token: String,
86    },
87    /// Refused. Not an error -- an answer.
88    Denied,
89}
90
91/// Asks a server for a token.
92///
93/// `client_id` identifies this installation and must stay the same across
94/// restarts: it is what the administrator sees in the list, and asking
95/// again under a new name would put a second entry in front of them.
96///
97/// Asking again under the *same* name while an earlier request is still
98/// unanswered is refused by the server -- restarting mid-wait reaches
99/// this, not just misuse -- and comes back as
100/// [`Requested::AlreadyPending`] rather than as an error, since it is
101/// functionally the same situation as still waiting, not a new kind of
102/// failure.
103///
104/// # Errors
105///
106/// If the server cannot be reached, or answers something this cannot make
107/// sense of at all.
108pub fn request_access(
109    http_base: &str,
110    client_id: &str,
111    description: &str,
112    permissions: Permissions,
113    trust: &Trust,
114) -> Result<Requested, ClientError> {
115    let body = serde_json::json!({
116        "clientId": client_id,
117        "description": description,
118        "permissions": permissions.as_str(),
119    });
120
121    // The server's answer to a clash is meaningful, not just a status code
122    // to react to -- it names exactly this situation in `message`. Reading
123    // it needs the body of a 4xx response, which ureq discards by default
124    // in favour of turning it straight into an error.
125    let mut response = agent(trust)?
126        .post(&format!("{http_base}/signalk/v1/access/requests"))
127        .config()
128        .http_status_as_error(false)
129        .build()
130        .send_json(&body)
131        .map_err(|error| ClientError::Http(error.to_string()))?;
132
133    let answer: serde_json::Value = response
134        .body_mut()
135        .read_json()
136        .map_err(|error| ClientError::Http(error.to_string()))?;
137
138    read_requested(&answer)
139}
140
141/// Reads one answer to a fresh access request.
142///
143/// `state` is what tells a usable request apart from a refused one, not
144/// whether a `requestId` is present -- a request refused outright (a
145/// validation failure, or the clash this exists to recognise) still names
146/// the id of the now-dead request it refused, and polling that would not
147/// time out and would not error, only sit at `Pending` forever. A human
148/// approves nothing synchronously with the `POST`, so `PENDING` is the only
149/// state a fresh, pollable request is ever found in.
150fn read_requested(answer: &serde_json::Value) -> Result<Requested, ClientError> {
151    let message = answer.get("message").and_then(serde_json::Value::as_str);
152
153    if answer.get("state").and_then(serde_json::Value::as_str) == Some("PENDING") {
154        return answer
155            .get("requestId")
156            .and_then(serde_json::Value::as_str)
157            .map(|request_id| {
158                Requested::New(PendingRequest {
159                    request_id: request_id.to_owned(),
160                })
161            })
162            .ok_or_else(|| ClientError::Protocol(format!("no requestId in {answer}")));
163    }
164
165    // Refused outright. Recognised by what the server actually said, not
166    // guessed at from the status code alone.
167    if answer.get("statusCode").and_then(serde_json::Value::as_u64) == Some(400)
168        && message.is_some_and(|text| text.contains("already requested access"))
169    {
170        return Ok(Requested::AlreadyPending);
171    }
172
173    Err(ClientError::Protocol(message.map_or_else(
174        || format!("could not ask for access: {answer}"),
175        str::to_owned,
176    )))
177}
178
179/// Asks what became of a request.
180///
181/// # Errors
182///
183/// If the server cannot be reached, or answers something unreadable.
184pub fn check_access(
185    http_base: &str,
186    request: &PendingRequest,
187    trust: &Trust,
188) -> Result<Access, ClientError> {
189    let answer: serde_json::Value = agent(trust)?
190        .get(&format!(
191            "{http_base}/signalk/v1/requests/{}",
192            request.request_id
193        ))
194        .call()
195        .map_err(|error| ClientError::Http(error.to_string()))?
196        .body_mut()
197        .read_json()
198        .map_err(|error| ClientError::Http(error.to_string()))?;
199
200    Ok(read_access(&answer))
201}
202
203/// Reads one answer to a request enquiry.
204fn read_access(answer: &serde_json::Value) -> Access {
205    if answer.get("state").and_then(serde_json::Value::as_str) != Some("COMPLETED") {
206        return Access::Pending;
207    }
208
209    let request = answer.get("accessRequest");
210    let permission = request
211        .and_then(|request| request.get("permission"))
212        .and_then(serde_json::Value::as_str);
213    let token = request
214        .and_then(|request| request.get("token"))
215        .and_then(serde_json::Value::as_str);
216
217    match (permission, token) {
218        (Some("APPROVED"), Some(token)) => Access::Approved {
219            token: token.to_owned(),
220        },
221        // Approved but tokenless should not happen; treated as a refusal
222        // rather than as an approval, because acting on the optimistic
223        // reading would mean connecting with no credentials at all.
224        (Some("APPROVED"), None) | (Some(_), _) => Access::Denied,
225        (None, _) => Access::Pending,
226    }
227}
228
229/// An HTTP agent configured to trust only the boat's own authority.
230///
231/// Built per call rather than kept around: an access request and its
232/// approval poll are two calls made once in the life of an installation,
233/// and a shared agent would only add state to reason about. `pub(crate)`
234/// beyond this module for [`crate::resources`], which calls this on
235/// every list/publish rather than once -- still built per call there
236/// too, since the TLS setup this does is cheap next to the network round
237/// trip it precedes, and a second implementation of the same "believe
238/// only the boat's own authority" logic is a security-relevant thing to
239/// risk drifting apart, not a small utility worth duplicating for its
240/// own sake.
241pub(crate) fn agent(trust: &Trust) -> Result<ureq::Agent, ClientError> {
242    let mut tls = ureq::tls::TlsConfig::builder();
243    if !trust.is_plaintext() {
244        tls = tls.root_certs(ureq::tls::RootCerts::Specific(std::sync::Arc::new(
245            trust.ureq_certificates(),
246        )));
247    }
248
249    Ok(ureq::Agent::config_builder()
250        .timeout_global(Some(TIMEOUT))
251        .tls_config(tls.build())
252        .build()
253        .into())
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    fn answer(text: &str) -> serde_json::Value {
261        serde_json::from_str(text).expect("valid json")
262    }
263
264    #[test]
265    fn a_fresh_request_is_read() {
266        let requested = read_requested(&answer(
267            r#"{"state":"PENDING","requestId":"c5c310ed","statusCode":202}"#,
268        ))
269        .expect("a fresh request");
270        assert_eq!(
271            requested,
272            Requested::New(PendingRequest {
273                request_id: "c5c310ed".to_owned()
274            })
275        );
276    }
277
278    #[test]
279    fn a_clash_is_told_from_an_ordinary_failure() {
280        // The shape captured verbatim from a real server.
281        let clash = read_requested(&answer(
282            r#"{"state":"COMPLETED","requestId":"77980139","statusCode":400,
283                "message":"A device with clientId 'client-x' has already requested access"}"#,
284        ))
285        .expect("read as a clash, not an error");
286        assert_eq!(clash, Requested::AlreadyPending);
287    }
288
289    #[test]
290    fn the_clash_requestid_is_never_offered_as_something_to_poll() {
291        // It names the refused duplicate, not the original request. A
292        // caller that polled it would wait on something that can never
293        // resolve -- checked here by requiring AlreadyPending to carry no
294        // PendingRequest at all, so there is nothing to poll by mistake.
295        let clash = read_requested(&answer(
296            r#"{"state":"COMPLETED","requestId":"77980139","statusCode":400,
297                "message":"A device with clientId 'client-x' has already requested access"}"#,
298        ))
299        .expect("read as a clash, not an error");
300        assert!(!matches!(clash, Requested::New(_)));
301    }
302
303    #[test]
304    fn an_unrelated_400_is_still_an_error() {
305        // Missing description, bad permissions value, and the like: a real
306        // failure, not this one specific clash. Carries a requestId too --
307        // the id of the request that was refused -- which is exactly why
308        // `state` and not "is there a requestId" is what read_requested
309        // checks first.
310        assert!(
311            read_requested(&answer(
312                r#"{"state":"COMPLETED","requestId":"x","statusCode":400,
313                    "message":"Invalid permissions value"}"#
314            ))
315            .is_err()
316        );
317    }
318
319    #[test]
320    fn a_fresh_request_is_pending() {
321        // The shape a real server answers with, kept verbatim.
322        assert_eq!(
323            read_access(&answer(
324                r#"{"state":"PENDING","requestId":"c5c310ed","statusCode":202}"#
325            )),
326            Access::Pending
327        );
328    }
329
330    #[test]
331    fn an_approved_request_carries_the_token() {
332        let access = read_access(&answer(
333            r#"{"state":"COMPLETED","requestId":"c5c310ed","statusCode":200,
334                "accessRequest":{"permission":"APPROVED","token":"a.b.c"}}"#,
335        ));
336        assert_eq!(
337            access,
338            Access::Approved {
339                token: "a.b.c".to_owned()
340            }
341        );
342    }
343
344    #[test]
345    fn a_refusal_is_an_answer_and_not_an_error() {
346        assert_eq!(
347            read_access(&answer(
348                r#"{"state":"COMPLETED","accessRequest":{"permission":"DENIED"}}"#
349            )),
350            Access::Denied
351        );
352    }
353
354    #[test]
355    fn approved_without_a_token_is_treated_as_a_refusal() {
356        // Should not happen. If it does, the optimistic reading would have
357        // the client connect with no credentials and fail later, somewhere
358        // less obvious.
359        assert_eq!(
360            read_access(&answer(
361                r#"{"state":"COMPLETED","accessRequest":{"permission":"APPROVED"}}"#
362            )),
363            Access::Denied
364        );
365    }
366
367    #[test]
368    fn permissions_are_spelled_the_way_the_server_spells_them() {
369        assert_eq!(Permissions::ReadOnly.as_str(), "readonly");
370        assert_eq!(Permissions::ReadWrite.as_str(), "readwrite");
371    }
372}