Skip to main content

zlayer_types/
jwt.rs

1//! JWT claims and related auth wire types.
2//!
3//! Lifted from `zlayer-api` so cross-crate consumers (`zlayer-agent`,
4//! the CLI, future federation code) can name the JWT claims without
5//! depending on `zlayer-api`. Token signing/verification helpers stay
6//! in `zlayer-api::auth`.
7
8use serde::{Deserialize, Serialize};
9use std::time::{Duration, SystemTime, UNIX_EPOCH};
10
11use crate::storage::{PermissionLevel, TokenScope};
12
13/// JWT claims used by every protected endpoint.
14///
15/// `node_id` is `Some` only on node JWTs — tokens issued by the leader
16/// during cluster join with `roles: ["node"]` so a node can authenticate
17/// to its peers' internal endpoints distinct from any user identity.
18/// All other JWT issuers leave it `None`.
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct Claims {
21    /// Subject — user id, api-key id, or `"node:{node_id}"` for node JWTs.
22    pub sub: String,
23
24    /// Expiration time (Unix seconds).
25    pub exp: u64,
26
27    /// Issued at (Unix seconds).
28    pub iat: u64,
29
30    /// Issuer (canonically `"zlayer"`; federation will use a cluster-id form).
31    pub iss: String,
32
33    /// Role claims, e.g. `["admin"]`, `["operator"]`, `["node"]`.
34    ///
35    /// `#[serde(default)]` preserves back-compat with tokens minted before
36    /// the field existed.
37    #[serde(default)]
38    pub roles: Vec<String>,
39
40    /// Email — embedded for session JWTs so the manager UI doesn't
41    /// have to round-trip to the user store on every request.
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub email: Option<String>,
44
45    /// Cluster-wide node UUID. `Some` only for JWTs minted by the
46    /// leader at cluster-join time (where `roles` contains `"node"`).
47    /// All other token kinds leave this `None`.
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub node_id: Option<String>,
50
51    /// Least-privilege scope grants baked into the token. Empty for human /
52    /// admin / node tokens (whose authority comes from `roles`); non-empty for
53    /// scoped access tokens minted for containers, jobs, or CI runners. When
54    /// non-empty, the token is authorized for a request only if some scope
55    /// [`TokenScope::satisfies`] it (an `admin` role still short-circuits).
56    ///
57    /// `#[serde(default)]` keeps back-compat with pre-scope tokens.
58    #[serde(default, skip_serializing_if = "Vec::is_empty")]
59    pub scopes: Vec<TokenScope>,
60
61    /// Token id — the revocation handle. `Some` only on managed scoped tokens
62    /// that have a [`crate::storage::StoredAccessToken`] record; the auth layer
63    /// rejects the token if its `jti` has been revoked. Human/node tokens leave
64    /// this `None` and skip the revocation lookup (stateless fast path).
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub jti: Option<String>,
67}
68
69impl Claims {
70    /// Create new claims.
71    ///
72    /// `node_id` is left `None`; node JWTs are constructed via struct
73    /// literal at the cluster-join site.
74    ///
75    /// # Panics
76    ///
77    /// Panics if the system clock is before the Unix epoch.
78    pub fn new(
79        subject: impl Into<String>,
80        expiry: Duration,
81        roles: Vec<String>,
82        email: Option<String>,
83    ) -> Self {
84        let now = SystemTime::now()
85            .duration_since(UNIX_EPOCH)
86            .expect("system clock before Unix epoch")
87            .as_secs();
88
89        Self {
90            sub: subject.into(),
91            exp: now + expiry.as_secs(),
92            iat: now,
93            iss: "zlayer".to_string(),
94            roles,
95            email,
96            node_id: None,
97            scopes: Vec::new(),
98            jti: None,
99        }
100    }
101
102    /// Create claims for a scoped access token: an attenuated bearer carrying
103    /// `scopes` and a `jti` revocation handle. `roles` is usually empty (or a
104    /// non-`admin` marker); authority comes from `scopes`.
105    ///
106    /// # Panics
107    ///
108    /// Panics if the system clock is before the Unix epoch.
109    pub fn new_scoped(
110        subject: impl Into<String>,
111        expiry: Duration,
112        roles: Vec<String>,
113        scopes: Vec<TokenScope>,
114        jti: impl Into<String>,
115    ) -> Self {
116        let now = SystemTime::now()
117            .duration_since(UNIX_EPOCH)
118            .expect("system clock before Unix epoch")
119            .as_secs();
120
121        Self {
122            sub: subject.into(),
123            exp: now + expiry.as_secs(),
124            iat: now,
125            iss: "zlayer".to_string(),
126            roles,
127            email: None,
128            node_id: None,
129            scopes,
130            jti: Some(jti.into()),
131        }
132    }
133
134    /// Check if token is expired.
135    ///
136    /// # Panics
137    ///
138    /// Panics if the system clock is before the Unix epoch.
139    #[must_use]
140    pub fn is_expired(&self) -> bool {
141        let now = SystemTime::now()
142            .duration_since(UNIX_EPOCH)
143            .expect("system clock before Unix epoch")
144            .as_secs();
145        self.exp < now
146    }
147
148    /// Check if the user has a specific role
149    #[must_use]
150    pub fn has_role(&self, role: &str) -> bool {
151        self.roles.iter().any(|r| r == role || r == "admin")
152    }
153
154    /// Whether this token is a scoped access token (carries embedded scopes).
155    #[must_use]
156    pub fn is_scoped(&self) -> bool {
157        !self.scopes.is_empty()
158    }
159
160    /// Whether the token authorizes `level` access to
161    /// `(resource_kind, resource_id)`.
162    ///
163    /// An `admin` role short-circuits to `true`. Otherwise authorization is
164    /// granted iff some embedded [`TokenScope`] satisfies the request. A token
165    /// with no scopes and no `admin` role returns `false` here — non-scoped
166    /// human authority is decided by role/permission checks elsewhere, not by
167    /// this method.
168    #[must_use]
169    pub fn satisfies(
170        &self,
171        resource_kind: &str,
172        resource_id: Option<&str>,
173        level: PermissionLevel,
174    ) -> bool {
175        if self.roles.iter().any(|r| r == "admin") {
176            return true;
177        }
178        self.scopes
179            .iter()
180            .any(|s| s.satisfies(resource_kind, resource_id, level))
181    }
182}