1use thiserror::Error;
2
3use crate::{AuthContext, KeyClass, Limits, TargetKind, PROGRAM_READ_AUDIENCE, SCOPE_READ};
4
5#[derive(Debug, Clone)]
7pub struct ProgramReadAuthorization {
8 pub subject: String,
10 pub issuer: String,
12 pub key_class: KeyClass,
14 pub metering_key: String,
16 pub target_id: String,
18 pub program_id: String,
20 pub program_release_hash: String,
22 pub limits: Limits,
24 pub plan: Option<String>,
26 pub expires_at: u64,
28 pub jti: String,
30 pub actor_key: Option<String>,
32 pub account_key: Option<String>,
34 pub consumer_key: Option<String>,
36 pub policy_version: Option<u32>,
38 pub account_limits: Limits,
40}
41
42impl ProgramReadAuthorization {
43 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 pub fn actor_key(&self) -> &str {
117 crate::claims::resolve_policy_identity(self.actor_key.as_deref(), &self.subject)
118 }
119
120 pub fn consumer_key(&self) -> &str {
122 crate::claims::resolve_policy_identity(self.consumer_key.as_deref(), &self.subject)
123 }
124
125 pub fn account_key(&self) -> &str {
128 crate::claims::resolve_policy_identity(self.account_key.as_deref(), &self.metering_key)
129 }
130
131 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#[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}