Skip to main content

axess_identity/
lib.rs

1//! Identity primitives for the axess workspace.
2//!
3//! Two layers in one crate:
4//!
5//! 1. **Typed identifiers** ([`id`] module, re-exported at the crate
6//!    root): [`TenantId`], [`UserId`], [`DeviceId`], [`SessionId`],
7//!    [`EventId`]: all `FooId(Uuid)` newtypes (16 bytes, `Copy`)
8//!    minted via the [`define_id!`] macro. Adopters can declare their
9//!    own domain ids (`AccountId`, `OrderId`, …) with the same shape.
10//! 2. **Principal abstraction**: [`Principal`] unifies human and
11//!    workload identity under one type so authorization policies, audit
12//!    trails, and downstream consumers treat both kinds symmetrically.
13//!    [`HumanPrincipal`] (a user behind an authenticated session) and
14//!    [`WorkloadPrincipal`] (a service, batch job, agent, CI runner,
15//!    anything that authenticates without an interactive session) both
16//!    carry [`TenantId`] so the multi-tenant rail cuts through every
17//!    consumer uniformly.
18//!
19//! # Workload identity model
20//!
21//! Workload identifiers follow the [SPIFFE-ID](https://github.com/spiffe/spiffe/blob/main/standards/SPIFFE-ID.md)
22//! URI shape from day one (`spiffe://<trust-domain>/<path>`), even when
23//! resolved from a non-SPIFFE source ([`Issuer::Cli`] today; JWT-SVID,
24//! mTLS, and SPIRE land later). Using the SPIFFE format up front means
25//! the on-wire identity string does not change when those resolution
26//! modes light up; only the [`Issuer`] variant flips.
27//!
28//! # Layering
29//!
30//! Foundation crate, deliberately small: depends only on `axess-rng`
31//! (for the DST-injectable `SecureRng` trait), `uuid`, and `thiserror`.
32//! No tokio, no axum, no Cedar. axess-core layers two more pieces on
33//! top:
34//! - `SessionResolver`: extracts a [`HumanPrincipal`] from an
35//!   authenticated `AuthSession` (depends on axess-core's session
36//!   state machine).
37//! - `ToCedarEntity` trait: emits `cedar_policy::Entity` values for
38//!   adopters using axess Cedar authorization (depends on cedar-policy).
39//!
40//! Downstream consumers that only need the principal *data* (event
41//! envelope stamping, log spans, audit attribution) pull in
42//! `axess-identity` directly and skip the heavier axess-core dep.
43
44#![forbid(unsafe_code)]
45#![deny(missing_docs)]
46#![cfg_attr(docsrs, feature(doc_cfg))]
47
48use std::collections::BTreeMap;
49
50pub mod human;
51pub mod id;
52pub mod resolver;
53#[cfg(any(test, feature = "testing"))]
54#[cfg_attr(docsrs, doc(cfg(feature = "testing")))]
55pub mod testing;
56pub mod workload;
57
58pub use human::HumanPrincipal;
59pub use id::*;
60pub use resolver::{CliResolver, CliResolverBuilder, PrincipalResolver};
61pub use workload::{TrustDomain, WorkloadId, WorkloadPrincipal};
62
63/// An authenticated principal: either a human user or a workload.
64///
65/// Constructed by a [`PrincipalResolver`] impl once authentication has
66/// succeeded; consumers downstream (authorization, audit, event
67/// stamping) treat both kinds symmetrically via the shared accessors.
68#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
69#[cfg_attr(feature = "serde", serde(tag = "kind", rename_all = "snake_case"))]
70#[derive(Clone, Debug, PartialEq, Eq)]
71pub enum Principal {
72    /// A human user behind an authenticated session.
73    Human(HumanPrincipal),
74    /// A workload: service, batch job, agent, CI runner, etc.
75    Workload(WorkloadPrincipal),
76}
77
78impl Principal {
79    /// The tenant this principal belongs to. Both human and workload
80    /// principals are tenant-scoped; cross-tenant operations require
81    /// a separate principal per tenant.
82    pub fn tenant_id(&self) -> &TenantId {
83        match self {
84            Self::Human(h) => &h.tenant_id,
85            Self::Workload(w) => &w.tenant_id,
86        }
87    }
88
89    /// Arbitrary key-value attributes attached to the principal.
90    /// Empty for baseline principals; populated from JWT claims or
91    /// other resolver-specific metadata once federated resolvers
92    /// land.
93    pub fn attributes(&self) -> &BTreeMap<String, serde_json::Value> {
94        match self {
95            Self::Human(h) => &h.attributes,
96            Self::Workload(w) => &w.attributes,
97        }
98    }
99}
100
101/// How a principal's identity was vouched for at resolution time.
102///
103/// The SPIRE variant lands when a concrete adopter use case arrives;
104/// it is deliberately not yet present so the enum doesn't carry a
105/// constructor that no resolver impl ever produces.
106#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
107#[cfg_attr(feature = "serde", serde(tag = "kind", rename_all = "snake_case"))]
108#[derive(Clone, Debug, PartialEq, Eq)]
109pub enum Issuer {
110    /// Identity supplied via CLI args or environment variables.
111    /// Trust comes from the operator who started the process.
112    Cli,
113    /// Identity verified from a SPIFFE JWT-SVID. Trust comes from the
114    /// SPIFFE-aware IdP whose JWKS signed the token; the resolver
115    /// pinned the trust domain at construction.
116    JwtSvid,
117    /// Identity verified from a SPIFFE X509-SVID presented over mTLS.
118    /// Trust comes from the rustls peer-cert chain validation already
119    /// performed by the TLS terminator; the resolver pinned the trust
120    /// domain at construction and extracts the SPIFFE-ID from the leaf
121    /// certificate's `SAN URI` field.
122    Mtls,
123    /// Identity verified from a bearer JWT via the generic
124    /// [`WorkloadResolver`](https://docs.rs/axess-factors/latest/axess_factors/federation/struct.WorkloadResolver.html).
125    /// The adopter-supplied claim-mapping closure decides how the
126    /// verified JWT claims map onto the SPIFFE-shape `WorkloadId` +
127    /// tenant slug. Covers Kubernetes service accounts, GitHub Actions
128    /// OIDC, GitLab CI OIDC, Okta, Azure AD, Auth0, axess's
129    /// `LocalIdP`, and any other JWT-issuing IdP; adopters write a
130    /// small claim parser + mapper per issuer they care about.
131    OAuth,
132    /// Adopter-labelled issuer for cases where the generic [`OAuth`]
133    /// variant's wire-string (`"oauth"`) is not specific enough for
134    /// audit logs or Cedar policies. Construct via [`Issuer::custom`]
135    /// which validates the label format (`[a-z0-9_]{1,32}`).
136    ///
137    /// [`OAuth`]: Self::OAuth
138    Custom(String),
139}
140
141impl Issuer {
142    /// Build an [`Issuer::Custom`] with a validated label.
143    ///
144    /// Labels must match `[a-z0-9_]{1,32}` so that wire-strings
145    /// (audit events, Cedar attribute values, SIEM grep patterns)
146    /// stay short, stable, and grep-safe across issuers.
147    /// Pre-defined examples: `"github_actions"`, `"kubernetes"`,
148    /// `"gitlab_ci"`, `"circleci"`, `"buildkite"`, `"local_idp"`.
149    pub fn custom(label: impl AsRef<str>) -> Result<Self, IdentityError> {
150        let s = label.as_ref();
151        if s.is_empty() || s.len() > 32 {
152            return Err(IdentityError::InvalidComponent(format!(
153                "Issuer::custom label length must be 1..=32, got {}",
154                s.len()
155            )));
156        }
157        if !s
158            .bytes()
159            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
160        {
161            return Err(IdentityError::InvalidComponent(format!(
162                "Issuer::custom label must match [a-z0-9_], got {s:?}"
163            )));
164        }
165        Ok(Self::Custom(s.to_string()))
166    }
167
168    /// Stable lowercase wire-string for this variant. Use as the
169    /// Cedar attribute value and any other on-wire serialization.
170    pub fn as_str(&self) -> &str {
171        match self {
172            Self::Cli => "cli",
173            Self::JwtSvid => "jwt_svid",
174            Self::Mtls => "mtls",
175            Self::OAuth => "oauth",
176            Self::Custom(s) => s.as_str(),
177        }
178    }
179}
180
181/// Errors from principal construction and identity parsing.
182#[derive(Debug, thiserror::Error)]
183pub enum IdentityError {
184    /// A SPIFFE-ID URI failed validation.
185    #[error("invalid SPIFFE identifier: {0}")]
186    InvalidSpiffeId(String),
187
188    /// A trust domain string failed validation per the SPIFFE spec.
189    #[error("invalid trust domain: {0}")]
190    InvalidTrustDomain(String),
191
192    /// An empty or malformed input where one of the WorkloadId
193    /// components (service name, tenant slug) was required.
194    #[error("invalid workload identifier component: {0}")]
195    InvalidComponent(String),
196
197    /// A resolver was asked to produce a principal from a context
198    /// where no authenticated identity is available (e.g. a Guest
199    /// session, or a worker before identity bootstrap).
200    #[error("no authenticated identity available")]
201    NotAuthenticated,
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    fn sample_tenant() -> TenantId {
209        TenantId::from_bytes([1u8; 16])
210    }
211
212    fn sample_user() -> UserId {
213        UserId::from_bytes([2u8; 16])
214    }
215
216    #[test]
217    fn human_principal_tenant_id_accessor() {
218        let tenant = sample_tenant();
219        let human = HumanPrincipal {
220            user_id: sample_user(),
221            tenant_id: tenant,
222            session_id: None,
223            attributes: BTreeMap::new(),
224        };
225        let p = Principal::Human(human);
226        assert_eq!(p.tenant_id(), &tenant);
227    }
228
229    #[test]
230    fn workload_principal_tenant_id_accessor() {
231        let tenant = sample_tenant();
232        let trust = TrustDomain::new("gnomes.local").unwrap();
233        let wid = WorkloadId::build(&trust, "compute-worker", "ekekrantz").unwrap();
234        let workload = WorkloadPrincipal {
235            workload_id: wid,
236            trust_domain: trust,
237            issuer: Issuer::Cli,
238            tenant_id: tenant,
239            tenant_slug: "ekekrantz".to_string(),
240            service_name: "compute-worker".to_string(),
241            attributes: BTreeMap::new(),
242        };
243        let p = Principal::Workload(workload);
244        assert_eq!(p.tenant_id(), &tenant);
245    }
246
247    #[test]
248    fn attributes_accessor_returns_empty_by_default() {
249        let trust = TrustDomain::new("gnomes.local").unwrap();
250        let wid = WorkloadId::build(&trust, "feed-worker", "ekekrantz").unwrap();
251        let workload = WorkloadPrincipal {
252            workload_id: wid,
253            trust_domain: trust,
254            issuer: Issuer::Cli,
255            tenant_id: sample_tenant(),
256            tenant_slug: "ekekrantz".to_string(),
257            service_name: "feed-worker".to_string(),
258            attributes: BTreeMap::new(),
259        };
260        let p = Principal::Workload(workload);
261        assert!(p.attributes().is_empty());
262    }
263
264    /// `attributes()` returns the LIVE attribute map, not a fresh
265    /// empty one. The mutation `-> Box::leak(Box::new(BTreeMap::new()))`
266    /// would hide any populated attributes.
267    #[test]
268    fn attributes_accessor_returns_populated_human_map() {
269        let mut attrs = BTreeMap::new();
270        attrs.insert("amr".to_string(), serde_json::json!(["pwd", "mfa"]));
271        let human = HumanPrincipal {
272            user_id: sample_user(),
273            tenant_id: sample_tenant(),
274            session_id: None,
275            attributes: attrs,
276        };
277        let p = Principal::Human(human);
278        let seen = p.attributes();
279        assert_eq!(seen.len(), 1);
280        assert_eq!(seen.get("amr"), Some(&serde_json::json!(["pwd", "mfa"])));
281    }
282
283    #[test]
284    fn attributes_accessor_returns_populated_workload_map() {
285        let trust = TrustDomain::new("gnomes.local").unwrap();
286        let wid = WorkloadId::build(&trust, "feed-worker", "ekekrantz").unwrap();
287        let mut attrs = BTreeMap::new();
288        attrs.insert("region".to_string(), serde_json::json!("eu-west-1"));
289        let workload = WorkloadPrincipal {
290            workload_id: wid,
291            trust_domain: trust,
292            issuer: Issuer::Cli,
293            tenant_id: sample_tenant(),
294            tenant_slug: "ekekrantz".to_string(),
295            service_name: "feed-worker".to_string(),
296            attributes: attrs,
297        };
298        let p = Principal::Workload(workload);
299        assert_eq!(
300            p.attributes().get("region"),
301            Some(&serde_json::json!("eu-west-1"))
302        );
303    }
304
305    #[test]
306    fn issuer_as_str_is_stable_lowercase() {
307        assert_eq!(Issuer::Cli.as_str(), "cli");
308        assert_eq!(Issuer::JwtSvid.as_str(), "jwt_svid");
309        assert_eq!(Issuer::Mtls.as_str(), "mtls");
310        assert_eq!(Issuer::OAuth.as_str(), "oauth");
311        assert_eq!(
312            Issuer::custom("github_actions").unwrap().as_str(),
313            "github_actions"
314        );
315    }
316
317    #[test]
318    fn issuer_custom_validation_rejects_bad_labels() {
319        // Empty: rejected.
320        assert!(Issuer::custom("").is_err());
321        // Too long (>32): rejected.
322        assert!(Issuer::custom("a".repeat(33)).is_err());
323        // Uppercase: rejected.
324        assert!(Issuer::custom("GitHubActions").is_err());
325        // Dashes / dots / spaces: rejected.
326        assert!(Issuer::custom("github-actions").is_err());
327        assert!(Issuer::custom("github.actions").is_err());
328        assert!(Issuer::custom("github actions").is_err());
329        // Accepted shapes.
330        assert!(Issuer::custom("k8s").is_ok());
331        assert!(Issuer::custom("github_actions").is_ok());
332        assert!(Issuer::custom("circleci_2_1").is_ok());
333    }
334
335    #[test]
336    fn issuer_custom_accepts_exactly_32_chars() {
337        let label = "a".repeat(32);
338        assert!(
339            Issuer::custom(&label).is_ok(),
340            "32-char label must be accepted (kills `> -> >=` boundary mutation)"
341        );
342        let too_long = "a".repeat(33);
343        assert!(
344            Issuer::custom(&too_long).is_err(),
345            "33-char label must be rejected"
346        );
347    }
348
349    #[cfg(feature = "serde")]
350    #[test]
351    fn principal_serde_round_trip_human() {
352        let human = HumanPrincipal {
353            user_id: sample_user(),
354            tenant_id: sample_tenant(),
355            session_id: None,
356            attributes: BTreeMap::new(),
357        };
358        let p = Principal::Human(human);
359        let json = serde_json::to_string(&p).unwrap();
360        let back: Principal = serde_json::from_str(&json).unwrap();
361        assert_eq!(p, back);
362    }
363
364    #[cfg(feature = "serde")]
365    #[test]
366    fn principal_serde_round_trip_workload() {
367        let trust = TrustDomain::new("gnomes.local").unwrap();
368        let wid = WorkloadId::build(&trust, "market-worker", "world_cup").unwrap();
369        let workload = WorkloadPrincipal {
370            workload_id: wid,
371            trust_domain: trust,
372            issuer: Issuer::Cli,
373            tenant_id: sample_tenant(),
374            tenant_slug: "world_cup".to_string(),
375            service_name: "market-worker".to_string(),
376            attributes: BTreeMap::new(),
377        };
378        let p = Principal::Workload(workload);
379        let json = serde_json::to_string(&p).unwrap();
380        let back: Principal = serde_json::from_str(&json).unwrap();
381        assert_eq!(p, back);
382    }
383}