1use thiserror::Error;
2
3use crate::{
4 AuthContext, KeyClass, Limits, TargetKind, SCOPE_FORK_READ, SCOPE_READ,
5 SCOPE_TRANSACTION_INSPECT, SCOPE_TRANSACTION_SEND, SOLANA_GATEWAY_AUDIENCE,
6};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum SolanaGatewayScope {
11 Read,
12 TransactionInspect,
13 TransactionSend,
14 ForkRead,
15}
16
17impl SolanaGatewayScope {
18 pub const fn as_str(self) -> &'static str {
19 match self {
20 Self::Read => SCOPE_READ,
21 Self::TransactionInspect => SCOPE_TRANSACTION_INSPECT,
22 Self::TransactionSend => SCOPE_TRANSACTION_SEND,
23 Self::ForkRead => SCOPE_FORK_READ,
24 }
25 }
26}
27
28impl std::fmt::Display for SolanaGatewayScope {
29 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30 formatter.write_str(self.as_str())
31 }
32}
33
34#[derive(Debug, Clone)]
36pub struct SolanaGatewayAuthorization {
37 pub subject: String,
38 pub issuer: String,
39 pub key_class: KeyClass,
40 pub metering_key: String,
41 pub target_id: String,
42 pub limits: Limits,
43 pub plan: Option<String>,
44 pub expires_at: u64,
45 pub jti: String,
46 pub actor_key: Option<String>,
48 pub account_key: Option<String>,
50 pub consumer_key: Option<String>,
52 pub policy_version: Option<u32>,
54 pub account_limits: Limits,
56}
57
58impl SolanaGatewayAuthorization {
59 pub fn validate_target(
61 context: &AuthContext,
62 expected_target_id: &str,
63 ) -> Result<(), SolanaGatewayAuthorizationError> {
64 if context.audience != SOLANA_GATEWAY_AUDIENCE {
65 return Err(SolanaGatewayAuthorizationError::InvalidAudience {
66 actual: context.audience.clone(),
67 });
68 }
69
70 match context.target_kind {
71 Some(TargetKind::SolanaGatewayBinding) => {}
72 Some(actual) => {
73 return Err(SolanaGatewayAuthorizationError::InvalidTargetKind { actual });
74 }
75 None => return Err(SolanaGatewayAuthorizationError::MissingClaim("targetKind")),
76 }
77
78 let target_id = context
79 .target_id
80 .as_deref()
81 .filter(|target_id| !target_id.is_empty())
82 .ok_or(SolanaGatewayAuthorizationError::MissingClaim("targetId"))?;
83 if target_id != expected_target_id {
84 return Err(SolanaGatewayAuthorizationError::TargetIdMismatch {
85 expected: expected_target_id.to_string(),
86 actual: target_id.to_string(),
87 });
88 }
89 Ok(())
90 }
91
92 pub fn try_from_context(
94 context: &AuthContext,
95 expected_target_id: &str,
96 required_scope: SolanaGatewayScope,
97 ) -> Result<Self, SolanaGatewayAuthorizationError> {
98 Self::validate_target(context, expected_target_id)?;
99 let target_id = context
100 .target_id
101 .as_deref()
102 .expect("validated gateway target ID");
103
104 if !context.has_scope(required_scope.as_str()) {
105 return Err(SolanaGatewayAuthorizationError::MissingScope {
106 required: required_scope,
107 });
108 }
109
110 Ok(Self {
111 subject: context.subject.clone(),
112 issuer: context.issuer.clone(),
113 key_class: context.key_class,
114 metering_key: context.metering_key.clone(),
115 target_id: target_id.to_string(),
116 limits: context.limits.clone(),
117 plan: context.plan.clone(),
118 expires_at: context.expires_at,
119 jti: context.jti.clone(),
120 actor_key: context.actor_key.clone(),
121 account_key: context.account_key.clone(),
122 consumer_key: context.consumer_key.clone(),
123 policy_version: context.policy_version,
124 account_limits: context.account_limits.clone(),
125 })
126 }
127
128 pub fn actor_key(&self) -> &str {
130 crate::claims::resolve_policy_identity(self.actor_key.as_deref(), &self.subject)
131 }
132
133 pub fn consumer_key(&self) -> &str {
135 crate::claims::resolve_policy_identity(self.consumer_key.as_deref(), &self.subject)
136 }
137
138 pub fn account_key(&self) -> &str {
141 crate::claims::resolve_policy_identity(self.account_key.as_deref(), &self.metering_key)
142 }
143
144 pub fn is_legacy_policy(&self) -> bool {
146 self.actor_key.is_none()
147 && self.account_key.is_none()
148 && self.consumer_key.is_none()
149 && self.policy_version.is_none()
150 }
151}
152
153#[derive(Debug, Clone, PartialEq, Eq, Error)]
155pub enum SolanaGatewayAuthorizationError {
156 #[error("invalid Solana gateway audience: {actual}")]
157 InvalidAudience { actual: String },
158 #[error("missing required Solana gateway claim: {0}")]
159 MissingClaim(&'static str),
160 #[error("invalid Solana gateway target kind: {actual:?}")]
161 InvalidTargetKind { actual: TargetKind },
162 #[error("Solana gateway target mismatch: expected {expected}, got {actual}")]
163 TargetIdMismatch { expected: String, actual: String },
164 #[error("Solana gateway authorization requires the {required} scope")]
165 MissingScope { required: SolanaGatewayScope },
166}
167
168#[cfg(test)]
169mod tests {
170 use super::*;
171 use crate::{SessionClaims, SigningKey, TokenSigner, TokenVerifier};
172
173 const TARGET_ID: &str = "gateway-us-east-1";
174
175 fn claims(scope: &str) -> SessionClaims {
176 SessionClaims::solana_gateway_builder("issuer", "user:1", TARGET_ID)
177 .with_scope(scope)
178 .with_metering_key("api_key:42")
179 .build()
180 }
181
182 fn context(scope: &str) -> AuthContext {
183 AuthContext::from_claims(claims(scope))
184 }
185
186 #[test]
187 fn signed_gateway_claims_validate_for_each_exact_scope() {
188 let signing_key = SigningKey::generate();
189 let token = TokenSigner::new(signing_key.clone(), "issuer")
190 .sign(claims(
191 "read transaction:inspect transaction:send transaction:send-extra",
192 ))
193 .unwrap();
194 let context = TokenVerifier::new(
195 signing_key.verifying_key(),
196 "issuer",
197 SOLANA_GATEWAY_AUDIENCE,
198 )
199 .verify(&token, None, None)
200 .unwrap();
201
202 for scope in [
203 SolanaGatewayScope::Read,
204 SolanaGatewayScope::TransactionInspect,
205 SolanaGatewayScope::TransactionSend,
206 ] {
207 let authorization =
208 SolanaGatewayAuthorization::try_from_context(&context, TARGET_ID, scope).unwrap();
209 assert_eq!(authorization.target_id, TARGET_ID);
210 assert_eq!(authorization.metering_key, "api_key:42");
211 }
212 }
213
214 #[test]
215 fn v2_policy_fields_propagate_into_authorization() {
216 let account_limits = Limits {
217 max_transaction_send_requests_per_minute: Some(120),
218 max_transaction_concurrency: Some(8),
219 ..Limits::default()
220 };
221 let claims = SessionClaims::solana_gateway_builder("issuer", "user:1", TARGET_ID)
222 .with_metering_key("account:42")
223 .with_actor_key("user:1")
224 .with_account_key("account:42")
225 .with_consumer_key("consumer:abc123")
226 .with_policy_version(4)
227 .with_account_limits(account_limits.clone())
228 .build();
229 let authorization = SolanaGatewayAuthorization::try_from_context(
230 &AuthContext::from_claims(claims),
231 TARGET_ID,
232 SolanaGatewayScope::Read,
233 )
234 .unwrap();
235
236 assert!(!authorization.is_legacy_policy());
237 assert_eq!(authorization.actor_key(), "user:1");
238 assert_eq!(authorization.consumer_key(), "consumer:abc123");
239 assert_eq!(authorization.account_key(), "account:42");
240 assert_eq!(authorization.policy_version, Some(4));
241 assert_eq!(authorization.account_limits, account_limits);
242
243 let legacy = SolanaGatewayAuthorization::try_from_context(
244 &context(SCOPE_READ),
245 TARGET_ID,
246 SolanaGatewayScope::Read,
247 )
248 .unwrap();
249 assert!(legacy.is_legacy_policy());
250 assert_eq!(legacy.consumer_key(), "user:1");
251 assert_eq!(legacy.account_key(), "api_key:42");
252 }
253
254 #[test]
255 fn similarly_named_scope_does_not_authorize_send() {
256 let error = SolanaGatewayAuthorization::try_from_context(
257 &context("transaction:send-extra"),
258 TARGET_ID,
259 SolanaGatewayScope::TransactionSend,
260 )
261 .unwrap_err();
262
263 assert_eq!(
264 error,
265 SolanaGatewayAuthorizationError::MissingScope {
266 required: SolanaGatewayScope::TransactionSend
267 }
268 );
269 }
270
271 #[test]
272 fn fork_read_and_read_do_not_imply_each_other() {
273 let fork = context(SCOPE_FORK_READ);
274 SolanaGatewayAuthorization::try_from_context(
275 &fork,
276 TARGET_ID,
277 SolanaGatewayScope::ForkRead,
278 )
279 .unwrap();
280 for scope in [
281 SolanaGatewayScope::Read,
282 SolanaGatewayScope::TransactionInspect,
283 SolanaGatewayScope::TransactionSend,
284 ] {
285 assert_eq!(
286 SolanaGatewayAuthorization::try_from_context(&fork, TARGET_ID, scope).unwrap_err(),
287 SolanaGatewayAuthorizationError::MissingScope { required: scope }
288 );
289 }
290 assert_eq!(
291 SolanaGatewayAuthorization::try_from_context(
292 &context(SCOPE_READ),
293 TARGET_ID,
294 SolanaGatewayScope::ForkRead,
295 )
296 .unwrap_err(),
297 SolanaGatewayAuthorizationError::MissingScope {
298 required: SolanaGatewayScope::ForkRead
299 }
300 );
301 assert_eq!(SolanaGatewayScope::ForkRead.as_str(), "fork:read");
302 }
303
304 #[test]
305 fn wrong_audience_kind_or_target_is_rejected() {
306 let mut wrong_audience = context(SCOPE_READ);
307 wrong_audience.audience = "deployment-1".into();
308 assert!(matches!(
309 SolanaGatewayAuthorization::try_from_context(
310 &wrong_audience,
311 TARGET_ID,
312 SolanaGatewayScope::Read
313 ),
314 Err(SolanaGatewayAuthorizationError::InvalidAudience { .. })
315 ));
316
317 let deployment = AuthContext::from_claims(
318 SessionClaims::builder("issuer", "user:1", SOLANA_GATEWAY_AUDIENCE)
319 .with_target(TargetKind::Deployment, TARGET_ID)
320 .build(),
321 );
322 assert!(matches!(
323 SolanaGatewayAuthorization::try_from_context(
324 &deployment,
325 TARGET_ID,
326 SolanaGatewayScope::Read
327 ),
328 Err(SolanaGatewayAuthorizationError::InvalidTargetKind {
329 actual: TargetKind::Deployment
330 })
331 ));
332
333 assert!(matches!(
334 SolanaGatewayAuthorization::try_from_context(
335 &context(SCOPE_READ),
336 "gateway-eu-west-1",
337 SolanaGatewayScope::Read
338 ),
339 Err(SolanaGatewayAuthorizationError::TargetIdMismatch { .. })
340 ));
341 }
342}