pub mod attribute;
pub mod types_gen;
use crate::error::AxonFlowError;
use crate::AxonFlowClient;
pub use attribute::{Attribute, AttributeMap, AttributeValue};
pub use types_gen::*;
impl AuthZenErrorCode {
pub fn retryable(&self) -> bool {
matches!(self, AuthZenErrorCode::EvaluationUnavailable)
}
}
impl AuthZenError {
pub fn at(mut self, pointer: &str) -> Self {
self.pointer = if pointer.is_empty() {
None
} else {
Some(pointer.to_string())
};
self
}
pub fn supporting<S: Into<String>>(mut self, supported: impl IntoIterator<Item = S>) -> Self {
self.supported = supported.into_iter().map(Into::into).collect();
self
}
pub fn retryable(&self) -> bool {
self.code.retryable()
}
}
impl std::fmt::Display for AuthZenError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match &self.pointer {
Some(p) => write!(f, "axonflow: {} at {}: {}", self.code, p, self.message),
None => write!(f, "axonflow: {}: {}", self.code, self.message),
}
}
}
impl std::error::Error for AuthZenError {}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum AuthZenEvaluationError {
#[error("{0}")]
Refused(#[from] AuthZenError),
#[error(
"the server answered with AuthZEN profile {received:?}; this build can only interpret \
{understood:?}. The obligations and approval challenge that constrain an allow are \
carried in that payload, so the decision cannot be acted on safely. Upgrade the SDK."
)]
UnreadableProfile {
received: String,
understood: &'static str,
},
#[error("the server's decision cannot be acted on: {detail}")]
UnusableResponse {
detail: String,
},
#[error(
"this request cannot be sent as built. At {pointer}: {reason} Re-resolve the attribute and \
build a NEW request; resending this one cannot succeed."
)]
Unresolved {
pointer: String,
reason: String,
},
#[error("the request could not be encoded: {detail}")]
UnusableRequest {
detail: String,
},
#[error("the evaluation request failed: {0}")]
Transport(#[from] AxonFlowError),
}
impl AuthZenEvaluationError {
pub fn retryable(&self) -> bool {
match self {
AuthZenEvaluationError::Refused(e) => e.retryable(),
AuthZenEvaluationError::Transport(e) => e.is_retryable(),
AuthZenEvaluationError::UnreadableProfile { .. }
| AuthZenEvaluationError::UnusableResponse { .. }
| AuthZenEvaluationError::Unresolved { .. }
| AuthZenEvaluationError::UnusableRequest { .. } => false,
}
}
pub fn as_refusal(&self) -> Option<&AuthZenError> {
match self {
AuthZenEvaluationError::Refused(e) => Some(e),
_ => None,
}
}
}
impl AuthZenRequest {
pub fn evaluating(
subject: AuthZenSubject,
action: AuthZenAction,
resource: AuthZenResource,
) -> Self {
AuthZenRequest {
subject: Some(subject),
action: Some(action),
resource: Some(resource),
context: AttributeMap::new(),
}
}
pub fn with_query(mut self, query: Attribute<String>) -> Self {
set_query(&mut self.context, query);
self
}
pub fn with_correlation(mut self, key: &str, value: Attribute<String>) -> Self {
set_correlation(&mut self.context, key, value);
self
}
}
impl AuthZenBulk {
pub fn over(evaluations: impl IntoIterator<Item = AuthZenRequest>) -> Self {
AuthZenBulk::new(evaluations.into_iter().collect())
}
pub fn with_subject(mut self, subject: AuthZenSubject) -> Self {
self.subject = Some(subject);
self
}
pub fn with_action(mut self, action: AuthZenAction) -> Self {
self.action = Some(action);
self
}
pub fn with_resource(mut self, resource: AuthZenResource) -> Self {
self.resource = Some(resource);
self
}
pub fn with_query(mut self, query: Attribute<String>) -> Self {
set_query(&mut self.context, query);
self
}
pub fn with_correlation(mut self, key: &str, value: Attribute<String>) -> Self {
set_correlation(&mut self.context, key, value);
self
}
}
fn set_query(context: &mut AttributeMap, query: Attribute<String>) {
if let Some(args) = context.nested_for_write("args") {
args.record("query", query.map(AttributeValue::from));
}
}
fn set_correlation(context: &mut AttributeMap, key: &str, value: Attribute<String>) {
if let Some(correlation) = context.nested_for_write("correlation") {
correlation.record(key, value.map(AttributeValue::from));
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct AuthZenDecision {
decision: bool,
context: AuthZenResponseContext,
}
impl AuthZenDecision {
pub fn allowed(&self) -> bool {
self.decision && self.context.state == AuthZenOperationalState::Allow
}
pub fn state(&self) -> &AuthZenOperationalState {
&self.context.state
}
pub fn category(&self) -> &AuthZenCategory {
&self.context.category
}
pub fn reason(&self) -> Option<&AuthZenReasonCode> {
self.context.reason.as_ref()
}
pub fn obligations(&self) -> &[AuthZenObligation] {
&self.context.obligations
}
pub fn mandatory_obligations(&self) -> impl Iterator<Item = &AuthZenObligation> {
self.context.obligations.iter().filter(|o| o.mandatory)
}
pub fn approval(&self) -> Option<&AuthZenApprovalRequirement> {
self.context.approval.as_ref()
}
pub fn decision_id(&self) -> &str {
&self.context.decision_id
}
pub fn schema_version(&self) -> &str {
&self.context.schema_version
}
pub fn context(&self) -> &AuthZenResponseContext {
&self.context
}
fn from_response(response: AuthZenResponse) -> Result<Self, AuthZenEvaluationError> {
let context = match response.context {
Some(c) => c,
None => {
return Err(AuthZenEvaluationError::UnusableResponse {
detail: format!(
"the response carries no profile payload, though this request negotiated \
{AUTHZEN_PROFILE_HEADER}: {AUTHZEN_PROFILE_V1}. The obligations and the \
approval challenge ride in that payload, so an allow cannot be \
distinguished from an allow this client must not act on"
),
})
}
};
if context.profile != AUTHZEN_PROFILE_V1 {
return Err(AuthZenEvaluationError::UnreadableProfile {
received: context.profile,
understood: AUTHZEN_PROFILE_V1,
});
}
let response = AuthZenResponse {
decision: response.decision,
context: Some(context),
};
response
.validate("")
.map_err(|e| AuthZenEvaluationError::UnusableResponse {
detail: e.to_string(),
})?;
let context = response.context.expect("set immediately above");
let state_allows = context.state == AuthZenOperationalState::Allow;
if state_allows != response.decision {
return Err(AuthZenEvaluationError::UnusableResponse {
detail: format!(
"the decision boolean is {} but the operational state is {}; the contract \
makes them one outcome, so a body where they disagree cannot be acted on",
response.decision, context.state
),
});
}
Ok(AuthZenDecision {
decision: response.decision,
context,
})
}
}
fn local_refusal(refusal: AuthZenError) -> AuthZenEvaluationError {
if refusal.code == AuthZenErrorCode::EvaluationUnavailable {
return AuthZenEvaluationError::Unresolved {
pointer: refusal.pointer.clone().unwrap_or_default(),
reason: refusal.message.clone(),
};
}
AuthZenEvaluationError::Refused(refusal)
}
impl AxonFlowClient {
pub async fn evaluate(
&self,
request: AuthZenRequest,
) -> Result<AuthZenDecision, AuthZenEvaluationError> {
self.evaluate_envelope(AuthZenEnvelope {
evaluation: Some(request),
evaluations: None,
})
.await
}
pub async fn evaluate_all(
&self,
bulk: AuthZenBulk,
) -> Result<AuthZenDecision, AuthZenEvaluationError> {
self.evaluate_envelope(AuthZenEnvelope {
evaluation: None,
evaluations: Some(bulk),
})
.await
}
async fn evaluate_envelope(
&self,
envelope: AuthZenEnvelope,
) -> Result<AuthZenDecision, AuthZenEvaluationError> {
if let Err(refusal) = envelope.validate("") {
return Err(local_refusal(refusal));
}
let body =
serde_json::to_vec(&envelope).map_err(|e| AuthZenEvaluationError::UnusableRequest {
detail: e.to_string(),
})?;
let url = format!("{}{}", self.endpoint(), AUTHZEN_PATH);
let mut headers = vec![(AUTHZEN_PROFILE_HEADER, AUTHZEN_PROFILE_V1)];
headers.extend(self.pep_handshake_header());
let response = self.raw_post_json_bytes(&url, body, &headers).await?;
let status = response.status();
let raw = response
.bytes()
.await
.map_err(|e| AuthZenEvaluationError::Transport(AxonFlowError::HttpError(e)))?;
if !status.is_success() {
let client_error = status.is_client_error();
if let Ok(refusal) = serde_json::from_slice::<AuthZenError>(&raw) {
let usable = !refusal.code.as_str().is_empty() && !refusal.message.is_empty();
if usable && (client_error || refusal.code.is_known()) {
return Err(AuthZenEvaluationError::Refused(refusal));
}
}
return Err(AuthZenEvaluationError::Transport(AxonFlowError::ApiError {
status: status.as_u16(),
message: String::from_utf8_lossy(&raw).into_owned(),
}));
}
let decoded: AuthZenResponse =
serde_json::from_slice(&raw).map_err(|e| AuthZenEvaluationError::UnusableResponse {
detail: format!(
"the decision could not be decoded: {e}; body={}",
String::from_utf8_lossy(&raw)
),
})?;
AuthZenDecision::from_response(decoded)
}
}