treetop-client 0.0.3

Typed async Rust client for Treetop policy authorization servers
Documentation

treetop-client

A Rust client library for Treetop policy authorization servers.

Treetop is a Cedar-based policy evaluation service. This client provides a typed, async API for evaluating authorization requests, managing policies, and querying server status.

Compatibility

treetop-client is pre-1.0; API compatibility is not guaranteed between 0.0.x releases.

This version targets treetop-rest v0.0.12. CI verifies the stable health, version, policy, and authorization contract against v0.0.4 through v0.0.12; v0.0.12 receives the complete endpoint suite. Newer response fields use #[serde(default)] for backward compatibility.

Features

  • Type-driven design -- strongly typed request/response types with serde, wire-compatible with the Treetop REST API
  • Capability-safe uploads -- upload methods exist only on Client<CanUpload> values built with a validated token
  • Connection pooling -- built on reqwest with configurable pool sizes and idle timeouts
  • Secure token handling -- upload tokens backed by SecretString (zeroized on drop, redacted in Debug output)
  • Validated construction -- fallible constructors preserve Cedar, entity, attribute, context, and header invariants
  • TLS by default -- uses rustls without an OpenSSL/system-TLS dependency, with optional custom root certificates
  • Bounded I/O -- request and successful-response bodies are capped at 16 MiB by default, with configurable limits
  • Fluent endpoint calls -- typed detail and output transitions for authorization and user-policy queries
  • Batch authorization -- evaluate multiple authorization requests in a single API call
  • Correlation IDs -- clone-with-override pattern for request tracing without shared mutable state
  • Schema management -- download and upload Cedar schema data alongside policies
  • Operational endpoints -- typed liveness/readiness checks and access to the generated OpenAPI document

Installation

Add to your Cargo.toml:

[dependencies]
treetop-client = "0.0.3"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }

Quick start

use treetop_client::{
    Action, AuthorizeRequest, Client, Request, Resource, User,
};

#[tokio::main]
async fn main() -> treetop_client::Result<()> {
    // Create a client
    let client = Client::builder("https://treetop.example.com").build()?;

    // Check server health
    client.health().await?;

    // Simple authorization check
    let allowed = client
        .is_allowed(Request::new(
            User::new("alice")?,
            Action::new("view")?,
            Resource::new("Document", "doc-42")?,
        ))
        .await?;

    println!("Allowed: {allowed}");
    Ok(())
}

Usage

Client configuration

use std::time::Duration;
use treetop_client::{Client, RequestLimits, UploadToken};

let client = Client::builder("https://treetop.example.com")
    .connect_timeout(Duration::from_secs(5))
    .request_timeout(Duration::from_secs(30))
    .pool_idle_timeout(Duration::from_secs(90))
    .pool_max_idle_per_host(10)
    .max_request_bytes(16 * 1024 * 1024)
    .max_response_bytes(16 * 1024 * 1024)
    .request_limits(RequestLimits::default())
    .upload_token(UploadToken::new("my-secret-token")?)
    .build()?;

Without .upload_token(...), build() returns Client<ReadOnly> and upload methods are not available. Adding the token transitions the builder and resulting client to CanUpload; read and authorization methods remain available in both states.

Upload tokens require HTTPS unless the destination is loopback. For an explicitly accepted plaintext development server, opt in with .danger_allow_insecure_uploads(true). The default HTTP client also rejects redirects so a token cannot be forwarded to a redirect target. A custom reqwest client bypasses that redirect policy, so configure its policy deliberately.

For custom TLS configuration:

use treetop_client::Client;

let client = Client::builder("https://treetop.internal")
    .add_root_certificate(my_ca_cert)
    .build()?;

Or bring your own pre-configured reqwest client:

let client = Client::builder("https://treetop.example.com")
    .with_reqwest_client(my_reqwest_client)
    .build()?;

Correlation IDs

Correlation IDs are managed via a clone-with-override pattern. The cloned client shares the same connection pool:

let traced = client.with_correlation_id("req-abc-123")?;
traced.authorize(&request).await?;  // sends x-correlation-id header

// Original client is unaffected
client.authorize(&request).await?;  // no correlation header

// Fluent endpoint calls can override correlation without cloning the client
client
    .authorization(&request)
    .correlation_id("req-def-456")?
    .send()
    .await?;

Authorization

Request fields are private and exposed through read-only accessors. Constructors and fluent setters validate before returning a value, while batch- and server-dependent invariants are checked before transport. Duplicate request IDs are rejected, and response IDs, indices, ordering, counts, policy versions, and decisions are checked against the submitted batch. AttrValue::ip() always validates its IP/CIDR value.

Single check

use treetop_client::{Action, Request, Resource, User};

let allowed = client
    .is_allowed(Request::new(
        User::new("alice")?.with_group_names(&["admins"])?,
        Action::new("delete")?,
        Resource::new("Host", "web-01")?,
    ))
    .await?;

