cratestack_axum/envelope_layer/mode.rs
1//! [`EnvelopeMode`] and the per-op [`EnvelopePolicy`] that picks it.
2
3use super::policy_request::PolicyRequest;
4
5/// How the layer treats one op's traffic (ADR 0006 §2).
6///
7/// `#[non_exhaustive]` (API-review decision, 2026-09-26): a later mode
8/// (say, "signed requests only, unsigned responses") must not be a breaking
9/// change for a policy that matches on this.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
11#[non_exhaustive]
12pub enum EnvelopeMode {
13 /// Every request must be signed, `GET`, `HEAD` and `DELETE` included
14 /// (they seal an empty payload; decision D3). An unsigned one is refused
15 /// with the unsigned `401`. Every response is sealed.
16 ///
17 /// A signed `HEAD` still sends its COSE message as a request body,
18 /// which RFC 9110 §9.3.2 gives no semantics: hyper's server and
19 /// `reqwest` pass it through over HTTP/1.1, but an intermediary may
20 /// drop it (the request then fails verification, `401`) or refuse the
21 /// request. Prefer `GET` under `Required`.
22 Required,
23 /// A signed request is opened and its response is always sealed, as
24 /// under `Required` (decision S3). An unsigned one runs as before; its
25 /// response is sealed only when it carries a valid `Cratestack-Nonce`
26 /// and the [`super::ResponseSealPolicy`] asks for it (D10). A signed
27 /// request that fails verification is still the `401`, never downgraded
28 /// to unsigned.
29 Optional,
30 /// Plain traffic passes untouched. A request with a COSE body is still
31 /// refused (`415`) rather than forwarded unverified.
32 Off,
33}
34
35impl EnvelopeMode {
36 /// The stricter of two modes: `Required` over `Optional` over `Off`.
37 /// How a `/rpc/batch` call's frames combine (decision B1).
38 pub(super) fn strictest(self, other: Self) -> Self {
39 let rank = |mode: Self| match mode {
40 Self::Required => 2,
41 Self::Optional => 1,
42 Self::Off => 0,
43 };
44 if rank(other) > rank(self) {
45 other
46 } else {
47 self
48 }
49 }
50}
51
52/// Picks the [`EnvelopeMode`] for a request, from the op it addresses.
53///
54/// [`EnvelopeMode`] implements it (one mode for every op), and so does any
55/// `Fn(&PolicyRequest<'_>) -> EnvelopeMode`, so a per-op policy is a
56/// closure:
57///
58/// ```
59/// use cratestack_axum::envelope_layer::{EnvelopeMode, PolicyRequest};
60///
61/// let policy = |request: &PolicyRequest<'_>| {
62/// if request.is_subscription() { EnvelopeMode::Optional } else { EnvelopeMode::Required }
63/// };
64/// # let _ = policy;
65/// ```
66///
67/// There is no default: the layer's builder refuses to build without one
68/// (decision D9). A policy sees no header ([`PolicyRequest`]), so no
69/// request can talk it into a weaker mode.
70///
71/// **What the layer enforces whatever this returns:** a request with a
72/// COSE body is opened (or, under `Off`, refused) regardless; under
73/// `Required` no unsigned request reaches the router and no response leaves
74/// unsealed; a `/rpc/batch` call runs under the strictest mode of `batch`
75/// and every frame's op (decision B1). A policy can only choose how strict
76/// to be for plain traffic, never make the layer accept an unverified
77/// envelope.
78pub trait EnvelopePolicy: Send + Sync + 'static {
79 /// The mode for this op.
80 fn mode(&self, request: &PolicyRequest<'_>) -> EnvelopeMode;
81
82 /// The mode for traffic the layer cannot attribute to one op: a route
83 /// the router matched but the resolver cannot bind (a wrong mount
84 /// prefix, a hand-written route; decision S2), or a `/rpc/batch` body
85 /// whose frames it cannot read (B1). Under `Required` the first fails
86 /// closed with a `500` unless the route is allow-listed
87 /// ([`super::EnvelopeLayerBuilder::allow_unresolved`]), and the second
88 /// is refused. Under a non-`Required` answer, both pass through, and so
89 /// does a method the schema does not generate on a generated path
90 /// (otherwise the layer's `405`), unsigned.
91 ///
92 /// The default is `Required`, which fails closed: a per-op closure
93 /// cannot say what it would have answered for an op nobody can name.
94 /// An [`EnvelopeMode`] used as the policy answers itself. For a
95 /// closure, [`super::EnvelopeLayerBuilder::unresolved_mode`] replaces
96 /// it explicitly, without writing a type.
97 fn unresolved_mode(&self) -> EnvelopeMode {
98 EnvelopeMode::Required
99 }
100}
101
102impl EnvelopePolicy for EnvelopeMode {
103 fn mode(&self, _request: &PolicyRequest<'_>) -> EnvelopeMode {
104 *self
105 }
106
107 fn unresolved_mode(&self) -> EnvelopeMode {
108 *self
109 }
110}
111
112impl<F> EnvelopePolicy for F
113where
114 F: Fn(&PolicyRequest<'_>) -> EnvelopeMode + Send + Sync + 'static,
115{
116 fn mode(&self, request: &PolicyRequest<'_>) -> EnvelopeMode {
117 self(request)
118 }
119}
120
121/// A policy whose `unresolved_mode` the builder replaced
122/// ([`super::EnvelopeLayerBuilder::unresolved_mode`]).
123pub(super) struct WithUnresolved {
124 pub(super) policy: Box<dyn EnvelopePolicy>,
125 pub(super) unresolved: EnvelopeMode,
126}
127
128impl EnvelopePolicy for WithUnresolved {
129 fn mode(&self, request: &PolicyRequest<'_>) -> EnvelopeMode {
130 self.policy.mode(request)
131 }
132
133 fn unresolved_mode(&self) -> EnvelopeMode {
134 self.unresolved
135 }
136}
137
138#[cfg(test)]
139mod tests {
140 use super::EnvelopeMode::{Off, Optional, Required};
141
142 #[test]
143 fn strictest_is_symmetric_and_ranks_required_first() {
144 for (a, b, expected) in [
145 (Off, Optional, Optional),
146 (Optional, Required, Required),
147 (Off, Required, Required),
148 (Off, Off, Off),
149 ] {
150 assert_eq!(a.strictest(b), expected);
151 assert_eq!(b.strictest(a), expected);
152 }
153 }
154}