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}