Skip to main content

prospero_types/
auth.rs

1//! Authentication DTOs shared by prosperod and the WASM dashboard (#2).
2//!
3//! Serde-only so they compile to wasm32 alongside the rest of this crate.
4
5use serde::{Deserialize, Serialize};
6
7/// A token's permission level. Ordered: `Read < Operate < Admin`, so a route's
8/// requirement is met when `principal.scope >= required`.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11#[cfg_attr(feature = "openapi", derive(schemars::JsonSchema))]
12pub enum Scope {
13    /// Every GET (fleet, usage, events, SSE, metrics).
14    Read,
15    /// Read plus agent actions (spawn, kill, respawn, input, end-input, rm).
16    Operate,
17    /// Operate plus workspace add / remove / config.
18    Admin,
19}
20
21impl Scope {
22    /// The lowercase wire/config spelling.
23    pub fn as_str(&self) -> &'static str {
24        match self {
25            Scope::Read => "read",
26            Scope::Operate => "operate",
27            Scope::Admin => "admin",
28        }
29    }
30
31    /// Parse the lowercase spelling; anything else is `None`.
32    pub fn parse(s: &str) -> Option<Scope> {
33        match s {
34            "read" => Some(Scope::Read),
35            "operate" => Some(Scope::Operate),
36            "admin" => Some(Scope::Admin),
37            _ => None,
38        }
39    }
40}
41
42/// `GET /api/session` / `POST /api/session` response.
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(tag = "auth", rename_all = "snake_case")]
45#[cfg_attr(feature = "openapi", derive(schemars::JsonSchema))]
46pub enum SessionInfo {
47    /// No tokens configured: the dashboard skips sign-in and shows every control.
48    Disabled,
49    /// Authenticated with a named token.
50    Token {
51        /// The tokens-file name of the credential in use.
52        token_name: String,
53        /// Its scope.
54        scope: Scope,
55        /// RFC-3339 cookie expiry; `None` when authenticated by bearer header.
56        #[serde(default, skip_serializing_if = "Option::is_none")]
57        expires_at: Option<String>,
58    },
59}
60
61/// `POST /api/session` body.
62#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
63#[cfg_attr(feature = "openapi", derive(schemars::JsonSchema))]
64pub struct SignInBody {
65    /// The raw `pspo_…` token pasted by the operator.
66    pub token: String,
67}
68
69/// Manual `Debug`: `token` is the raw, still-usable credential (spec §3 —
70/// tokens are never in unredacted `Debug`), so it's never printed.
71impl std::fmt::Debug for SignInBody {
72    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73        f.debug_struct("SignInBody")
74            .field("token", &"<redacted>")
75            .finish()
76    }
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82
83    #[test]
84    fn scopes_are_ordered() {
85        assert!(Scope::Read < Scope::Operate);
86        assert!(Scope::Operate < Scope::Admin);
87    }
88
89    #[test]
90    fn scope_parse_round_trips_and_rejects_unknown() {
91        for s in [Scope::Read, Scope::Operate, Scope::Admin] {
92            assert_eq!(Scope::parse(s.as_str()), Some(s));
93        }
94        assert_eq!(Scope::parse("Admin"), None);
95        assert_eq!(Scope::parse("write"), None);
96    }
97
98    #[test]
99    fn session_info_wire_shapes() {
100        assert_eq!(
101            serde_json::to_value(SessionInfo::Disabled).unwrap(),
102            serde_json::json!({"auth": "disabled"})
103        );
104        let t = SessionInfo::Token {
105            token_name: "alice".into(),
106            scope: Scope::Admin,
107            expires_at: None,
108        };
109        assert_eq!(
110            serde_json::to_value(&t).unwrap(),
111            serde_json::json!({"auth": "token", "token_name": "alice", "scope": "admin"})
112        );
113        let back: SessionInfo = serde_json::from_value(serde_json::to_value(&t).unwrap()).unwrap();
114        assert_eq!(back, t);
115    }
116
117    #[test]
118    fn sign_in_body_debug_redacts_the_token() {
119        let body = SignInBody {
120            token: "pspo_supersecretvalue".to_string(),
121        };
122        let debug = format!("{body:?}");
123        assert!(!debug.contains("pspo_supersecretvalue"), "{debug}");
124        assert!(debug.contains("<redacted>"), "{debug}");
125    }
126}