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}