Skip to main content

arete_auth/
solana_gateway.rs

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/// One exact permission understood by the Solana gateway.
9#[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/// Authorization for one regional Solana gateway binding.
35#[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    /// Raw signed actor identity; use [`SolanaGatewayAuthorization::actor_key`] to resolve.
47    pub actor_key: Option<String>,
48    /// Raw signed account identity; use [`SolanaGatewayAuthorization::account_key`] to resolve.
49    pub account_key: Option<String>,
50    /// Raw signed consumer identity; use [`SolanaGatewayAuthorization::consumer_key`] to resolve.
51    pub consumer_key: Option<String>,
52    /// Monotonic account policy version, absent on legacy tokens.
53    pub policy_version: Option<u32>,
54    /// Aggregate account limits, defaulted when the token carries none.
55    pub account_limits: Limits,
56}
57
58impl SolanaGatewayAuthorization {
59    /// Validate only the stable audience and exact gateway target claims.
60    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    /// Validate verified claims against an exact gateway binding and permission.
93    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    /// Resolved actor identity: `actor_key` claim, falling back to `sub`.
129    pub fn actor_key(&self) -> &str {
130        crate::claims::resolve_policy_identity(self.actor_key.as_deref(), &self.subject)
131    }
132
133    /// Resolved consumer identity: `consumer_key` claim, falling back to `sub`.
134    pub fn consumer_key(&self) -> &str {
135        crate::claims::resolve_policy_identity(self.consumer_key.as_deref(), &self.subject)
136    }
137
138    /// Resolved account identity: `account_key` claim, falling back to
139    /// `metering_key`.
140    pub fn account_key(&self) -> &str {
141        crate::claims::resolve_policy_identity(self.account_key.as_deref(), &self.metering_key)
142    }
143
144    /// True when the token predates the v2 policy contract.
145    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/// Failure to authorize verified claims for a Solana gateway binding.
154#[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}