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 std::time::Duration;
22
23use tf_transport::{HttpError, HttpRequest};
24
25/// Errors returned by [`TfDecideClient::decide`].
26#[derive(Debug)]
27pub enum ClientError {
28    Transport(HttpError),
29    Status { status: u16, body: String },
30    Encode(String),
31    Decode(String),
32}
33
34impl std::fmt::Display for ClientError {
35    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36        match self {
37            ClientError::Transport(e) => write!(f, "transport error: {e}"),
38            ClientError::Status { status, body } => {
39                write!(f, "daemon returned non-success status {status}: {body}")
40            }
41            ClientError::Encode(e) => write!(f, "encode error: {e}"),
42            ClientError::Decode(e) => write!(f, "decode error: {e}"),
43        }
44    }
45}
46
47impl std::error::Error for ClientError {}
48
49impl From<HttpError> for ClientError {
50    fn from(e: HttpError) -> Self {
51        ClientError::Transport(e)
52    }
53}
54
55/// Decide-request body sent to tf-daemon.
56#[derive(serde::Serialize, Default, Debug, Clone)]
57pub struct DecideRequest {
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub actor: Option<String>,
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub host_token: Option<String>,
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub host_token_kind: Option<String>,
64    pub action: String,
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub target: Option<String>,
67    #[serde(default)]
68    pub context: serde_json::Value,
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub trace_id: Option<String>,
71}
72
73/// Decide-response body returned by tf-daemon.
74#[derive(serde::Deserialize, Debug, Clone)]
75pub struct DecideResponse {
76    pub decision: String,
77    #[serde(default)]
78    pub reason: String,
79    #[serde(default)]
80    pub approval_id: Option<String>,
81    #[serde(default)]
82    pub proof_id: String,
83    #[serde(default)]
84    pub actor_resolved: Option<String>,
85    #[serde(default)]
86    pub trust_level: Option<String>,
87    #[serde(default)]
88    pub authority_mode: Option<String>,
89    #[serde(default)]
90    pub danger_tags: Vec<String>,
91}
92
93/// Shared mini-client for `/v1/decide`.
94#[derive(Clone, Debug)]
95pub struct TfDecideClient {
96    daemon_url: String,
97    admin_token: String,
98    timeout: Duration,
99}
100
101impl TfDecideClient {
102    /// Build a new client. `daemon_url` must NOT end with a trailing slash.
103    pub fn new(daemon_url: impl Into<String>, admin_token: impl Into<String>) -> Self {
104        let mut url = daemon_url.into();
105        while url.ends_with('/') {
106            url.pop();
107        }
108        Self {
109            daemon_url: url,
110            admin_token: admin_token.into(),
111            timeout: Duration::from_secs(5),
112        }
113    }
114
115    /// Override the per-request timeout.
116    pub fn with_timeout(mut self, timeout: Duration) -> Self {
117        self.timeout = timeout;
118        self
119    }
120
121    /// The daemon URL this client is bound to (sans trailing slash).
122    pub fn daemon_url(&self) -> &str {
123        &self.daemon_url
124    }
125
126    /// Call `POST {daemon}/v1/decide` and decode the response.
127    pub async fn decide(&self, req: &DecideRequest) -> Result<DecideResponse, ClientError> {
128        let url = format!("{}/v1/decide", self.daemon_url);
129        let body = serde_json::to_vec(req).map_err(|e| ClientError::Encode(e.to_string()))?;
130        let resp = HttpRequest::post(url)
131            .bearer_auth(&self.admin_token)
132            .json_body(body)
133            .timeout(self.timeout)
134            .send()
135            .await?;
136        if !(200..300).contains(&resp.status) {
137            let body = String::from_utf8_lossy(&resp.body).to_string();
138            return Err(ClientError::Status {
139                status: resp.status,
140                body,
141            });
142        }
143        let parsed: DecideResponse =
144            serde_json::from_slice(&resp.body).map_err(|e| ClientError::Decode(e.to_string()))?;
145        Ok(parsed)
146    }
147}
148
149/// Convenience: decision string is "allow" (case-insensitive).
150pub fn is_allow(d: &DecideResponse) -> bool {
151    d.decision.eq_ignore_ascii_case("allow")
152}
153
154/// Convenience: decision string is "deny" (case-insensitive).
155pub fn is_deny(d: &DecideResponse) -> bool {
156    d.decision.eq_ignore_ascii_case("deny")
157}
158
159/// Convenience: decision string is "approval" or "approval_required".
160pub fn is_approval(d: &DecideResponse) -> bool {
161    let s = d.decision.to_ascii_lowercase();
162    s == "approval" || s == "approval_required" || s == "approval-required"
163}