Skip to main content

tf_decide_client/
lib.rs

1//! tf-decide-client — minimal HTTP client to call tf-daemon's `/v1/decide` endpoint.
2//!
3//! This crate is consumed by every framework adapter (axum, tonic, actix-web,
4//! rocket, warp, poem, salvo, hyper) so they share one wire format and one set
5//! of decision/result types.
6//!
7//! Usage:
8//!
9//! ```no_run
10//! # use tf_decide_client::{TfDecideClient, DecideRequest};
11//! # async fn run() {
12//! let client = TfDecideClient::new("http://127.0.0.1:7080", "admin-token");
13//! let req = DecideRequest {
14//!     action: "GET /api/widgets".into(),
15//!     ..Default::default()
16//! };
17//! let _resp = client.decide(&req).await.unwrap();
18//! # }
19//! ```
20
21use reqwest::Client;
22use std::time::Duration;
23
24/// Errors returned by [`TfDecideClient::decide`].
25#[derive(Debug, thiserror::Error)]
26pub enum ClientError {
27    #[error("transport error: {0}")]
28    Transport(#[from] reqwest::Error),
29    #[error("daemon returned non-success status {status}: {body}")]
30    Status { status: u16, body: String },
31    #[error("decode error: {0}")]
32    Decode(String),
33}
34
35/// Decide-request body sent to tf-daemon.
36#[derive(serde::Serialize, Default, Debug, Clone)]
37pub struct DecideRequest {
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub actor: Option<String>,
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub host_token: Option<String>,
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub host_token_kind: Option<String>,
44    pub action: String,
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub target: Option<String>,
47    #[serde(default)]
48    pub context: serde_json::Value,
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub trace_id: Option<String>,
51}
52
53/// Decide-response body returned by tf-daemon.
54#[derive(serde::Deserialize, Debug, Clone)]
55pub struct DecideResponse {
56    pub decision: String,
57    #[serde(default)]
58    pub reason: String,
59    #[serde(default)]
60    pub approval_id: Option<String>,
61    #[serde(default)]
62    pub proof_id: String,
63    #[serde(default)]
64    pub actor_resolved: Option<String>,
65    #[serde(default)]
66    pub trust_level: Option<String>,
67    #[serde(default)]
68    pub authority_mode: Option<String>,
69    #[serde(default)]
70    pub danger_tags: Vec<String>,
71}
72
73/// Shared mini-client for `/v1/decide`.
74#[derive(Clone, Debug)]
75pub struct TfDecideClient {
76    daemon_url: String,
77    admin_token: String,
78    http: Client,
79}
80
81impl TfDecideClient {
82    /// Build a new client. `daemon_url` must NOT end with a trailing slash.
83    pub fn new(daemon_url: impl Into<String>, admin_token: impl Into<String>) -> Self {
84        let http = Client::builder()
85            .timeout(Duration::from_secs(5))
86            .build()
87            .expect("reqwest client");
88        let mut url = daemon_url.into();
89        while url.ends_with('/') {
90            url.pop();
91        }
92        Self {
93            daemon_url: url,
94            admin_token: admin_token.into(),
95            http,
96        }
97    }
98
99    /// Build a client using a custom underlying [`reqwest::Client`].
100    pub fn with_client(
101        daemon_url: impl Into<String>,
102        admin_token: impl Into<String>,
103        http: Client,
104    ) -> Self {
105        let mut url = daemon_url.into();
106        while url.ends_with('/') {
107            url.pop();
108        }
109        Self {
110            daemon_url: url,
111            admin_token: admin_token.into(),
112            http,
113        }
114    }
115
116    /// The daemon URL this client is bound to (sans trailing slash).
117    pub fn daemon_url(&self) -> &str {
118        &self.daemon_url
119    }
120
121    /// Call `POST {daemon}/v1/decide` and decode the response.
122    pub async fn decide(&self, req: &DecideRequest) -> Result<DecideResponse, ClientError> {
123        let url = format!("{}/v1/decide", self.daemon_url);
124        let resp = self
125            .http
126            .post(&url)
127            .bearer_auth(&self.admin_token)
128            .json(req)
129            .send()
130            .await?;
131        let status = resp.status();
132        if !status.is_success() {
133            let body = resp.text().await.unwrap_or_default();
134            return Err(ClientError::Status {
135                status: status.as_u16(),
136                body,
137            });
138        }
139        let parsed: DecideResponse = resp
140            .json()
141            .await
142            .map_err(|e| ClientError::Decode(e.to_string()))?;
143        Ok(parsed)
144    }
145}
146
147/// Convenience: decision string is "allow" (case-insensitive).
148pub fn is_allow(d: &DecideResponse) -> bool {
149    d.decision.eq_ignore_ascii_case("allow")
150}
151
152/// Convenience: decision string is "deny" (case-insensitive).
153pub fn is_deny(d: &DecideResponse) -> bool {
154    d.decision.eq_ignore_ascii_case("deny")
155}
156
157/// Convenience: decision string is "approval" or "approval_required".
158pub fn is_approval(d: &DecideResponse) -> bool {
159    let s = d.decision.to_ascii_lowercase();
160    s == "approval" || s == "approval_required" || s == "approval-required"
161}