Skip to main content

agent_core/core/auth/
cloud.rs

1//! Shared, credential-free cloud provider contracts.
2//!
3//! Provider-local modules consume these types. They intentionally contain no
4//! tokens, keys, authorization headers, absolute runtime URLs, or signing inputs.
5use serde::{Deserialize, Serialize};
6use std::{fmt, str::FromStr};
7
8use super::provider::OAuthProviderId;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
11#[serde(rename_all = "kebab-case")]
12pub enum CloudProviderId {
13    AzureOpenAi,
14    AwsBedrock,
15    GoogleVertex,
16}
17
18impl CloudProviderId {
19    pub const fn as_str(self) -> &'static str {
20        match self {
21            Self::AzureOpenAi => "azure-openai",
22            Self::AwsBedrock => "aws-bedrock",
23            Self::GoogleVertex => "google-vertex",
24        }
25    }
26    /// Whether this cloud route supports tool use. Cloud broker routes are
27    /// text-only until full tool translation exists (spec §5.5); the value is
28    /// sourced from the provider descriptor so listing and enforcement agree.
29    pub fn supports_tools(self) -> bool {
30        CLOUD_PROVIDER_DESCRIPTORS
31            .iter()
32            .find(|d| d.id == self)
33            .is_some_and(|d| d.supports_tools)
34    }
35}
36impl fmt::Display for CloudProviderId {
37    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
38        f.write_str(self.as_str())
39    }
40}
41impl FromStr for CloudProviderId {
42    type Err = String;
43    fn from_str(s: &str) -> Result<Self, Self::Err> {
44        match s {
45            "azure-openai" => Ok(Self::AzureOpenAi),
46            "aws-bedrock" => Ok(Self::AwsBedrock),
47            "google-vertex" => Ok(Self::GoogleVertex),
48            _ => Err(format!("unknown canonical cloud provider id: {s}")),
49        }
50    }
51}
52
53/// Persist a credential-free cloud route while retaining the broker's opaque context.
54/// The context is not interpreted outside the broker and contains no account identity.
55pub fn qualify_model_route(model_id: &str, context_ref: &str) -> Result<String, String> {
56    if !context_ref.starts_with("ctx-")
57        || context_ref.len() > 128
58        || !context_ref
59            .bytes()
60            .all(|b| b.is_ascii_alphanumeric() || b == b'-')
61        || model_id.contains("#synaps-context=")
62    {
63        return Err("invalid opaque cloud model context".into());
64    }
65    Ok(format!("{model_id}#synaps-context={context_ref}"))
66}
67
68pub fn split_model_route(route: &str) -> (&str, Option<&str>) {
69    route
70        .rsplit_once("#synaps-context=")
71        .map_or((route, None), |(model, context)| (model, Some(context)))
72}
73
74/// General auth identity. Existing OAuth identity remains strongly typed.
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
76pub enum ProviderId {
77    OAuth(OAuthProviderId),
78    Cloud(CloudProviderId),
79}
80impl TryFrom<&str> for ProviderId {
81    type Error = String;
82    fn try_from(s: &str) -> Result<Self, Self::Error> {
83        s.parse::<OAuthProviderId>()
84            .map(Self::OAuth)
85            .or_else(|_| s.parse::<CloudProviderId>().map(Self::Cloud))
86    }
87}
88
89/// Credential mechanism used behind the broker boundary.
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub enum AuthIdentity {
92    OAuthBearer,
93    AwsTemporaryCredentials,
94}
95impl AuthIdentity {
96    pub const fn for_provider(provider: ProviderId) -> Self {
97        match provider {
98            ProviderId::Cloud(CloudProviderId::AwsBedrock) => Self::AwsTemporaryCredentials,
99            _ => Self::OAuthBearer,
100        }
101    }
102}
103
104fn simple(value: &str, name: &str) -> Result<(), String> {
105    if value.is_empty()
106        || value.len() > 128
107        || !value
108            .bytes()
109            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
110    {
111        Err(format!("invalid {name}"))
112    } else {
113        Ok(())
114    }
115}
116fn region(value: &str) -> Result<(), String> {
117    if value.len() >= 3
118        && value
119            .bytes()
120            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
121        && value.contains('-')
122    {
123        Ok(())
124    } else {
125        Err("invalid region/location".into())
126    }
127}
128
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
130pub struct AzureOpenAiConfig {
131    pub tenant: String,
132    pub subscription_id: String,
133    pub resource_group: String,
134    pub resource_name: String,
135    pub deployment: String,
136}
137impl AzureOpenAiConfig {
138    pub fn new(
139        tenant: impl Into<String>,
140        subscription_id: impl Into<String>,
141        resource_group: impl Into<String>,
142        resource_name: impl Into<String>,
143        deployment: impl Into<String>,
144    ) -> Result<Self, String> {
145        let v = Self {
146            tenant: tenant.into(),
147            subscription_id: subscription_id.into(),
148            resource_group: resource_group.into(),
149            resource_name: resource_name.into(),
150            deployment: deployment.into(),
151        };
152        if v.tenant == "common" {
153            return Err("tenant 'common' is not allowed".into());
154        }
155        for (x, n) in [
156            (&v.tenant, "tenant"),
157            (&v.subscription_id, "subscription id"),
158            (&v.resource_group, "resource group"),
159            (&v.resource_name, "resource name"),
160            (&v.deployment, "deployment"),
161        ] {
162            simple(x, n)?;
163        }
164        Ok(v)
165    }
166}
167
168#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
169pub struct AwsBedrockConfig {
170    pub sso_start_url: String,
171    pub sso_region: String,
172    pub account_id: String,
173    pub role_name: String,
174    pub bedrock_region: String,
175}
176impl AwsBedrockConfig {
177    pub fn new(
178        sso_start_url: impl Into<String>,
179        sso_region: impl Into<String>,
180        account_id: impl Into<String>,
181        role_name: impl Into<String>,
182        bedrock_region: impl Into<String>,
183    ) -> Result<Self, String> {
184        let v = Self {
185            sso_start_url: sso_start_url.into(),
186            sso_region: sso_region.into(),
187            account_id: account_id.into(),
188            role_name: role_name.into(),
189            bedrock_region: bedrock_region.into(),
190        };
191        let url = url::Url::parse(&v.sso_start_url).map_err(|_| "invalid SSO start URL")?;
192        if url.scheme() != "https" || url.host_str().is_none() {
193            return Err("SSO start URL must be HTTPS".into());
194        }
195        region(&v.sso_region)?;
196        region(&v.bedrock_region)?;
197        if v.account_id.len() != 12 || !v.account_id.bytes().all(|b| b.is_ascii_digit()) {
198            return Err("invalid AWS account id".into());
199        }
200        simple(&v.role_name, "role name")?;
201        Ok(v)
202    }
203}
204
205#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
206pub struct GoogleVertexConfig {
207    pub project_id: String,
208    pub location: String,
209}
210impl GoogleVertexConfig {
211    pub fn new(project_id: impl Into<String>, location: impl Into<String>) -> Result<Self, String> {
212        let v = Self {
213            project_id: project_id.into(),
214            location: location.into(),
215        };
216        region(&v.location)?;
217        if v.project_id.len() < 6
218            || v.project_id.len() > 30
219            || !v
220                .project_id
221                .bytes()
222                .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
223        {
224            return Err("invalid Google project id".into());
225        }
226        Ok(v)
227    }
228}
229
230#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
231#[serde(tag = "operation", rename_all = "snake_case")]
232pub enum BrokerOperation {
233    Catalog {
234        provider: CloudProviderId,
235        context_ref: String,
236        #[serde(default)]
237        allow_stale: bool,
238    },
239    Invoke {
240        provider: CloudProviderId,
241        context_ref: String,
242        model_id: String,
243        request: InvokeRequest,
244    },
245}
246#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
247pub struct InvokeRequest {
248    pub messages: Vec<BrokerMessage>,
249    #[serde(default)]
250    pub tools: Vec<BrokerTool>,
251    pub stream: bool,
252    #[serde(default)]
253    pub options: InvokeOptions,
254}
255#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
256pub struct BrokerMessage {
257    pub role: MessageRole,
258    pub content: String,
259}
260#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
261#[serde(rename_all = "snake_case")]
262pub enum MessageRole {
263    System,
264    User,
265    Assistant,
266    Tool,
267}
268#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
269pub struct BrokerTool {
270    pub name: String,
271    pub description: String,
272    pub input_schema: serde_json::Value,
273}
274#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
275pub struct InvokeOptions {
276    pub max_output_tokens: Option<u32>,
277    pub temperature_milli: Option<u16>,
278}
279
280#[derive(Debug, Clone, Copy, PartialEq, Eq)]
281pub struct CloudProviderDescriptor {
282    pub id: CloudProviderId,
283    pub display_name: &'static str,
284    /// Cloud operations always execute behind the typed broker boundary.
285    pub typed_broker_only: bool,
286    /// Production login needs a Synaps-owned/configured public OAuth app.
287    pub registration_required: bool,
288    /// Tool-use capability. `false` = text-only route: tool-requiring modes
289    /// fail pre-flight, before any credential lookup or network access.
290    pub supports_tools: bool,
291}
292
293pub const CLOUD_PROVIDER_DESCRIPTORS: [CloudProviderDescriptor; 3] = [
294    CloudProviderDescriptor {
295        id: CloudProviderId::AzureOpenAi,
296        display_name: "Azure OpenAI",
297        typed_broker_only: true,
298        registration_required: true,
299        supports_tools: false,
300    },
301    CloudProviderDescriptor {
302        id: CloudProviderId::AwsBedrock,
303        display_name: "AWS Bedrock",
304        typed_broker_only: true,
305        registration_required: false,
306        supports_tools: false,
307    },
308    CloudProviderDescriptor {
309        id: CloudProviderId::GoogleVertex,
310        display_name: "Google Vertex",
311        typed_broker_only: true,
312        registration_required: true,
313        supports_tools: false,
314    },
315];
316
317pub fn cloud_provider_descriptors() -> &'static [CloudProviderDescriptor] {
318    &CLOUD_PROVIDER_DESCRIPTORS
319}
320
321#[cfg(test)]
322mod descriptor_tests {
323    use super::*;
324
325    #[test]
326    fn cloud_descriptors_are_complete_and_never_vend_credentials() {
327        let descriptors = cloud_provider_descriptors();
328        assert_eq!(descriptors.len(), 3);
329        assert_eq!(descriptors[0].id.as_str(), "azure-openai");
330        assert_eq!(descriptors[1].id.as_str(), "aws-bedrock");
331        assert_eq!(descriptors[2].id.as_str(), "google-vertex");
332        assert!(descriptors.iter().all(|d| d.typed_broker_only));
333        assert_eq!(
334            descriptors
335                .iter()
336                .filter(|d| d.registration_required)
337                .map(|d| d.id)
338                .collect::<Vec<_>>(),
339            vec![CloudProviderId::AzureOpenAi, CloudProviderId::GoogleVertex]
340        );
341    }
342
343    /// Spec §5.5: until full tool translation exists, every cloud route must
344    /// advertise itself as text-only (no tool support).
345    #[test]
346    fn cloud_routes_advertise_text_only() {
347        for descriptor in cloud_provider_descriptors() {
348            assert!(
349                !descriptor.supports_tools,
350                "{} must advertise text-only until tool translation lands",
351                descriptor.id
352            );
353            assert!(!descriptor.id.supports_tools());
354        }
355    }
356}