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}