Batch authorization

use treetop_client::{Action, AttrValue, AuthorizeRequest, Request, Resource, User};

let batch = AuthorizeRequest::new()
    .add_request(Request::new(
        User::new("alice")?,
        Action::new("view")?,
        Resource::new("Document", "doc-1")?,
    ))
    .add_request_with_id("check-2", Request::new(
        User::new("bob")?,
        Action::new("edit")?,
        Resource::new("Document", "doc-1")?
            .with_attr("owner", AttrValue::String("alice".to_string()))?,
    ))?;

let response = client.authorization(&batch).send().await?;

println!("Successful: {}, Failed: {}", response.successes(), response.failures());

// Look up a result by client-provided ID
if let Some(result) = response.find_by_id("check-2") {
    println!("check-2 index: {}", result.index);
}

Detailed authorization (includes matching policies)

let response = client.authorization(&batch).detailed().send().await?;

Resources with attributes

use treetop_client::{AttrValue, Resource};

let resource = Resource::new("Host", "web-01.example.com")?
    .with_attr("ip", AttrValue::ip("10.0.0.1")?)?
    .with_attr("environment", AttrValue::String("production".to_string()))?
    .with_attr("critical", AttrValue::Bool(true))?
    .with_attr("priority", AttrValue::Long(1))?;

Namespaced types

Users, groups, and actions support Cedar namespaces:

use treetop_client::{Action, Group, User};

let user = User::new("alice")?
    .with_namespace(vec!["MyApp".to_string()])?
    .with_group_names(&["admins", "editors"])?;

let action = Action::new("delete")?
    .with_namespace(vec!["Admin".to_string()])?;

let group = Group::new("superusers")?
    .with_namespace(vec!["MyApp".to_string()])?;

Policy management

// Download policies as structured data
let download = client.get_policies().await?;

// Download policies as raw Cedar DSL
let cedar_text = client.get_policies_raw().await?;

// Upload policies (requires upload token)
let metadata = client
    .upload_policies_raw("permit(principal, action, resource);")
    .await?;

// List policies for a specific user
let user_policies = client
    .user_policies("alice")?
    .group("admins")?
    .namespace("MyApp")?
    .send()
    .await?;

Schema management

// Download schema as structured metadata
let schema = client.get_schema().await?;

// Download schema as raw Cedar schema JSON
let raw_schema = client.get_schema_raw().await?;

// Upload schema (requires upload token)
let metadata = client
    .upload_schema_raw(r#"{"": {"entityTypes": {}, "actions": {}}}"#)
    .await?;

Server status

let version = client.version().await?;
println!("Server: {}, Cedar: {}", version.version, version.core.cedar);

let status = client.status().await?;
println!("Policies loaded: {}", status.policy_configuration.policies.entries);
println!("Context supported: {}", status.request_context.supported);
if let Some(source) = &status.policy_configuration.policies.source {
    println!("Policy source: {}", source.as_str());
}

The canonical operational probes and generated OpenAPI document are available directly:

client.livez().await?;
if client.readyz().await? {
    println!("Server is ready");
}

let openapi = client.openapi().await?;
println!("OpenAPI version: {}", openapi["openapi"]);

Request context

Request-scoped context is serialized on the wire via AuthRequest.context and evaluated by treetop-rest v0.0.12. The client automatically enforces RequestLimits::default() before transport; configure limits reported by a differently configured server with ClientBuilder::request_limits().

use std::collections::HashMap;
use treetop_client::{Action, AttrValue, AuthRequest, AuthorizeRequest, Request, Resource, User};

let mut context = HashMap::new();
context.insert("env".to_string(), AttrValue::String("prod".to_string()));

let request = AuthRequest::new(Request::new(
    User::new("alice")?,
    Action::new("view")?,
    Resource::new("Photo", "VacationPhoto94.jpg")?,
))
    .with_context(context)?;
let batch = AuthorizeRequest::from_auth_requests([request])?;

let response = client.authorization(&batch).send().await?;

Inspect status.request_context if you need to know whether the server runtime is currently schema-backed or running in permissive fallback mode. Uploading a schema via upload_schema_raw() or upload_schema_json() lets you verify the schema-backed path explicitly.

Prometheus metrics

let metrics_text = client.metrics().await?;

Error handling

All methods return treetop_client::Result<T>, which uses TreetopError:

use treetop_client::TreetopError;

match client.health().await {
    Ok(()) => println!("Server is healthy"),
    Err(TreetopError::Transport(e)) => println!("Network error: {e}"),
    Err(TreetopError::Api { status, message }) => {
        println!("Server returned HTTP {status}: {message}");
    }
    Err(e) => println!("Other error: {e}"),
}

License

MIT

Releasing

Stable tags drive the crates.io and GitHub release workflow. See RELEASING.md for the release process.