Skip to main content

everruns_core/
credential_schema.rs

1// Shared credential form schema
2//
3// Declarative description of the credential fields an integration needs,
4// rendered by the Settings UI and validated before saving. Shared by two
5// front doors (see specs/providers.md "Credentials"):
6//
7// - Provider drivers (`DriverDescriptor::credential_schema`) — org-scoped
8//   vendor accounts that power agent execution.
9// - Connectors (`Connector::form_schema`) — user-scoped
10//   accounts on external services used by tools.
11//
12// Multi-field credentials (Bedrock AWS keys, Microsoft Entra ID OAuth) are
13// declared as discrete typed fields rather than a single opaque password the
14// operator hand-authors as JSON. The submitted field map is assembled into a
15// single credential document (`assemble_credential_document`) that is stored in
16// the existing envelope-encrypted credential field, and parsed back into a
17// typed field map at driver-construction time (`parse_credential_document`).
18// Keeping the storage shape a JSON document means existing Bedrock/MAI rows —
19// which already store such a document — keep resolving unchanged.
20
21use std::collections::BTreeMap;
22
23use serde::Serialize;
24
25/// Describes the form fields and instructions for entering a credential.
26#[derive(Debug, Clone, Serialize)]
27#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
28pub struct CredentialFormSchema {
29    /// Input fields to render.
30    pub fields: Vec<FormField>,
31    /// Markdown instructions shown above the form (how to get the key, etc.).
32    pub instructions_markdown: String,
33}
34
35impl CredentialFormSchema {
36    /// Schema with no fields (keyless integrations, e.g. test simulators).
37    pub fn empty() -> Self {
38        Self {
39            fields: Vec::new(),
40            instructions_markdown: String::new(),
41        }
42    }
43
44    /// The common single-field API key schema.
45    pub fn api_key(instructions_markdown: impl Into<String>) -> Self {
46        Self {
47            fields: vec![FormField::password("api_key", "API Key").required()],
48            instructions_markdown: instructions_markdown.into(),
49        }
50    }
51
52    /// Whether the schema declares any mutually-exclusive credential groups
53    /// (e.g. "API key" *or* "OAuth"). When it does, at least one complete group
54    /// is required by [`CredentialFormSchema::validate`].
55    pub fn has_groups(&self) -> bool {
56        self.fields.iter().any(|f| f.group.is_some())
57    }
58
59    /// Validate a submitted field map against this schema.
60    ///
61    /// Rules:
62    /// - Every ungrouped required field must be present and non-empty.
63    /// - Fields sharing a `group` label form a mutually-exclusive alternative
64    ///   (e.g. MAI's "API key" group vs its "Entra ID OAuth" group). A group is
65    ///   *touched* when any of its fields has a value; a touched group must have
66    ///   all of its required fields filled (no partially-entered OAuth blocks).
67    /// - When the schema declares any groups, at least one group must be
68    ///   complete, so a provider cannot be saved with no credential at all.
69    ///
70    /// Returns the list of human-readable validation errors; empty means valid.
71    /// Unknown keys are ignored — drivers read only the fields they declare.
72    pub fn validate(&self, fields: &BTreeMap<String, String>) -> Vec<String> {
73        let filled = |name: &str| fields.get(name).is_some_and(|v| !v.trim().is_empty());
74        let mut errors = Vec::new();
75
76        // Ungrouped required fields are always mandatory.
77        for field in self.fields.iter().filter(|f| f.group.is_none()) {
78            if field.required && !filled(&field.name) {
79                errors.push(format!("{} is required.", field.label));
80            }
81        }
82
83        if !self.has_groups() {
84            return errors;
85        }
86
87        // Grouped fields: collect group labels in declaration order.
88        let mut group_labels: Vec<&str> = Vec::new();
89        for field in &self.fields {
90            if let Some(group) = field.group.as_deref()
91                && !group_labels.contains(&group)
92            {
93                group_labels.push(group);
94            }
95        }
96
97        let mut any_group_complete = false;
98        for label in group_labels {
99            let group_fields: Vec<&FormField> = self
100                .fields
101                .iter()
102                .filter(|f| f.group.as_deref() == Some(label))
103                .collect();
104            let touched = group_fields.iter().any(|f| filled(&f.name));
105            let complete = group_fields.iter().all(|f| !f.required || filled(&f.name));
106            if touched && !complete {
107                for field in group_fields
108                    .iter()
109                    .filter(|f| f.required && !filled(&f.name))
110                {
111                    errors.push(format!("{} ({}) is required.", field.label, label));
112                }
113            }
114            if complete && touched {
115                any_group_complete = true;
116            }
117        }
118
119        if !any_group_complete && errors.is_empty() {
120            errors.push("Provide credentials for one of the available methods.".to_string());
121        }
122
123        errors
124    }
125}
126
127/// A single form field.
128#[derive(Debug, Clone, Default, Serialize)]
129#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
130pub struct FormField {
131    /// Field name used as the key when submitting (e.g. "api_key").
132    pub name: String,
133    /// Label shown next to the input.
134    pub label: String,
135    /// Input type.
136    pub field_type: FieldType,
137    /// Whether the field is required.
138    pub required: bool,
139    /// Placeholder text inside the input.
140    #[serde(skip_serializing_if = "Option::is_none")]
141    pub placeholder: Option<String>,
142    /// Help text shown below the input.
143    #[serde(skip_serializing_if = "Option::is_none")]
144    pub help_text: Option<String>,
145    /// Default value the UI pre-fills (e.g. an OAuth scope or AWS region). The
146    /// stored credential omits unfilled optional fields, so drivers still apply
147    /// their own defaults; this only seeds the form input.
148    #[serde(skip_serializing_if = "Option::is_none")]
149    pub default_value: Option<String>,
150    /// Mutually-exclusive group label this field belongs to. Fields sharing a
151    /// label are one alternative credential method (e.g. "API key" vs "OAuth");
152    /// ungrouped fields are always part of the credential. `None` for the
153    /// common single-method case.
154    #[serde(skip_serializing_if = "Option::is_none")]
155    pub group: Option<String>,
156}
157
158impl FormField {
159    /// A field of the given type with no extra metadata.
160    fn new(name: impl Into<String>, label: impl Into<String>, field_type: FieldType) -> Self {
161        Self {
162            name: name.into(),
163            label: label.into(),
164            field_type,
165            required: false,
166            placeholder: None,
167            help_text: None,
168            default_value: None,
169            group: None,
170        }
171    }
172
173    /// A masked password/secret field.
174    pub fn password(name: impl Into<String>, label: impl Into<String>) -> Self {
175        Self::new(name, label, FieldType::Password)
176    }
177
178    /// A plain text field.
179    pub fn text(name: impl Into<String>, label: impl Into<String>) -> Self {
180        Self::new(name, label, FieldType::Text)
181    }
182
183    /// Mark the field as required.
184    pub fn required(mut self) -> Self {
185        self.required = true;
186        self
187    }
188
189    /// Set placeholder text.
190    pub fn with_placeholder(mut self, placeholder: impl Into<String>) -> Self {
191        self.placeholder = Some(placeholder.into());
192        self
193    }
194
195    /// Set help text shown below the input.
196    pub fn with_help(mut self, help_text: impl Into<String>) -> Self {
197        self.help_text = Some(help_text.into());
198        self
199    }
200
201    /// Set a default value the form pre-fills.
202    pub fn with_default(mut self, default_value: impl Into<String>) -> Self {
203        self.default_value = Some(default_value.into());
204        self
205    }
206
207    /// Assign the field to a mutually-exclusive credential group.
208    pub fn in_group(mut self, group: impl Into<String>) -> Self {
209        self.group = Some(group.into());
210        self
211    }
212}
213
214/// Input field type for rendering.
215#[derive(Debug, Clone, Copy, Default, Serialize)]
216#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
217#[serde(rename_all = "snake_case")]
218pub enum FieldType {
219    /// Masked password/secret input.
220    #[default]
221    Password,
222    /// Plain text input.
223    Text,
224    /// URL input.
225    Url,
226}
227
228/// Assemble a submitted credential field map into the single document string
229/// that is envelope-encrypted and stored.
230///
231/// Empty values are dropped so unfilled optional fields are not persisted. A
232/// lone `api_key` field is stored as the raw key (the long-standing simple
233/// shape), so single-key providers and the dev env-var fallback keep their
234/// exact storage format. Any other field set is stored as a deterministic JSON
235/// object keyed by field name — the shape Bedrock and MAI already use.
236///
237/// Returns `None` when nothing was supplied (no credential to store).
238pub fn assemble_credential_document(fields: &BTreeMap<String, String>) -> Option<String> {
239    let non_empty: BTreeMap<&str, &str> = fields
240        .iter()
241        .filter(|(_, v)| !v.trim().is_empty())
242        .map(|(k, v)| (k.as_str(), v.as_str()))
243        .collect();
244
245    match non_empty.len() {
246        0 => None,
247        1 if non_empty.contains_key("api_key") => Some(non_empty["api_key"].to_string()),
248        _ => Some(serde_json::to_string(&non_empty).expect("string map serializes")),
249    }
250}
251
252/// Parse a stored credential document back into a typed field map.
253///
254/// A JSON object of string values (Bedrock/MAI multi-field documents) parses
255/// into its fields. Anything else — a raw API key, or a legacy non-JSON
256/// credential — is treated as the `api_key` field, so existing single-key
257/// providers keep resolving without re-encryption.
258pub fn parse_credential_document(document: Option<&str>) -> BTreeMap<String, String> {
259    let Some(document) = document else {
260        return BTreeMap::new();
261    };
262
263    if let Ok(serde_json::Value::Object(map)) = serde_json::from_str::<serde_json::Value>(document)
264    {
265        let mut fields = BTreeMap::new();
266        for (key, value) in map {
267            if let serde_json::Value::String(s) = value {
268                fields.insert(key, s);
269            }
270        }
271        // A JSON object that carried no string fields is not a credential
272        // document we understand; fall back to treating it as an opaque key.
273        if !fields.is_empty() {
274            return fields;
275        }
276    }
277
278    BTreeMap::from([("api_key".to_string(), document.to_string())])
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    fn map(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
286        pairs
287            .iter()
288            .map(|(k, v)| (k.to_string(), v.to_string()))
289            .collect()
290    }
291
292    #[test]
293    fn assemble_lone_api_key_stays_raw() {
294        let doc = assemble_credential_document(&map(&[("api_key", "sk-123")]));
295        assert_eq!(doc.as_deref(), Some("sk-123"));
296    }
297
298    #[test]
299    fn assemble_multi_field_is_json_and_drops_empty() {
300        let doc = assemble_credential_document(&map(&[
301            ("access_key_id", "AKID"),
302            ("secret_access_key", "SECRET"),
303            ("region", ""),
304        ]))
305        .expect("doc");
306        let parsed: serde_json::Value = serde_json::from_str(&doc).unwrap();
307        assert_eq!(parsed["access_key_id"], "AKID");
308        assert_eq!(parsed["secret_access_key"], "SECRET");
309        assert!(parsed.get("region").is_none(), "empty fields dropped");
310    }
311
312    #[test]
313    fn assemble_nothing_is_none() {
314        assert!(assemble_credential_document(&map(&[("api_key", "  ")])).is_none());
315        assert!(assemble_credential_document(&BTreeMap::new()).is_none());
316    }
317
318    #[test]
319    fn parse_round_trips_multi_field() {
320        let doc = assemble_credential_document(&map(&[
321            ("access_key_id", "AKID"),
322            ("secret_access_key", "SECRET"),
323        ]))
324        .unwrap();
325        let fields = parse_credential_document(Some(&doc));
326        assert_eq!(fields["access_key_id"], "AKID");
327        assert_eq!(fields["secret_access_key"], "SECRET");
328    }
329
330    #[test]
331    fn parse_raw_key_becomes_api_key_field() {
332        let fields = parse_credential_document(Some("sk-raw-key"));
333        assert_eq!(fields["api_key"], "sk-raw-key");
334    }
335
336    #[test]
337    fn parse_legacy_json_oauth_document() {
338        // Existing MAI rows stored OAuth as a JSON document; they must still
339        // parse into discrete fields after the migration to typed credentials.
340        let legacy = r#"{"tenant_id":"t","client_id":"c","client_secret":"s"}"#;
341        let fields = parse_credential_document(Some(legacy));
342        assert_eq!(fields["tenant_id"], "t");
343        assert_eq!(fields["client_secret"], "s");
344    }
345
346    #[test]
347    fn parse_none_is_empty() {
348        assert!(parse_credential_document(None).is_empty());
349    }
350
351    #[test]
352    fn validate_required_field_missing() {
353        let schema = CredentialFormSchema::api_key("");
354        let errors = schema.validate(&BTreeMap::new());
355        assert_eq!(errors.len(), 1);
356        assert!(errors[0].contains("API Key"));
357    }
358
359    #[test]
360    fn validate_grouped_either_or() {
361        // MAI-style schema: an API-key group OR an OAuth group.
362        let schema = CredentialFormSchema {
363            fields: vec![
364                FormField::password("api_key", "API Key")
365                    .required()
366                    .in_group("API key"),
367                FormField::text("tenant_id", "Tenant ID")
368                    .required()
369                    .in_group("OAuth"),
370                FormField::text("client_id", "Client ID")
371                    .required()
372                    .in_group("OAuth"),
373                FormField::password("client_secret", "Client Secret")
374                    .required()
375                    .in_group("OAuth"),
376            ],
377            instructions_markdown: String::new(),
378        };
379
380        // Neither group filled → error.
381        assert!(!schema.validate(&BTreeMap::new()).is_empty());
382        // API key alone → valid.
383        assert!(schema.validate(&map(&[("api_key", "k")])).is_empty());
384        // Full OAuth group → valid.
385        assert!(
386            schema
387                .validate(&map(&[
388                    ("tenant_id", "t"),
389                    ("client_id", "c"),
390                    ("client_secret", "s"),
391                ]))
392                .is_empty()
393        );
394        // Partial OAuth group → error naming the missing fields.
395        let errors = schema.validate(&map(&[("tenant_id", "t")]));
396        assert!(errors.iter().any(|e| e.contains("Client ID")));
397        assert!(errors.iter().any(|e| e.contains("Client Secret")));
398    }
399}