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    /// Raw signed actor identity; use [`ProgramReadAuthorization::actor_key`] to resolve.
31    pub actor_key: Option<String>,
32    /// Raw signed account identity; use [`ProgramReadAuthorization::account_key`] to resolve.
33    pub account_key: Option<String>,
34    /// Raw signed consumer identity; use [`ProgramReadAuthorization::consumer_key`] to resolve.
35    pub consumer_key: Option<String>,
36    /// Monotonic account policy version, absent on legacy tokens.
37    pub policy_version: Option<u32>,
38    /// Aggregate account limits, defaulted when the token carries none.
39    pub account_limits: Limits,
40}
41
42impl ProgramReadAuthorization {
43    /// Convert a verified context into authorization for the requested resource.
44    pub fn try_from_context(
45        context: &AuthContext,
46        expected_target_id: &str,
47        expected_program_id: &str,
48        expected_program_release_hash: &str,
49    ) -> Result<Self, ProgramReadAuthorizationError> {
50        if context.audience != PROGRAM_READ_AUDIENCE {
51            return Err(ProgramReadAuthorizationError::InvalidAudience {
52                actual: context.audience.clone(),
53            });
54        }
55
56        match context.target_kind {
57            Some(TargetKind::ProgramReadBinding) => {}
58            Some(actual) => {
59                return Err(ProgramReadAuthorizationError::InvalidTargetKind { actual });
60            }
61            None => return Err(ProgramReadAuthorizationError::MissingClaim("targetKind")),
62        }
63
64        let target_id = required_claim(context.target_id.as_deref(), "targetId")?;
65        if target_id != expected_target_id {
66            return Err(ProgramReadAuthorizationError::TargetIdMismatch {
67                expected: expected_target_id.to_string(),
68                actual: target_id.to_string(),
69            });
70        }
71
72        let program_id = required_claim(context.program_id.as_deref(), "programId")?;
73        if program_id != expected_program_id {
74            return Err(ProgramReadAuthorizationError::ProgramIdMismatch {
75                expected: expected_program_id.to_string(),
76                actual: program_id.to_string(),
77            });
78        }
79
80        let program_release_hash = required_claim(
81            context.program_release_hash.as_deref(),
82            "programReleaseHash",
83        )?;
84        if program_release_hash != expected_program_release_hash {
85            return Err(ProgramReadAuthorizationError::ProgramReleaseHashMismatch {
86                expected: expected_program_release_hash.to_string(),
87                actual: program_release_hash.to_string(),
88            });
89        }
90
91        if !context.has_scope(SCOPE_READ) {
92            return Err(ProgramReadAuthorizationError::MissingReadScope);
93        }
94
95        Ok(Self {
96            subject: context.subject.clone(),
97            issuer: context.issuer.clone(),
98            key_class: context.key_class,
99            metering_key: context.metering_key.clone(),
100            target_id: target_id.to_string(),
101            program_id: program_id.to_string(),
102            program_release_hash: program_release_hash.to_string(),
103            limits: context.limits.clone(),
104            plan: context.plan.clone(),
105            expires_at: context.expires_at,
106            jti: context.jti.clone(),
107            actor_key: context.actor_key.clone(),
108            account_key: context.account_key.clone(),
109            consumer_key: context.consumer_key.clone(),
110            policy_version: context.policy_version,
111            account_limits: context.account_limits.clone(),
112        })
113    }
114
115    /// Resolved actor identity: `actor_key` claim, falling back to `sub`.
116    pub fn actor_key(&self) -> &str {
117        crate::claims::resolve_policy_identity(self.actor_key.as_deref(), &self.subject)
118    }
119
120    /// Resolved consumer identity: `consumer_key` claim, falling back to `sub`.
121    pub fn consumer_key(&self) -> &str {
122        crate::claims::resolve_policy_identity(self.consumer_key.as_deref(), &self.subject)
123    }
124
125    /// Resolved account identity: `account_key` claim, falling back to
126    /// `metering_key`.
127    pub fn account_key(&self) -> &str {
128        crate::claims::resolve_policy_identity(self.account_key.as_deref(), &self.metering_key)
129    }
130
131    /// True when the token predates the v2 policy contract.
132    pub fn is_legacy_policy(&self) -> bool {
133        self.actor_key.is_none()
134            && self.account_key.is_none()
135            && self.consumer_key.is_none()
136            && self.policy_version.is_none()
137    }
138}
139
140impl<'a> TryFrom<(&'a AuthContext, &'a str, &'a str, &'a str)> for ProgramReadAuthorization {
141    type Error = ProgramReadAuthorizationError;
142
143    fn try_from(
144        (context, target_id, program_id, program_release_hash): (
145            &'a AuthContext,
146            &'a str,
147            &'a str,
148            &'a str,
149        ),
150    ) -> Result<Self, Self::Error> {
151        Self::try_from_context(context, target_id, program_id, program_release_hash)
152    }
153}
154
155fn required_claim<'a>(
156    value: Option<&'a str>,
157    name: &'static str,
158) -> Result<&'a str, ProgramReadAuthorizationError> {
159    value
160        .filter(|value| !value.is_empty())
161        .ok_or(ProgramReadAuthorizationError::MissingClaim(name))
162}
163
164/// Failure to authorize a verified token for an exact program read.
165#[derive(Debug, Clone, PartialEq, Eq, Error)]
166pub enum ProgramReadAuthorizationError {
167    #[error("invalid program-read audience: {actual}")]
168    InvalidAudience { actual: String },
169    #[error("missing required program-read claim: {0}")]
170    MissingClaim(&'static str),
171    #[error("invalid program-read target kind: {actual:?}")]
172    InvalidTargetKind { actual: TargetKind },
173    #[error("program-read target mismatch: expected {expected}, got {actual}")]
174    TargetIdMismatch { expected: String, actual: String },
175    #[error("program mismatch: expected {expected}, got {actual}")]
176    ProgramIdMismatch { expected: String, actual: String },
177    #[error("program release mismatch: expected {expected}, got {actual}")]
178    ProgramReleaseHashMismatch { expected: String, actual: String },
179    #[error("program-read authorization requires the read scope")]
180    MissingReadScope,
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use crate::{SessionClaims, SigningKey, TokenSigner, TokenVerifier};
187
188    const TARGET_ID: &str = "binding-1";
189    const PROGRAM_ID: &str = "program-1";
190    const RELEASE_HASH: &str = "arete:h1:program-release:sha256:release-1";
191
192    fn claims() -> SessionClaims {
193        SessionClaims::program_read_builder("issuer", "user:1", TARGET_ID, PROGRAM_ID, RELEASE_HASH)
194            .with_metering_key("api_key:42")
195            .with_limits(Limits {
196                max_http_requests_per_minute: Some(120),
197                max_http_batch_addresses: Some(50),
198                ..Limits::default()
199            })
200            .build()
201    }
202
203    fn context() -> AuthContext {
204        AuthContext::from_claims(claims())
205    }
206
207    fn authorize(
208        context: &AuthContext,
209    ) -> Result<ProgramReadAuthorization, ProgramReadAuthorizationError> {
210        ProgramReadAuthorization::try_from_context(context, TARGET_ID, PROGRAM_ID, RELEASE_HASH)
211    }
212
213    #[test]
214    fn typed_program_read_authorizes_exact_resource() {
215        let signing_key = SigningKey::generate();
216        let verifying_key = signing_key.verifying_key();
217        let token = TokenSigner::new(signing_key, "issuer")
218            .sign(claims())
219            .unwrap();
220        let context = TokenVerifier::new(verifying_key, "issuer", PROGRAM_READ_AUDIENCE)
221            .verify(&token, None, None)
222            .unwrap();
223        let authorization = authorize(&context).unwrap();
224
225        assert_eq!(authorization.subject, "user:1");
226        assert_eq!(authorization.metering_key, "api_key:42");
227        assert_eq!(authorization.target_id, TARGET_ID);
228        assert_eq!(authorization.program_id, PROGRAM_ID);
229        assert_eq!(authorization.program_release_hash, RELEASE_HASH);
230        assert_eq!(authorization.limits.max_http_batch_addresses, Some(50));
231    }
232
233    #[test]
234    fn v2_policy_fields_propagate_into_authorization() {
235        let account_limits = Limits {
236            max_http_requests_per_minute: Some(6000),
237            ..Limits::default()
238        };
239        let claims = SessionClaims::program_read_builder(
240            "issuer",
241            "user:1",
242            TARGET_ID,
243            PROGRAM_ID,
244            RELEASE_HASH,
245        )
246        .with_metering_key("account:42")
247        .with_actor_key("user:1")
248        .with_account_key("account:42")
249        .with_consumer_key("consumer:abc123")
250        .with_policy_version(9)
251        .with_account_limits(account_limits.clone())
252        .build();
253        let authorization = authorize(&AuthContext::from_claims(claims)).unwrap();
254
255        assert!(!authorization.is_legacy_policy());
256        assert_eq!(authorization.actor_key(), "user:1");
257        assert_eq!(authorization.consumer_key(), "consumer:abc123");
258        assert_eq!(authorization.account_key(), "account:42");
259        assert_eq!(authorization.policy_version, Some(9));
260        assert_eq!(authorization.account_limits, account_limits);
261
262        let legacy = authorize(&context()).unwrap();
263        assert!(legacy.is_legacy_policy());
264        assert_eq!(legacy.consumer_key(), "user:1");
265        assert_eq!(legacy.account_key(), "api_key:42");
266    }
267
268    #[test]
269    fn missing_or_wrong_target_rejects() {
270        let mut missing_kind = context();
271        missing_kind.target_kind = None;
272        assert!(matches!(
273            authorize(&missing_kind),
274            Err(ProgramReadAuthorizationError::MissingClaim("targetKind"))
275        ));
276
277        let mut missing = context();
278        missing.target_id = None;
279        assert!(matches!(
280            authorize(&missing),
281            Err(ProgramReadAuthorizationError::MissingClaim("targetId"))
282        ));
283
284        let mut wrong = context();
285        wrong.target_id = Some("binding-2".into());
286        assert!(matches!(
287            authorize(&wrong),
288            Err(ProgramReadAuthorizationError::TargetIdMismatch { .. })
289        ));
290    }
291
292    #[test]
293    fn wrong_audience_rejects() {
294        let mut context = context();
295        context.audience = "deployment-1".into();
296        assert!(matches!(
297            authorize(&context),
298            Err(ProgramReadAuthorizationError::InvalidAudience { .. })
299        ));
300    }
301
302    #[test]
303    fn missing_read_scope_rejects() {
304        let mut context = context();
305        context.scope = "transaction:inspect".into();
306        assert!(matches!(
307            authorize(&context),
308            Err(ProgramReadAuthorizationError::MissingReadScope)
309        ));
310    }
311
312    #[test]
313    fn missing_or_wrong_program_rejects() {
314        let mut missing = context();
315        missing.program_id = None;
316        assert!(matches!(
317            authorize(&missing),
318            Err(ProgramReadAuthorizationError::MissingClaim("programId"))
319        ));
320
321        let mut wrong = context();
322        wrong.program_id = Some("program-2".into());
323        assert!(matches!(
324            authorize(&wrong),
325            Err(ProgramReadAuthorizationError::ProgramIdMismatch { .. })
326        ));
327    }
328
329    #[test]
330    fn missing_or_wrong_release_rejects() {
331        let mut missing = context();
332        missing.program_release_hash = None;
333        assert!(matches!(
334            authorize(&missing),
335            Err(ProgramReadAuthorizationError::MissingClaim(
336                "programReleaseHash"
337            ))
338        ));
339
340        let mut wrong = context();
341        wrong.program_release_hash = Some("release-2".into());
342        assert!(matches!(
343            authorize(&wrong),
344            Err(ProgramReadAuthorizationError::ProgramReleaseHashMismatch { .. })
345        ));
346    }
347
348    #[test]
349    fn deployment_token_cannot_authorize_program_read() {
350        let context = AuthContext::from_claims(
351            SessionClaims::builder("issuer", "subject", PROGRAM_READ_AUDIENCE)
352                .with_target(TargetKind::Deployment, TARGET_ID)
353                .with_program_id(PROGRAM_ID)
354                .with_program_release_hash(RELEASE_HASH)
355                .build(),
356        );
357
358        assert!(matches!(
359            authorize(&context),
360            Err(ProgramReadAuthorizationError::InvalidTargetKind {
361                actual: TargetKind::Deployment
362            })
363        ));
364    }
365}