Skip to main content

plexus_auth_core/tenant/
resolver.rs

1//! `TenantResolver` — derives a sealed `Tenant` from an `AuthContext`.
2//!
3//! The framework invokes a `TenantResolver` once per request, after
4//! `SessionValidator::validate` produces the `AuthContext` and before
5//! method-scope authorization runs. The resulting `Tenant` flows through
6//! the dispatch as an extension; activation methods that declare a
7//! `&Tenant` parameter extract it from there.
8//!
9//! Two reference implementations are provided:
10//!
11//! - [`ClaimTenantResolver`] — the 80% case: pull a configured claim out
12//!   of `AuthContext.metadata` (default key: `"tenant_id"`). When the
13//!   claim is absent and `single_user_fallback` is true, fall back to
14//!   the verified user id (single-user-deployment safe default).
15//!
16//! - [`SingleTenantResolver`] — the explicit opt-out: always resolve to
17//!   one fixed tenant value, regardless of the caller. Use this for
18//!   single-user dev installs that want tenancy off; the opt-out is
19//!   grep-able in the hub builder.
20//!
21//! See `plans/AUTHZ/AUTHZ-DATA-S01-output.md` §2 for the trait design.
22
23use crate::auth::AuthContext;
24use crate::tenant::types::{Tenant, TenantError};
25use async_trait::async_trait;
26
27/// Derives a sealed `Tenant` for an authenticated caller.
28///
29/// Backends register one resolver per hub via the hub builder
30/// (`with_tenant_resolver`); the framework invokes it once per request
31/// at dispatch entry, post-authentication and pre-method-scope-check.
32///
33/// # Failure handling
34///
35/// A `TenantError` result is converted by the dispatch layer to
36/// `AuthzError::Forbidden { reason: TenantBoundary }`; the underlying
37/// variant is captured in the `AuditRecord` (with
38/// `AuditDenyReason::TenantBoundary`) for operator investigation. The
39/// wire response is the generic forbidden error — no information is
40/// leaked about whether the failure was a missing claim, a backend
41/// lookup miss, or a malformed identifier.
42///
43/// # Bounds
44///
45/// The `Send + Sync + 'static` bounds are required by the framework's
46/// dispatch invocation pattern; the resolver is shared as an
47/// `Arc<dyn TenantResolver>` across all concurrent requests.
48#[async_trait]
49pub trait TenantResolver: Send + Sync + 'static {
50    /// Derive the tenant for the verified caller.
51    ///
52    /// Implementations should:
53    ///
54    /// - Return `Ok(Tenant)` when the caller resolves cleanly.
55    /// - Return `Err(TenantError::UnresolvedFromAuthContext)` when no
56    ///   tenant can be derived (anonymous caller, missing claim, empty
57    ///   lookup) — this is the typical denial path.
58    /// - Return `Err(TenantError::BackendResolverFailed(...))` when an
59    ///   internal lookup mechanism failed (database error, upstream
60    ///   service timeout, etc.).
61    ///
62    /// The `Ok` value is constructed through this crate's framework-
63    /// internal helpers (`mint_tenant_from_str`, crate-private), which
64    /// validate and seal the value. Resolver implementations therefore
65    /// cannot bypass the validation rules pinned on `Tenant`.
66    async fn resolve(&self, auth: &AuthContext) -> Result<Tenant, TenantError>;
67}
68
69/// Framework-internal helper that mints a `Tenant` from a string.
70///
71/// This is the only path exposed to resolver implementations (which live
72/// inside `plexus-auth-core`, so the `pub(crate)` constructor is
73/// reachable). Future ticket AUTHZ-DATA-1-DISPATCH will expose this as
74/// `pub(crate)` to plexus-core via a sealed mint trait; for now,
75/// resolvers live in this crate alongside the type and use the
76/// crate-private constructor directly.
77///
78/// Returns `TenantError::InvalidShape` if the candidate fails the
79/// validation rules pinned on `Tenant`.
80pub(crate) fn mint_tenant_from_str(s: impl Into<String>) -> Result<Tenant, TenantError> {
81    Tenant::try_new(s)
82}
83
84/// Reference impl: derive the tenant from an `AuthContext` claim.
85///
86/// Reads a configured claim key from `AuthContext.metadata` (e.g.
87/// `"tenant_id"`, `"realm"`, `"org_id"`). When `single_user_fallback`
88/// is true and the claim is absent, falls back to the caller's
89/// `user_id` so single-user deployments map each user 1:1 to their own
90/// tenant by default.
91///
92/// # Why `single_user_fallback` defaults to `true`
93///
94/// AUTHZ-DATA-S01-output Q2 leans toward the dev-safe default: a
95/// deployment without an explicit `tenant_id` claim should not silently
96/// serve cross-user data. Falling back to the user id means a
97/// misconfigured single-user install gets per-user isolation
98/// automatically; deployments that want a hard "claim required" gate
99/// flip the bool to `false`.
100#[derive(Debug, Clone)]
101pub struct ClaimTenantResolver {
102    /// The metadata key to look up (e.g. `"tenant_id"`, `"realm"`,
103    /// `"org_id"`).
104    pub claim_key: String,
105    /// When the claim is absent, fall back to the caller's `user_id` as
106    /// the tenant value. The single-user-deployment safe default.
107    pub single_user_fallback: bool,
108}
109
110impl ClaimTenantResolver {
111    /// Construct with the default claim key `"tenant_id"` and
112    /// `single_user_fallback = true`. Matches the existing
113    /// `AuthContext::tenant()` helper's primary lookup key.
114    pub fn new() -> Self {
115        Self {
116            claim_key: "tenant_id".into(),
117            single_user_fallback: true,
118        }
119    }
120
121    /// Construct with a custom claim key. `single_user_fallback`
122    /// remains at the dev-safe `true` default; toggle the field
123    /// directly if a strict "claim required" gate is needed.
124    pub fn with_claim_key(claim_key: impl Into<String>) -> Self {
125        Self {
126            claim_key: claim_key.into(),
127            single_user_fallback: true,
128        }
129    }
130}
131
132impl Default for ClaimTenantResolver {
133    fn default() -> Self {
134        Self::new()
135    }
136}
137
138#[async_trait]
139impl TenantResolver for ClaimTenantResolver {
140    async fn resolve(&self, auth: &AuthContext) -> Result<Tenant, TenantError> {
141        if let Some(claim) = auth.get_metadata_string(&self.claim_key) {
142            return mint_tenant_from_str(claim);
143        }
144        if self.single_user_fallback && auth.is_authenticated() {
145            return mint_tenant_from_str(auth.user_id.clone());
146        }
147        Err(TenantError::UnresolvedFromAuthContext)
148    }
149}
150
151/// Reference impl: always resolve to one fixed tenant.
152///
153/// The explicit opt-out for single-user dev installs that want tenancy
154/// off (or for deployments that want a deliberately single-tenant
155/// posture). The opt-out is grep-able in the hub builder code: a
156/// reviewer searching for `SingleTenantResolver` finds every deployment
157/// that has consciously disabled multi-tenancy.
158#[derive(Debug, Clone)]
159pub struct SingleTenantResolver {
160    fixed: Tenant,
161}
162
163impl SingleTenantResolver {
164    /// Construct with the literal tenant value `"default"`.
165    ///
166    /// Panics only if `"default"` fails `Tenant::try_new` validation —
167    /// which it cannot (non-empty, short, all printable ASCII). Encoded
168    /// as `expect` so a future change to the validation rules surfaces
169    /// the failure here rather than silently shipping a non-functional
170    /// resolver.
171    pub fn new() -> Self {
172        Self {
173            fixed: Tenant::try_new("default")
174                .expect("the literal \"default\" satisfies Tenant::try_new validation"),
175        }
176    }
177
178    /// Construct with a custom fixed tenant identifier.
179    ///
180    /// Returns an error if the candidate fails `Tenant::try_new`
181    /// validation (empty, too long, non-printable bytes). The
182    /// `pub(crate)` reach to the constructor is acceptable here
183    /// because this builder is itself part of `plexus-auth-core`'s
184    /// blessed API: a backend operator who calls `with_fixed("acme")`
185    /// is explicitly opting in to a fixed tenant value at hub
186    /// configuration time, exactly as AUTHZ-DATA-S01-output §2
187    /// describes ("the opt-out is explicit and grep-able").
188    pub fn with_fixed(value: impl Into<String>) -> Result<Self, TenantError> {
189        Ok(Self {
190            fixed: Tenant::try_new(value)?,
191        })
192    }
193}
194
195impl Default for SingleTenantResolver {
196    fn default() -> Self {
197        Self::new()
198    }
199}
200
201#[async_trait]
202impl TenantResolver for SingleTenantResolver {
203    async fn resolve(&self, _auth: &AuthContext) -> Result<Tenant, TenantError> {
204        Ok(self.fixed.clone())
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use serde_json::json;
212
213    fn ctx_with_metadata(user_id: &str, metadata: serde_json::Value) -> AuthContext {
214        AuthContext::new(
215            user_id.into(),
216            "sess-1".into(),
217            vec![],
218            metadata,
219        )
220    }
221
222    #[tokio::test]
223    async fn claim_resolver_pulls_tenant_id_by_default() {
224        let r = ClaimTenantResolver::new();
225        let auth = ctx_with_metadata("alice", json!({"tenant_id": "acme-corp"}));
226        let t = r.resolve(&auth).await.unwrap();
227        assert_eq!(t.as_str(), "acme-corp");
228    }
229
230    #[tokio::test]
231    async fn claim_resolver_honors_custom_claim_key() {
232        let r = ClaimTenantResolver::with_claim_key("org_id");
233        let auth = ctx_with_metadata("alice", json!({"org_id": "neon-9"}));
234        let t = r.resolve(&auth).await.unwrap();
235        assert_eq!(t.as_str(), "neon-9");
236    }
237
238    #[tokio::test]
239    async fn claim_resolver_ignores_irrelevant_claims() {
240        let r = ClaimTenantResolver {
241            claim_key: "tenant_id".into(),
242            single_user_fallback: false,
243        };
244        let auth = ctx_with_metadata("alice", json!({"realm": "prod"}));
245        let err = r.resolve(&auth).await.unwrap_err();
246        assert_eq!(err, TenantError::UnresolvedFromAuthContext);
247    }
248
249    #[tokio::test]
250    async fn claim_resolver_falls_back_to_user_id_when_enabled() {
251        let r = ClaimTenantResolver {
252            claim_key: "tenant_id".into(),
253            single_user_fallback: true,
254        };
255        let auth = ctx_with_metadata("alice-uuid", json!({}));
256        let t = r.resolve(&auth).await.unwrap();
257        assert_eq!(t.as_str(), "alice-uuid");
258    }
259
260    #[tokio::test]
261    async fn claim_resolver_does_not_fall_back_when_disabled() {
262        let r = ClaimTenantResolver {
263            claim_key: "tenant_id".into(),
264            single_user_fallback: false,
265        };
266        let auth = ctx_with_metadata("alice", json!({}));
267        let err = r.resolve(&auth).await.unwrap_err();
268        assert_eq!(err, TenantError::UnresolvedFromAuthContext);
269    }
270
271    #[tokio::test]
272    async fn claim_resolver_rejects_anonymous_even_with_fallback() {
273        let r = ClaimTenantResolver {
274            claim_key: "tenant_id".into(),
275            single_user_fallback: true,
276        };
277        let auth = AuthContext::anonymous();
278        let err = r.resolve(&auth).await.unwrap_err();
279        assert_eq!(err, TenantError::UnresolvedFromAuthContext);
280    }
281
282    #[tokio::test]
283    async fn claim_resolver_surfaces_invalid_shape_when_claim_malformed() {
284        let r = ClaimTenantResolver::new();
285        // Claim contains a NUL — fails Tenant::try_new validation.
286        let auth = ctx_with_metadata("alice", json!({"tenant_id": "evil\0tenant"}));
287        let err = r.resolve(&auth).await.unwrap_err();
288        assert_eq!(err, TenantError::InvalidShape);
289    }
290
291    #[tokio::test]
292    async fn claim_resolver_prefers_claim_over_fallback() {
293        let r = ClaimTenantResolver {
294            claim_key: "tenant_id".into(),
295            single_user_fallback: true,
296        };
297        let auth = ctx_with_metadata("alice", json!({"tenant_id": "acme-corp"}));
298        let t = r.resolve(&auth).await.unwrap();
299        // Claim wins over fallback to user_id.
300        assert_eq!(t.as_str(), "acme-corp");
301    }
302
303    #[tokio::test]
304    async fn single_tenant_resolver_returns_default() {
305        let r = SingleTenantResolver::new();
306        let auth = ctx_with_metadata("alice", json!({"tenant_id": "ignored"}));
307        let t = r.resolve(&auth).await.unwrap();
308        assert_eq!(t.as_str(), "default");
309    }
310
311    #[tokio::test]
312    async fn single_tenant_resolver_ignores_metadata() {
313        // The whole point: the AuthContext is irrelevant to the result.
314        let r = SingleTenantResolver::new();
315        let auth1 = ctx_with_metadata("alice", json!({"tenant_id": "acme"}));
316        let auth2 = ctx_with_metadata("bob", json!({"tenant_id": "neon"}));
317        let auth3 = AuthContext::anonymous();
318        assert_eq!(r.resolve(&auth1).await.unwrap().as_str(), "default");
319        assert_eq!(r.resolve(&auth2).await.unwrap().as_str(), "default");
320        assert_eq!(r.resolve(&auth3).await.unwrap().as_str(), "default");
321    }
322
323    #[tokio::test]
324    async fn single_tenant_resolver_with_custom_fixed_value() {
325        let r = SingleTenantResolver::with_fixed("dev-tenant").unwrap();
326        let auth = AuthContext::anonymous();
327        let t = r.resolve(&auth).await.unwrap();
328        assert_eq!(t.as_str(), "dev-tenant");
329    }
330
331    #[tokio::test]
332    async fn single_tenant_resolver_rejects_invalid_fixed_value() {
333        let err = SingleTenantResolver::with_fixed("").unwrap_err();
334        assert_eq!(err, TenantError::InvalidShape);
335        let err = SingleTenantResolver::with_fixed("evil\0").unwrap_err();
336        assert_eq!(err, TenantError::InvalidShape);
337    }
338
339    #[tokio::test]
340    async fn single_tenant_resolver_never_fails_after_construction() {
341        // The trait method itself doesn't return an error path for this
342        // impl; we exercise enough auth shapes to demonstrate that.
343        let r = SingleTenantResolver::new();
344        for auth in [
345            AuthContext::anonymous(),
346            ctx_with_metadata("alice", json!({})),
347            ctx_with_metadata("bob", json!({"tenant_id": "weird"})),
348        ] {
349            assert!(r.resolve(&auth).await.is_ok());
350        }
351    }
352
353    /// Sanity: the trait is object-safe (can be stored as
354    /// `Arc<dyn TenantResolver>`). The hub builder relies on this.
355    #[tokio::test]
356    async fn resolver_is_object_safe() {
357        use std::sync::Arc;
358        let r: Arc<dyn TenantResolver> = Arc::new(SingleTenantResolver::new());
359        let auth = AuthContext::anonymous();
360        let t = r.resolve(&auth).await.unwrap();
361        assert_eq!(t.as_str(), "default");
362    }
363}