Skip to main content

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}