Skip to content

Commit 047b4d5

Browse files
committed
Azure auth migration
Signed-off-by: Zhiwei Liang <zhiwei.liang@zliang.me>
1 parent e7d4dc9 commit 047b4d5

2 files changed

Lines changed: 152 additions & 11 deletions

File tree

crates/key-value-azure/src/lib.rs

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ use serde::Deserialize;
55
use spin_factor_key_value::runtime_config::spin::MakeKeyValueStore;
66

77
pub use store::{
8-
KeyValueAzureCosmos, KeyValueAzureCosmosAuthOptions, KeyValueAzureCosmosRuntimeConfigOptions,
8+
AzureCredentialKind, KeyValueAzureCosmos, KeyValueAzureCosmosAuthOptions,
9+
KeyValueAzureCosmosRuntimeConfigOptions,
910
};
1011

1112
/// A key-value store that uses Azure Cosmos as the backend.
@@ -16,7 +17,7 @@ pub struct AzureKeyValueStore {
1617
impl AzureKeyValueStore {
1718
/// Creates a new `AzureKeyValueStore`.
1819
///
19-
/// When `app_id` is provided, the store will a partition key of `$app_id/$store_name`,
20+
/// When `app_id` is provided, the store will use a partition key of `$app_id/$store_name`,
2021
/// otherwise the partition key will be `id`.
2122
pub fn new(app_id: Option<String>) -> Self {
2223
Self { app_id }
@@ -33,15 +34,32 @@ pub struct AzureCosmosKeyValueRuntimeConfig {
3334
/// The Azure Cosmos DB database.
3435
database: String,
3536
/// The Azure Cosmos DB container where data is stored.
36-
/// The CosmosDB container must be created with the default partition
37-
/// key path, /id
37+
/// The container's partition key path must be `/id` (the default) — or
38+
/// `/store_id` if the store is constructed with an `app_id`.
3839
container: String,
3940

4041
/// Optional. The Azure region the spin application is running in (or the
4142
/// closest Azure region to it), used as the proximity-sorting anchor
4243
/// for the Azure SDK's region selection. When omitted, defaults to
4344
/// East US.
4445
region: Option<String>,
46+
47+
/// Optional. When `key` is omitted, selects which Azure AD credential to
48+
/// use: "managed_identity", "workload_identity", "service_principal", or
49+
/// "developer_tools". When omitted, defaults to developer tools (Azure CLI
50+
/// / azd), intended for local development. Ignored when `key` is set.
51+
///
52+
/// "service_principal" reads `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, and
53+
/// `AZURE_CLIENT_SECRET` from the environment.
54+
///
55+
/// There is intentionally no automatic fallback between credential types;
56+
/// name the one matching your deployment.
57+
auth_type: Option<String>,
58+
59+
/// Optional. Only used with `auth_type = "managed_identity"`: the client ID
60+
/// of a user-assigned managed identity to authenticate. When omitted, the
61+
/// system-assigned identity is used. Ignored with any other `auth_type`.
62+
client_id: Option<String>,
4563
}
4664

4765
impl MakeKeyValueStore for AzureKeyValueStore {
@@ -59,7 +77,12 @@ impl MakeKeyValueStore for AzureKeyValueStore {
5977
Some(key) => KeyValueAzureCosmosAuthOptions::RuntimeConfigValues(
6078
KeyValueAzureCosmosRuntimeConfigOptions::new(key),
6179
),
62-
None => KeyValueAzureCosmosAuthOptions::DeveloperTools,
80+
None => {
81+
KeyValueAzureCosmosAuthOptions::AadCredential(AzureCredentialKind::from_auth_type(
82+
runtime_config.auth_type.as_deref(),
83+
runtime_config.client_id,
84+
)?)
85+
}
6386
};
6487
let region = runtime_config
6588
.region

crates/key-value-azure/src/store.rs

Lines changed: 124 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,130 @@ impl KeyValueAzureCosmosRuntimeConfigOptions {
4949
pub enum KeyValueAzureCosmosAuthOptions {
5050
/// Runtime Config values indicates the account and key have been specified directly
5151
RuntimeConfigValues(KeyValueAzureCosmosRuntimeConfigOptions),
52-
/// Uses DeveloperToolsCredential when the runtime config omits `key`.
52+
/// An Azure AD token credential, used when the runtime config omits `key`.
5353
///
54-
/// Athenticated via developer tools only: Azure CLI (`az login`),
55-
/// then Azure Developer CLI (`azd auth login`).
54+
/// The specific credential is chosen by the operator via the `auth_type`
55+
/// runtime-config field (defaulting to developer tools for local
56+
/// development). There is deliberately no fallback *between* credential
57+
/// types: `azure_identity` 1.0 removed `EnvironmentCredential` /
58+
/// `DefaultAzureCredential` because silently trying a different identity
59+
/// after one fails is a security footgun (Azure/azure-sdk-for-rust#2283).
60+
/// This mirrors their recommended "specific credential" pattern.
61+
AadCredential(AzureCredentialKind),
62+
}
63+
64+
/// The specific Azure AD credential to use when authenticating to Cosmos
65+
/// without an account key.
66+
///
67+
/// Each variant maps to exactly one `azure_identity` credential; the operator
68+
/// names the one matching their deployment via the `auth_type` runtime-config
69+
/// field. Modeled on `azure_identity`'s `specific_credential.rs` example.
70+
#[derive(Clone, Debug, Default, PartialEq, Eq)]
71+
pub enum AzureCredentialKind {
72+
/// Developer tools: Azure CLI (`az login`), then Azure Developer CLI
73+
/// (`azd auth login`). Intended for local development; the default when
74+
/// `auth_type` is omitted.
75+
#[default]
5676
DeveloperTools,
77+
/// Managed identity (Azure VM, App Service, or AKS with managed identity).
78+
///
79+
/// `client_id` optionally selects a *user-assigned* managed identity by its
80+
/// client ID; when `None`, the SDK uses the *system-assigned* identity.
81+
ManagedIdentity { client_id: Option<String> },
82+
/// Workload identity (AKS federated token).
83+
WorkloadIdentity,
84+
/// Service principal authenticated with a client secret. Reads
85+
/// `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, and `AZURE_CLIENT_SECRET` from the
86+
/// environment — the same variables the legacy SDK's `EnvironmentCredential`
87+
/// used, so existing deployments keep working without config changes.
88+
ServicePrincipal,
89+
}
90+
91+
impl AzureCredentialKind {
92+
/// Parses the `auth_type` runtime-config value into a credential kind.
93+
///
94+
/// `None` (the field omitted) defaults to [`AzureCredentialKind::DeveloperTools`],
95+
/// intended for local development. An unrecognized value is an error rather
96+
/// than a silent fallback.
97+
///
98+
/// `client_id` selects a user-assigned managed identity by its client ID. It
99+
/// only applies to `managed_identity`; with any other `auth_type` it is
100+
/// ignored.
101+
pub fn from_auth_type(auth_type: Option<&str>, client_id: Option<String>) -> Result<Self> {
102+
// Case-insensitive, but the value must be one of the canonical
103+
// snake_case names: a non-canonical form (e.g. a space or hyphen
104+
// separator) is rejected rather than silently normalized, so the
105+
// accepted set matches exactly what the docs and the error below list.
106+
// `client_id` is consumed only by the `managed_identity` arm; for any
107+
// other auth type it is simply dropped.
108+
match auth_type.map(|s| s.to_lowercase()).as_deref() {
109+
None => Ok(Self::default()),
110+
Some("developer_tools") => Ok(Self::DeveloperTools),
111+
Some("managed_identity") => Ok(Self::ManagedIdentity { client_id }),
112+
Some("workload_identity") => Ok(Self::WorkloadIdentity),
113+
Some("service_principal") => Ok(Self::ServicePrincipal),
114+
Some(other) => anyhow::bail!(
115+
"unknown Azure Cosmos `auth_type` {other:?}; expected one of \
116+
\"managed_identity\", \"workload_identity\", \"service_principal\", \
117+
or \"developer_tools\" (or set `key` for account-key auth)"
118+
),
119+
}
120+
}
121+
122+
/// Constructs the corresponding `azure_identity` token credential.
123+
///
124+
/// This runs the credential's own setup (which may fail — e.g. if the
125+
/// environment for workload identity or service principal is absent), so it
126+
/// is called lazily when the Cosmos client is first built.
127+
fn credential(&self) -> azure_core::Result<Arc<dyn TokenCredential>> {
128+
match self {
129+
Self::DeveloperTools => Ok(azure_identity::DeveloperToolsCredential::new(None)?),
130+
Self::ManagedIdentity { client_id } => {
131+
// Pass options only when a user-assigned client ID was given;
132+
// `None` keeps the SDK default of the system-assigned identity.
133+
let options =
134+
client_id
135+
.as_ref()
136+
.map(|id| azure_identity::ManagedIdentityCredentialOptions {
137+
user_assigned_id: Some(azure_identity::UserAssignedId::ClientId(
138+
id.clone(),
139+
)),
140+
..Default::default()
141+
});
142+
Ok(azure_identity::ManagedIdentityCredential::new(options)?)
143+
}
144+
Self::WorkloadIdentity => Ok(azure_identity::WorkloadIdentityCredential::new(None)?),
145+
Self::ServicePrincipal => {
146+
// azure_identity 1.0 removed the env-driven `EnvironmentCredential`,
147+
// so read the same variables it used and pass them to
148+
// `ClientSecretCredential` explicitly. A missing variable surfaces
149+
// here (lazily, when the client is first built) as a clear error.
150+
let tenant_id = service_principal_env("AZURE_TENANT_ID")?;
151+
let client_id = service_principal_env("AZURE_CLIENT_ID")?;
152+
let secret = service_principal_env("AZURE_CLIENT_SECRET")?;
153+
Ok(azure_identity::ClientSecretCredential::new(
154+
&tenant_id,
155+
client_id,
156+
secret.into(),
157+
None,
158+
)?)
159+
}
160+
}
161+
}
162+
}
163+
164+
/// Reads a required Service Principal environment variable, mapping a missing
165+
/// value to a clear credential error.
166+
fn service_principal_env(name: &str) -> azure_core::Result<String> {
167+
std::env::var(name).map_err(|_| {
168+
azure_core::Error::with_message(
169+
azure_core::error::ErrorKind::Credential,
170+
format!(
171+
"Azure Cosmos `service_principal` auth requires the `{name}` \
172+
environment variable to be set"
173+
),
174+
)
175+
})
57176
}
58177

59178
impl KeyValueAzureCosmos {
@@ -91,9 +210,8 @@ async fn build_cosmos_client(
91210
KeyValueAzureCosmosAuthOptions::RuntimeConfigValues(config) => {
92211
AccountReference::with_authentication_key(endpoint, Secret::from(config.key.clone()))
93212
}
94-
KeyValueAzureCosmosAuthOptions::DeveloperTools => {
95-
let credential: Arc<dyn TokenCredential> =
96-
azure_identity::DeveloperToolsCredential::new(None).map_err(log_error)?;
213+
KeyValueAzureCosmosAuthOptions::AadCredential(kind) => {
214+
let credential = kind.credential().map_err(log_error)?;
97215
AccountReference::with_credential(endpoint, credential)
98216
}
99217
};

0 commit comments

Comments
 (0)