protocol_droid/domain/a2a/agent_card/security_requirement.rs
1//! `SecurityRequirement` — a list of schemes an operation requires.
2//!
3//! # What this is
4//!
5//! A `SecurityRequirement` is a single rule of the form
6//! "this operation needs scheme X with scopes A, B, C,
7//! AND scheme Y". The agent publishes a list of these
8//! rules on its [`AgentCard`](super::AgentCard); a client
9//! matches a request to one of the rules to decide what
10//! credentials to send.
11//!
12//! In `OpenAPI 3.x` (which the A2A spec follows), the
13//! wire form is:
14//!
15//! ```json
16//! {
17//! "securityRequirements": [
18//! { "apiKey": [] },
19//! { "oauth2": ["read", "write"] }
20//! ]
21//! }
22//! ```
23//!
24//! Each entry is a single object whose keys are scheme
25//! names and whose values are lists of required scopes.
26//! A client must satisfy **all** the schemes in one
27//! entry to use the operation; **any** of the entries
28//! being satisfied is enough.
29//!
30//! # Why the proto shape is different
31//!
32//! The proto's `SecurityRequirement` is
33//! `map<string, StringList> schemes` — a single
34//! `StringList` wrapper per value, producing:
35//!
36//! ```json
37//! { "schemes": { "apiKey": { "list": [] } } }
38//! ```
39//!
40//! The `StringList` is the proto's own wire shape, not
41//! the `OpenAPI` one. The A2A spec follows `OpenAPI` for
42//! the JSON form; the proto is a separate definition.
43//! We model the proto's shape (a single `StringList`
44//! per value) so the wire form round-trips with the
45//! rest of the A2A JSON ecosystem — the spec's JSON
46//! example, where it exists, will use the
47//! `StringList` shape the proto produces.
48//!
49//! # Empty requirements are meaningful
50//!
51//! In `OpenAPI 3.x`, a `securityRequirement: [{}]`
52//! explicitly says "this operation requires no
53//! authentication" — it overrides the agent's
54//! default-deny. A card with no
55//! `security_requirements` field defaults to
56//! "every operation requires no authentication".
57//! The two are different signals and the type
58//! allows both.
59
60use std::collections::BTreeMap;
61use std::sync::Arc;
62
63use serde::{Deserialize, Serialize};
64
65/// A single security requirement — one rule an
66/// operation must satisfy.
67///
68/// The wire form is the proto's: a `schemes` map
69/// from scheme name to a [`StringList`] of
70/// required scopes. An entry with an empty `schemes`
71/// map explicitly says "this operation requires no
72/// authentication" (`OpenAPI 3.x` semantics).
73#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(rename_all = "camelCase")]
75pub struct SecurityRequirement {
76 /// The schemes this requirement demands, keyed by
77 /// the scheme name (the same name as the key in
78 /// [`AgentCard::security_schemes`](super::AgentCard::security_schemes)).
79 /// The list of scopes is per-scheme; for schemes
80 /// that don't take scopes (API key, HTTP, mTLS)
81 /// the list is empty.
82 #[serde(default)]
83 pub schemes: BTreeMap<Arc<str>, StringList>,
84}
85
86impl SecurityRequirement {
87 /// Starts a new empty requirement — equivalent to
88 /// `OpenAPI 3.x`'s `securityRequirement: [{}]`,
89 /// "no authentication required".
90 #[must_use]
91 pub fn new() -> Self {
92 Self::default()
93 }
94
95 /// Adds a scheme the requirement demands. The
96 /// `scopes` list is the scopes the operation
97 /// requires; for non-OAuth schemes the list
98 /// should be empty.
99 #[must_use]
100 pub fn with_scheme(
101 mut self,
102 name: impl Into<Arc<str>>,
103 scopes: impl IntoIterator<Item = impl Into<Arc<str>>>,
104 ) -> Self {
105 self.schemes.insert(
106 name.into(),
107 StringList {
108 list: scopes.into_iter().map(Into::into).collect(),
109 },
110 );
111 self
112 }
113}
114
115/// A list of strings — the proto's wire shape for a
116/// `repeated string` value that lives inside a `map`.
117///
118/// The proto's `StringList` is a one-field message
119/// wrapping `repeated string list = 1`; the wire form
120/// is `{"list": [...]}`. The struct exists so
121/// [`SecurityRequirement`]'s `schemes` map value
122/// serialises with the same shape the proto produces.
123#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
124pub struct StringList {
125 /// The string values.
126 pub list: Vec<Arc<str>>,
127}
128
129#[cfg(test)]
130mod tests {
131 use std::sync::Arc;
132
133 use super::{SecurityRequirement, StringList};
134
135 type TestError = Box<dyn std::error::Error>;
136
137 #[test]
138 fn empty_requirement_serialises_as_openapi_no_auth() -> Result<(), TestError> {
139 // OpenAPI's "no authentication required" is
140 // `securityRequirement: [{}]`. The proto's
141 // shape serialises this as
142 // `{"schemes": {}}` — the `schemes` field is
143 // present but empty, distinguishing it from a
144 // missing field.
145 let req = SecurityRequirement::new();
146 let value = serde_json::to_value(&req)?;
147 let Some(obj) = value.as_object() else {
148 return Err("a SecurityRequirement must serialise to a JSON object"
149 .to_owned()
150 .into());
151 };
152 let Some(schemes) = obj.get("schemes").and_then(serde_json::Value::as_object) else {
153 return Err("schemes must be a JSON object".to_owned().into());
154 };
155 assert!(schemes.is_empty());
156 Ok(())
157 }
158
159 #[test]
160 fn requirement_with_api_key_serialises_with_proto_shape() -> Result<(), TestError> {
161 let req = SecurityRequirement::new().with_scheme("apiKey", std::iter::empty::<Arc<str>>());
162 let value = serde_json::to_value(&req)?;
163 let Some(obj) = value.as_object() else {
164 return Err("a SecurityRequirement must serialise to a JSON object"
165 .to_owned()
166 .into());
167 };
168 let Some(schemes) = obj.get("schemes").and_then(serde_json::Value::as_object) else {
169 return Err("schemes must be a JSON object".to_owned().into());
170 };
171 let Some(api_key) = schemes.get("apiKey").and_then(serde_json::Value::as_object) else {
172 return Err("apiKey must be a JSON object".to_owned().into());
173 };
174 let list = api_key.get("list").and_then(serde_json::Value::as_array);
175 let list = list.map(std::vec::Vec::as_slice).unwrap_or_default();
176 assert!(list.is_empty());
177 Ok(())
178 }
179
180 #[test]
181 fn requirement_with_oauth_scopes_round_trips() -> Result<(), TestError> {
182 let req = SecurityRequirement::new()
183 .with_scheme("oauth2", [Arc::from("read"), Arc::from("write")]);
184 let json = serde_json::to_string(&req)?;
185 let parsed: SecurityRequirement = serde_json::from_str(&json)?;
186 assert_eq!(parsed, req);
187 let value = serde_json::to_value(&req)?;
188 let Some(obj) = value.as_object() else {
189 return Err("a SecurityRequirement must serialise to a JSON object"
190 .to_owned()
191 .into());
192 };
193 let Some(schemes) = obj.get("schemes").and_then(serde_json::Value::as_object) else {
194 return Err("schemes must be a JSON object".to_owned().into());
195 };
196 let Some(oauth2) = schemes.get("oauth2").and_then(serde_json::Value::as_object) else {
197 return Err("oauth2 must be a JSON object".to_owned().into());
198 };
199 let list = oauth2
200 .get("list")
201 .and_then(serde_json::Value::as_array)
202 .map(std::vec::Vec::as_slice)
203 .unwrap_or_default();
204 let scope_strs: Vec<&str> = list.iter().filter_map(serde_json::Value::as_str).collect();
205 assert_eq!(scope_strs, ["read", "write"]);
206 Ok(())
207 }
208
209 #[test]
210 fn string_list_round_trips() -> Result<(), TestError> {
211 let list = StringList {
212 list: vec![Arc::from("a"), Arc::from("b")],
213 };
214 let json = serde_json::to_string(&list)?;
215 let parsed: StringList = serde_json::from_str(&json)?;
216 assert_eq!(parsed, list);
217 Ok(())
218 }
219}