Skip to main content

axonflow_sdk_rust/
typed_policies.rs

1//! Typed policy authoring: the v11 successor to the legacy policy routes.
2//!
3//! A v11 platform authors policy as a typed document: validated, published as a
4//! signed artifact pinned by its digest, and promoted to active. Six routes
5//! under `/api/v1/typed-policies` do that, and the agent proxies all six with
6//! this client's credentials. Reach them as
7//! [`client.typed_policies()`](crate::AxonFlowClient::typed_policies):
8//!
9//! - [`edition`](TypedPolicies::edition): what this deployment may author,
10//!   consulted BEFORE a publication rather than learned from a refusal.
11//! - [`validate`](TypedPolicies::validate): every finding for a candidate
12//!   document. It answers the same on every edition; the edition's boundary
13//!   applies at publication.
14//! - [`publish`](TypedPolicies::publish): validates, compiles, runs the
15//!   declared fixtures, signs, and pins the artifact by its digest.
16//! - [`activate`](TypedPolicies::activate): promotes a published digest to
17//!   active.
18//! - [`active`](TypedPolicies::active): the document in force, as the exact
19//!   bytes that were signed, or `None` when nothing is active.
20//! - [`system`](TypedPolicies::system): the platform's own controls,
21//!   read-only.
22//!
23//! The organization and the author are the ones this client's credentials
24//! resolve to. The agent stamps both, and neither can be named in a request.
25//!
26//! Activation PROMOTES: a digest whose version does not advance past the active
27//! one is refused. Rolling back to an earlier document and withdrawing the
28//! active one are operations of the customer portal, behind its session; the
29//! agent does not proxy them, so this module has no method for either. The
30//! per-policy override routes are retired (`PerPolicyOverrideRetired`): an
31//! organization changes a shipped control through its typed document.
32//!
33//! On an edition with separation of duties, `publish` refuses every
34//! publication with the finding code `APPROVER_IS_AUTHOR`: publishing through
35//! this route names no approver, and such a deployment approves in the customer
36//! portal.
37//!
38//! Every refusal is [`AxonFlowError::TypedPolicyRefusal`], carrying the HTTP
39//! status, the platform's `reason` and, where there are any, the findings. A
40//! `401` stays [`AxonFlowError::ApiError`] with `status: 401`, the client's
41//! authentication error. These routes do not read the PEP capability
42//! declaration, and the client never sends it on them. They need a v11.0.0
43//! platform.
44//!
45//! The answer types are `#[non_exhaustive]`: the platform adds members to these
46//! answers, and a member this SDK learns later is then not a breaking change.
47
48use crate::client::AxonFlowClient;
49use crate::error::AxonFlowError;
50// The platform marshals a nil Go slice or map as JSON `null`, not `[]` or `{}`
51// (a clean validation answers `"findings": null`), so every collection it
52// declares without `omitempty` reads `null` as empty through this helper.
53use crate::types::agent::null_to_default;
54use serde::{Deserialize, Serialize};
55use serde_json::{Map, Value};
56use std::collections::BTreeMap;
57use std::fmt;
58
59/// The route prefix the six operations share.
60pub const TYPED_POLICIES_PATH: &str = "/api/v1/typed-policies";
61
62/// What this edition may spend (the spec's `EditionConstructReport`).
63///
64/// The booleans are `Option`s: the platform always sends them, so absent and
65/// `false` are different answers, and an absent flag must not read as a
66/// permission's zero value.
67#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
68#[non_exhaustive]
69pub struct EditionConstructReport {
70    /// `community`, `evaluation` or `enterprise`.
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub edition: Option<String>,
73    #[serde(default, deserialize_with = "null_to_default")]
74    pub obligation_families: Vec<String>,
75    #[serde(default, deserialize_with = "null_to_default")]
76    pub attribute_namespaces: Vec<String>,
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub group_scope: Option<bool>,
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub separation_of_duties: Option<bool>,
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub tier_established: Option<bool>,
83    /// Constructs withheld for want of an edition ruling, not by one.
84    #[serde(default, deserialize_with = "null_to_default")]
85    pub reserved: Vec<String>,
86}
87
88/// One declared save-time or publication result (the spec's
89/// `AuthoringFinding`).
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91#[non_exhaustive]
92pub struct AuthoringFinding {
93    /// The finding code, for example `ACTION_NOT_REGISTERED`.
94    pub code: String,
95    /// `reject` or `warn`.
96    pub severity: String,
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub policy_id: Option<String>,
99    /// The declared, code-level sentence.
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub summary: Option<String>,
102    /// What was wrong, naming the offending value.
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub detail: Option<String>,
105}
106
107/// A candidate document and its fixtures (the spec's
108/// `TypedAuthoringDocumentRequest`). [`TypedPolicies::validate`] and
109/// [`TypedPolicies::publish`] build it.
110#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
111#[non_exhaustive]
112pub struct TypedAuthoringDocumentRequest {
113    /// The authoring document. Opaque to this SDK on purpose: the spec declares
114    /// it `type: object`, the authoring model itself rather than a mirror of
115    /// it, and a typed struct would drop the members a later vocabulary adds.
116    pub document: Value,
117    /// The author-declared cases the publication gauntlet runs. `None` omits
118    /// the member; `Some(vec![])` sends `[]`.
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    pub fixtures: Option<Vec<Value>>,
121}
122
123impl TypedAuthoringDocumentRequest {
124    /// The request for `document` and its `fixtures`, as
125    /// [`TypedPolicies::validate`] and [`TypedPolicies::publish`] send it.
126    pub fn new(document: &Value, fixtures: Option<&[Value]>) -> Self {
127        Self {
128            document: document.clone(),
129            fixtures: fixtures.map(<[Value]>::to_vec),
130        }
131    }
132}
133
134/// What this deployment may author.
135#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
136#[non_exhaustive]
137pub struct TypedAuthoringEdition {
138    #[serde(default)]
139    pub success: bool,
140    /// The configured authoring vocabulary.
141    #[serde(default, skip_serializing_if = "Option::is_none")]
142    pub catalog: Option<String>,
143    /// The vocabulary snapshot's content digest: its identity, which a refusal,
144    /// a decision and a proof carry too.
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub catalog_digest: Option<String>,
147    /// The integer the wire carries for this vocabulary.
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub registry_version: Option<i64>,
150    /// True for a test-world vocabulary, which the platform refuses to
151    /// activate a document against.
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub catalog_fixture: Option<bool>,
154    /// The one authority root this surface publishes under.
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    pub root: Option<String>,
157    /// Customer-authored POLICIES (rules) admitted per organization; `-1` is
158    /// unlimited. The member's name is historical: an organization has one
159    /// active document, and the ceiling counts the policies inside it.
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    pub max_documents: Option<i64>,
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub constructs: Option<EditionConstructReport>,
164    /// `process`, `database` or `unavailable`.
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub persistence: Option<String>,
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub signing_key_custody: Option<String>,
169}
170
171/// Every finding for a candidate document. `success` is false when any is a
172/// rejection.
173#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
174#[non_exhaustive]
175pub struct TypedPolicyValidation {
176    #[serde(default)]
177    pub success: bool,
178    #[serde(default, deserialize_with = "null_to_default")]
179    pub findings: Vec<AuthoringFinding>,
180}
181
182/// A published artifact. Activation names `digest`, never the version.
183#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
184#[non_exhaustive]
185pub struct TypedPolicyPublication {
186    #[serde(default)]
187    pub success: bool,
188    pub digest: String,
189    #[serde(default, skip_serializing_if = "Option::is_none")]
190    pub version: Option<i64>,
191    #[serde(default, deserialize_with = "null_to_default")]
192    pub findings: Vec<AuthoringFinding>,
193    /// The platform's report of the shipped template's controls this document
194    /// omits, as the platform sent it; absent when it omits none.
195    #[serde(default, skip_serializing_if = "Option::is_none")]
196    pub template_omissions: Option<Value>,
197    /// Why that report could not be produced, when it could not.
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub template_omissions_unavailable: Option<String>,
200}
201
202/// The audited activation record.
203#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
204#[non_exhaustive]
205pub struct TypedPolicyActivation {
206    #[serde(default)]
207    pub success: bool,
208    #[serde(default, deserialize_with = "null_to_default")]
209    pub activation: Map<String, Value>,
210    /// The platform's report of the shipped template's controls the activated
211    /// document omits, as the platform sent it; absent when it omits none.
212    #[serde(default, skip_serializing_if = "Option::is_none")]
213    pub template_omissions: Option<Value>,
214    /// Why that report could not be produced, when it could not.
215    #[serde(default, skip_serializing_if = "Option::is_none")]
216    pub template_omissions_unavailable: Option<String>,
217}
218
219/// The document in force.
220///
221/// `source` is the exact byte sequence that was signed, so a caller can verify
222/// it; `document` is the same bytes parsed.
223#[derive(Debug, Clone, PartialEq)]
224#[non_exhaustive]
225pub struct ActiveTypedPolicy {
226    pub source: Vec<u8>,
227    pub document: Value,
228}
229
230/// One shipped control, with what happens when it cannot be evaluated.
231#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
232#[non_exhaustive]
233pub struct TypedPolicySystemControl {
234    pub id: String,
235    #[serde(default, skip_serializing_if = "Option::is_none")]
236    pub name: Option<String>,
237    #[serde(default, skip_serializing_if = "Option::is_none")]
238    pub authority: Option<String>,
239    /// `enforcement`, `gating_risk` or `advisory`.
240    #[serde(default, skip_serializing_if = "Option::is_none")]
241    pub assurance: Option<String>,
242    /// Whether an organization may not override it. The platform omits the
243    /// member when it is false, so absent reads as `false`.
244    #[serde(default)]
245    pub mandatory: bool,
246    #[serde(default, skip_serializing_if = "Option::is_none")]
247    pub description: Option<String>,
248    #[serde(default, deserialize_with = "null_to_default")]
249    pub obligations: Vec<Value>,
250}
251
252/// The platform's own controls: the system root activated beneath every
253/// organization.
254#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
255#[non_exhaustive]
256pub struct TypedPolicySystemCorpus {
257    #[serde(default, skip_serializing_if = "Option::is_none")]
258    pub root: Option<String>,
259    #[serde(default, skip_serializing_if = "Option::is_none")]
260    pub version: Option<i64>,
261    /// The digest an enforcing engine anchors to.
262    #[serde(default, skip_serializing_if = "Option::is_none")]
263    pub digest: Option<String>,
264    #[serde(default, skip_serializing_if = "Option::is_none")]
265    pub authority: Option<String>,
266    #[serde(default, deserialize_with = "null_to_default")]
267    pub controls: Vec<TypedPolicySystemControl>,
268    #[serde(default, deserialize_with = "null_to_default")]
269    pub assurance_counts: BTreeMap<String, i64>,
270    #[serde(default, deserialize_with = "null_to_default")]
271    pub document: Map<String, Value>,
272}
273
274/// A typed policy authoring request the platform refused.
275///
276/// `status` is the HTTP status and `reason` the platform's reason, for example
277/// `publication_refused` (422), `document_refused` (422, with the save-time
278/// findings), `activation_refused` (409), `tier_limit` (402, with `code`
279/// naming the limit and `policy` the policy that crossed it) or `artifact_cap`
280/// (429). `findings` holds the declared findings a refused publication or
281/// document carries. `retry_after` is the seconds from `Retry-After`: the
282/// platform sends it on a refusal it asks the caller to retry, such as a `402`
283/// `tier_limit` raised because admission could not be checked, which is how
284/// that refusal differs from the ceiling itself. `message` is the platform's
285/// own explanation, or `HTTP <status> from <route>` when it gave none.
286#[derive(Debug, Clone, PartialEq, Eq)]
287#[non_exhaustive]
288pub struct TypedPolicyRefusal {
289    pub status: u16,
290    pub reason: Option<String>,
291    pub code: Option<String>,
292    /// The policy that crossed a tier boundary, when the refusal names one.
293    pub policy: Option<String>,
294    pub message: String,
295    pub findings: Vec<AuthoringFinding>,
296    pub retry_after: Option<u64>,
297}
298
299impl fmt::Display for TypedPolicyRefusal {
300    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
301        match &self.reason {
302            Some(reason) => write!(
303                f,
304                "typed policy request refused (HTTP {}, {reason}): {}",
305                self.status, self.message
306            ),
307            None => write!(
308                f,
309                "typed policy request refused (HTTP {}): {}",
310                self.status, self.message
311            ),
312        }
313    }
314}
315
316/// The error for a non-2xx answer: the client's authentication error for a
317/// `401`, the typed refusal for everything else.
318///
319/// The body is read member by member, so a member of an unexpected type leaves
320/// the others read, and a body that is not JSON at all still names its status.
321fn refusal(status: u16, retry_after: Option<u64>, body: &[u8], route: &str) -> AxonFlowError {
322    let parsed: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
323    let text = |member: &str| {
324        parsed
325            .get(member)
326            .and_then(Value::as_str)
327            .filter(|s| !s.is_empty())
328            .map(str::to_string)
329    };
330    if status == 401 {
331        // The platform's `error` when it names one, else the body itself,
332        // trimmed, so a plain-text 401 from the agent keeps its words, else
333        // the status and the route.
334        let raw = String::from_utf8_lossy(body).trim().to_string();
335        let message = text("error")
336            .or_else(|| Some(raw).filter(|s| !s.is_empty()))
337            .unwrap_or_else(|| format!("HTTP {status} from {route}"));
338        return AxonFlowError::ApiError { status, message };
339    }
340    let message = text("error").unwrap_or_else(|| format!("HTTP {status} from {route}"));
341    let findings = parsed
342        .get("findings")
343        .and_then(Value::as_array)
344        .map(|all| {
345            all.iter()
346                .filter_map(|f| serde_json::from_value::<AuthoringFinding>(f.clone()).ok())
347                .collect()
348        })
349        .unwrap_or_default();
350    AxonFlowError::TypedPolicyRefusal(Box::new(TypedPolicyRefusal {
351        status,
352        reason: text("reason"),
353        code: text("code"),
354        policy: text("policy"),
355        message,
356        findings,
357        retry_after,
358    }))
359}
360
361/// The error for a 2xx answer whose body is not a JSON object.
362fn not_an_object(status: u16, route: &str) -> AxonFlowError {
363    AxonFlowError::ApiError {
364        status,
365        message: format!(
366            "{TYPED_POLICIES_PATH}{route} answered {status} with a body that is not an object"
367        ),
368    }
369}
370
371/// `Retry-After` in seconds, when it is a plain non-negative integer.
372fn retry_after(response: &reqwest::Response) -> Option<u64> {
373    response
374        .headers()
375        .get(reqwest::header::RETRY_AFTER)
376        .and_then(|v| v.to_str().ok())
377        .filter(|v| !v.is_empty() && v.bytes().all(|b| b.is_ascii_digit()))
378        .and_then(|v| v.parse().ok())
379}
380
381/// `client.typed_policies()`: typed policy authoring through the agent. See
382/// [`crate::typed_policies`] for what each operation does and what it does
383/// not.
384///
385/// It borrows the client, so it presents that client's credentials and
386/// per-user identity; a client derived with
387/// [`as_user`](AxonFlowClient::as_user) gets a namespace bound to itself.
388#[derive(Clone, Copy)]
389pub struct TypedPolicies<'a> {
390    client: &'a AxonFlowClient,
391}
392
393impl AxonFlowClient {
394    /// Typed policy authoring: the six `/api/v1/typed-policies` routes. See
395    /// [`crate::typed_policies`].
396    pub fn typed_policies(&self) -> TypedPolicies<'_> {
397        TypedPolicies { client: self }
398    }
399}
400
401impl TypedPolicies<'_> {
402    fn url(&self, route: &str) -> String {
403        format!("{}{TYPED_POLICIES_PATH}{route}", self.client.endpoint())
404    }
405
406    /// A 2xx answer's body as a JSON object; the error for any other answer.
407    async fn object(
408        &self,
409        response: reqwest::Response,
410        route: &str,
411    ) -> Result<Value, AxonFlowError> {
412        let status = response.status().as_u16();
413        let wait = retry_after(&response);
414        let body = response.bytes().await?;
415        if !(200..300).contains(&status) {
416            return Err(refusal(status, wait, &body, route));
417        }
418        match serde_json::from_slice::<Value>(&body) {
419            Ok(value) if value.is_object() => Ok(value),
420            _ => Err(not_an_object(status, route)),
421        }
422    }
423
424    async fn get(&self, route: &str) -> Result<Value, AxonFlowError> {
425        let response = self.client.raw_get_as(&self.url(route), None).await?;
426        self.object(response, route).await
427    }
428
429    async fn post<T: Serialize>(&self, route: &str, payload: &T) -> Result<Value, AxonFlowError> {
430        let body = serde_json::to_vec(payload)?;
431        let response = self
432            .client
433            .raw_post_json_bytes(&self.url(route), body, &[])
434            .await?;
435        self.object(response, route).await
436    }
437
438    /// What this deployment may author: its construct boundary and policy
439    /// ceiling.
440    pub async fn edition(&self) -> Result<TypedAuthoringEdition, AxonFlowError> {
441        Ok(serde_json::from_value(self.get("/edition").await?)?)
442    }
443
444    /// Every finding for a candidate document, ordered and complete.
445    ///
446    /// A document that is refused is still a successful validation: read
447    /// `success` and the findings rather than expecting an error.
448    pub async fn validate(
449        &self,
450        document: &Value,
451        fixtures: Option<&[Value]>,
452    ) -> Result<TypedPolicyValidation, AxonFlowError> {
453        let request = TypedAuthoringDocumentRequest::new(document, fixtures);
454        Ok(serde_json::from_value(
455            self.post("/validate", &request).await?,
456        )?)
457    }
458
459    /// Publish a document as a signed artifact, pinned by its digest.
460    ///
461    /// `fixtures` are the author-declared cases the publication gauntlet runs;
462    /// a publication without any is refused, since no policy in the document
463    /// has then been shown to do anything.
464    ///
465    /// # Errors
466    ///
467    /// [`AxonFlowError::TypedPolicyRefusal`]: `422` with the findings
468    /// (`publication_refused`, or `document_refused` for the save-time checks;
469    /// an edition boundary or `APPROVER_IS_AUTHOR` appears as a finding code),
470    /// `402` `tier_limit`, `429` `artifact_cap`, or `400` for a malformed
471    /// request.
472    pub async fn publish(
473        &self,
474        document: &Value,
475        fixtures: Option<&[Value]>,
476    ) -> Result<TypedPolicyPublication, AxonFlowError> {
477        let request = TypedAuthoringDocumentRequest::new(document, fixtures);
478        Ok(serde_json::from_value(
479            self.post("/publish", &request).await?,
480        )?)
481    }
482
483    /// Promote a published digest to active. The activation is audited and
484    /// names the caller. An empty or absent `reason` is not sent.
485    ///
486    /// # Errors
487    ///
488    /// [`AxonFlowError::TypedPolicyRefusal`] `409` `activation_refused` when
489    /// the digest is not admitted, its version does not advance, its parent is
490    /// not the active digest, or the caller may not activate it.
491    pub async fn activate(
492        &self,
493        digest: &str,
494        reason: Option<&str>,
495    ) -> Result<TypedPolicyActivation, AxonFlowError> {
496        let mut payload = Map::new();
497        payload.insert("digest".into(), Value::String(digest.to_string()));
498        if let Some(reason) = reason.filter(|r| !r.is_empty()) {
499            payload.insert("reason".into(), Value::String(reason.to_string()));
500        }
501        Ok(serde_json::from_value(
502            self.post("/activate", &payload).await?,
503        )?)
504    }
505
506    /// The document in force, as the exact bytes that were signed, or `None`
507    /// when nothing is active.
508    ///
509    /// `None` is the platform's own answer: a `404` whose reason is
510    /// `nothing_active`. Any other `404` is a
511    /// [`TypedPolicyRefusal`](AxonFlowError::TypedPolicyRefusal) with status
512    /// `404`: a platform without the typed routes (before v11.0.0), or a base
513    /// URL that is not an AxonFlow agent, is reported as such rather than as
514    /// "nothing active".
515    ///
516    /// `None` is only as reliable as that reason: the platform currently also
517    /// answers `nothing_active` when its document store cannot be read
518    /// (getaxonflow/axonflow-enterprise#4255).
519    pub async fn active(&self) -> Result<Option<ActiveTypedPolicy>, AxonFlowError> {
520        let route = "/active";
521        let response = self.client.raw_get_as(&self.url(route), None).await?;
522        let status = response.status().as_u16();
523        let wait = retry_after(&response);
524        let source = response.bytes().await?.to_vec();
525        if status == 404
526            && serde_json::from_slice::<Value>(&source)
527                .ok()
528                .and_then(|body| body.get("reason").cloned())
529                == Some(Value::String("nothing_active".into()))
530        {
531            return Ok(None);
532        }
533        if !(200..300).contains(&status) {
534            return Err(refusal(status, wait, &source, route));
535        }
536        match serde_json::from_slice::<Value>(&source) {
537            Ok(document) if document.is_object() => {
538                Ok(Some(ActiveTypedPolicy { source, document }))
539            }
540            _ => Err(not_an_object(status, route)),
541        }
542    }
543
544    /// The platform's own controls, read-only: the shipped system corpus.
545    pub async fn system(&self) -> Result<TypedPolicySystemCorpus, AxonFlowError> {
546        let body = self.get("/system").await?;
547        let system = match body.get("system") {
548            Some(system @ Value::Object(_)) => system.clone(),
549            _ => Value::Object(Map::new()),
550        };
551        Ok(serde_json::from_value(system)?)
552    }
553}