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;