Skip to main content

plexus_auth_core/
forward.rs

1//! Forwarding-policy primitives — `CallSite`, `ForwardDerivation`,
2//! `ForwardPolicyName`, the `ForwardPolicy` trait, and the v1 named impls
3//! (`IdentityOnly`, `PassThrough`, `Anonymous`).
4//!
5//! Per AUTHLANG-S01-output §1 (pinned design) and AUTHLANG-2.
6//!
7//! # Sealed-type invariant (load-bearing)
8//!
9//! A [`ForwardPolicy`] impl receives a sealed `&AuthContext` and a
10//! `&CallSite` and returns a [`ForwardDerivation`] — **parameters** for
11//! how to derive the callee's auth context, NOT a constructed
12//! [`AuthContext`]. The framework consumes the derivation and mints the
13//! next sealed context via [`crate::auth::AuthContext::derive_callee_context`],
14//! which is `pub(crate)` to `plexus-auth-core`. Activations and other
15//! downstream crates cannot reach that constructor.
16//!
17//! Per AUTHZ-0 §"The sealed-type pattern": the policy proposes; the
18//! framework disposes. Policies can **shrink** a context (drop fields)
19//! but never **grow** it (add or set fields). `ForwardDerivation`'s shape
20//! enforces this structurally — every field is a "keep" flag; there is no
21//! "add" or "set" knob.
22//!
23//! # Module surface
24//!
25//! - [`CallSite`] — one edge in the call graph at policy-run time.
26//! - [`ForwardDerivation`] — a flag set returned by the policy.
27//! - [`ForwardPolicyName`] — newtype identifying which policy ran (audit).
28//! - [`ForwardPolicy`] trait — what custom impls implement.
29//! - [`IdentityOnly`], [`PassThrough`], [`Anonymous`] — v1 built-ins.
30
31use crate::auth::AuthContext;
32use crate::capabilities::MethodPath;
33use crate::principal::Principal;
34use serde::{Deserialize, Serialize};
35
36/// Identifies a single edge in the call graph at the moment a policy runs.
37///
38/// Constructed by the framework at the dispatch point (`route_to_child` in
39/// plexus-core, wired in by AUTHLANG-3) and passed to
40/// [`ForwardPolicy::forward`]. Policies inspect it to make routing-aware
41/// decisions (e.g., "PassThrough only when callee is in `audit.*`"); the
42/// three built-in policies ignore it.
43///
44/// Per AUTHZ-0 vocabulary: `caller` and `callee`, NOT parent/child.
45#[derive(Debug, Clone)]
46pub struct CallSite {
47    /// The framework-stamped immediate-caller principal. Sealed value; the
48    /// framework auto-stamps it at dispatch per AUTHZ-0 principle 6.
49    pub caller: Principal,
50
51    /// The fully qualified method path on the callee being invoked
52    /// (e.g., `solar.earth.luna.info`).
53    pub callee_method: MethodPath,
54}
55
56impl CallSite {
57    /// Construct a `CallSite` from its sealed components.
58    ///
59    /// `CallSite` is a thin coupling of a sealed `Principal` with a
60    /// validated `MethodPath`. It is not itself sealed beyond the seals on
61    /// its fields: a downstream crate that already holds a `Principal` (it
62    /// cannot — the constructors are `pub(crate)`) and a valid
63    /// `MethodPath` could synthesize one. The construction path that
64    /// matters in practice is the framework's `route_to_child` (AUTHLANG-3),
65    /// which holds the only access to a freshly stamped `Principal`.
66    pub fn new(caller: Principal, callee_method: MethodPath) -> Self {
67        Self {
68            caller,
69            callee_method,
70        }
71    }
72}
73
74/// What a policy returns: a derivation request, NOT a constructed context.
75///
76/// The framework consumes this and mints the next sealed `AuthContext` for
77/// the callee. The shape is **intentionally minimal** for v1 — four "keep"
78/// flags, one per logical group of the caller's context. Future composable
79/// primitives (AUTHLANG v2) will replace this with a richer combinator AST
80/// without breaking the v1 trait signature.
81///
82/// # Derive-only invariant
83///
84/// Every field is a "keep" flag: forward this field from the caller to the
85/// callee, or drop it. There is no "add this role" or "set this user_id"
86/// knob. Policies cannot escalate authority across a boundary — the
87/// most-permissive a callee context can be is exactly the caller's
88/// context.
89///
90/// # Field-to-`AuthContext` mapping (today)
91///
92/// | Flag | Maps to fields on the current `AuthContext` |
93/// |---|---|
94/// | `keep_verified_user` | `user_id`, `session_id` (identity of the originator) |
95/// | `keep_roles` | `roles` |
96/// | `keep_capabilities` | (no field yet; reserved for AUTHZ-DATA / AUTHZ-CRED work) |
97/// | `keep_metadata` | `metadata` |
98///
99/// `keep_capabilities` is intentionally surfaced now so the v1 shape is
100/// forward-compatible: when the sealed-context migration adds a
101/// capabilities field, no policy impl signature changes.
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
103pub struct ForwardDerivation {
104    /// Forward the IdP-verified originator's identity (`user_id`, `session_id`).
105    pub keep_verified_user: bool,
106    /// Forward the caller's role set (`roles`).
107    pub keep_roles: bool,
108    /// Forward the caller's capability set. Reserved for the
109    /// AUTHZ-DATA / AUTHZ-CRED migration; today this flag is a no-op on
110    /// `AuthContext` because the field does not yet exist.
111    pub keep_capabilities: bool,
112    /// Forward the caller's opaque metadata bag (`metadata`).
113    pub keep_metadata: bool,
114}
115
116impl ForwardDerivation {
117    /// Identity-only: keep verified user; drop roles, capabilities, metadata.
118    pub const IDENTITY_ONLY: Self = Self {
119        keep_verified_user: true,
120        keep_roles: false,
121        keep_capabilities: false,
122        keep_metadata: false,
123    };
124
125    /// Pass-through: keep every flag.
126    pub const PASS_THROUGH: Self = Self {
127        keep_verified_user: true,
128        keep_roles: true,
129        keep_capabilities: true,
130        keep_metadata: true,
131    };
132
133    /// Anonymous: keep no flag.
134    pub const ANONYMOUS: Self = Self {
135        keep_verified_user: false,
136        keep_roles: false,
137        keep_capabilities: false,
138        keep_metadata: false,
139    };
140}
141
142/// Stable identifier for a forwarding policy, surfaced into audit records
143/// and diagnostics.
144///
145/// `ForwardPolicyName` wraps a `&'static str` because the v1 named policies
146/// are compile-time constants. Custom impls construct their names via
147/// [`ForwardPolicyName::new`] (a `const fn`), so naming remains structural.
148#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
149#[serde(transparent)]
150pub struct ForwardPolicyName(&'static str);
151
152impl ForwardPolicyName {
153    /// Construct a policy name from a `&'static str`. `const`-callable.
154    pub const fn new(name: &'static str) -> Self {
155        Self(name)
156    }
157
158    /// Borrow the underlying static string.
159    pub fn as_str(&self) -> &'static str {
160        self.0
161    }
162}
163
164impl std::fmt::Display for ForwardPolicyName {
165    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
166        f.write_str(self.0)
167    }
168}
169
170/// The forwarding-policy trait.
171///
172/// v1 is intentionally minimal: one `name`, one infallible `forward`. A
173/// custom impl receives the caller's sealed [`AuthContext`] and the
174/// [`CallSite`] for the edge being dispatched, and returns a
175/// [`ForwardDerivation`] describing which fields the framework should
176/// retain when constructing the callee's context.
177///
178/// # Why infallible
179///
180/// The three built-in policies are pure tag manipulation. A fallible
181/// variant would force every wire-in site to handle an error case that
182/// none of the v1 built-ins can produce. Future fallible policies are a
183/// sibling trait (`FallibleForwardPolicy`), added without breaking v1's
184/// shape. See AUTHLANG-S01-output §1.
185///
186/// # Object safety
187///
188/// `Send + Sync + 'static` matches the existing `Arc<dyn ChildRouter>` and
189/// `Arc<dyn AuditSink>` patterns in plexus-core, so wire-in code can hold
190/// a `Arc<dyn ForwardPolicy>`.
191pub trait ForwardPolicy: Send + Sync + 'static {
192    /// Stable identifier for this policy. Used in audit records.
193    fn name(&self) -> ForwardPolicyName;
194
195    /// Derive forwarding parameters for the callee.
196    ///
197    /// Returns a `ForwardDerivation` — **parameters**, not a constructed
198    /// context. The framework calls
199    /// [`AuthContext::derive_callee_context`](crate::auth::AuthContext::derive_callee_context)
200    /// with these parameters to mint the sealed callee context.
201    fn forward(&self, caller_ctx: &AuthContext, site: &CallSite) -> ForwardDerivation;
202}
203
204// --- v1 named impls --------------------------------------------------------
205
206/// The `identity_only` policy name (stable string surfaced in audit).
207pub const IDENTITY_ONLY_NAME: ForwardPolicyName = ForwardPolicyName::new("identity_only");
208/// The `pass_through` policy name (stable string surfaced in audit).
209pub const PASS_THROUGH_NAME: ForwardPolicyName = ForwardPolicyName::new("pass_through");
210/// The `anonymous` policy name (stable string surfaced in audit).
211pub const ANONYMOUS_NAME: ForwardPolicyName = ForwardPolicyName::new("anonymous");
212
213/// Identity-only: forwards the caller's IdP-verified user identity and
214/// drops roles, capabilities, and metadata.
215///
216/// Recommended default. The callee learns *who* invoked it (so it can
217/// re-evaluate authorization against its own scopes per AUTHZ-S01
218/// default-deny) without inheriting the caller's *authority*. Fixes the
219/// confused-deputy class for any callee that reads `raw_ctx`.
220///
221/// Mirror of: HTTP cookies + per-page server-side authorization.
222#[derive(Debug, Clone, Copy)]
223pub struct IdentityOnly;
224
225impl ForwardPolicy for IdentityOnly {
226    fn name(&self) -> ForwardPolicyName {
227        IDENTITY_ONLY_NAME
228    }
229    fn forward(&self, _ctx: &AuthContext, _site: &CallSite) -> ForwardDerivation {
230        ForwardDerivation::IDENTITY_ONLY
231    }
232}
233
234/// Pass-through: forward every field of the caller's context.
235///
236/// Explicit opt-out for activations whose callees genuinely need the
237/// caller's roles, capabilities, or metadata to make local decisions.
238/// Activations declaring this policy should justify it in review — the
239/// audit record will surface the choice on every dispatch.
240#[derive(Debug, Clone, Copy)]
241pub struct PassThrough;
242
243impl ForwardPolicy for PassThrough {
244    fn name(&self) -> ForwardPolicyName {
245        PASS_THROUGH_NAME
246    }
247    fn forward(&self, _ctx: &AuthContext, _site: &CallSite) -> ForwardDerivation {
248        ForwardDerivation::PASS_THROUGH
249    }
250}
251
252/// Anonymous: drop the entire `AuthContext`.
253///
254/// Explicit lockdown. The callee sees no identity, no roles, no
255/// capabilities. Use for activations that should NEVER inherit caller
256/// context (e.g., a public-facing echo service whose responses must not
257/// leak who invoked them).
258#[derive(Debug, Clone, Copy)]
259pub struct Anonymous;
260
261impl ForwardPolicy for Anonymous {
262    fn name(&self) -> ForwardPolicyName {
263        ANONYMOUS_NAME
264    }
265    fn forward(&self, _ctx: &AuthContext, _site: &CallSite) -> ForwardDerivation {
266        ForwardDerivation::ANONYMOUS
267    }
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    fn sample_callsite() -> CallSite {
275        CallSite::new(
276            Principal::anonymous_sealed(),
277            MethodPath::try_new("solar.earth.luna.info").unwrap(),
278        )
279    }
280
281    fn sample_ctx() -> AuthContext {
282        AuthContext::new(
283            "alice".to_string(),
284            "sess-1".to_string(),
285            vec!["admin".to_string()],
286            serde_json::json!({"tenant_id": "acme"}),
287        )
288    }
289
290    #[test]
291    fn identity_only_returns_identity_only_constant() {
292        let policy = IdentityOnly;
293        assert_eq!(policy.name(), IDENTITY_ONLY_NAME);
294        assert_eq!(policy.name().as_str(), "identity_only");
295        let d = policy.forward(&sample_ctx(), &sample_callsite());
296        assert_eq!(d, ForwardDerivation::IDENTITY_ONLY);
297        assert!(d.keep_verified_user);
298        assert!(!d.keep_roles);
299        assert!(!d.keep_capabilities);
300        assert!(!d.keep_metadata);
301    }
302
303    #[test]
304    fn pass_through_returns_pass_through_constant() {
305        let policy = PassThrough;
306        assert_eq!(policy.name(), PASS_THROUGH_NAME);
307        assert_eq!(policy.name().as_str(), "pass_through");
308        let d = policy.forward(&sample_ctx(), &sample_callsite());
309        assert_eq!(d, ForwardDerivation::PASS_THROUGH);
310        assert!(d.keep_verified_user);
311        assert!(d.keep_roles);
312        assert!(d.keep_capabilities);
313        assert!(d.keep_metadata);
314    }
315
316    #[test]
317    fn anonymous_returns_anonymous_constant() {
318        let policy = Anonymous;
319        assert_eq!(policy.name(), ANONYMOUS_NAME);
320        assert_eq!(policy.name().as_str(), "anonymous");
321        let d = policy.forward(&sample_ctx(), &sample_callsite());
322        assert_eq!(d, ForwardDerivation::ANONYMOUS);
323        assert!(!d.keep_verified_user);
324        assert!(!d.keep_roles);
325        assert!(!d.keep_capabilities);
326        assert!(!d.keep_metadata);
327    }
328
329    #[test]
330    fn three_constants_are_distinct() {
331        // Sanity: the named constants are not accidentally equal.
332        assert_ne!(ForwardDerivation::IDENTITY_ONLY, ForwardDerivation::PASS_THROUGH);
333        assert_ne!(ForwardDerivation::IDENTITY_ONLY, ForwardDerivation::ANONYMOUS);
334        assert_ne!(ForwardDerivation::PASS_THROUGH, ForwardDerivation::ANONYMOUS);
335    }
336
337    #[test]
338    fn policy_holdable_as_trait_object() {
339        // `Send + Sync + 'static` bound lets us store as `Arc<dyn ForwardPolicy>`,
340        // which is how plexus-core's wire-in (AUTHLANG-3) will carry them.
341        use std::sync::Arc;
342        let policies: Vec<Arc<dyn ForwardPolicy>> = vec![
343            Arc::new(IdentityOnly),
344            Arc::new(PassThrough),
345            Arc::new(Anonymous),
346        ];
347        let names: Vec<&'static str> = policies.iter().map(|p| p.name().as_str()).collect();
348        assert_eq!(names, vec!["identity_only", "pass_through", "anonymous"]);
349    }
350
351    #[test]
352    fn policy_name_display_uses_inner_string() {
353        assert_eq!(format!("{}", IDENTITY_ONLY_NAME), "identity_only");
354    }
355}