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}
31
32impl ProgramReadAuthorization {
33 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#[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}