Skip to main content

arete_auth/
solana_gateway.rs

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