Skip to main content

auths_core/policy/
device.rs

1//! Device authorization policy.
2//!
3//! This module implements the device authorization rules that determine
4//! whether a device attestation grants permission for a specific action.
5
6use auths_verifier::core::{Attestation, Capability};
7use chrono::{DateTime, Utc};
8
9use super::Decision;
10
11/// An action that requires authorization.
12///
13/// Actions map to capabilities - a device can only perform an action
14/// if its attestation includes the corresponding capability.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum Action {
17    /// Sign a git commit
18    SignCommit,
19    /// Sign a release
20    SignRelease,
21    /// Manage organization members
22    ManageMembers,
23    /// Rotate identity keys
24    RotateKeys,
25    /// Custom action (must match a custom capability)
26    Custom(String),
27}
28
29impl Action {
30    /// Convert action to the corresponding capability.
31    ///
32    /// Returns `Err` if a custom action string is invalid (e.g., empty, too long,
33    /// contains invalid characters, or uses a reserved namespace).
34    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
47/// Authorize a device to perform an action.
48///
49/// # Sans-IO Design
50///
51/// All inputs are passed explicitly:
52/// - No storage access (attestation provided by caller)
53/// - No system clock (time injected via `now`)
54/// - Pure function: same inputs always produce same output
55///
56/// # Rules (evaluated in order)
57///
58/// 1. **Not revoked**: `!att.is_revoked()`
59/// 2. **Not expired**: `att.expires_at > now` OR `att.expires_at.is_none()`
60/// 3. **Issuer matches**: `att.issuer == expected_issuer`
61/// 4. **Capability allows action**: `action.to_capability() in att.capabilities`
62///
63/// # Arguments
64///
65/// * `attestation` - The device's attestation
66/// * `expected_issuer` - The expected issuer DID (e.g., `did:keri:E...`)
67/// * `action` - The action the device wants to perform
68/// * `now` - Current time for expiry checks
69///
70/// # Returns
71///
72/// `Decision::Allow` if all rules pass, `Decision::Deny` otherwise.
73///
74/// # Examples
75///
76/// ```rust
77/// use auths_core::policy::{Decision, device::{Action, authorize_device}};
78/// use auths_verifier::core::{Attestation, Capability, Ed25519PublicKey, Ed25519Signature};
79/// use auths_verifier::types::DeviceDID;
80/// use chrono::Utc;
81///
82/// let attestation = Attestation {
83///     version: 1,
84///     rid: "test".into(),
85///     issuer: "did:keri:ETest".into(),
86///     subject: DeviceDID::new("did:key:z6Mk..."),
87///     device_public_key: Ed25519PublicKey::from_bytes([0u8; 32]),
88///     identity_signature: Ed25519Signature::empty(),
89///     device_signature: Ed25519Signature::empty(),
90///     revoked_at: None,
91///     expires_at: None,
92///     timestamp: None,
93///     note: None,
94///     payload: None,
95///     role: None,
96///     capabilities: vec![Capability::sign_commit()],
97///     delegated_by: None,
98///     signer_type: None,
99/// };
100///
101/// let decision = authorize_device(
102///     &attestation,
103///     "did:keri:ETest",
104///     &Action::SignCommit,
105///     Utc::now(),
106/// );
107///
108/// assert!(decision.is_allowed());
109/// ```
110pub fn authorize_device(
111    attestation: &Attestation,
112    expected_issuer: &str,
113    action: &Action,
114    now: DateTime<Utc>,
115) -> Decision {
116    // Rule 1: Not revoked
117    if attestation.is_revoked() {
118        return Decision::deny("attestation is revoked");
119    }
120
121    // Rule 2: Not expired (expires_at <= now means expired)
122    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    // Rule 3: Issuer matches expected
132    if attestation.issuer != expected_issuer {
133        return Decision::deny(format!(
134            "issuer mismatch: expected '{}', got '{}'",
135            expected_issuer, attestation.issuer
136        ));
137    }
138
139    // Rule 4: Capability allows action
140    let required_capability = match action.to_capability() {
141        Ok(cap) => cap,
142        Err(msg) => return Decision::deny(msg),
143    };
144
145    // Empty capabilities means no permissions
146    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    // All rules passed
158    Decision::allow(format!(
159        "device authorized for '{}'",
160        capability_name(&required_capability)
161    ))
162}
163
164/// Get a human-readable name for a capability.
165fn 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()), // revoked
222            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), // expired
240            "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), // exactly at boundary = expired (uses <=)
257            "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), // not yet expired
273            "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", // wrong issuer
289            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()], // has SignRelease, not SignCommit
308        );
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![], // no capabilities
325        );
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        // Should allow SignRelease since it's in the list
349        let decision = authorize_device(&att, "did:keri:ETest", &Action::SignRelease, now);
350        assert!(decision.is_allowed());
351
352        // Should allow ManageMembers since it's in the list
353        let decision = authorize_device(&att, "did:keri:ETest", &Action::ManageMembers, now);
354        assert!(decision.is_allowed());
355
356        // Should deny RotateKeys since it's not in the list
357        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        // Matching custom capability allows
372        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        // Non-matching custom capability denies
381        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        // Invalid characters in custom action should deny
401        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        // If both revoked and expired, should report revoked (earlier in order)
414        let past = Utc::now() - Duration::hours(1);
415        let att = make_attestation(
416            Some(Utc::now()), // revoked
417            Some(past),       // also expired
418            "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")); // revoked checked first
427    }
428}