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