Skip to main content

adk_auth/secrets/
authorizing.rs

1//! Per-tool authorization for secret access.
2//!
3//! A tool holding a context can name any secret, and a
4//! [`SecretProvider`](super::provider::SecretProvider) receives only
5//! that name. Policy therefore collapses to whatever the backing cloud credentials can
6//! read, and nothing distinguishes a weather tool asking for its own API key from the
7//! same tool asking for a payment credential.
8//!
9//! [`AuthorizingSecretService`] closes that at the ADK layer: a declarative grant per
10//! tool decides before the provider is called, and every decision is recorded without
11//! the secret value.
12
13use std::collections::HashMap;
14use std::sync::Arc;
15
16use adk_core::{AdkError, Result, SecretRequest, SecretService};
17use async_trait::async_trait;
18
19/// What a single tool may read.
20#[derive(Debug, Clone, Default, PartialEq, Eq)]
21pub struct SecretGrant {
22    /// Secret names allowed verbatim.
23    names: Vec<String>,
24    /// Name prefixes allowed, for namespaced secrets such as `billing/`.
25    prefixes: Vec<String>,
26}
27
28impl SecretGrant {
29    /// A grant that allows nothing.
30    pub fn none() -> Self {
31        Self::default()
32    }
33
34    /// Allow an exact secret name.
35    #[must_use]
36    pub fn name(mut self, name: impl Into<String>) -> Self {
37        self.names.push(name.into());
38        self
39    }
40
41    /// Allow every name beginning with `prefix`.
42    ///
43    /// Use this for a namespace the tool owns. A prefix is a blunt instrument: prefer
44    /// exact names where the set is known.
45    #[must_use]
46    pub fn prefix(mut self, prefix: impl Into<String>) -> Self {
47        self.prefixes.push(prefix.into());
48        self
49    }
50
51    /// Whether this grant covers `name`.
52    fn allows(&self, name: &str) -> bool {
53        self.names.iter().any(|allowed| allowed == name)
54            || self.prefixes.iter().any(|prefix| name.starts_with(prefix.as_str()))
55    }
56}
57
58/// Records secret access decisions.
59///
60/// Implementations must not receive or log secret values — only the decision and the
61/// identity around it.
62pub trait SecretAuditSink: Send + Sync {
63    /// Called once per decision, before the provider is consulted on an allow.
64    fn record(&self, decision: SecretAccessDecision<'_>);
65}
66
67/// A single allow or deny, carrying no secret value.
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub struct SecretAccessDecision<'a> {
70    /// Whether access was permitted.
71    pub allowed: bool,
72    /// The secret name requested.
73    pub name: &'a str,
74    /// The requesting tool, when the access came from one.
75    pub tool_name: Option<&'a str>,
76    /// The user the run belongs to.
77    pub user_id: Option<&'a str>,
78    /// The invocation the access happened in.
79    pub invocation_id: Option<&'a str>,
80    /// Why the decision went the way it did.
81    pub reason: &'static str,
82}
83
84/// Wraps a [`SecretService`] with declarative per-tool grants and an audit record.
85///
86/// A request whose tool has no grant, or whose grant does not cover the name, is
87/// refused **before** the inner service is called, so a denied name never reaches the
88/// provider.
89///
90/// # Example
91///
92/// ```rust,ignore
93/// use adk_auth::secrets::{AuthorizingSecretService, SecretGrant};
94/// use std::sync::Arc;
95///
96/// let service = AuthorizingSecretService::new(inner)
97///     .grant("weather_lookup", SecretGrant::none().name("weather-api-key"))
98///     .grant("charge_card", SecretGrant::none().prefix("billing/"));
99/// ```
100pub struct AuthorizingSecretService {
101    inner: Arc<dyn SecretService>,
102    grants: HashMap<String, SecretGrant>,
103    /// What an access with no tool identity may read.
104    untooled: SecretGrant,
105    audit: Option<Arc<dyn SecretAuditSink>>,
106}
107
108impl AuthorizingSecretService {
109    /// Wrap `inner`, denying everything until grants are added.
110    pub fn new(inner: Arc<dyn SecretService>) -> Self {
111        Self { inner, grants: HashMap::new(), untooled: SecretGrant::none(), audit: None }
112    }
113
114    /// Grant `tool_name` access to the secrets described by `grant`.
115    #[must_use]
116    pub fn grant(mut self, tool_name: impl Into<String>, grant: SecretGrant) -> Self {
117        self.grants.insert(tool_name.into(), grant);
118        self
119    }
120
121    /// Grant access for requests that carry no tool identity.
122    ///
123    /// These are accesses made by the agent itself rather than by a dispatched tool.
124    /// They are denied by default, because a request with no identity cannot be
125    /// attributed.
126    #[must_use]
127    pub fn grant_untooled(mut self, grant: SecretGrant) -> Self {
128        self.untooled = grant;
129        self
130    }
131
132    /// Record every decision to `sink`.
133    #[must_use]
134    pub fn with_audit_sink(mut self, sink: Arc<dyn SecretAuditSink>) -> Self {
135        self.audit = Some(sink);
136        self
137    }
138
139    /// Decide whether `request` is permitted, and why.
140    fn decide(&self, request: &SecretRequest) -> (bool, &'static str) {
141        match &request.tool_name {
142            Some(tool_name) => match self.grants.get(tool_name) {
143                Some(grant) if grant.allows(&request.name) => (true, "granted to tool"),
144                Some(_) => (false, "secret not in the tool's grant"),
145                None => (false, "no grant for tool"),
146            },
147            None => {
148                if self.untooled.allows(&request.name) {
149                    (true, "granted without tool identity")
150                } else {
151                    (false, "no grant for a request without tool identity")
152                }
153            }
154        }
155    }
156
157    fn record(&self, request: &SecretRequest, allowed: bool, reason: &'static str) {
158        let decision = SecretAccessDecision {
159            allowed,
160            name: &request.name,
161            tool_name: request.tool_name.as_deref(),
162            user_id: request.user_id.as_deref(),
163            invocation_id: request.invocation_id.as_deref(),
164            reason,
165        };
166        if allowed {
167            tracing::info!(
168                secret.name = %decision.name,
169                tool.name = decision.tool_name.unwrap_or("<none>"),
170                user.id = decision.user_id.unwrap_or("<unknown>"),
171                invocation.id = decision.invocation_id.unwrap_or("<unknown>"),
172                decision.reason = reason,
173                "secret access allowed"
174            );
175        } else {
176            tracing::warn!(
177                secret.name = %decision.name,
178                tool.name = decision.tool_name.unwrap_or("<none>"),
179                user.id = decision.user_id.unwrap_or("<unknown>"),
180                invocation.id = decision.invocation_id.unwrap_or("<unknown>"),
181                decision.reason = reason,
182                "secret access denied"
183            );
184        }
185        if let Some(sink) = &self.audit {
186            sink.record(decision);
187        }
188    }
189}
190
191impl std::fmt::Debug for AuthorizingSecretService {
192    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
193        f.debug_struct("AuthorizingSecretService")
194            .field("granted_tools", &self.grants.keys().collect::<Vec<_>>())
195            .field("audited", &self.audit.is_some())
196            .finish()
197    }
198}
199
200#[async_trait]
201impl SecretService for AuthorizingSecretService {
202    /// Denies unconditionally.
203    ///
204    /// # Errors
205    ///
206    /// A bare name carries no identity, so there is nothing to authorize against.
207    /// Callers reach this service through
208    /// [`SecretService::get_secret_for`], which the framework uses.
209    async fn get_secret(&self, name: &str) -> Result<String> {
210        let request = SecretRequest::new(name);
211        self.record(&request, false, "no identity supplied");
212        Err(AdkError::unauthorized(
213            adk_core::ErrorComponent::Tool,
214            "secret.no_identity",
215            format!("secret '{name}' was requested without identity, so it cannot be authorized"),
216        ))
217    }
218
219    async fn get_secret_for(&self, request: &SecretRequest) -> Result<String> {
220        let (allowed, reason) = self.decide(request);
221        self.record(request, allowed, reason);
222        if !allowed {
223            // The provider is never consulted, so a denied name is not even looked up.
224            return Err(AdkError::unauthorized(
225                adk_core::ErrorComponent::Tool,
226                "secret.access_denied",
227                format!(
228                    "tool {} is not permitted to read secret '{}': {reason}",
229                    request.tool_name.as_deref().unwrap_or("<none>"),
230                    request.name
231                ),
232            ));
233        }
234        self.inner.get_secret_for(request).await
235    }
236}