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, PolicyEngine, mint_capability},
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.
34    ///
35    /// On success, returns `SecureAgent<Authenticated>`. The unauthenticated
36    /// agent is *consumed* — you can't hold onto the unauthenticated handle
37    /// after calling this.
38    pub fn authenticate(
39        self,
40        credentials: Credentials,
41    ) -> Result<SecureAgent<Authenticated>, AgentError> {
42        let inner = self.inner.authenticate(credentials)?;
43        Ok(SecureAgent { inner })
44    }
45}
46
47impl SecureAgent<Authenticated> {
48    /// The authenticated subject identity.
49    pub fn subject(&self) -> &str {
50        self.inner.subject()
51    }
52
53    /// Access the underlying policy engine.
54    ///
55    /// Useful for composing raw `check()` calls alongside capability-based access.
56    pub fn engine(&self) -> Arc<dyn PolicyEngine> {
57        self.inner.engine().clone()
58    }
59
60    /// Request a capability for permission `P` on `resource`.
61    ///
62    /// This is the *only* way to obtain a `Capability<P, R>` from outside
63    /// `typesec-core`. The policy engine is called, the decision is logged,
64    /// and either a capability or an error is returned.
65    ///
66    /// The capability is a zero-sized proof token — holding it means the policy
67    /// engine approved the request at the time of this call.
68    pub async fn request_capability<P: Permission, R: Resource>(
69        &self,
70        resource: &R,
71    ) -> Result<Capability<P, R>, CapabilityError> {
72        let subject = self.subject();
73        let action = P::name();
74        let resource_id = resource.resource_id();
75
76        debug!(subject, action, resource_id, "requesting capability");
77
78        let cap = mint_capability::<P, R>(self.inner.engine().as_ref(), subject, resource)?;
79
80        info!(subject, action, resource_id, "capability granted");
81
82        Ok(cap)
83    }
84
85    /// Execute an async action, requiring a valid capability as proof.
86    ///
87    /// The key design point: `execute` takes `cap: &Capability<P, R>` as an
88    /// argument. There is no code path through `execute` that doesn't hold a
89    /// capability. If you don't have a capability, you can't call this method
90    /// (the type system ensures it).
91    ///
92    /// This is different from:
93    /// ```rust,ignore
94    /// // ❌ Guard-based — the check can be skipped, the condition forgotten.
95    /// if has_permission { do_thing(); }
96    ///
97    /// // ✅ Capability-based — the capability IS the check.
98    /// agent.execute(&cap, &resource, action).await?;
99    /// ```
100    pub async fn execute<P, R, F, Fut>(
101        &self,
102        cap: &Capability<P, R>,
103        resource: &R,
104        action: F,
105    ) -> Result<(), crate::executor::TaskError>
106    where
107        P: Permission,
108        R: Resource,
109        F: FnOnce(&R) -> Fut,
110        Fut: std::future::Future<Output = Result<(), crate::executor::TaskError>>,
111    {
112        info!(
113            subject = %self.subject(),
114            permission = %Capability::<P, R>::permission_name(),
115            resource = %cap.resource_id(),
116            "executing with capability"
117        );
118
119        action(resource).await
120    }
121}
122
123impl<S: AgentState> std::fmt::Debug for SecureAgent<S> {
124    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125        write!(f, "SecureAgent({:?})", self.inner)
126    }
127}
128
129/// Builder for [`SecureAgent`] — convenient when wiring multiple engines together.
130pub struct AgentBuilder {
131    engine: Option<Arc<dyn PolicyEngine>>,
132}
133
134impl AgentBuilder {
135    /// Create a new builder.
136    pub fn new() -> Self {
137        Self { engine: None }
138    }
139
140    /// Set the policy engine.
141    pub fn with_engine(mut self, engine: Arc<dyn PolicyEngine>) -> Self {
142        self.engine = Some(engine);
143        self
144    }
145
146    /// Compose two engines: `primary` first, falling back to `fallback` on delegation.
147    ///
148    /// Uses [`CombineStrategy::PriorityOrder`]: the primary engine's answer wins
149    /// unless it delegates, in which case the fallback is tried.
150    ///
151    /// For more control (e.g., `DenyOverrides`, `AllowIfAny`), build a
152    /// [`typesec_core::ComposedEngine`] directly with [`typesec_core::PolicyEngineBuilder`]
153    /// and pass it to [`AgentBuilder::with_engine`].
154    pub fn with_composed_engine(
155        mut self,
156        primary: Arc<dyn PolicyEngine>,
157        fallback: Arc<dyn PolicyEngine>,
158    ) -> Self {
159        use typesec_core::combinator::{CombineStrategy, PolicyEngineBuilder};
160        let engine = PolicyEngineBuilder::new()
161            .add_engine(primary)
162            .add_engine(fallback)
163            .strategy(CombineStrategy::PriorityOrder)
164            .build();
165        self.engine = Some(Arc::new(engine));
166        self
167    }
168
169    /// Build the agent.
170    pub fn build(self) -> Result<SecureAgent<Unauthenticated>, String> {
171        let engine = self.engine.ok_or("no policy engine configured")?;
172        Ok(SecureAgent::new(engine))
173    }
174}
175
176impl Default for AgentBuilder {
177    fn default() -> Self {
178        Self::new()
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use std::sync::Arc;
186    use typesec_core::{permissions::CanRead, policy::PolicyResult, resource::GenericResource};
187
188    struct AllowAll;
189    impl PolicyEngine for AllowAll {
190        fn check(&self, _: &str, _: &str, _: &str) -> PolicyResult {
191            PolicyResult::Allow
192        }
193    }
194
195    struct DenyAll;
196    impl PolicyEngine for DenyAll {
197        fn check(&self, _: &str, _: &str, _: &str) -> PolicyResult {
198            PolicyResult::Deny("DenyAll".into())
199        }
200    }
201
202    #[tokio::test]
203    async fn full_flow_allow() {
204        let agent = SecureAgent::new(Arc::new(AllowAll));
205        let agent = agent
206            .authenticate(Credentials::new("agent:test", "tok"))
207            .expect("auth ok");
208        let resource = GenericResource::new("reports/q1", "report");
209        let cap: Capability<CanRead, GenericResource> = agent
210            .request_capability(&resource)
211            .await
212            .expect("should get cap");
213        assert_eq!(cap.subject(), "agent:test");
214    }
215
216    #[tokio::test]
217    async fn denied_request_returns_error() {
218        let agent = SecureAgent::new(Arc::new(DenyAll));
219        let agent = agent
220            .authenticate(Credentials::new("agent:test", "tok"))
221            .expect("auth ok");
222        let resource = GenericResource::new("reports/q1", "report");
223        let result: Result<Capability<CanRead, GenericResource>, _> =
224            agent.request_capability(&resource).await;
225        assert!(result.is_err());
226    }
227
228    #[tokio::test]
229    async fn execute_requires_capability() {
230        let agent = SecureAgent::new(Arc::new(AllowAll));
231        let agent = agent
232            .authenticate(Credentials::new("agent:test", "tok"))
233            .expect("auth ok");
234        let resource = GenericResource::new("reports/q1", "report");
235        let cap: Capability<CanRead, GenericResource> =
236            agent.request_capability(&resource).await.expect("cap ok");
237
238        let executed = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
239        let executed_clone = executed.clone();
240
241        agent
242            .execute(&cap, &resource, |_r| {
243                let flag = executed_clone.clone();
244                Box::pin(async move {
245                    flag.store(true, std::sync::atomic::Ordering::SeqCst);
246                    Ok(())
247                })
248            })
249            .await
250            .expect("execute ok");
251
252        assert!(executed.load(std::sync::atomic::Ordering::SeqCst));
253    }
254}