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}