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_async},
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    /// Async policy engines can do their work without blocking the executor;
82    /// synchronous engines use the default async adapter in `typesec-core`.
83    pub async fn request_capability<P: Permission, R: Resource>(
84        &self,
85        resource: &R,
86    ) -> Result<Capability<P, R>, CapabilityError> {
87        self.request_capability_with(resource, MintOptions::default())
88            .await
89    }
90
91    /// Like [`request_capability`][Self::request_capability], but with explicit
92    /// lease parameters: a custom TTL and/or a
93    /// [`RevocationEpoch`][typesec_core::RevocationEpoch] binding so the
94    /// capability can be invalidated mid-lease (e.g. on policy reload).
95    pub async fn request_capability_with<P: Permission, R: Resource>(
96        &self,
97        resource: &R,
98        options: MintOptions,
99    ) -> Result<Capability<P, R>, CapabilityError> {
100        let subject = self.subject().to_owned();
101        let action = P::name();
102        let resource_id = resource.resource_id().to_owned();
103        let engine = self.inner.engine().clone();
104
105        debug!(%subject, action, %resource_id, "requesting capability");
106
107        let cap = mint_capability_for_id_async::<P, R>(
108            engine.as_ref(),
109            subject.as_str(),
110            resource_id.as_str(),
111            &options,
112        )
113        .await?;
114
115        info!(%subject, action, %resource_id, "capability granted");
116
117        Ok(cap)
118    }
119
120    /// Execute an async action, requiring a valid capability as proof.
121    ///
122    /// The key design point: `execute` takes `cap: &Capability<P, R>` as an
123    /// argument. There is no code path through `execute` that doesn't hold a
124    /// capability. If you don't have a capability, you can't call this method
125    /// (the type system ensures it).
126    ///
127    /// The phantom types prove the *kind* of access; two runtime checks bind
128    /// the proof to this call: the capability must have been minted for this
129    /// agent's subject (no confused-deputy use of another agent's token), and
130    /// for this exact resource instance (a cap for `reports/q1` cannot act on
131    /// `reports/q2`).
132    ///
133    /// This is different from:
134    /// ```rust,ignore
135    /// // ❌ Guard-based — the check can be skipped, the condition forgotten.
136    /// if has_permission { do_thing(); }
137    ///
138    /// // ✅ Capability-based — the capability IS the check.
139    /// agent.execute(&cap, &resource, action).await?;
140    /// ```
141    pub async fn execute<P, R, F, Fut>(
142        &self,
143        cap: &Capability<P, R>,
144        resource: &R,
145        action: F,
146    ) -> Result<(), crate::executor::TaskError>
147    where
148        P: Permission,
149        R: Resource,
150        F: FnOnce(&R) -> Fut,
151        Fut: std::future::Future<Output = Result<(), crate::executor::TaskError>>,
152    {
153        if cap.subject() != self.subject() {
154            return Err(crate::executor::TaskError::CapabilityMismatch(format!(
155                "capability was minted for subject '{}', not '{}'",
156                cap.subject(),
157                self.subject()
158            )));
159        }
160        if cap.resource_id() != resource.resource_id() {
161            return Err(crate::executor::TaskError::CapabilityMismatch(format!(
162                "capability covers resource '{}', not '{}'",
163                cap.resource_id(),
164                resource.resource_id()
165            )));
166        }
167        cap.ensure_active()?;
168
169        info!(
170            subject = %self.subject(),
171            permission = %Capability::<P, R>::permission_name(),
172            resource = %cap.resource_id(),
173            "executing with capability"
174        );
175
176        action(resource).await
177    }
178}
179
180impl<S: AgentState> std::fmt::Debug for SecureAgent<S> {
181    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
182        write!(f, "SecureAgent({:?})", self.inner)
183    }
184}
185
186/// Builder for [`SecureAgent`] — convenient when wiring multiple engines together.
187pub struct AgentBuilder {
188    engine: Option<Arc<dyn PolicyEngine>>,
189}
190
191impl AgentBuilder {
192    /// Create a new builder.
193    pub fn new() -> Self {
194        Self { engine: None }
195    }
196
197    /// Set the policy engine.
198    pub fn with_engine(mut self, engine: Arc<dyn PolicyEngine>) -> Self {
199        self.engine = Some(engine);
200        self
201    }
202
203    /// Compose two engines: `primary` first, falling back to `fallback` on delegation.
204    ///
205    /// Uses [`CombineStrategy::PriorityOrder`]: the primary engine's answer wins
206    /// unless it delegates, in which case the fallback is tried.
207    ///
208    /// For more control (e.g., `DenyOverrides`, `AllowIfAny`), build a
209    /// [`typesec_core::ComposedEngine`] directly with [`typesec_core::PolicyEngineBuilder`]
210    /// and pass it to [`AgentBuilder::with_engine`].
211    pub fn with_composed_engine(
212        self,
213        primary: Arc<dyn PolicyEngine>,
214        fallback: Arc<dyn PolicyEngine>,
215    ) -> Self {
216        self.with_composed_engine_strategy(
217            primary,
218            fallback,
219            typesec_core::combinator::CombineStrategy::PriorityOrder,
220        )
221    }
222
223    /// Compose two engines with an explicit combination strategy.
224    pub fn with_composed_engine_strategy(
225        mut self,
226        primary: Arc<dyn PolicyEngine>,
227        fallback: Arc<dyn PolicyEngine>,
228        strategy: typesec_core::combinator::CombineStrategy,
229    ) -> Self {
230        use typesec_core::combinator::PolicyEngineBuilder;
231        let engine = PolicyEngineBuilder::new()
232            .add_engine(primary)
233            .add_engine(fallback)
234            .strategy(strategy)
235            .build();
236        self.engine = Some(Arc::new(engine));
237        self
238    }
239
240    /// Build the agent.
241    pub fn build(self) -> Result<SecureAgent<Unauthenticated>, String> {
242        let engine = self.engine.ok_or("no policy engine configured")?;
243        Ok(SecureAgent::new(engine))
244    }
245}
246
247impl Default for AgentBuilder {
248    fn default() -> Self {
249        Self::new()
250    }
251}
252
253#[cfg(test)]
254mod tests;