axonflow_sdk_rust/read_identity.rs
1//! Read-path per-user identity and the platform's read-scope contract.
2//!
3//! Since platform #2922 the role-scoped read routes (audit / decisions /
4//! overrides) answer from the identity the CALLER presents, not from the tenant
5//! credential alone. The tenant credential in `Authorization` says which
6//! organization is asking; it does not say WHO. A caller that presents no
7//! per-user identity to an enterprise stack is not "a caller who sees
8//! everything" and is not "a caller who sees nothing by coincidence" — it is a
9//! caller the platform cannot scope, and every scoped read it makes returns
10//! zero rows by construction.
11//!
12//! This module carries the whole surface:
13//!
14//! - the per-user identity itself ([`AxonFlowConfig::user_token`] for a
15//! client-wide identity, the `*_as` read methods for a per-call one, and
16//! [`AxonFlowClient::as_user`] for a process acting on behalf of several
17//! people), stamped as the `X-User-Token` header from exactly ONE site —
18//! `AxonFlowClient::dispatch`, which every request goes through. There is no
19//! per-method header plumbing, deliberately: the platform reads the header
20//! once in its own proxy middleware (`platform/agent/proxy.go`
21//! `proxyAuthMiddleware`), not per route, so a per-method sprinkle here would
22//! be a second, drifting copy of a decision the platform makes in one place.
23//!
24//! - the response side of the same contract: `X-Axonflow-Read-Scope`, which the
25//! platform stamps on every scoped read (`platform/orchestrator/read_scope.go`
26//! `applyReadScopeHeader`) to say which of the three scopes the answer was
27//! computed under. Without it, a 404 from explain and an empty list from
28//! `list_decisions` are indistinguishable from "the row is not there", which
29//! is how a governed read comes to report a confident, vacuous nothing.
30//!
31//! [`AxonFlowConfig::user_token`]: crate::AxonFlowConfig::user_token
32//! [`AxonFlowClient::as_user`]: crate::AxonFlowClient::as_user
33
34use std::fmt;
35
36/// The request header carrying the per-user identity.
37///
38/// This constant is the SDK's only spelling of it. The header is set in exactly
39/// one place (`AxonFlowClient::dispatch`); if you find yourself setting it in a
40/// method, the method is the wrong altitude.
41pub const HEADER_USER_TOKEN: &str = "X-User-Token";
42
43/// The response header the platform stamps on scoped reads.
44pub const HEADER_READ_SCOPE: &str = "X-Axonflow-Read-Scope";
45
46/// The scope the platform computed a role-scoped read under, taken from the
47/// `X-Axonflow-Read-Scope` response header.
48///
49/// Three named variants are the platform's closed set. Two states are NOT in it
50/// and are deliberately distinct from each other and from the three:
51///
52/// - [`ReadScope::Absent`] — the response carried no such header. That is what a
53/// pre-#2922 platform, a non-scoped route, or a proxy that dropped the header
54/// looks like. It means "not stated", never "none": treating an absent header
55/// as a scope of `none` would turn every older stack's perfectly good read
56/// into a refusal.
57///
58/// - [`ReadScope::Other`] — a scope a newer platform names and this build does
59/// not recognise. Its VALUE is preserved rather than folded into one of the
60/// three, so a caller can see what it was — trimmed and lower-cased, like the
61/// three named ones, because the same normalisation has to apply to every
62/// value or the recognised set would depend on a proxy's header casing. It
63/// never triggers a refusal:
64/// this header is the platform's account of a decision it has ALREADY made and
65/// applied, so an unrecognised value is a reporting gap on our side, not a
66/// licence to invent an outcome.
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub enum ReadScope {
69 /// No `X-Axonflow-Read-Scope` header at all. Distinct from [`ReadScope::None`].
70 Absent,
71 /// Tenant-wide: a tenant-wide role (admin / owner / policy_admin), or a
72 /// Community / Community-SaaS deployment where the whole tenant is the one
73 /// operator.
74 Tenant,
75 /// Narrowed to the rows attributed to the identity presented. A miss under
76 /// this scope means "not among yours", which is NOT the same statement as
77 /// "not there" — see [`ReadScopeRefusal`].
78 OwnRows,
79 /// The platform RESOLVED no per-user identity and the caller holds no
80 /// tenant-wide authority, so it returned zero rows by construction. Under
81 /// this scope a read CANNOT have returned data, so its empty answer says
82 /// nothing about what exists.
83 ///
84 /// "Resolved none" is wider than "presented none", and the difference is
85 /// worth knowing before you go looking in the wrong place. A token that
86 /// validates perfectly still resolves to no identity when its address is one
87 /// the platform reserves for SHARED, non-personal identities — the whole of
88 /// `@axonflow.local` and `@axonflow.internal`, plus the community and
89 /// evaluator addresses. Those name a pool of callers rather than a person,
90 /// and scoping a read to one would return the pool, so the platform
91 /// deliberately censuses them to nothing. A per-user token minted with an
92 /// address in one of those domains therefore reads exactly like no token at
93 /// all. (Easy to hit: the platform's own `generate-jwt.sh` defaults to
94 /// `demo-user@axonflow.local`.)
95 None,
96 /// A scope this build does not recognise, preserved (trimmed and
97 /// lower-cased, as every value on this header is).
98 Other(String),
99}
100
101impl ReadScope {
102 /// The scope a response header names.
103 ///
104 /// Trimmed and lower-cased, for the same reason the platform's own header
105 /// helpers are: a proxy that normalises header casing or appends whitespace
106 /// must not silently change the answer. The cost of getting that wrong is
107 /// one-sided and quiet — a scope spelled `None` would fall to
108 /// [`ReadScope::Other`] and the vacuous empty page it describes would come
109 /// back as data again.
110 pub fn parse(header: Option<&str>) -> Self {
111 match header {
112 None => ReadScope::Absent,
113 Some(raw) => match raw.trim().to_ascii_lowercase().as_str() {
114 "" => ReadScope::Absent,
115 "tenant" => ReadScope::Tenant,
116 "own-rows" => ReadScope::OwnRows,
117 "none" => ReadScope::None,
118 other => ReadScope::Other(other.to_string()),
119 },
120 }
121 }
122
123 /// The scope a response reports.
124 pub fn of(response: &reqwest::Response) -> Self {
125 Self::parse(
126 response
127 .headers()
128 .get(HEADER_READ_SCOPE)
129 .and_then(|v| v.to_str().ok()),
130 )
131 }
132}
133
134impl fmt::Display for ReadScope {
135 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
136 match self {
137 ReadScope::Absent => write!(f, ""),
138 ReadScope::Tenant => write!(f, "tenant"),
139 ReadScope::OwnRows => write!(f, "own-rows"),
140 ReadScope::None => write!(f, "none"),
141 ReadScope::Other(value) => write!(f, "{value}"),
142 }
143 }
144}
145
146/// A role-scoped read whose answer was decided by the caller's identity scope
147/// rather than by the data.
148///
149/// Carried by [`AxonFlowError::ReadScope`], which exists because "no rows" and
150/// "no identity" are the same bytes on the wire. The platform distinguishes them
151/// in the `X-Axonflow-Read-Scope` header; this is that distinction made visible,
152/// so a read that could not have succeeded reports a cause instead of a
153/// confident nothing.
154///
155/// Two shapes, told apart by [`ReadScopeRefusal::identity_missing`]:
156///
157/// - [`ReadScope::None`] — no identity was RESOLVED; the read returned zero rows
158/// by construction and says nothing about what exists.
159/// - [`ReadScope::OwnRows`] — an identity WAS resolved, and the row is not among
160/// the ones attributed to it. That does NOT mean the row exists and belongs to
161/// somebody else: the platform answers "not attributed to you" and "not there
162/// at all" with the identical 404, deliberately, so that a miss cannot be used
163/// to probe for another user's rows. This reports the scope, not a claim about
164/// what exists.
165///
166/// The presented token is never included in the message: it is safe to log,
167/// which is the point of putting the diagnosis in a type rather than in a string
168/// the caller assembles from the credential.
169///
170/// [`AxonFlowError::ReadScope`]: crate::AxonFlowError::ReadScope
171#[derive(Debug, Clone, PartialEq, Eq)]
172pub struct ReadScopeRefusal {
173 /// What was read, e.g. `"decision"`.
174 pub resource: String,
175 /// The identifier that was read; `None` for a list read.
176 pub identifier: Option<String>,
177 /// The scope the platform reported.
178 pub scope: ReadScope,
179 /// The HTTP status the platform answered with (404 for a scoped miss, 200
180 /// for a scoped-empty page).
181 pub status: u16,
182}
183
184impl ReadScopeRefusal {
185 /// Whether the read failed because no per-user identity was RESOLVED, as
186 /// opposed to one being resolved and not matching.
187 pub fn identity_missing(&self) -> bool {
188 self.scope == ReadScope::None
189 }
190
191 fn subject(&self) -> String {
192 match &self.identifier {
193 Some(id) => format!("{} {:?}", self.resource, id),
194 None => self.resource.clone(),
195 }
196 }
197}
198
199impl fmt::Display for ReadScopeRefusal {
200 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
201 if self.identity_missing() {
202 write!(
203 f,
204 "HTTP {}: {}: the platform resolved no per-user identity for this read \
205 ({HEADER_READ_SCOPE}: {}), so it returned zero rows by construction and the \
206 empty answer says nothing about what exists. Either no identity was presented \
207 — set AxonFlowConfig::user_token, use the *_as read methods, or derive a client \
208 with as_user(..) — or the one presented carries an address the platform \
209 reserves for shared identities (@axonflow.local, @axonflow.internal), which \
210 resolves to nobody. (platform #2922)",
211 self.status,
212 self.subject(),
213 self.scope,
214 )
215 } else {
216 write!(
217 f,
218 "HTTP {}: {} was not found among the rows this identity can see: the platform \
219 reports {HEADER_READ_SCOPE}: {}, so the read was narrowed to the identity's own \
220 rows. It is either not attributed to this identity or not there at all — the \
221 platform answers both the same way ON PURPOSE, so that a miss cannot be used to \
222 probe for the existence of another user's rows, and this SDK cannot tell them \
223 apart either. A tenant-wide role (admin, owner or policy_admin) reads the whole \
224 tenant. (platform #2922)",
225 self.status,
226 self.subject(),
227 self.scope,
228 )
229 }
230 }
231}
232
233/// The typed refusal for a scoped read that came back with nothing, or `None`
234/// when the scope does not explain the result.
235///
236/// `None` for [`ReadScope::Tenant`] (the caller could see the whole tenant and it
237/// still was not there — a genuine miss), for [`ReadScope::Absent`] (the platform
238/// did not state a scope; see [`ReadScope`] for why absent is not none), and for
239/// [`ReadScope::Other`] (a newer platform's; reporting a cause we cannot actually
240/// read would be a confident wrong diagnosis).
241pub fn read_scope_refusal(
242 resource: &str,
243 identifier: Option<&str>,
244 scope: ReadScope,
245 status: u16,
246) -> Option<ReadScopeRefusal> {
247 match scope {
248 ReadScope::None | ReadScope::OwnRows => Some(ReadScopeRefusal {
249 resource: resource.to_string(),
250 identifier: identifier.map(str::to_string),
251 scope,
252 status,
253 }),
254 _ => None,
255 }
256}
257
258/// The typed refusal for a scoped read that came back EMPTY under a scope that
259/// could not have returned a row; `None` in every other case.
260///
261/// One helper rather than a check at each read, because "the page is empty and
262/// the scope is none" is one rule and the reads that need it decode their body
263/// on more than one path each. A rule copied per return site is a rule that ends
264/// up applied on some of them.
265///
266/// The emptiness guard is as load-bearing as the scope guard: a non-empty page
267/// is never turned into an error, whatever the header says. And only
268/// [`ReadScope::None`] refuses — an own-rows or tenant-wide read that
269/// legitimately found nothing is a real answer, and replacing it with an error
270/// would swap one wrong report for another.
271pub fn refuse_vacuous_scoped_page(
272 resource: &str,
273 scope: ReadScope,
274 status: u16,
275 rows: usize,
276) -> Option<ReadScopeRefusal> {
277 if rows > 0 || scope != ReadScope::None {
278 return None;
279 }
280 Some(ReadScopeRefusal {
281 resource: resource.to_string(),
282 identifier: None,
283 scope,
284 status,
285 })
286}
287
288/// The diagnosis for a per-user identity that cannot be an HTTP header value.
289///
290/// Reported rather than dropped. Dropping it is the worst of the three outcomes
291/// available: the read then goes out unidentified, the platform answers with a
292/// scoped-empty page, and the SDK tells the caller "no identity was presented"
293/// — which is true of the wire and false of what they did. A caller who set a
294/// token and gets told they set none has been sent to look in the wrong place.
295///
296/// The token is NEVER in the message. The offending byte's index and CLASS are,
297/// because those are what you need to find it in your own minting code, and
298/// neither is a fragment of the credential. The byte's VALUE is withheld too.
299///
300/// The rejected set is narrower than "not printable ASCII", and getting that
301/// wrong points the caller at an innocent byte. A header value admits obs-text,
302/// so every byte from 0x80 up is LEGAL: `café-token` is sent as-is, and so is a
303/// token with an emoji in it — measured against `HeaderValue::from_str`, not
304/// inferred from the RFC. What it refuses is exactly the C0 controls except
305/// tab, plus DEL. A diagnostic that treated non-ASCII as the offender would,
306/// for a token like `café…\n…`, report the position of the `é` and send someone
307/// to fix the one character that was fine.
308pub(crate) fn unusable_token(token: &str) -> String {
309 let offender = token
310 .bytes()
311 .position(|b| (b < 0x20 && b != b'\t') || b == 0x7f);
312 let detail = match offender {
313 Some(index) => format!(
314 "a control character at byte {index} (an embedded newline or carriage return is the \
315 usual cause; a LEADING or TRAILING one is trimmed on the way in, so this one is \
316 inside the value. Non-ASCII bytes are NOT the problem — a header value admits them)"
317 ),
318 // Unreachable via HeaderValue::from_str, whose rejection set is exactly
319 // the bytes tested above. Named rather than asserted: a future header
320 // crate with a wider rule must not make this arm claim a cause it did
321 // not observe.
322 None => "a byte HTTP header values do not admit".to_string(),
323 };
324 format!(
325 "the per-user identity is not a usable HTTP header value: it contains {detail}. \
326 The token itself is deliberately omitted from this message. It was NOT sent, and the \
327 read was NOT attempted — an unsendable identity is reported rather than dropped, \
328 because a dropped one would have made this read silently unidentified. \
329 (length {} bytes, counted after trimming)",
330 token.len()
331 )
332}
333
334/// Whether two URLs are the same origin: scheme, host AND port.
335///
336/// Subdomains are NOT trusted, deliberately: this header is an identity
337/// assertion, not a session cookie, and "close enough" is not a property an
338/// identity should have.
339pub(crate) fn same_origin(a: &url::Url, b: &url::Url) -> bool {
340 a.scheme() == b.scheme()
341 && a.host_str() == b.host_str()
342 && a.port_or_known_default() == b.port_or_known_default()
343}