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}