Skip to main content

auths_keri/
capability.rs

1//! Validated capability identifiers — the atomic unit of authorization in Auths.
2
3use serde::{Deserialize, Serialize};
4use std::fmt;
5
6/// Well-known capability string: permission to sign commits.
7pub const SIGN_COMMIT: &str = "sign_commit";
8/// Well-known capability string: permission to sign releases.
9pub const SIGN_RELEASE: &str = "sign_release";
10/// Well-known capability string: permission to add/remove organization members.
11pub const MANAGE_MEMBERS: &str = "manage_members";
12/// Well-known capability string: permission to rotate keys for an identity.
13pub const ROTATE_KEYS: &str = "rotate_keys";
14
15/// Error type for capability parsing and validation.
16#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
17pub enum CapabilityError {
18    /// The capability string is empty.
19    #[error("capability is empty")]
20    Empty,
21    /// The capability string exceeds the maximum length.
22    #[error("capability exceeds 64 chars: {0}")]
23    TooLong(usize),
24    /// The capability string contains invalid characters.
25    #[error("invalid characters in capability '{0}': only alphanumeric, ':', '-', '_' allowed")]
26    InvalidChars(String),
27    /// The capability uses the reserved 'auths:' namespace.
28    #[error(
29        "reserved namespace 'auths:' — use well-known constructors or choose a different prefix"
30    )]
31    ReservedNamespace,
32    /// The capability uses a reserved infrastructure namespace prefix.
33    #[error("the '{0}' prefix is reserved for infrastructure capabilities")]
34    ReservedInfraNamespace(String),
35}
36
37/// A validated capability identifier.
38///
39/// Capabilities are the atomic unit of authorization in Auths.
40/// They follow a namespace convention:
41///
42/// - Well-known capabilities: `sign_commit`, `sign_release`, `manage_members`, `rotate_keys`
43/// - Custom capabilities: any valid string (alphanumeric + `:` + `-` + `_`, max 64 chars)
44///
45/// The `auths:` prefix is reserved for future well-known capabilities and cannot be
46/// used in custom capabilities created via `parse()`.
47///
48/// # Examples
49///
50/// ```
51/// use auths_keri::Capability;
52///
53/// // Well-known capabilities
54/// let cap = Capability::sign_commit();
55/// assert_eq!(cap.as_str(), "sign_commit");
56///
57/// // Custom capabilities
58/// let custom = Capability::parse("acme:deploy").unwrap();
59/// assert_eq!(custom.as_str(), "acme:deploy");
60///
61/// // Reserved namespace is rejected
62/// assert!(Capability::parse("auths:custom").is_err());
63/// ```
64#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
65#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
66#[serde(try_from = "String", into = "String")]
67pub struct Capability(String);
68
69impl Capability {
70    /// Maximum length for capability strings.
71    pub const MAX_LEN: usize = 64;
72
73    /// Reserved namespace prefix for Auths well-known capabilities.
74    const RESERVED_PREFIX: &'static str = "auths:";
75
76    /// Reserved infrastructure capability namespace prefixes.
77    const RESERVED_INFRA_PREFIXES: &'static [&'static str] =
78        &["compute:", "network:", "storage:", "runtime:", "env:"];
79
80    // ========================================================================
81    // Well-known capability constructors
82    // ========================================================================
83
84    /// Creates the `sign_commit` capability.
85    ///
86    /// Grants permission to sign commits.
87    #[inline]
88    pub fn sign_commit() -> Self {
89        Self(SIGN_COMMIT.to_string())
90    }
91
92    /// Creates the `sign_release` capability.
93    ///
94    /// Grants permission to sign releases.
95    #[inline]
96    pub fn sign_release() -> Self {
97        Self(SIGN_RELEASE.to_string())
98    }
99
100    /// Creates the `manage_members` capability.
101    ///
102    /// Grants permission to add/remove members in an organization.
103    #[inline]
104    pub fn manage_members() -> Self {
105        Self(MANAGE_MEMBERS.to_string())
106    }
107
108    /// Creates the `rotate_keys` capability.
109    ///
110    /// Grants permission to rotate keys for an identity.
111    #[inline]
112    pub fn rotate_keys() -> Self {
113        Self(ROTATE_KEYS.to_string())
114    }
115
116    // ========================================================================
117    // Parsing and validation
118    // ========================================================================
119
120    /// Parses and validates a capability string.
121    ///
122    /// This is the primary way to create custom capabilities. The input is
123    /// trimmed and lowercased to produce a canonical form.
124    ///
125    /// # Validation Rules
126    ///
127    /// - Non-empty
128    /// - Maximum 64 characters
129    /// - Only alphanumeric characters, colons (`:`), hyphens (`-`), and underscores (`_`)
130    /// - Cannot start with `auths:` (reserved namespace)
131    ///
132    /// # Examples
133    ///
134    /// ```
135    /// use auths_keri::Capability;
136    ///
137    /// // Valid custom capabilities
138    /// assert!(Capability::parse("deploy").is_ok());
139    /// assert!(Capability::parse("acme:deploy").is_ok());
140    /// assert!(Capability::parse("org:team:action").is_ok());
141    ///
142    /// // Invalid capabilities
143    /// assert!(Capability::parse("").is_err());           // empty
144    /// assert!(Capability::parse("has space").is_err());  // invalid char
145    /// assert!(Capability::parse("auths:custom").is_err()); // reserved namespace
146    /// ```
147    pub fn parse(raw: &str) -> Result<Self, CapabilityError> {
148        let canonical = raw.trim().to_lowercase();
149
150        if canonical.is_empty() {
151            return Err(CapabilityError::Empty);
152        }
153        if canonical.len() > Self::MAX_LEN {
154            return Err(CapabilityError::TooLong(canonical.len()));
155        }
156        if !canonical
157            .chars()
158            .all(|c| c.is_alphanumeric() || c == ':' || c == '-' || c == '_')
159        {
160            return Err(CapabilityError::InvalidChars(canonical));
161        }
162        if canonical.starts_with(Self::RESERVED_PREFIX) {
163            return Err(CapabilityError::ReservedNamespace);
164        }
165        for prefix in Self::RESERVED_INFRA_PREFIXES {
166            if canonical.starts_with(prefix) {
167                return Err(CapabilityError::ReservedInfraNamespace(prefix.to_string()));
168            }
169        }
170
171        Ok(Self(canonical))
172    }
173
174    // ========================================================================
175    // Accessors
176    // ========================================================================
177
178    /// Returns the canonical string representation of this capability.
179    ///
180    /// This is the authoritative string form used for comparison, display,
181    /// and serialization.
182    #[inline]
183    pub fn as_str(&self) -> &str {
184        &self.0
185    }
186
187    /// Returns `true` if this is a well-known Auths capability.
188    pub fn is_well_known(&self) -> bool {
189        matches!(
190            self.0.as_str(),
191            SIGN_COMMIT | SIGN_RELEASE | MANAGE_MEMBERS | ROTATE_KEYS
192        )
193    }
194
195    /// Returns the namespace portion of the capability (before first colon), if any.
196    pub fn namespace(&self) -> Option<&str> {
197        self.0.split(':').next().filter(|_| self.0.contains(':'))
198    }
199}
200
201impl fmt::Display for Capability {
202    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
203        f.write_str(&self.0)
204    }
205}
206
207impl TryFrom<String> for Capability {
208    type Error = CapabilityError;
209
210    fn try_from(s: String) -> Result<Self, Self::Error> {
211        let canonical = s.trim().to_lowercase();
212
213        if canonical.is_empty() {
214            return Err(CapabilityError::Empty);
215        }
216        if canonical.len() > Self::MAX_LEN {
217            return Err(CapabilityError::TooLong(canonical.len()));
218        }
219        if !canonical
220            .chars()
221            .all(|c| c.is_alphanumeric() || c == ':' || c == '-' || c == '_')
222        {
223            return Err(CapabilityError::InvalidChars(canonical));
224        }
225
226        // During deserialization, allow well-known capabilities and auths: prefix
227        // This ensures backward compatibility with existing attestations
228        Ok(Self(canonical))
229    }
230}
231
232impl std::str::FromStr for Capability {
233    type Err = CapabilityError;
234
235    /// Parses a capability string with CLI-friendly alias resolution.
236    ///
237    /// Normalizes the input (trim, lowercase, replace hyphens with underscores)
238    /// and matches well-known capabilities before falling through to
239    /// `Capability::parse()` for custom capability validation.
240    ///
241    /// Args:
242    /// * `s`: The capability string (e.g., "sign_commit", "Sign-Commit").
243    ///
244    /// Usage:
245    /// ```
246    /// use auths_keri::Capability;
247    /// let cap: Capability = "sign_commit".parse().unwrap();
248    /// assert_eq!(cap.as_str(), "sign_commit");
249    /// ```
250    fn from_str(s: &str) -> Result<Self, Self::Err> {
251        let normalized = s.trim().to_lowercase().replace('-', "_");
252        match normalized.as_str() {
253            "sign_commit" | "signcommit" => Ok(Capability::sign_commit()),
254            "sign_release" | "signrelease" => Ok(Capability::sign_release()),
255            "manage_members" | "managemembers" => Ok(Capability::manage_members()),
256            "rotate_keys" | "rotatekeys" => Ok(Capability::rotate_keys()),
257            _ => Capability::parse(&normalized),
258        }
259    }
260}
261
262impl From<Capability> for String {
263    fn from(cap: Capability) -> Self {
264        cap.0
265    }
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    // ========================================================================
273    // Capability serialization tests
274    // ========================================================================
275
276    #[test]
277    fn capability_serializes_to_snake_case() {
278        assert_eq!(
279            serde_json::to_string(&Capability::sign_commit()).unwrap(),
280            r#""sign_commit""#
281        );
282        assert_eq!(
283            serde_json::to_string(&Capability::sign_release()).unwrap(),
284            r#""sign_release""#
285        );
286        assert_eq!(
287            serde_json::to_string(&Capability::manage_members()).unwrap(),
288            r#""manage_members""#
289        );
290        assert_eq!(
291            serde_json::to_string(&Capability::rotate_keys()).unwrap(),
292            r#""rotate_keys""#
293        );
294    }
295
296    #[test]
297    fn capability_deserializes_from_snake_case() {
298        assert_eq!(
299            serde_json::from_str::<Capability>(r#""sign_commit""#).unwrap(),
300            Capability::sign_commit()
301        );
302        assert_eq!(
303            serde_json::from_str::<Capability>(r#""sign_release""#).unwrap(),
304            Capability::sign_release()
305        );
306        assert_eq!(
307            serde_json::from_str::<Capability>(r#""manage_members""#).unwrap(),
308            Capability::manage_members()
309        );
310        assert_eq!(
311            serde_json::from_str::<Capability>(r#""rotate_keys""#).unwrap(),
312            Capability::rotate_keys()
313        );
314    }
315
316    #[test]
317    fn capability_custom_serializes_as_string() {
318        let cap = Capability::parse("acme:deploy").unwrap();
319        assert_eq!(serde_json::to_string(&cap).unwrap(), r#""acme:deploy""#);
320    }
321
322    #[test]
323    fn capability_custom_deserializes_unknown_strings() {
324        // Unknown strings become custom capabilities
325        let cap: Capability = serde_json::from_str(r#""custom-capability""#).unwrap();
326        assert_eq!(cap, Capability::parse("custom-capability").unwrap());
327    }
328
329    // ========================================================================
330    // Capability parse() validation tests
331    // ========================================================================
332
333    #[test]
334    fn capability_parse_accepts_valid_strings() {
335        assert!(Capability::parse("deploy").is_ok());
336        assert!(Capability::parse("acme:deploy").is_ok());
337        assert!(Capability::parse("my-custom-cap").is_ok());
338        assert!(Capability::parse("org:team:action").is_ok());
339        assert!(Capability::parse("with_underscore").is_ok()); // underscore allowed
340    }
341
342    #[test]
343    fn capability_parse_rejects_invalid_strings() {
344        // Empty
345        assert!(matches!(Capability::parse(""), Err(CapabilityError::Empty)));
346
347        // Too long
348        assert!(matches!(
349            Capability::parse(&"a".repeat(65)),
350            Err(CapabilityError::TooLong(65))
351        ));
352
353        // Invalid characters
354        assert!(matches!(
355            Capability::parse("has spaces"),
356            Err(CapabilityError::InvalidChars(_))
357        ));
358        assert!(matches!(
359            Capability::parse("has.dot"),
360            Err(CapabilityError::InvalidChars(_))
361        ));
362    }
363
364    #[test]
365    fn capability_parse_rejects_reserved_namespace() {
366        assert!(matches!(
367            Capability::parse("auths:custom"),
368            Err(CapabilityError::ReservedNamespace)
369        ));
370        assert!(matches!(
371            Capability::parse("auths:sign_commit"),
372            Err(CapabilityError::ReservedNamespace)
373        ));
374    }
375
376    #[test]
377    fn capability_parse_accepts_role_markers() {
378        // The org delegation layer encodes role markers ("role:admin") inside
379        // capability vecs; "role:" is not a reserved prefix and must round-trip.
380        let cap = Capability::parse("role:admin").unwrap();
381        assert_eq!(cap.as_str(), "role:admin");
382
383        let json = serde_json::to_string(&cap).unwrap();
384        let roundtrip: Capability = serde_json::from_str(&json).unwrap();
385        assert_eq!(cap, roundtrip);
386    }
387
388    #[test]
389    fn capability_parse_normalizes_to_lowercase() {
390        let cap = Capability::parse("DEPLOY").unwrap();
391        assert_eq!(cap.as_str(), "deploy");
392
393        let cap = Capability::parse("ACME:Deploy").unwrap();
394        assert_eq!(cap.as_str(), "acme:deploy");
395    }
396
397    #[test]
398    fn capability_parse_trims_whitespace() {
399        let cap = Capability::parse("  deploy  ").unwrap();
400        assert_eq!(cap.as_str(), "deploy");
401    }
402
403    // ========================================================================
404    // Capability equality and hashing tests
405    // ========================================================================
406
407    #[test]
408    fn capability_is_hashable() {
409        use std::collections::HashSet;
410        let mut set = HashSet::new();
411        set.insert(Capability::sign_commit());
412        set.insert(Capability::sign_release());
413        set.insert(Capability::parse("test").unwrap());
414        assert_eq!(set.len(), 3);
415        assert!(set.contains(&Capability::sign_commit()));
416    }
417
418    #[test]
419    fn capability_equality_with_different_construction_paths() {
420        // Well-known constructor equals deserialized
421        let from_constructor = Capability::sign_commit();
422        let from_deser: Capability = serde_json::from_str(r#""sign_commit""#).unwrap();
423        assert_eq!(from_constructor, from_deser);
424
425        // Parse equals deserialized for custom capabilities
426        let from_parse = Capability::parse("acme:deploy").unwrap();
427        let from_deser: Capability = serde_json::from_str(r#""acme:deploy""#).unwrap();
428        assert_eq!(from_parse, from_deser);
429    }
430
431    // ========================================================================
432    // Capability display and accessor tests
433    // ========================================================================
434
435    #[test]
436    fn capability_display_matches_canonical_form() {
437        assert_eq!(Capability::sign_commit().to_string(), "sign_commit");
438        assert_eq!(Capability::sign_release().to_string(), "sign_release");
439        assert_eq!(Capability::manage_members().to_string(), "manage_members");
440        assert_eq!(Capability::rotate_keys().to_string(), "rotate_keys");
441        assert_eq!(
442            Capability::parse("acme:deploy").unwrap().to_string(),
443            "acme:deploy"
444        );
445    }
446
447    #[test]
448    fn capability_as_str_returns_canonical_form() {
449        assert_eq!(Capability::sign_commit().as_str(), "sign_commit");
450        assert_eq!(Capability::sign_release().as_str(), "sign_release");
451        assert_eq!(Capability::manage_members().as_str(), "manage_members");
452        assert_eq!(Capability::rotate_keys().as_str(), "rotate_keys");
453        assert_eq!(
454            Capability::parse("acme:deploy").unwrap().as_str(),
455            "acme:deploy"
456        );
457    }
458
459    #[test]
460    fn capability_is_well_known() {
461        assert!(Capability::sign_commit().is_well_known());
462        assert!(Capability::sign_release().is_well_known());
463        assert!(Capability::manage_members().is_well_known());
464        assert!(Capability::rotate_keys().is_well_known());
465        assert!(!Capability::parse("custom").unwrap().is_well_known());
466    }
467
468    #[test]
469    fn capability_namespace() {
470        assert_eq!(
471            Capability::parse("acme:deploy").unwrap().namespace(),
472            Some("acme")
473        );
474        assert_eq!(
475            Capability::parse("org:team:action").unwrap().namespace(),
476            Some("org")
477        );
478        assert_eq!(Capability::parse("deploy").unwrap().namespace(), None);
479    }
480
481    // ========================================================================
482    // Capability vec serialization tests
483    // ========================================================================
484
485    #[test]
486    fn capability_vec_serializes_as_array() {
487        let caps = vec![Capability::sign_commit(), Capability::sign_release()];
488        let json = serde_json::to_string(&caps).unwrap();
489        assert_eq!(json, r#"["sign_commit","sign_release"]"#);
490    }
491
492    #[test]
493    fn capability_vec_deserializes_from_array() {
494        let json = r#"["sign_commit","manage_members","custom-cap"]"#;
495        let caps: Vec<Capability> = serde_json::from_str(json).unwrap();
496        assert_eq!(caps.len(), 3);
497        assert_eq!(caps[0], Capability::sign_commit());
498        assert_eq!(caps[1], Capability::manage_members());
499        assert_eq!(caps[2], Capability::parse("custom-cap").unwrap());
500    }
501
502    // ========================================================================
503    // Serde roundtrip tests (critical for backward compat)
504    // ========================================================================
505
506    #[test]
507    fn capability_serde_roundtrip_well_known() {
508        let caps = vec![
509            Capability::sign_commit(),
510            Capability::sign_release(),
511            Capability::manage_members(),
512            Capability::rotate_keys(),
513        ];
514        for cap in caps {
515            let json = serde_json::to_string(&cap).unwrap();
516            let roundtrip: Capability = serde_json::from_str(&json).unwrap();
517            assert_eq!(cap, roundtrip);
518        }
519    }
520
521    #[test]
522    fn capability_serde_roundtrip_custom() {
523        let caps = vec![
524            Capability::parse("deploy").unwrap(),
525            Capability::parse("acme:deploy").unwrap(),
526            Capability::parse("org:team:action").unwrap(),
527        ];
528        for cap in caps {
529            let json = serde_json::to_string(&cap).unwrap();
530            let roundtrip: Capability = serde_json::from_str(&json).unwrap();
531            assert_eq!(cap, roundtrip);
532        }
533    }
534}