use thiserror::Error;
use crate::{
AuthContext, KeyClass, Limits, TargetKind, SCOPE_READ, SCOPE_TRANSACTION_INSPECT,
SCOPE_TRANSACTION_SEND, SOLANA_GATEWAY_AUDIENCE,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SolanaGatewayScope {
Read,
TransactionInspect,
TransactionSend,
}
impl SolanaGatewayScope {
pub const fn as_str(self) -> &'static str {
match self {
Self::Read => SCOPE_READ,
Self::TransactionInspect => SCOPE_TRANSACTION_INSPECT,
Self::TransactionSend => SCOPE_TRANSACTION_SEND,
}
}
}
impl std::fmt::Display for SolanaGatewayScope {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(self.as_str())
}
}
#[derive(Debug, Clone)]
pub struct SolanaGatewayAuthorization {
pub subject: String,
pub issuer: String,
pub key_class: KeyClass,
pub metering_key: String,
pub target_id: String,
pub limits: Limits,
pub plan: Option<String>,
pub expires_at: u64,
pub jti: String,
pub actor_key: Option<String>,
pub account_key: Option<String>,
pub consumer_key: Option<String>,
pub policy_version: Option<u32>,
pub account_limits: Limits,
}
impl SolanaGatewayAuthorization {
pub fn validate_target(
context: &AuthContext,
expected_target_id: &str,
) -> Result<(), SolanaGatewayAuthorizationError> {
if context.audience != SOLANA_GATEWAY_AUDIENCE {
return Err(SolanaGatewayAuthorizationError::InvalidAudience {
actual: context.audience.clone(),
});
}
match context.target_kind {
Some(TargetKind::SolanaGatewayBinding) => {}
Some(actual) => {
return Err(SolanaGatewayAuthorizationError::InvalidTargetKind { actual });
}
None => return Err(SolanaGatewayAuthorizationError::MissingClaim("targetKind")),
}
let target_id = context
.target_id
.as_deref()
.filter(|target_id| !target_id.is_empty())
.ok_or(SolanaGatewayAuthorizationError::MissingClaim("targetId"))?;
if target_id != expected_target_id {
return Err(SolanaGatewayAuthorizationError::TargetIdMismatch {
expected: expected_target_id.to_string(),
actual: target_id.to_string(),
});
}
Ok(())
}
pub fn try_from_context(
context: &AuthContext,
expected_target_id: &str,
required_scope: SolanaGatewayScope,
) -> Result<Self, SolanaGatewayAuthorizationError> {
Self::validate_target(context, expected_target_id)?;
let target_id = context
.target_id
.as_deref()
.expect("validated gateway target ID");
if !context.has_scope(required_scope.as_str()) {
return Err(SolanaGatewayAuthorizationError::MissingScope {
required: required_scope,
});
}
Ok(Self {
subject: context.subject.clone(),
issuer: context.issuer.clone(),
key_class: context.key_class,
metering_key: context.metering_key.clone(),
target_id: target_id.to_string(),
limits: context.limits.clone(),
plan: context.plan.clone(),
expires_at: context.expires_at,
jti: context.jti.clone(),
actor_key: context.actor_key.clone(),
account_key: context.account_key.clone(),
consumer_key: context.consumer_key.clone(),
policy_version: context.policy_version,
account_limits: context.account_limits.clone(),
})
}
pub fn actor_key(&self) -> &str {
crate::claims::resolve_policy_identity(self.actor_key.as_deref(), &self.subject)
}
pub fn consumer_key(&self) -> &str {
crate::claims::resolve_policy_identity(self.consumer_key.as_deref(), &self.subject)
}
pub fn account_key(&self) -> &str {
crate::claims::resolve_policy_identity(self.account_key.as_deref(), &self.metering_key)
}
pub fn is_legacy_policy(&self) -> bool {
self.actor_key.is_none()
&& self.account_key.is_none()
&& self.consumer_key.is_none()
&& self.policy_version.is_none()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum SolanaGatewayAuthorizationError {
#[error("invalid Solana gateway audience: {actual}")]
InvalidAudience { actual: String },
#[error("missing required Solana gateway claim: {0}")]
MissingClaim(&'static str),
#[error("invalid Solana gateway target kind: {actual:?}")]
InvalidTargetKind { actual: TargetKind },
#[error("Solana gateway target mismatch: expected {expected}, got {actual}")]
TargetIdMismatch { expected: String, actual: String },
#[error("Solana gateway authorization requires the {required} scope")]
MissingScope { required: SolanaGatewayScope },
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{SessionClaims, SigningKey, TokenSigner, TokenVerifier};
const TARGET_ID: &str = "gateway-us-east-1";
fn claims(scope: &str) -> SessionClaims {
SessionClaims::solana_gateway_builder("issuer", "user:1", TARGET_ID)
.with_scope(scope)
.with_metering_key("api_key:42")
.build()
}
fn context(scope: &str) -> AuthContext {
AuthContext::from_claims(claims(scope))
}
#[test]
fn signed_gateway_claims_validate_for_each_exact_scope() {
let signing_key = SigningKey::generate();
let token = TokenSigner::new(signing_key.clone(), "issuer")
.sign(claims(
"read transaction:inspect transaction:send transaction:send-extra",
))
.unwrap();
let context = TokenVerifier::new(
signing_key.verifying_key(),
"issuer",
SOLANA_GATEWAY_AUDIENCE,
)
.verify(&token, None, None)
.unwrap();
for scope in [
SolanaGatewayScope::Read,
SolanaGatewayScope::TransactionInspect,
SolanaGatewayScope::TransactionSend,
] {
let authorization =
SolanaGatewayAuthorization::try_from_context(&context, TARGET_ID, scope).unwrap();
assert_eq!(authorization.target_id, TARGET_ID);
assert_eq!(authorization.metering_key, "api_key:42");
}
}
#[test]
fn v2_policy_fields_propagate_into_authorization() {
let account_limits = Limits {
max_transaction_send_requests_per_minute: Some(120),
max_transaction_concurrency: Some(8),
..Limits::default()
};
let claims = SessionClaims::solana_gateway_builder("issuer", "user:1", TARGET_ID)
.with_metering_key("account:42")
.with_actor_key("user:1")
.with_account_key("account:42")
.with_consumer_key("consumer:abc123")
.with_policy_version(4)
.with_account_limits(account_limits.clone())
.build();
let authorization = SolanaGatewayAuthorization::try_from_context(
&AuthContext::from_claims(claims),
TARGET_ID,
SolanaGatewayScope::Read,
)
.unwrap();
assert!(!authorization.is_legacy_policy());
assert_eq!(authorization.actor_key(), "user:1");
assert_eq!(authorization.consumer_key(), "consumer:abc123");
assert_eq!(authorization.account_key(), "account:42");
assert_eq!(authorization.policy_version, Some(4));
assert_eq!(authorization.account_limits, account_limits);
let legacy = SolanaGatewayAuthorization::try_from_context(
&context(SCOPE_READ),
TARGET_ID,
SolanaGatewayScope::Read,
)
.unwrap();
assert!(legacy.is_legacy_policy());
assert_eq!(legacy.consumer_key(), "user:1");
assert_eq!(legacy.account_key(), "api_key:42");
}
#[test]
fn similarly_named_scope_does_not_authorize_send() {
let error = SolanaGatewayAuthorization::try_from_context(
&context("transaction:send-extra"),
TARGET_ID,
SolanaGatewayScope::TransactionSend,
)
.unwrap_err();
assert_eq!(
error,
SolanaGatewayAuthorizationError::MissingScope {
required: SolanaGatewayScope::TransactionSend
}
);
}
#[test]
fn wrong_audience_kind_or_target_is_rejected() {
let mut wrong_audience = context(SCOPE_READ);
wrong_audience.audience = "deployment-1".into();
assert!(matches!(
SolanaGatewayAuthorization::try_from_context(
&wrong_audience,
TARGET_ID,
SolanaGatewayScope::Read
),
Err(SolanaGatewayAuthorizationError::InvalidAudience { .. })
));
let deployment = AuthContext::from_claims(
SessionClaims::builder("issuer", "user:1", SOLANA_GATEWAY_AUDIENCE)
.with_target(TargetKind::Deployment, TARGET_ID)
.build(),
);
assert!(matches!(
SolanaGatewayAuthorization::try_from_context(
&deployment,
TARGET_ID,
SolanaGatewayScope::Read
),
Err(SolanaGatewayAuthorizationError::InvalidTargetKind {
actual: TargetKind::Deployment
})
));
assert!(matches!(
SolanaGatewayAuthorization::try_from_context(
&context(SCOPE_READ),
"gateway-eu-west-1",
SolanaGatewayScope::Read
),
Err(SolanaGatewayAuthorizationError::TargetIdMismatch { .. })
));
}
}