Skip to main content

axonflow_sdk_rust/authzen/
mod.rs

1//! AuthZEN-native authorization.
2//!
3//! This is the surface the ADR-065 compatibility plan commits to in all five
4//! SDKs. It talks to `POST /api/v1/access/evaluation`, whose wire shape is
5//! generated from the platform's canonical contract (see [`types_gen`]);
6//! nothing outside that file re-states a field name or an enum value.
7//!
8//! # What this replaces, and when
9//!
10//! Nothing yet. The existing decision surface ([`crate::pep`],
11//! [`crate::AxonFlowClient::proxy_llm_call`]) stays wire-stable through all of
12//! v11 and is not deprecated here. This is the surface to write NEW
13//! integrations against, because at v11 the engine behind it changes to the
14//! ADR-065 Policy Decision Point with no wire change - an integration written
15//! against it migrates once rather than twice. See
16//! `docs/AUTHZEN_MIGRATION_DRAFT.md`.
17//!
18//! # The one thing worth knowing before you call it
19//!
20//! The server refuses anything it cannot evaluate rather than evaluating around
21//! it. Send a subject property, an unrecognised context member, or an argument
22//! beside the query, and you get an [`AuthZenError`] naming the exact member -
23//! not a decision computed without it. That is deliberate: a decision that
24//! silently ignored an attribute would tell you the attribute was weighed when
25//! it was not, and every audit of that decision would inherit the claim.
26//!
27//! This SDK holds the same line on its own side of the wire. An attribute the
28//! CALLER could not resolve never reaches the server either - see
29//! [`attribute`], which is the module to read before writing any code that
30//! fills in a `properties` bag or a correlation key.
31//!
32//! # Local and remote refusals name the same MEMBER
33//!
34//! The SDK validates before sending, and a local refusal carries the JSON
35//! Pointer the server would have sent for the same bytes. The CODE may be
36//! narrower on the server side: this client knows only that a required member
37//! is missing and says `incomplete_evaluation`, while the server additionally
38//! knows which values it can evaluate and narrows the same condition to
39//! `unsupported_subject` with a `supported` list. Branch on the pointer for
40//! "which member"; read the code as the server's more specific reading when
41//! there is one.
42//!
43//! # Example
44//!
45//! ```no_run
46//! use axonflow_sdk_rust::authzen::{
47//!     Attribute, AuthZenAction, AuthZenRequest, AuthZenResource, AuthZenSubject,
48//! };
49//! # async fn demo(client: &axonflow_sdk_rust::AxonFlowClient) -> Result<(), Box<dyn std::error::Error>> {
50//! let request = AuthZenRequest::evaluating(
51//!     AuthZenSubject::new("gateway", "llm-gateway-01"),
52//!     AuthZenAction::new("llm.completion"),
53//!     AuthZenResource::new("llm", "llm"),
54//! )
55//! .with_query(Attribute::known("what is our refund policy?"))
56//! .with_correlation("x-session-id", Attribute::known("sess-4711"));
57//!
58//! let decision = client.evaluate(request).await?;
59//! if !decision.allowed() {
60//!     println!("blocked: {} ({})", decision.state(), decision.category());
61//! }
62//! # Ok(())
63//! # }
64//! ```
65
66pub mod attribute;
67pub mod types_gen;
68
69use crate::error::AxonFlowError;
70use crate::AxonFlowClient;
71
72pub use attribute::{Attribute, AttributeMap, AttributeValue};
73pub use types_gen::*;
74
75// `AUTHZEN_PATH` and `AUTHZEN_PROFILE_HEADER` are GENERATED (types_gen, re-exported
76// above through `pub use types_gen::*`). They used to be literals here - the
77// SDK's own copy of two strings the platform also wrote by hand - and nothing
78// compared the copies (axonflow-enterprise#3603). The artifact now carries them,
79// so a platform rename is a regenerate-and-diff failure rather than a runtime
80// 404. The SDK always sends the profile header: AuthZEN 1.0's response is a bare
81// boolean, and the four-valued state, the obligations and the approval challenge
82// ride in the response context, which the server returns only to a caller that
83// asked for it by version.
84
85// ---------------------------------------------------------------------------
86// Refusals
87// ---------------------------------------------------------------------------
88
89impl AuthZenErrorCode {
90    /// Whether the caller could get a different answer by sending the same
91    /// request again.
92    ///
93    /// Only a dependency failure is. Every other code names something about the
94    /// request itself, which will not change on a retry - so a client that
95    /// retries on any refusal burns its budget on requests that cannot succeed.
96    ///
97    /// A code this build does not know is NOT retryable. Guessing the other way
98    /// would turn every future code into a retry loop against a server that has
99    /// already given its final answer.
100    pub fn retryable(&self) -> bool {
101        matches!(self, AuthZenErrorCode::EvaluationUnavailable)
102    }
103}
104
105impl AuthZenError {
106    /// Attaches the JSON Pointer naming the member at fault.
107    ///
108    /// `"unsupported_action"` without the offending member is a puzzle rather
109    /// than a diagnosis, which is why the server never sends one without a
110    /// pointer and neither does this SDK.
111    ///
112    /// An EMPTY pointer is dropped rather than sent. The root has no member to
113    /// name, and `"pointer": ""` renders as `... at : ...` and reads to a caller
114    /// as a member whose name is the empty string. The server sends no pointer
115    /// at all for a refusal about the request as a whole, and neither does this.
116    pub fn at(mut self, pointer: &str) -> Self {
117        self.pointer = if pointer.is_empty() {
118            None
119        } else {
120            Some(pointer.to_string())
121        };
122        self
123    }
124
125    /// Attaches the values that WOULD have been accepted.
126    pub fn supporting<S: Into<String>>(mut self, supported: impl IntoIterator<Item = S>) -> Self {
127        self.supported = supported.into_iter().map(Into::into).collect();
128        self
129    }
130
131    /// Whether retrying this exact request could produce a different answer.
132    pub fn retryable(&self) -> bool {
133        self.code.retryable()
134    }
135}
136
137impl std::fmt::Display for AuthZenError {
138    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
139        match &self.pointer {
140            Some(p) => write!(f, "axonflow: {} at {}: {}", self.code, p, self.message),
141            None => write!(f, "axonflow: {}: {}", self.code, self.message),
142        }
143    }
144}
145
146impl std::error::Error for AuthZenError {}
147
148/// Everything that can come back instead of a decision.
149///
150/// The variants are separated by what a caller should DO, not by where the
151/// failure happened:
152///
153/// * [`Self::Refused`] - fix the request; the refusal names the member.
154/// * [`Self::Unresolved`] - re-resolve an attribute and build a NEW request.
155/// * [`Self::UnreadableProfile`] - upgrade the SDK.
156/// * [`Self::UnusableResponse`] - a server contract violation to report.
157/// * [`Self::UnusableRequest`] - the envelope could not be encoded; a backstop.
158/// * [`Self::Transport`] - no answer; may simply be retried.
159///
160/// Collapsing them into one opaque error would leave a caller with a string to
161/// match on.
162///
163/// `#[non_exhaustive]` because this enum has no catch-all variant and is a
164/// public surface committed through v11. Without the attribute, every
165/// downstream `match` over the six variants is exhaustive, and the first
166/// outcome this surface learns to distinguish would break all of them. With it,
167/// a caller writes a `_` arm once and a seventh variant is a minor release.
168#[derive(Debug, thiserror::Error)]
169#[non_exhaustive]
170pub enum AuthZenEvaluationError {
171    /// The request was refused rather than evaluated - by the server, or by
172    /// this client before the round trip.
173    ///
174    /// Both name the SAME MEMBER: a local refusal carries the JSON Pointer the
175    /// server would have sent for the same bytes, verified against a live
176    /// server by `runtime-e2e/authzen_evaluation`.
177    ///
178    /// The CODE may be narrower on the server side, and that is not a defect in
179    /// either. This client knows only that a required member is missing, and
180    /// says `incomplete_evaluation`; the server additionally knows which values
181    /// it can evaluate, and narrows the same condition to `unsupported_subject`
182    /// with a `supported` list. Branch on the pointer for "which member", and
183    /// treat the code as the server's more specific reading when there is one.
184    #[error("{0}")]
185    Refused(#[from] AuthZenError),
186
187    /// The server answered in a profile this build cannot interpret.
188    ///
189    /// NOT retryable, and not folded into [`Self::Refused`] for exactly that
190    /// reason: `evaluation_unavailable` is the enumeration's retryable code,
191    /// and reporting "upgrade the SDK" through it would send a client into a
192    /// retry loop against a server that will answer identically every time.
193    #[error(
194        "the server answered with AuthZEN profile {received:?}; this build can only interpret \
195         {understood:?}. The obligations and approval challenge that constrain an allow are \
196         carried in that payload, so the decision cannot be acted on safely. Upgrade the SDK."
197    )]
198    UnreadableProfile {
199        /// What the server said it was speaking.
200        received: String,
201        /// What this build can read.
202        understood: &'static str,
203    },
204
205    /// The server answered `200` with a body this build will not act on.
206    ///
207    /// A decision that cannot be read completely is not a decision. Acting on
208    /// the half that parsed is how an allow carrying a mandatory obligation
209    /// reaches an enforcement point that never saw it.
210    #[error("the server's decision cannot be acted on: {detail}")]
211    UnusableResponse {
212        /// What about the body could not be trusted.
213        detail: String,
214    },
215
216    /// The request could not be SENT as built: it carries an attribute the
217    /// caller could not resolve.
218    ///
219    /// Separate from [`Self::Refused`], and NOT retryable, because the two need
220    /// opposite actions from the caller. A server `evaluation_unavailable` says
221    /// "send these bytes again"; this says "re-resolve the attribute and build a
222    /// NEW request". Reporting it as retryable - which an earlier version of
223    /// this SDK did - sends a `while err.retryable()` loop against a request
224    /// whose refusal is frozen inside it, so every attempt produces the
225    /// identical error until the budget runs out.
226    ///
227    /// The OPERATION may well succeed once the attribute resolves. That is a
228    /// statement about a different request, and it is why this carries the
229    /// pointer and the reason rather than a boolean.
230    #[error(
231        "this request cannot be sent as built. At {pointer}: {reason} Re-resolve the attribute and \
232         build a NEW request; resending this one cannot succeed."
233    )]
234    Unresolved {
235        /// The JSON Pointer naming the member nobody could resolve.
236        pointer: String,
237        /// The refusal message, which carries the reason the caller gave.
238        reason: String,
239    },
240
241    /// The envelope could not be encoded.
242    ///
243    /// A backstop, not an ordinary outcome: the only way to reach it is to
244    /// bypass validation and hand the encoder an unresolved attribute. It is
245    /// distinct from [`Self::UnusableResponse`] because that one names a SERVER
246    /// contract violation to report, and an operator handed one label for both
247    /// cannot tell "the platform is emitting a body I must file a bug about"
248    /// from "my own request was not built correctly".
249    #[error("the request could not be encoded: {detail}")]
250    UnusableRequest {
251        /// What about the envelope could not be encoded.
252        detail: String,
253    },
254
255    /// The request never got an answer: connection, timeout, credentials, or a
256    /// non-refusal error status.
257    ///
258    /// This surface does NOT apply the client's [`crate::RetryConfig`]: that
259    /// executor is wired to the proxy path's request type, and retrying an
260    /// authorization decision on the caller's behalf is a policy decision this
261    /// SDK does not make for them. Retry is the caller's, guided by
262    /// [`AuthZenEvaluationError::retryable`].
263    #[error("the evaluation request failed: {0}")]
264    Transport(#[from] AxonFlowError),
265}
266
267impl AuthZenEvaluationError {
268    /// Whether sending the same request again could produce a different answer.
269    ///
270    /// This is the whole retryable set, in one place, so a caller never has to
271    /// assemble it from status codes:
272    ///
273    /// * a refusal - only when its code is `evaluation_unavailable`;
274    /// * a transport failure - timeout, connect, `5xx`, `429`;
275    /// * an unreadable profile - never;
276    /// * an unusable response - never;
277    /// * an unresolved attribute - NEVER, because the refusal is frozen inside
278    ///   the request. The OPERATION may succeed once the attribute resolves,
279    ///   but that is a different request, and this method answers only about
280    ///   this one.
281    /// * an unencodable request - never.
282    pub fn retryable(&self) -> bool {
283        match self {
284            AuthZenEvaluationError::Refused(e) => e.retryable(),
285            AuthZenEvaluationError::Transport(e) => e.is_retryable(),
286            AuthZenEvaluationError::UnreadableProfile { .. }
287            | AuthZenEvaluationError::UnusableResponse { .. }
288            | AuthZenEvaluationError::Unresolved { .. }
289            | AuthZenEvaluationError::UnusableRequest { .. } => false,
290        }
291    }
292
293    /// The typed refusal, when there is one.
294    pub fn as_refusal(&self) -> Option<&AuthZenError> {
295        match self {
296            AuthZenEvaluationError::Refused(e) => Some(e),
297            _ => None,
298        }
299    }
300}
301
302// ---------------------------------------------------------------------------
303// Building a request
304// ---------------------------------------------------------------------------
305
306impl AuthZenRequest {
307    /// One subject performing one action on one resource.
308    pub fn evaluating(
309        subject: AuthZenSubject,
310        action: AuthZenAction,
311        resource: AuthZenResource,
312    ) -> Self {
313        AuthZenRequest {
314            subject: Some(subject),
315            action: Some(action),
316            resource: Some(resource),
317            context: AttributeMap::new(),
318        }
319    }
320
321    /// The content the policy engine inspects, at `context.args.query`.
322    ///
323    /// Takes an [`Attribute`] rather than a `String` because a gateway does not
324    /// always have the content in hand: a request whose body failed to decode
325    /// has a query nobody could read, and evaluating as though there were
326    /// nothing to inspect is the difference between "no content" and "content I
327    /// could not see".
328    pub fn with_query(mut self, query: Attribute<String>) -> Self {
329        set_query(&mut self.context, query);
330        self
331    }
332
333    /// One audit correlation key, at `context.correlation.<key>`.
334    ///
335    /// The deployment records an allowlisted, capped set of these; a key it does
336    /// not record is refused by name rather than dropped, because telling a
337    /// caller a key was captured when it was not is the same lie in both
338    /// directions.
339    pub fn with_correlation(mut self, key: &str, value: Attribute<String>) -> Self {
340        set_correlation(&mut self.context, key, value);
341        self
342    }
343}
344
345impl AuthZenBulk {
346    /// Several preconditions of ONE operation.
347    pub fn over(evaluations: impl IntoIterator<Item = AuthZenRequest>) -> Self {
348        AuthZenBulk::new(evaluations.into_iter().collect())
349    }
350
351    /// The subject every entry inherits unless it names its own.
352    pub fn with_subject(mut self, subject: AuthZenSubject) -> Self {
353        self.subject = Some(subject);
354        self
355    }
356
357    /// The action every entry inherits unless it names its own.
358    pub fn with_action(mut self, action: AuthZenAction) -> Self {
359        self.action = Some(action);
360        self
361    }
362
363    /// The resource every entry inherits unless it names its own.
364    pub fn with_resource(mut self, resource: AuthZenResource) -> Self {
365        self.resource = Some(resource);
366        self
367    }
368
369    /// The shared `context.args.query` every entry inherits.
370    pub fn with_query(mut self, query: Attribute<String>) -> Self {
371        set_query(&mut self.context, query);
372        self
373    }
374
375    /// A shared audit correlation key.
376    pub fn with_correlation(mut self, key: &str, value: Attribute<String>) -> Self {
377        set_correlation(&mut self.context, key, value);
378        self
379    }
380}
381
382/// Writes `context.args.query`, creating the nested bag if it is not there.
383///
384/// If `context.args` OR `context.args.query` already holds an UNRESOLVED
385/// attribute, the write is declined and the `Unknown` stays. The rule applies
386/// at BOTH levels: guarding only the parent left the defect reachable one level
387/// down, which is where a caller would actually hit it. Overwriting it was the fail-open this
388/// whole module exists to prevent, arriving through its own builder: a caller
389/// that had recorded "nobody could read the request body" and then wrote a
390/// recovered partial query over it would have produced a complete-looking
391/// envelope, passed `validate`, and been handed a verdict that named every
392/// attribute it weighed. Leaving the `Unknown` in place means the envelope is
393/// refused at `/…/context/args` and never sent.
394fn set_query(context: &mut AttributeMap, query: Attribute<String>) {
395    if let Some(args) = context.nested_for_write("args") {
396        args.record("query", query.map(AttributeValue::from));
397    }
398}
399
400/// Writes one `context.correlation.<key>`, creating the nested bag if needed.
401///
402/// Declines the write over an unresolved `context.correlation`, for the reason
403/// in [`set_query`].
404fn set_correlation(context: &mut AttributeMap, key: &str, value: Attribute<String>) {
405    if let Some(correlation) = context.nested_for_write("correlation") {
406        correlation.record(key, value.map(AttributeValue::from));
407    }
408}
409
410// ---------------------------------------------------------------------------
411// Reading a decision
412// ---------------------------------------------------------------------------
413
414/// A decision this build could read COMPLETELY.
415///
416/// The type exists so that "the profile payload was there and hung together" is
417/// established once, by construction, rather than re-asked at every accessor.
418/// A [`AuthZenResponse`] that failed any of those checks never becomes one of
419/// these - it becomes an [`AuthZenEvaluationError`].
420#[derive(Clone, Debug, PartialEq)]
421pub struct AuthZenDecision {
422    decision: bool,
423    context: AuthZenResponseContext,
424}
425
426impl AuthZenDecision {
427    /// Whether the enforcement point may proceed.
428    ///
429    /// Read this rather than comparing the state yourself. It requires BOTH the
430    /// collapsed boolean and the operational state to say `ALLOW`: exactly one
431    /// state permits execution, and a caller that branches on anything else -
432    /// "not DENY", say - treats a CHALLENGE or an ERROR as permission.
433    ///
434    /// A decision whose boolean and state DISAGREE never reaches this method;
435    /// it is refused as an unusable response, because there is no reading of
436    /// such a body that is not a guess.
437    ///
438    /// Which makes the `state == ALLOW` conjunct here UNREACHABLE while that
439    /// refusal stands: by the time a value is an `AuthZenDecision`, the two
440    /// already agree. It is kept because the two checks live in different
441    /// functions, and this is not the one a future refactor of the decoding
442    /// path is likely to touch. No test kills a mutant that deletes it - the
443    /// mutation gate is where that was measured, not assumed - and saying so
444    /// here is better than a comment implying coverage that does not exist.
445    ///
446    /// An allow is not the end of it: a mandatory obligation the enforcement
447    /// point cannot discharge means the operation must NOT proceed. See
448    /// [`AuthZenDecision::mandatory_obligations`].
449    pub fn allowed(&self) -> bool {
450        self.decision && self.context.state == AuthZenOperationalState::Allow
451    }
452
453    /// The four-valued operational state.
454    pub fn state(&self) -> &AuthZenOperationalState {
455        &self.context.state
456    }
457
458    /// The coarse outcome category.
459    pub fn category(&self) -> &AuthZenCategory {
460        &self.context.category
461    }
462
463    /// The safe machine reason, when the server sent one.
464    pub fn reason(&self) -> Option<&AuthZenReasonCode> {
465        self.context.reason.as_ref()
466    }
467
468    /// Every instruction the enforcement point must discharge.
469    pub fn obligations(&self) -> &[AuthZenObligation] {
470        &self.context.obligations
471    }
472
473    /// The obligations that must be discharged for the allow to stand.
474    ///
475    /// An allow with an undischarged mandatory obligation is not an allow. A
476    /// caller that cannot discharge one must block.
477    pub fn mandatory_obligations(&self) -> impl Iterator<Item = &AuthZenObligation> {
478        self.context.obligations.iter().filter(|o| o.mandatory)
479    }
480
481    /// The approval challenge the contract declares for a `CHALLENGE` state.
482    ///
483    /// NO DEPLOYED SERVER POPULATES THIS TODAY. The v10 route is an adapter over
484    /// the legacy evaluation, and its handler builds the response context
485    /// without an `approval` member - so a `CHALLENGE` arrives with this empty,
486    /// and a caller that writes `decision.approval().unwrap()` panics on its
487    /// first real challenge. It is surfaced because the contract declares it and
488    /// the ADR-065 Policy Decision Point fills it at v11; until then, treat an
489    /// empty approval on a CHALLENGE as the normal case and read
490    /// [`AuthZenDecision::state`] and [`AuthZenDecision::category`] instead.
491    pub fn approval(&self) -> Option<&AuthZenApprovalRequirement> {
492        self.context.approval.as_ref()
493    }
494
495    /// The id of the entry that DETERMINED the outcome.
496    ///
497    /// For a plural envelope this names the entry that decided the meet, not
498    /// the last one evaluated - it is the id an operator looks up to explain
499    /// the outcome.
500    pub fn decision_id(&self) -> &str {
501        &self.context.decision_id
502    }
503
504    /// The contract version the server evaluated under.
505    pub fn schema_version(&self) -> &str {
506        &self.context.schema_version
507    }
508
509    /// The whole profile payload, for a caller that wants a member this type
510    /// does not surface.
511    pub fn context(&self) -> &AuthZenResponseContext {
512        &self.context
513    }
514
515    /// Checks everything that has to hold before a body becomes a decision.
516    fn from_response(response: AuthZenResponse) -> Result<Self, AuthZenEvaluationError> {
517        // AN ABSENT CONTEXT IS A BLANKED CONTEXT, NOT AN EMPTY ONE.
518        //
519        // The server omits the profile payload for a caller that did not
520        // negotiate - and this SDK ALWAYS negotiates. So a 200 with no context
521        // is a server that ignored the header or a proxy that stripped it, and
522        // the parts this build cannot see are exactly the parts that constrain
523        // an allow: the obligations and the approval challenge. Reading it as
524        // "no obligations" leaves `allowed()` returning true and the caller
525        // proceeding on an allow whose mandatory redaction it never saw.
526        let context = match response.context {
527            Some(c) => c,
528            None => {
529                return Err(AuthZenEvaluationError::UnusableResponse {
530                    detail: format!(
531                        "the response carries no profile payload, though this request negotiated \
532                         {AUTHZEN_PROFILE_HEADER}: {AUTHZEN_PROFILE_V1}. The obligations and the \
533                         approval challenge ride in that payload, so an allow cannot be \
534                         distinguished from an allow this client must not act on"
535                    ),
536                })
537            }
538        };
539
540        // A profile from a version this build does not know is REFUSED, not
541        // silently dropped. It is also the case that matters at the v11
542        // cutover, which is precisely when a server starts speaking a profile
543        // an older SDK does not know.
544        if context.profile != AUTHZEN_PROFILE_V1 {
545            return Err(AuthZenEvaluationError::UnreadableProfile {
546                received: context.profile,
547                understood: AUTHZEN_PROFILE_V1,
548            });
549        }
550
551        // THE DECODED BODY IS VALIDATED, not assumed. Decoding establishes that
552        // the members are the right SHAPE; it says nothing about a required
553        // member being empty, an obligation naming no source policy, or an
554        // approval clause with no eligible approvers - each of which would be
555        // read by a caller as a fact about the decision.
556        let response = AuthZenResponse {
557            decision: response.decision,
558            context: Some(context),
559        };
560        response
561            .validate("")
562            .map_err(|e| AuthZenEvaluationError::UnusableResponse {
563                detail: e.to_string(),
564            })?;
565        let context = response.context.expect("set immediately above");
566
567        // The boolean and the state are two renderings of ONE outcome: the
568        // contract says `decision` is true exactly when the state is ALLOW. If
569        // they disagree, one of them is wrong and nothing here can tell which,
570        // so acting on either is a coin flip on an authorization decision.
571        //
572        // This also covers a state this build does not know: an unknown state
573        // with `decision: true` cannot be ALLOW as far as this build can tell,
574        // and is refused rather than proceeding.
575        let state_allows = context.state == AuthZenOperationalState::Allow;
576        if state_allows != response.decision {
577            return Err(AuthZenEvaluationError::UnusableResponse {
578                detail: format!(
579                    "the decision boolean is {} but the operational state is {}; the contract \
580                     makes them one outcome, so a body where they disagree cannot be acted on",
581                    response.decision, context.state
582                ),
583            });
584        }
585
586        Ok(AuthZenDecision {
587            decision: response.decision,
588            context,
589        })
590    }
591}
592
593/// Maps a LOCAL validation refusal onto the outcome the caller needs.
594///
595/// The one code that has to be re-read on this side is `evaluation_unavailable`.
596/// From the server it means "the evaluator could not be reached; send these
597/// bytes again". Produced locally it means "an attribute in this request was
598/// never resolved", and resending the identical request reproduces the identical
599/// refusal forever. Same code, opposite action, so they must not arrive as the
600/// same thing.
601fn local_refusal(refusal: AuthZenError) -> AuthZenEvaluationError {
602    if refusal.code == AuthZenErrorCode::EvaluationUnavailable {
603        return AuthZenEvaluationError::Unresolved {
604            pointer: refusal.pointer.clone().unwrap_or_default(),
605            reason: refusal.message.clone(),
606        };
607    }
608    AuthZenEvaluationError::Refused(refusal)
609}
610
611// ---------------------------------------------------------------------------
612// The client surface
613// ---------------------------------------------------------------------------
614
615impl AxonFlowClient {
616    /// Asks whether one subject may perform one action on one resource.
617    ///
618    /// Fails closed: every outcome that is not a readable decision is an error,
619    /// and there is no path through this function that returns an allow it
620    /// could not fully read.
621    pub async fn evaluate(
622        &self,
623        request: AuthZenRequest,
624    ) -> Result<AuthZenDecision, AuthZenEvaluationError> {
625        self.evaluate_envelope(AuthZenEnvelope {
626            evaluation: Some(request),
627            evaluations: None,
628        })
629        .await
630    }
631
632    /// Asks whether ONE operation is permitted against SEVERAL preconditions.
633    ///
634    /// It returns ONE decision, not one per entry. The entries of a bulk
635    /// request are preconditions of a single operation (moving a ticket must be
636    /// authorized against the destination project as well as against the
637    /// ticket), so they combine to the least permissive outcome: one denied
638    /// entry denies the operation. An API returning a list would invite a caller to
639    /// act on the entry it liked.
640    ///
641    /// Any member an entry omits is inherited from the envelope's shared base,
642    /// so the common case is a shared subject and action with one resource per
643    /// entry.
644    pub async fn evaluate_all(
645        &self,
646        bulk: AuthZenBulk,
647    ) -> Result<AuthZenDecision, AuthZenEvaluationError> {
648        self.evaluate_envelope(AuthZenEnvelope {
649            evaluation: None,
650            evaluations: Some(bulk),
651        })
652        .await
653    }
654
655    /// The one transport path both entry points share.
656    async fn evaluate_envelope(
657        &self,
658        envelope: AuthZenEnvelope,
659    ) -> Result<AuthZenDecision, AuthZenEvaluationError> {
660        // Validated before the round trip. The server enforces the same rules
661        // and answers with a typed refusal, so for most of these this is a
662        // convenience - a caller that mis-built an envelope learns it from a
663        // local error naming the member instead of from a 422.
664        //
665        // For ONE class it is not a convenience but the whole point: an
666        // attribute the caller could not resolve has no wire representation, so
667        // the server can never refuse it. Only this check can.
668        if let Err(refusal) = envelope.validate("") {
669            return Err(local_refusal(refusal));
670        }
671
672        let body =
673            serde_json::to_vec(&envelope).map_err(|e| AuthZenEvaluationError::UnusableRequest {
674                detail: e.to_string(),
675            })?;
676
677        let url = format!("{}{}", self.endpoint(), AUTHZEN_PATH);
678        // The evaluation route reads the PEP capability declaration, single and
679        // bulk alike.
680        let mut headers = vec![(AUTHZEN_PROFILE_HEADER, AUTHZEN_PROFILE_V1)];
681        headers.extend(self.pep_handshake_header());
682        let response = self.raw_post_json_bytes(&url, body, &headers).await?;
683
684        let status = response.status();
685        let raw = response
686            .bytes()
687            .await
688            .map_err(|e| AuthZenEvaluationError::Transport(AxonFlowError::HttpError(e)))?;
689
690        if !status.is_success() {
691            // A refusal is a typed document, so the caller can branch on the
692            // code and be pointed at the member to fix. A body that is not one
693            // still surfaces as an error - never as a decision.
694            //
695            // A 5xx is only read as a refusal when the code is one this build
696            // KNOWS. An unrecognised code round-trips as `Unknown`, which is
697            // deliberately non-retryable - so an ingress or sidecar answering
698            // 503 with its own JSON error body would otherwise turn a transient
699            // outage into a permanent refusal that a `while err.retryable()`
700            // loop will not retry. A 4xx is still read as a refusal whatever the
701            // code, because "fix the request" is right either way and the
702            // pointer is worth more than the code.
703            let client_error = status.is_client_error();
704            if let Ok(refusal) = serde_json::from_slice::<AuthZenError>(&raw) {
705                let usable = !refusal.code.as_str().is_empty() && !refusal.message.is_empty();
706                if usable && (client_error || refusal.code.is_known()) {
707                    return Err(AuthZenEvaluationError::Refused(refusal));
708                }
709            }
710            return Err(AuthZenEvaluationError::Transport(AxonFlowError::ApiError {
711                status: status.as_u16(),
712                message: String::from_utf8_lossy(&raw).into_owned(),
713            }));
714        }
715
716        // Strict decoding on the success path: every generated type carries
717        // `deny_unknown_fields`. An unknown member in a decision is a server
718        // speaking a profile this build does not understand, and quietly
719        // dropping it would mean acting on a partial reading of an
720        // authorization decision.
721        let decoded: AuthZenResponse =
722            serde_json::from_slice(&raw).map_err(|e| AuthZenEvaluationError::UnusableResponse {
723                detail: format!(
724                    "the decision could not be decoded: {e}; body={}",
725                    String::from_utf8_lossy(&raw)
726                ),
727            })?;
728
729        AuthZenDecision::from_response(decoded)
730    }
731}