1use auths_verifier::core::{Attestation, Capability};
7use chrono::{DateTime, Utc};
8
9use super::Decision;
10
11#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum Action {
17 SignCommit,
19 SignRelease,
21 ManageMembers,
23 RotateKeys,
25 Custom(String),
27}
28
29impl Action {
30 pub fn to_capability(&self) -> Result<Capability, String> {
35 match self {
36 Action::SignCommit => Ok(Capability::sign_commit()),
37 Action::SignRelease => Ok(Capability::sign_release()),
38 Action::ManageMembers => Ok(Capability::manage_members()),
39 Action::RotateKeys => Ok(Capability::rotate_keys()),
40 Action::Custom(s) => {
41 Capability::parse(s).map_err(|e| format!("invalid custom action '{}': {}", s, e))
42 }
43 }
44 }
45}
46
47pub fn authorize_device(
111 attestation: &Attestation,
112 expected_issuer: &str,
113 action: &Action,
114 now: DateTime<Utc>,
115) -> Decision {
116 if attestation.is_revoked() {
118 return Decision::deny("attestation is revoked");
119 }
120
121 if let Some(expires_at) = attestation.expires_at
123 && expires_at <= now
124 {
125 return Decision::deny(format!(
126 "attestation expired at {}",
127 expires_at.format("%Y-%m-%dT%H:%M:%SZ")
128 ));
129 }
130
131 if attestation.issuer != expected_issuer {
133 return Decision::deny(format!(
134 "issuer mismatch: expected '{}', got '{}'",
135 expected_issuer, attestation.issuer
136 ));
137 }
138
139 let required_capability = match action.to_capability() {
141 Ok(cap) => cap,
142 Err(msg) => return Decision::deny(msg),
143 };
144
145 if attestation.capabilities.is_empty() {
147 return Decision::deny("attestation has no capabilities");
148 }
149
150 if !attestation.capabilities.contains(&required_capability) {
151 return Decision::deny(format!(
152 "capability '{}' not granted",
153 capability_name(&required_capability)
154 ));
155 }
156
157 Decision::allow(format!(
159 "device authorized for '{}'",
160 capability_name(&required_capability)
161 ))
162}
163
164fn capability_name(cap: &Capability) -> &str {
166 cap.as_str()
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172 use auths_verifier::core::{Ed25519PublicKey, Ed25519Signature};
173 use auths_verifier::types::DeviceDID;
174 use chrono::Duration;
175
176 fn make_attestation(
177 revoked_at: Option<DateTime<Utc>>,
178 expires_at: Option<DateTime<Utc>>,
179 issuer: &str,
180 capabilities: Vec<Capability>,
181 ) -> Attestation {
182 Attestation {
183 version: 1,
184 rid: "test-rid".into(),
185 issuer: issuer.into(),
186 subject: DeviceDID::new("did:key:z6MkTest"),
187 device_public_key: Ed25519PublicKey::from_bytes([0u8; 32]),
188 identity_signature: Ed25519Signature::empty(),
189 device_signature: Ed25519Signature::empty(),
190 revoked_at,
191 expires_at,
192 timestamp: None,
193 note: None,
194 payload: None,
195 role: None,
196 capabilities,
197 delegated_by: None,
198 signer_type: None,
199 }
200 }
201
202 #[test]
203 fn valid_attestation_allows() {
204 let att = make_attestation(
205 None,
206 None,
207 "did:keri:ETest",
208 vec![Capability::sign_commit()],
209 );
210 let now = Utc::now();
211
212 let decision = authorize_device(&att, "did:keri:ETest", &Action::SignCommit, now);
213
214 assert!(decision.is_allowed());
215 assert!(decision.reason().contains("authorized"));
216 }
217
218 #[test]
219 fn revoked_attestation_denies() {
220 let att = make_attestation(
221 Some(Utc::now()), None,
223 "did:keri:ETest",
224 vec![Capability::sign_commit()],
225 );
226 let now = Utc::now();
227
228 let decision = authorize_device(&att, "did:keri:ETest", &Action::SignCommit, now);
229
230 assert!(decision.is_denied());
231 assert!(decision.reason().contains("revoked"));
232 }
233
234 #[test]
235 fn expired_attestation_denies() {
236 let past = Utc::now() - Duration::hours(1);
237 let att = make_attestation(
238 None,
239 Some(past), "did:keri:ETest",
241 vec![Capability::sign_commit()],
242 );
243 let now = Utc::now();
244
245 let decision = authorize_device(&att, "did:keri:ETest", &Action::SignCommit, now);
246
247 assert!(decision.is_denied());
248 assert!(decision.reason().contains("expired"));
249 }
250
251 #[test]
252 fn expired_at_boundary_denies() {
253 let now = Utc::now();
254 let att = make_attestation(
255 None,
256 Some(now), "did:keri:ETest",
258 vec![Capability::sign_commit()],
259 );
260
261 let decision = authorize_device(&att, "did:keri:ETest", &Action::SignCommit, now);
262
263 assert!(decision.is_denied());
264 assert!(decision.reason().contains("expired"));
265 }
266
267 #[test]
268 fn not_yet_expired_allows() {
269 let future = Utc::now() + Duration::hours(1);
270 let att = make_attestation(
271 None,
272 Some(future), "did:keri:ETest",
274 vec![Capability::sign_commit()],
275 );
276 let now = Utc::now();
277
278 let decision = authorize_device(&att, "did:keri:ETest", &Action::SignCommit, now);
279
280 assert!(decision.is_allowed());
281 }
282
283 #[test]
284 fn issuer_mismatch_denies() {
285 let att = make_attestation(
286 None,
287 None,
288 "did:keri:EWrongIssuer", vec![Capability::sign_commit()],
290 );
291 let now = Utc::now();
292
293 let decision = authorize_device(&att, "did:keri:EExpected", &Action::SignCommit, now);
294
295 assert!(decision.is_denied());
296 assert!(decision.reason().contains("issuer mismatch"));
297 assert!(decision.reason().contains("EExpected"));
298 assert!(decision.reason().contains("EWrongIssuer"));
299 }
300
301 #[test]
302 fn missing_capability_denies() {
303 let att = make_attestation(
304 None,
305 None,
306 "did:keri:ETest",
307 vec![Capability::sign_release()], );
309 let now = Utc::now();
310
311 let decision = authorize_device(&att, "did:keri:ETest", &Action::SignCommit, now);
312
313 assert!(decision.is_denied());
314 assert!(decision.reason().contains("sign_commit"));
315 assert!(decision.reason().contains("not granted"));
316 }
317
318 #[test]
319 fn empty_capabilities_denies() {
320 let att = make_attestation(
321 None,
322 None,
323 "did:keri:ETest",
324 vec![], );
326 let now = Utc::now();
327
328 let decision = authorize_device(&att, "did:keri:ETest", &Action::SignCommit, now);
329
330 assert!(decision.is_denied());
331 assert!(decision.reason().contains("no capabilities"));
332 }
333
334 #[test]
335 fn multiple_capabilities_allows_matching() {
336 let att = make_attestation(
337 None,
338 None,
339 "did:keri:ETest",
340 vec![
341 Capability::sign_commit(),
342 Capability::sign_release(),
343 Capability::manage_members(),
344 ],
345 );
346 let now = Utc::now();
347
348 let decision = authorize_device(&att, "did:keri:ETest", &Action::SignRelease, now);
350 assert!(decision.is_allowed());
351
352 let decision = authorize_device(&att, "did:keri:ETest", &Action::ManageMembers, now);
354 assert!(decision.is_allowed());
355
356 let decision = authorize_device(&att, "did:keri:ETest", &Action::RotateKeys, now);
358 assert!(decision.is_denied());
359 }
360
361 #[test]
362 fn custom_capability_works() {
363 let att = make_attestation(
364 None,
365 None,
366 "did:keri:ETest",
367 vec![Capability::parse("acme:deploy").unwrap()],
368 );
369 let now = Utc::now();
370
371 let decision = authorize_device(
373 &att,
374 "did:keri:ETest",
375 &Action::Custom("acme:deploy".into()),
376 now,
377 );
378 assert!(decision.is_allowed());
379
380 let decision = authorize_device(
382 &att,
383 "did:keri:ETest",
384 &Action::Custom("acme:other".into()),
385 now,
386 );
387 assert!(decision.is_denied());
388 }
389
390 #[test]
391 fn invalid_custom_action_denies() {
392 let att = make_attestation(
393 None,
394 None,
395 "did:keri:ETest",
396 vec![Capability::sign_commit()],
397 );
398 let now = Utc::now();
399
400 let decision = authorize_device(
402 &att,
403 "did:keri:ETest",
404 &Action::Custom("invalid action!!!".into()),
405 now,
406 );
407 assert!(decision.is_denied());
408 assert!(decision.reason().contains("invalid custom action"));
409 }
410
411 #[test]
412 fn rule_evaluation_order_revoked_first() {
413 let past = Utc::now() - Duration::hours(1);
415 let att = make_attestation(
416 Some(Utc::now()), Some(past), "did:keri:ETest",
419 vec![Capability::sign_commit()],
420 );
421 let now = Utc::now();
422
423 let decision = authorize_device(&att, "did:keri:ETest", &Action::SignCommit, now);
424
425 assert!(decision.is_denied());
426 assert!(decision.reason().contains("revoked")); }
428}