Skip to main content

arete_auth/
program_read.rs

1use thiserror::Error;
2
3use crate::{AuthContext, KeyClass, Limits, TargetKind, PROGRAM_READ_AUDIENCE, SCOPE_READ};
4
5/// Authorization for one exact program read, derived from verified claims.
6#[derive(Debug, Clone)]
7pub struct ProgramReadAuthorization {
8    /// Subject used for user attribution.
9    pub subject: String,
10    /// Issuer of the verified token.
11    pub issuer: String,
12    /// API key class used for policy and attribution.
13    pub key_class: KeyClass,
14    /// Opaque API-key or anonymous metering identity.
15    pub metering_key: String,
16    /// Authorized public program-read binding ID.
17    pub target_id: String,
18    /// Authorized Solana program ID.
19    pub program_id: String,
20    /// Authorized immutable program release hash.
21    pub program_release_hash: String,
22    /// Resource limits carried by the token.
23    pub limits: Limits,
24    /// Plan or access tier carried by the token.
25    pub plan: Option<String>,
26    /// Token expiration time.
27    pub expires_at: u64,
28    /// JWT ID for audit correlation.
29    pub jti: String,
30}
31
32impl ProgramReadAuthorization {
33    /// Convert a verified context into authorization for the requested resource.
34    pub fn try_from_context(
35        context: &AuthContext,
36        expected_target_id: &str,
37        expected_program_id: &str,
38        expected_program_release_hash: &str,
39    ) -> Result<Self, ProgramReadAuthorizationError> {
40        if context.audience != PROGRAM_READ_AUDIENCE {
41            return Err(ProgramReadAuthorizationError::InvalidAudience {
42                actual: context.audience.clone(),
43            });
44        }
45
46        match context.target_kind {
47            Some(TargetKind::ProgramReadBinding) => {}
48            Some(actual) => {
49                return Err(ProgramReadAuthorizationError::InvalidTargetKind { actual });
50            }
51            None => return Err(ProgramReadAuthorizationError::MissingClaim("targetKind")),
52        }
53
54        let target_id = required_claim(context.target_id.as_deref(), "targetId")?;
55        if target_id != expected_target_id {
56            return Err(ProgramReadAuthorizationError::TargetIdMismatch {
57                expected: expected_target_id.to_string(),
58                actual: target_id.to_string(),
59            });
60        }
61
62        let program_id = required_claim(context.program_id.as_deref(), "programId")?;
63        if program_id != expected_program_id {
64            return Err(ProgramReadAuthorizationError::ProgramIdMismatch {
65                expected: expected_program_id.to_string(),
66                actual: program_id.to_string(),
67            });
68        }
69
70        let program_release_hash = required_claim(
71            context.program_release_hash.as_deref(),
72            "programReleaseHash",
73        )?;
74        if program_release_hash != expected_program_release_hash {
75            return Err(ProgramReadAuthorizationError::ProgramReleaseHashMismatch {
76                expected: expected_program_release_hash.to_string(),
77                actual: program_release_hash.to_string(),
78            });
79        }
80
81        if !context.has_scope(SCOPE_READ) {
82            return Err(ProgramReadAuthorizationError::MissingReadScope);
83        }
84
85        Ok(Self {
86            subject: context.subject.clone(),
87            issuer: context.issuer.clone(),
88            key_class: context.key_class,
89            metering_key: context.metering_key.clone(),
90            target_id: target_id.to_string(),
91            program_id: program_id.to_string(),
92            program_release_hash: program_release_hash.to_string(),
93            limits: context.limits.clone(),
94            plan: context.plan.clone(),
95            expires_at: context.expires_at,
96            jti: context.jti.clone(),
97        })
98    }
99}
100
101impl<'a> TryFrom<(&'a AuthContext, &'a str, &'a str, &'a str)> for ProgramReadAuthorization {
102    type Error = ProgramReadAuthorizationError;
103
104    fn try_from(
105        (context, target_id, program_id, program_release_hash): (
106            &'a AuthContext,
107            &'a str,
108            &'a str,
109            &'a str,
110        ),
111    ) -> Result<Self, Self::Error> {
112        Self::try_from_context(context, target_id, program_id, program_release_hash)
113    }
114}
115
116fn required_claim<'a>(
117    value: Option<&'a str>,
118    name: &'static str,
119) -> Result<&'a str, ProgramReadAuthorizationError> {
120    value
121        .filter(|value| !value.is_empty())
122        .ok_or(ProgramReadAuthorizationError::MissingClaim(name))
123}
124
125/// Failure to authorize a verified token for an exact program read.
126#[derive(Debug, Clone, PartialEq, Eq, Error)]
127pub enum ProgramReadAuthorizationError {
128    #[error("invalid program-read audience: {actual}")]
129    InvalidAudience { actual: String },
130    #[error("missing required program-read claim: {0}")]
131    MissingClaim(&'static str),
132    #[error("invalid program-read target kind: {actual:?}")]
133    InvalidTargetKind { actual: TargetKind },
134    #[error("program-read target mismatch: expected {expected}, got {actual}")]
135    TargetIdMismatch { expected: String, actual: String },
136    #[error("program mismatch: expected {expected}, got {actual}")]
137    ProgramIdMismatch { expected: String, actual: String },
138    #[error("program release mismatch: expected {expected}, got {actual}")]
139    ProgramReleaseHashMismatch { expected: String, actual: String },
140    #[error("program-read authorization requires the read scope")]
141    MissingReadScope,
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    use crate::{SessionClaims, SigningKey, TokenSigner, TokenVerifier};
148
149    const TARGET_ID: &str = "binding-1";
150    const PROGRAM_ID: &str = "program-1";
151    const RELEASE_HASH: &str = "arete:h1:program-release:sha256:release-1";
152
153    fn claims() -> SessionClaims {
154        SessionClaims::program_read_builder("issuer", "user:1", TARGET_ID, PROGRAM_ID, RELEASE_HASH)
155            .with_metering_key("api_key:42")
156            .with_limits(Limits {
157                max_http_requests_per_minute: Some(120),
158                max_http_batch_addresses: Some(50),
159                ..Limits::default()
160            })
161            .build()
162    }
163
164    fn context() -> AuthContext {
165        AuthContext::from_claims(claims())
166    }
167
168    fn authorize(
169        context: &AuthContext,
170    ) -> Result<ProgramReadAuthorization, ProgramReadAuthorizationError> {
171        ProgramReadAuthorization::try_from_context(context, TARGET_ID, PROGRAM_ID, RELEASE_HASH)
172    }
173
174    #[test]
175    fn typed_program_read_authorizes_exact_resource() {
176        let signing_key = SigningKey::generate();
177        let verifying_key = signing_key.verifying_key();
178        let token = TokenSigner::new(signing_key, "issuer")
179            .sign(claims())
180            .unwrap();
181        let context = TokenVerifier::new(verifying_key, "issuer", PROGRAM_READ_AUDIENCE)
182            .verify(&token, None, None)
183            .unwrap();
184        let authorization = authorize(&context).unwrap();
185
186        assert_eq!(authorization.subject, "user:1");
187        assert_eq!(authorization.metering_key, "api_key:42");
188        assert_eq!(authorization.target_id, TARGET_ID);
189        assert_eq!(authorization.program_id, PROGRAM_ID);
190        assert_eq!(authorization.program_release_hash, RELEASE_HASH);
191        assert_eq!(authorization.limits.max_http_batch_addresses, Some(50));
192    }
193
194    #[test]
195    fn missing_or_wrong_target_rejects() {
196        let mut missing_kind = context();
197        missing_kind.target_kind = None;
198        assert!(matches!(
199            authorize(&missing_kind),
200            Err(ProgramReadAuthorizationError::MissingClaim("targetKind"))
201        ));
202
203        let mut missing = context();
204        missing.target_id = None;
205        assert!(matches!(
206            authorize(&missing),
207            Err(ProgramReadAuthorizationError::MissingClaim("targetId"))
208        ));
209
210        let mut wrong = context();
211        wrong.target_id = Some("binding-2".into());
212        assert!(matches!(
213            authorize(&wrong),
214            Err(ProgramReadAuthorizationError::TargetIdMismatch { .. })
215        ));
216    }
217
218    #[test]
219    fn wrong_audience_rejects() {
220        let mut context = context();
221        context.audience = "deployment-1".into();
222        assert!(matches!(
223            authorize(&context),
224            Err(ProgramReadAuthorizationError::InvalidAudience { .. })
225        ));
226    }
227
228    #[test]
229    fn missing_read_scope_rejects() {
230        let mut context = context();
231        context.scope = "transaction:inspect".into();
232        assert!(matches!(
233            authorize(&context),
234            Err(ProgramReadAuthorizationError::MissingReadScope)
235        ));
236    }
237
238    #[test]
239    fn missing_or_wrong_program_rejects() {
240        let mut missing = context();
241        missing.program_id = None;
242        assert!(matches!(
243            authorize(&missing),
244            Err(ProgramReadAuthorizationError::MissingClaim("programId"))
245        ));
246
247        let mut wrong = context();
248        wrong.program_id = Some("program-2".into());
249        assert!(matches!(
250            authorize(&wrong),
251            Err(ProgramReadAuthorizationError::ProgramIdMismatch { .. })
252        ));
253    }
254
255    #[test]
256    fn missing_or_wrong_release_rejects() {
257        let mut missing = context();
258        missing.program_release_hash = None;
259        assert!(matches!(
260            authorize(&missing),
261            Err(ProgramReadAuthorizationError::MissingClaim(
262                "programReleaseHash"
263            ))
264        ));
265
266        let mut wrong = context();
267        wrong.program_release_hash = Some("release-2".into());
268        assert!(matches!(
269            authorize(&wrong),
270            Err(ProgramReadAuthorizationError::ProgramReleaseHashMismatch { .. })
271        ));
272    }
273
274    #[test]
275    fn deployment_token_cannot_authorize_program_read() {
276        let context = AuthContext::from_claims(
277            SessionClaims::builder("issuer", "subject", PROGRAM_READ_AUDIENCE)
278                .with_target(TargetKind::Deployment, TARGET_ID)
279                .with_program_id(PROGRAM_ID)
280                .with_program_release_hash(RELEASE_HASH)
281                .build(),
282        );
283
284        assert!(matches!(
285            authorize(&context),
286            Err(ProgramReadAuthorizationError::InvalidTargetKind {
287                actual: TargetKind::Deployment
288            })
289        ));
290    }
291}