Skip to main content

axess_identity/
resolver.rs

1//! `PrincipalResolver` trait + `CliResolver` impl.
2//!
3//! The trait shape is the workload-identity roadmap pattern: one
4//! async method, one return type, multiple impls. Adopters select the
5//! impl appropriate to their context (CLI bootstrap for workers;
6//! session extraction provided in axess-core for HTTP handlers);
7//! downstream code consumes the resulting [`Principal`] uniformly.
8//!
9//! Two impls land here:
10//!
11//! - `CliResolver`: workload identity from CLI / env data, the
12//!   only resolution mode available before federated identity work
13//!   lights up. The builder shape avoids the long argument list of
14//!   a direct constructor; every field is required by the time
15//!   `build()` is called so the resulting resolver is always
16//!   complete.
17//! - [`MockResolver`](crate::testing::MockResolver): testing aid; lives
18//!   in [`crate::testing`].
19//!
20//! Session-aware resolution stays in axess-core (`SessionResolver`,
21//! depends on `AuthSession`).
22
23use std::collections::BTreeMap;
24
25use crate::TenantId;
26
27use crate::{IdentityError, Issuer, Principal, TrustDomain, WorkloadId, WorkloadPrincipal};
28
29/// Async resolver that produces a [`Principal`] from a context-specific
30/// identity source. See module-level docs for the dispatch model.
31pub trait PrincipalResolver: Send + Sync {
32    /// Resolve the principal. Implementations may hit the network
33    /// (future `JwtSvidResolver`), read a session
34    /// (axess-core's `SessionResolver`), or simply return a value
35    /// built at construction (`CliResolver`, [`MockResolver`](crate::testing::MockResolver)).
36    fn resolve(&self)
37    -> impl std::future::Future<Output = Result<Principal, IdentityError>> + Send;
38}
39
40/// CLI / environment-sourced workload-identity resolver.
41///
42/// The operator supplies trust domain, service name, tenant slug, and
43/// the typed [`TenantId`] (matching `tenants.id` in the adopter's
44/// store; adopters with a slug↔UUID registry resolve it once at
45/// startup before constructing the resolver). Holds the resolved
46/// principal as a frozen value; every call to [`resolve`](Self::resolve)
47/// returns the same `Principal::Workload`.
48///
49/// Future swap-in for JWT-SVID / mTLS / SPIRE happens at the resolver
50/// boundary; adopter code that depends on [`PrincipalResolver`] does
51/// not change.
52#[derive(Debug, Clone)]
53pub struct CliResolver {
54    workload: WorkloadPrincipal,
55}
56
57impl CliResolver {
58    /// Begin a builder for a [`CliResolver`]. All fields are required
59    /// by [`build`](CliResolverBuilder::build).
60    pub fn builder() -> CliResolverBuilder {
61        CliResolverBuilder::default()
62    }
63
64    /// Borrow the resolved workload principal without re-running
65    /// `resolve`. Useful for adopter code that needs the typed
66    /// principal at sync call sites (e.g. building log spans).
67    pub fn workload(&self) -> &WorkloadPrincipal {
68        &self.workload
69    }
70
71    /// Build a [`Principal::Human`] from the same principal data
72    /// (CLI-supplied tenant) instead of [`Principal::Workload`].
73    /// Useful only in narrow adapter contexts; most adopters want
74    /// the default `Workload` shape from [`resolve`](Self::resolve).
75    pub fn as_workload_principal(&self) -> &WorkloadPrincipal {
76        &self.workload
77    }
78}
79
80impl PrincipalResolver for CliResolver {
81    async fn resolve(&self) -> Result<Principal, IdentityError> {
82        Ok(Principal::Workload(self.workload.clone()))
83    }
84}
85
86/// Builder for [`CliResolver`]. Every field except `attributes` is
87/// required; `build()` returns `Err` if any are missing.
88#[derive(Debug, Default, Clone)]
89pub struct CliResolverBuilder {
90    trust_domain: Option<TrustDomain>,
91    service_name: Option<String>,
92    tenant_id: Option<TenantId>,
93    tenant_slug: Option<String>,
94    attributes: BTreeMap<String, serde_json::Value>,
95}
96
97impl CliResolverBuilder {
98    /// Set the trust domain. Required.
99    pub fn trust_domain(mut self, value: TrustDomain) -> Self {
100        self.trust_domain = Some(value);
101        self
102    }
103
104    /// Set the service name. Required.
105    pub fn service_name(mut self, value: impl Into<String>) -> Self {
106        self.service_name = Some(value.into());
107        self
108    }
109
110    /// Set the typed tenant identifier. Required.
111    pub fn tenant_id(mut self, value: TenantId) -> Self {
112        self.tenant_id = Some(value);
113        self
114    }
115
116    /// Set the human-readable tenant slug. Required.
117    pub fn tenant_slug(mut self, value: impl Into<String>) -> Self {
118        self.tenant_slug = Some(value.into());
119        self
120    }
121
122    /// Set arbitrary key-value attributes. Optional; defaults to empty.
123    pub fn attribute(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
124        self.attributes.insert(key.into(), value);
125        self
126    }
127
128    /// Build the resolver. Validates that every required field is set
129    /// and that the resulting [`WorkloadId`] is a valid SPIFFE URI.
130    pub fn build(self) -> Result<CliResolver, IdentityError> {
131        let trust_domain = self
132            .trust_domain
133            .ok_or_else(|| IdentityError::InvalidComponent("trust_domain not set".to_string()))?;
134        let service_name = self
135            .service_name
136            .ok_or_else(|| IdentityError::InvalidComponent("service_name not set".to_string()))?;
137        let tenant_id = self
138            .tenant_id
139            .ok_or_else(|| IdentityError::InvalidComponent("tenant_id not set".to_string()))?;
140        let tenant_slug = self
141            .tenant_slug
142            .ok_or_else(|| IdentityError::InvalidComponent("tenant_slug not set".to_string()))?;
143        let workload_id = WorkloadId::build(&trust_domain, &service_name, &tenant_slug)?;
144        let workload = WorkloadPrincipal {
145            workload_id,
146            trust_domain,
147            issuer: Issuer::Cli,
148            tenant_id,
149            tenant_slug,
150            service_name,
151            attributes: self.attributes,
152        };
153        Ok(CliResolver { workload })
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    fn sample_tenant() -> TenantId {
162        TenantId::from_bytes([9u8; 16])
163    }
164
165    #[tokio::test]
166    async fn cli_resolver_builds_workload_principal() {
167        let trust = TrustDomain::new("gnomes.local").unwrap();
168        let resolver = CliResolver::builder()
169            .trust_domain(trust.clone())
170            .service_name("compute-worker")
171            .tenant_id(sample_tenant())
172            .tenant_slug("ekekrantz")
173            .build()
174            .unwrap();
175        let principal = resolver.resolve().await.unwrap();
176        match principal {
177            Principal::Workload(w) => {
178                assert_eq!(
179                    w.workload_id.as_str(),
180                    "spiffe://gnomes.local/compute-worker/ekekrantz"
181                );
182                assert_eq!(w.trust_domain, trust);
183                assert_eq!(w.issuer, Issuer::Cli);
184                assert_eq!(w.tenant_id, sample_tenant());
185                assert_eq!(w.tenant_slug, "ekekrantz");
186                assert_eq!(w.service_name, "compute-worker");
187                assert!(w.attributes.is_empty());
188            }
189            Principal::Human(_) => panic!("expected Workload principal"),
190        }
191    }
192
193    #[tokio::test]
194    async fn cli_resolver_resolve_is_idempotent() {
195        let trust = TrustDomain::new("gnomes.local").unwrap();
196        let resolver = CliResolver::builder()
197            .trust_domain(trust)
198            .service_name("feed-worker")
199            .tenant_id(sample_tenant())
200            .tenant_slug("ekekrantz")
201            .build()
202            .unwrap();
203        let a = resolver.resolve().await.unwrap();
204        let b = resolver.resolve().await.unwrap();
205        assert_eq!(a, b);
206    }
207
208    #[test]
209    fn cli_resolver_builder_rejects_missing_trust_domain() {
210        let err = CliResolver::builder()
211            .service_name("compute-worker")
212            .tenant_id(sample_tenant())
213            .tenant_slug("ekekrantz")
214            .build()
215            .unwrap_err();
216        assert!(matches!(err, IdentityError::InvalidComponent(_)));
217    }
218
219    #[test]
220    fn cli_resolver_builder_rejects_missing_service_name() {
221        let trust = TrustDomain::new("gnomes.local").unwrap();
222        let err = CliResolver::builder()
223            .trust_domain(trust)
224            .tenant_id(sample_tenant())
225            .tenant_slug("ekekrantz")
226            .build()
227            .unwrap_err();
228        assert!(matches!(err, IdentityError::InvalidComponent(_)));
229    }
230
231    #[test]
232    fn cli_resolver_builder_rejects_missing_tenant_id() {
233        let trust = TrustDomain::new("gnomes.local").unwrap();
234        let err = CliResolver::builder()
235            .trust_domain(trust)
236            .service_name("compute-worker")
237            .tenant_slug("ekekrantz")
238            .build()
239            .unwrap_err();
240        assert!(matches!(err, IdentityError::InvalidComponent(_)));
241    }
242
243    #[test]
244    fn cli_resolver_builder_rejects_missing_tenant_slug() {
245        let trust = TrustDomain::new("gnomes.local").unwrap();
246        let err = CliResolver::builder()
247            .trust_domain(trust)
248            .service_name("compute-worker")
249            .tenant_id(sample_tenant())
250            .build()
251            .unwrap_err();
252        assert!(matches!(err, IdentityError::InvalidComponent(_)));
253    }
254
255    #[test]
256    fn cli_resolver_builder_rejects_invalid_service_name() {
257        let trust = TrustDomain::new("gnomes.local").unwrap();
258        let err = CliResolver::builder()
259            .trust_domain(trust)
260            .service_name("compute worker")
261            .tenant_id(sample_tenant())
262            .tenant_slug("ekekrantz")
263            .build()
264            .unwrap_err();
265        assert!(matches!(err, IdentityError::InvalidComponent(_)));
266    }
267
268    #[tokio::test]
269    async fn cli_resolver_carries_attributes() {
270        let trust = TrustDomain::new("gnomes.local").unwrap();
271        let resolver = CliResolver::builder()
272            .trust_domain(trust)
273            .service_name("compute-worker")
274            .tenant_id(sample_tenant())
275            .tenant_slug("ekekrantz")
276            .attribute("worker_pid", serde_json::json!(12345))
277            .attribute("hostname", serde_json::json!("worker-1.local"))
278            .build()
279            .unwrap();
280        let workload = resolver.workload();
281        assert_eq!(workload.attributes.len(), 2);
282        assert_eq!(workload.attributes["worker_pid"], serde_json::json!(12345));
283    }
284}