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}