Skip to main content

laravel_iam/
lib.rs

1//! # laravel-iam
2//!
3//! A thin, **fail-closed** Rust client for the [Laravel IAM](https://github.com/padosoft)
4//! authorization server. It speaks the canonical decision protocol
5//! (`POST {base_url}/decisions/check`) and verifies OIDC tokens against the server's JWKS —
6//! mirroring the production PHP client's wire contract exactly, in idiomatic async Rust.
7//!
8//! There is **no policy logic on the client**: every decision is the server's. The client only
9//! transports the question and the answer, and refuses to invent an "allow".
10//!
11//! ## Fail-closed guarantee
12//!
13//! A network error, timeout, 5xx, 4xx, malformed body or unverifiable token always becomes a
14//! **deny** — never an allow. Operations return `Result<_, IamError>`; the [`ResultExt::is_allowed`]
15//! helper collapses any error into `false` so a gate cannot accidentally open:
16//!
17//! ```no_run
18//! use laravel_iam::{IamClient, DecisionQuery, Subject, ResultExt};
19//! use serde_json::json;
20//!
21//! # async fn run() -> Result<(), Box<dyn std::error::Error>> {
22//! let iam = IamClient::builder()
23//!     .base_url("https://iam.example.com/api/iam/v1")
24//!     .token(std::env::var("IAM_SERVICE_TOKEN")?)
25//!     .build()?;
26//!
27//! let decision = iam.check(DecisionQuery {
28//!     subject: Subject::user("usr_123"),
29//!     application: Some("warehouse".into()),
30//!     permission: "stock.adjust".into(),
31//!     resource: Some("wh_milan".into()),
32//!     context: json!({ "amount": 300 }),
33//!     ..Default::default()
34//! }).await;
35//!
36//! // `decision` is `Result<Decision, IamError>`; on ANY error this is `false`.
37//! if !decision.is_allowed() {
38//!     // deny — fail-closed
39//! }
40//! # Ok(())
41//! # }
42//! ```
43//!
44//! ## Token verification
45//!
46//! [`IamClient::verify_token`] checks an ES256 signature against the cached JWKS plus the
47//! `iss`/`aud`/`exp` claims. Configure the expected issuer and audience on the builder.
48//!
49//! ## Features
50//!
51//! - `blocking` — adds a synchronous [`blocking::IamClient`] with identical semantics.
52
53#![forbid(unsafe_code)]
54#![warn(clippy::all, clippy::pedantic)]
55#![allow(clippy::module_name_repetitions)]
56
57mod client;
58mod config;
59mod error;
60mod types;
61mod wire;
62
63#[cfg(feature = "blocking")]
64pub mod blocking;
65
66pub use client::IamClient;
67pub use config::IamClientBuilder;
68pub use error::IamError;
69pub use types::{Claims, Decision, DecisionQuery, Resource, Subject};
70
71/// Fail-closed extension for `Result<Decision, IamError>`.
72///
73/// Lets a caller write `if iam.check(q).await.is_allowed()` and be certain that **every**
74/// error path — and every pending step-up — evaluates to `false`.
75pub trait ResultExt {
76    /// `true` only when the call succeeded **and** the decision is truly granted
77    /// (allowed and no pending step-up). Any [`IamError`] yields `false`.
78    fn is_allowed(&self) -> bool;
79}
80
81impl ResultExt for Result<Decision, IamError> {
82    fn is_allowed(&self) -> bool {
83        matches!(self, Ok(decision) if decision.is_allowed())
84    }
85}