Skip to main content

typesec_agent/
agent.rs

1//! SecureAgent — the main agent struct wiring typestate + capabilities together.
2
3use std::sync::Arc;
4
5use tracing::{debug, info};
6use typesec_core::{
7    Capability, Permission, Resource,
8    policy::{CapabilityError, MintOptions, PolicyEngine, mint_capability_for_id},
9    typestate::{Agent, AgentError, AgentState, Authenticated, Credentials, Unauthenticated},
10};
11
12/// A secure agent that ties together typestate, policy engines, and capabilities.
13///
14/// `S` is the typestate parameter: `Unauthenticated` or `Authenticated`.
15///
16/// # Why a newtype wrapper?
17///
18/// `typesec-core`'s `Agent` is the minimal typestate foundation. `SecureAgent`
19/// adds the async `request_capability` and `execute` methods on top, keeping
20/// the core crate dependency-free (no tokio).
21pub struct SecureAgent<S: AgentState> {
22    inner: Agent<S>,
23}
24
25impl SecureAgent<Unauthenticated> {
26    /// Create a new unauthenticated agent with the given policy engine.
27    pub fn new(engine: Arc<dyn PolicyEngine>) -> Self {
28        Self {
29            inner: Agent::new(engine),
30        }
31    }
32
33    /// Authenticate the agent against a credential verifier.
34    ///
35    /// On success, returns `SecureAgent<Authenticated>` whose subject is the
36    /// *verified* identity returned by the authenticator. The unauthenticated
37    /// agent is *consumed* — you can't hold onto the unauthenticated handle
38    /// after calling this.
39    pub fn authenticate_with(
40        self,
41        credentials: Credentials,
42        authenticator: &dyn typesec_core::typestate::Authenticator,
43    ) -> Result<SecureAgent<Authenticated>, AgentError> {
44        let inner = self.inner.authenticate_with(credentials, authenticator)?;
45        Ok(SecureAgent { inner })
46    }
47
48    /// Authenticate *without verifying the token* — the claimed subject is
49    /// trusted as-is. For examples, tests, and out-of-band identity only;
50    /// production code should use [`authenticate_with`][Self::authenticate_with].
51    pub fn authenticate_unverified(
52        self,
53        credentials: Credentials,
54    ) -> Result<SecureAgent<Authenticated>, AgentError> {
55        let inner = self.inner.authenticate_unverified(credentials)?;
56        Ok(SecureAgent { inner })
57    }
58}
59
60impl SecureAgent<Authenticated> {
61    /// The authenticated subject identity.
62    pub fn subject(&self) -> &str {
63        self.inner.subject()
64    }
65
66    /// Access the underlying policy engine.
67    ///
68    /// Useful for composing raw `check()` calls alongside capability-based access.
69    pub fn engine(&self) -> Arc<dyn PolicyEngine> {
70        self.inner.engine().clone()
71    }
72
73    /// Request a capability for permission `P` on `resource`.
74    ///
75    /// This is the *only* way to obtain a `Capability<P, R>` from outside
76    /// `typesec-core`. The policy engine is called, the decision is logged,
77    /// and either a capability or an error is returned.
78    ///
79    /// The capability is a zero-sized proof token — holding it means the policy
80    /// engine approved the request at the time of this call.
81    /// The policy check runs on tokio's blocking thread pool: engines may do
82    /// I/O (JWKS fetches, WorkOS FGA calls over a blocking HTTP client), and
83    /// running them inline would stall the async executor.
84    pub async fn request_capability<P: Permission, R: Resource>(
85        &self,
86        resource: &R,
87    ) -> Result<Capability<P, R>, CapabilityError> {
88        self.request_capability_with(resource, MintOptions::default())
89            .await
90    }
91
92    /// Like [`request_capability`][Self::request_capability], but with explicit
93    /// lease parameters: a custom TTL and/or a
94    /// [`RevocationEpoch`][typesec_core::RevocationEpoch] binding so the
95    /// capability can be invalidated mid-lease (e.g. on policy reload).
96    pub async fn request_capability_with<P: Permission, R: Resource>(
97        &self,
98        resource: &R,
99        options: MintOptions,
100    ) -> Result<Capability<P, R>, CapabilityError> {
101        let subject = self.subject().to_owned();
102        let action = P::name();
103        let resource_id = resource.resource_id().to_owned();
104        let engine = self.inner.engine().clone();
105
106        debug!(%subject, action, %resource_id, "requesting capability");
107
108        let cap = {
109            let subject = subject.clone();
110            let resource_id = resource_id.clone();
111            tokio::task::spawn_blocking(move || {
112                mint_capability_for_id::<P, R>(engine.as_ref(), &subject, &resource_id, &options)
113            })
114            .await
115            .map_err(|join_err| {
116                CapabilityError::EngineError(format!("policy check task failed: {join_err}"))
117            })??
118        };
119
120        info!(%subject, action, %resource_id, "capability granted");
121
122        Ok(cap)
123    }
124
125    /// Execute an async action, requiring a valid capability as proof.
126    ///
127    /// The key design point: `execute` takes `cap: &Capability<P, R>` as an
128    /// argument. There is no code path through `execute` that doesn't hold a
129    /// capability. If you don't have a capability, you can't call this method
130    /// (the type system ensures it).
131    ///
132    /// The phantom types prove the *kind* of access; two runtime checks bind
133    /// the proof to this call: the capability must have been minted for this
134    /// agent's subject (no confused-deputy use of another agent's token), and
135    /// for this exact resource instance (a cap for `reports/q1` cannot act on
136    /// `reports/q2`).
137    ///
138    /// This is different from:
139    /// ```rust,ignore
140    /// // ❌ Guard-based — the check can be skipped, the condition forgotten.
141    /// if has_permission { do_thing(); }
142    ///
143    /// // ✅ Capability-based — the capability IS the check.
144    /// agent.execute(&cap, &resource, action).await?;
145    /// ```
146    pub async fn execute<P, R, F, Fut>(
147        &self,
148        cap: &Capability<P, R>,
149        resource: &R,
150        action: F,
151    ) -> Result<(), crate::executor::TaskError>
152    where
153        P: Permission,
154        R: Resource,
155        F: FnOnce(&R) -> Fut,
156        Fut: std::future::Future<Output = Result<(), crate::executor::TaskError>>,
157    {
158        if cap.subject() != self.subject() {
159            return Err(crate::executor::TaskError::CapabilityMismatch(format!(
160                "capability was minted for subject '{}', not '{}'",
161                cap.subject(),
162                self.subject()
163            )));
164        }
165        if cap.resource_id() != resource.resource_id() {
166            return Err(crate::executor::TaskError::CapabilityMismatch(format!(
167                "capability covers resource '{}', not '{}'",
168                cap.resource_id(),
169                resource.resource_id()
170            )));
171        }
172        cap.ensure_active()?;
173
174        info!(
175            subject = %self.subject(),
176            permission = %Capability::<P, R>::permission_name(),
177            resource = %cap.resource_id(),
178            "executing with capability"
179        );
180
181        action(resource).await
182    }
183}
184
185impl<S: AgentState> std::fmt::Debug for SecureAgent<S> {
186    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
187        write!(f, "SecureAgent({:?})", self.inner)
188    }
189}
190
191/// Builder for [`SecureAgent`] — convenient when wiring multiple engines together.
192pub struct AgentBuilder {
193    engine: Option<Arc<dyn PolicyEngine>>,
194}
195
196impl AgentBuilder {
197    /// Create a new builder.
198    pub fn new() -> Self {
199        Self { engine: None }
200    }
201
202    /// Set the policy engine.
203    pub fn with_engine(mut self, engine: Arc<dyn PolicyEngine>) -> Self {
204        self.engine = Some(engine);
205        self
206    }
207
208    /// Compose two engines: `primary` first, falling back to `fallback` on delegation.
209    ///
210    /// Uses [`CombineStrategy::PriorityOrder`]: the primary engine's answer wins
211    /// unless it delegates, in which case the fallback is tried.
212    ///
213    /// For more control (e.g., `DenyOverrides`, `AllowIfAny`), build a
214    /// [`typesec_core::ComposedEngine`] directly with [`typesec_core::PolicyEngineBuilder`]
215    /// and pass it to [`AgentBuilder::with_engine`].
216    pub fn with_composed_engine(
217        mut self,
218        primary: Arc<dyn PolicyEngine>,
219        fallback: Arc<dyn PolicyEngine>,
220    ) -> Self {
221        use typesec_core::combinator::{CombineStrategy, PolicyEngineBuilder};
222        let engine = PolicyEngineBuilder::new()
223            .add_engine(primary)
224            .add_engine(fallback)
225            .strategy(CombineStrategy::PriorityOrder)
226            .build();
227        self.engine = Some(Arc::new(engine));
228        self
229    }
230
231    /// Build the agent.
232    pub fn build(self) -> Result<SecureAgent<Unauthenticated>, String> {
233        let engine = self.engine.ok_or("no policy engine configured")?;
234        Ok(SecureAgent::new(engine))
235    }
236}
237
238impl Default for AgentBuilder {
239    fn default() -> Self {
240        Self::new()
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247    use std::sync::Arc;
248    use typesec_core::{permissions::CanRead, policy::PolicyResult, resource::GenericResource};
249
250    struct AllowAll;
251    impl PolicyEngine for AllowAll {
252        fn check(&self, _: &str, _: &str, _: &str) -> PolicyResult {
253            PolicyResult::Allow
254        }
255    }
256
257    struct DenyAll;
258    impl PolicyEngine for DenyAll {
259        fn check(&self, _: &str, _: &str, _: &str) -> PolicyResult {
260            PolicyResult::Deny("DenyAll".into())
261        }
262    }
263
264    #[tokio::test]
265    async fn full_flow_allow() {
266        let agent = SecureAgent::new(Arc::new(AllowAll));
267        let agent = agent
268            .authenticate_unverified(Credentials::new("agent:test", "tok"))
269            .expect("auth ok");
270        let resource = GenericResource::new("reports/q1", "report");
271        let cap: Capability<CanRead, GenericResource> = agent
272            .request_capability(&resource)
273            .await
274            .expect("should get cap");
275        assert_eq!(cap.subject(), "agent:test");
276    }
277
278    #[tokio::test]
279    async fn denied_request_returns_error() {
280        let agent = SecureAgent::new(Arc::new(DenyAll));
281        let agent = agent
282            .authenticate_unverified(Credentials::new("agent:test", "tok"))
283            .expect("auth ok");
284        let resource = GenericResource::new("reports/q1", "report");
285        let result: Result<Capability<CanRead, GenericResource>, _> =
286            agent.request_capability(&resource).await;
287        assert!(result.is_err());
288    }
289
290    #[tokio::test]
291    async fn execute_requires_capability() {
292        let agent = SecureAgent::new(Arc::new(AllowAll));
293        let agent = agent
294            .authenticate_unverified(Credentials::new("agent:test", "tok"))
295            .expect("auth ok");
296        let resource = GenericResource::new("reports/q1", "report");
297        let cap: Capability<CanRead, GenericResource> =
298            agent.request_capability(&resource).await.expect("cap ok");
299
300        let executed = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
301        let executed_clone = executed.clone();
302
303        agent
304            .execute(&cap, &resource, |_r| {
305                let flag = executed_clone.clone();
306                Box::pin(async move {
307                    flag.store(true, std::sync::atomic::Ordering::SeqCst);
308                    Ok(())
309                })
310            })
311            .await
312            .expect("execute ok");
313
314        assert!(executed.load(std::sync::atomic::Ordering::SeqCst));
315    }
316
317    #[tokio::test]
318    async fn execute_rejects_capability_for_other_resource() {
319        let agent = SecureAgent::new(Arc::new(AllowAll));
320        let agent = agent
321            .authenticate_unverified(Credentials::new("agent:test", "tok"))
322            .expect("auth ok");
323        let q1 = GenericResource::new("reports/q1", "report");
324        let q2 = GenericResource::new("reports/q2", "report");
325        let cap: Capability<CanRead, GenericResource> =
326            agent.request_capability(&q1).await.expect("cap ok");
327
328        // Same resource type, different instance — must be rejected.
329        let result = agent
330            .execute(&cap, &q2, |_r| Box::pin(async { Ok(()) }))
331            .await;
332        assert!(matches!(
333            result,
334            Err(crate::executor::TaskError::CapabilityMismatch(_))
335        ));
336    }
337
338    #[tokio::test]
339    async fn execute_rejects_capability_for_other_subject() {
340        let resource = GenericResource::new("reports/q1", "report");
341
342        // Mint a capability as agent:other...
343        let other = SecureAgent::new(Arc::new(AllowAll))
344            .authenticate_unverified(Credentials::new("agent:other", "tok"))
345            .expect("auth ok");
346        let cap: Capability<CanRead, GenericResource> =
347            other.request_capability(&resource).await.expect("cap ok");
348
349        // ...and try to use it as agent:test.
350        let agent = SecureAgent::new(Arc::new(AllowAll))
351            .authenticate_unverified(Credentials::new("agent:test", "tok"))
352            .expect("auth ok");
353        let result = agent
354            .execute(&cap, &resource, |_r| Box::pin(async { Ok(()) }))
355            .await;
356        assert!(matches!(
357            result,
358            Err(crate::executor::TaskError::CapabilityMismatch(_))
359        ));
360    }
361}