mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-11-09 09:15:23 +08:00
4629 lines
171 KiB
Rust
4629 lines
171 KiB
Rust
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
//! Provider credential refresh state.
|
|
|
|
#![allow(clippy::result_large_err)]
|
|
|
|
use crate::credentials::RefreshMaterialScope;
|
|
use crate::persistence::{
|
|
ObjectListQuery, ObjectType, PersistenceError, Store, WriteCondition, current_time_ms,
|
|
};
|
|
use openshell_core::ObjectWorkspace;
|
|
use openshell_core::proto::{
|
|
CredentialHandle, Provider, ProviderCredentialRefreshRecoveryAction,
|
|
ProviderCredentialRefreshStatus, ProviderCredentialRefreshStrategy,
|
|
};
|
|
use openshell_core::{ObjectId, ObjectName};
|
|
use prost::Message;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::HashMap;
|
|
use std::time::Duration;
|
|
use tonic::{Code, Status};
|
|
use tracing::{info, warn};
|
|
|
|
use crate::storage_proto::{
|
|
StoredProviderCredentialRefreshStateV2 as StoredProviderCredentialRefreshState,
|
|
StoredRefreshMaterialDeletion,
|
|
};
|
|
|
|
const DEFAULT_REFRESH_BEFORE_SECONDS: i64 = 300;
|
|
const EXPIRATION_PRESENT_ANNOTATION: &str = "openshell.nvidia.com/refresh-expiration-present";
|
|
const DEFAULT_MAX_LIFETIME_SECONDS: i64 = 3600;
|
|
const REFRESH_ERROR_RETRY_SECONDS: i64 = 60;
|
|
const REFRESH_CONFIGURATION_RETRY_SECONDS: i64 = 60 * 60;
|
|
const MAX_OAUTH_ERROR_RESPONSE_BYTES: usize = 8 * 1024;
|
|
|
|
pub fn refresh_material_scope(
|
|
state: &StoredProviderCredentialRefreshState,
|
|
) -> RefreshMaterialScope<'_> {
|
|
RefreshMaterialScope {
|
|
provider_name: &state.provider_name,
|
|
workspace: state.object_workspace(),
|
|
provider_id: &state.provider_id,
|
|
credential_key: &state.credential_key,
|
|
}
|
|
}
|
|
|
|
impl ObjectType for StoredProviderCredentialRefreshState {
|
|
fn object_type() -> &'static str {
|
|
"provider_credential_refresh_state"
|
|
}
|
|
}
|
|
|
|
pub fn refresh_state_name(provider_id: &str, credential_key: &str) -> String {
|
|
let mut key = String::with_capacity(credential_key.len() * 2);
|
|
for byte in credential_key.as_bytes() {
|
|
use std::fmt::Write as _;
|
|
write!(&mut key, "{byte:02x}").expect("writing to String cannot fail");
|
|
}
|
|
format!("provider-refresh-{provider_id}-{key}")
|
|
}
|
|
|
|
/// Return the durable authorization epoch for one configured refresh grant.
|
|
///
|
|
/// Records created before the explicit epoch field was introduced use their
|
|
/// gateway-generated object ID as a stable migration epoch. An explicit
|
|
/// reconfiguration writes a new random epoch while preserving object metadata,
|
|
/// so reauthorization still revokes handles derived from the legacy value.
|
|
pub fn effective_authorization_epoch(
|
|
state: &StoredProviderCredentialRefreshState,
|
|
) -> Result<&str, Status> {
|
|
if !state.authorization_epoch.is_empty() {
|
|
return Ok(&state.authorization_epoch);
|
|
}
|
|
state
|
|
.metadata
|
|
.as_ref()
|
|
.map(|metadata| metadata.id.as_str())
|
|
.filter(|id| !id.is_empty())
|
|
.ok_or_else(|| {
|
|
Status::failed_precondition("provider refresh state has no authorization epoch")
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub async fn put_refresh_state(
|
|
store: &Store,
|
|
state: &StoredProviderCredentialRefreshState,
|
|
) -> Result<(), Status> {
|
|
store
|
|
.put_scoped_message(state, &state.provider_id)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("persist provider refresh state failed: {e}")))
|
|
}
|
|
|
|
/// Atomically claim a new provider-and-credential refresh identity.
|
|
///
|
|
/// The refresh name is unique within a workspace. A concurrent creator must
|
|
/// lose instead of overwriting the winner so its caller can delete any secret
|
|
/// material staged before this write.
|
|
pub async fn create_refresh_state(
|
|
store: &Store,
|
|
state: &StoredProviderCredentialRefreshState,
|
|
) -> Result<(), Status> {
|
|
match store
|
|
.create_scoped(
|
|
StoredProviderCredentialRefreshState::object_type(),
|
|
state.object_id(),
|
|
state.object_name(),
|
|
state.object_workspace(),
|
|
&state.provider_id,
|
|
&state.encode_to_vec(),
|
|
None,
|
|
)
|
|
.await
|
|
{
|
|
Ok(_) => Ok(()),
|
|
Err(PersistenceError::UniqueViolation { .. }) => Err(Status::aborted(
|
|
"provider refresh was concurrently configured",
|
|
)),
|
|
Err(err) => Err(Status::internal(format!(
|
|
"create provider refresh state failed: {err}"
|
|
))),
|
|
}
|
|
}
|
|
|
|
/// Persist an updated refresh state only if the row still exists with the
|
|
/// generation read at the start of the rotation.
|
|
///
|
|
/// Uses a version-matched UPDATE, which never inserts — so a refresh deleted
|
|
/// while an STS (or OAuth) request was in flight is not resurrected, and its
|
|
/// stored source-credential material is not recreated (CWE-362). Returns the new
|
|
/// resource version when persisted, or `None` when the refresh was deleted or
|
|
/// superseded by a concurrent write (in which case nothing was written).
|
|
async fn persist_refresh_state_if_current(
|
|
store: &Store,
|
|
state: &StoredProviderCredentialRefreshState,
|
|
expected_version: u64,
|
|
) -> Result<Option<u64>, Status> {
|
|
match store
|
|
.put_if(
|
|
StoredProviderCredentialRefreshState::object_type(),
|
|
state.object_id(),
|
|
state.object_name(),
|
|
state.object_workspace(),
|
|
&state.encode_to_vec(),
|
|
None,
|
|
WriteCondition::MatchResourceVersion(expected_version),
|
|
)
|
|
.await
|
|
{
|
|
Ok(result) => Ok(Some(result.resource_version)),
|
|
// The version-matched UPDATE matched no row: the refresh was deleted
|
|
// (current version `None`) or superseded by a concurrent write (a
|
|
// different current version). Either way nothing was written.
|
|
Err(PersistenceError::Conflict { .. }) => Ok(None),
|
|
Err(e) => Err(Status::internal(format!(
|
|
"persist provider refresh state failed: {e}"
|
|
))),
|
|
}
|
|
}
|
|
|
|
pub async fn replace_refresh_state_if_current(
|
|
store: &Store,
|
|
state: &StoredProviderCredentialRefreshState,
|
|
expected_version: u64,
|
|
) -> Result<bool, Status> {
|
|
Ok(
|
|
persist_refresh_state_if_current(store, state, expected_version)
|
|
.await?
|
|
.is_some(),
|
|
)
|
|
}
|
|
|
|
pub async fn list_refresh_states_for_provider(
|
|
store: &Store,
|
|
provider_id: &str,
|
|
) -> Result<Vec<StoredProviderCredentialRefreshState>, Status> {
|
|
store
|
|
.collect_messages(ObjectListQuery::Scope(provider_id))
|
|
.await
|
|
.map_err(|e| Status::internal(format!("list provider refresh states failed: {e}")))
|
|
}
|
|
|
|
pub async fn list_all_refresh_states(
|
|
store: &Store,
|
|
) -> Result<Vec<StoredProviderCredentialRefreshState>, Status> {
|
|
store
|
|
.collect_messages(ObjectListQuery::AllWorkspaces)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("list provider refresh states failed: {e}")))
|
|
}
|
|
|
|
pub async fn get_refresh_state(
|
|
store: &Store,
|
|
workspace: &str,
|
|
provider_id: &str,
|
|
credential_key: &str,
|
|
) -> Result<Option<StoredProviderCredentialRefreshState>, Status> {
|
|
let name = refresh_state_name(provider_id, credential_key);
|
|
store
|
|
.get_message_by_name::<StoredProviderCredentialRefreshState>(workspace, &name)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("fetch provider refresh state failed: {e}")))
|
|
}
|
|
|
|
pub async fn delete_refresh_state_with_credentials(
|
|
store: &Store,
|
|
credentials: &crate::credentials::CredentialRuntime,
|
|
workspace: &str,
|
|
provider_id: &str,
|
|
credential_key: &str,
|
|
) -> Result<bool, Status> {
|
|
let Some(state) = get_refresh_state(store, workspace, provider_id, credential_key).await?
|
|
else {
|
|
return Ok(false);
|
|
};
|
|
delete_observed_refresh_state_with_credentials(store, credentials, state).await?;
|
|
Ok(true)
|
|
}
|
|
|
|
/// Delete the observed refresh identity without resolving its name again.
|
|
pub async fn delete_observed_refresh_state_with_credentials(
|
|
store: &Store,
|
|
credentials: &crate::credentials::CredentialRuntime,
|
|
mut state: StoredProviderCredentialRefreshState,
|
|
) -> Result<(), Status> {
|
|
let mut version = state
|
|
.metadata
|
|
.as_ref()
|
|
.map_or(0, |metadata| metadata.resource_version);
|
|
if state
|
|
.metadata
|
|
.as_ref()
|
|
.is_some_and(|metadata| metadata.deletion_time.is_none())
|
|
{
|
|
if let Some(metadata) = state.metadata.as_mut() {
|
|
metadata.deletion_time =
|
|
openshell_core::time::timestamp_from_millis(current_time_ms()).ok();
|
|
}
|
|
state.authorization_epoch = uuid::Uuid::new_v4().to_string();
|
|
state.status = "deleting".to_string();
|
|
state.next_refresh_at_ms = i64::MAX;
|
|
let Some(current_version) =
|
|
persist_refresh_state_if_current(store, &state, version).await?
|
|
else {
|
|
if store
|
|
.get_message::<StoredProviderCredentialRefreshState>(state.object_id())
|
|
.await
|
|
.map_err(|err| {
|
|
Status::internal(format!("fetch provider refresh state failed: {err}"))
|
|
})?
|
|
.is_none()
|
|
{
|
|
return Ok(());
|
|
}
|
|
return Err(Status::aborted(
|
|
"provider refresh was concurrently modified during deletion",
|
|
));
|
|
};
|
|
version = current_version;
|
|
if let Some(metadata) = state.metadata.as_mut() {
|
|
metadata.resource_version = version;
|
|
}
|
|
}
|
|
|
|
delete_pending_secret_handles(credentials, &state).await?;
|
|
credentials
|
|
.delete_refresh_material_handles(
|
|
refresh_material_scope(&state),
|
|
&state.secret_material_handles,
|
|
)
|
|
.await?;
|
|
store
|
|
.delete_if(
|
|
StoredProviderCredentialRefreshState::object_type(),
|
|
state.object_id(),
|
|
version,
|
|
)
|
|
.await
|
|
.map_err(|err| match err {
|
|
PersistenceError::Conflict { .. } => {
|
|
Status::aborted("provider refresh was concurrently modified during deletion")
|
|
}
|
|
other => Status::internal(format!("delete provider refresh state failed: {other}")),
|
|
})?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn delete_refresh_states_for_provider_with_credentials(
|
|
store: &Store,
|
|
credentials: &crate::credentials::CredentialRuntime,
|
|
provider_id: &str,
|
|
) -> Result<u64, Status> {
|
|
let states = list_refresh_states_for_provider(store, provider_id).await?;
|
|
let mut deleted = 0;
|
|
for state in &states {
|
|
if delete_refresh_state_with_credentials(
|
|
store,
|
|
credentials,
|
|
state.object_workspace(),
|
|
provider_id,
|
|
&state.credential_key,
|
|
)
|
|
.await?
|
|
{
|
|
deleted += 1;
|
|
}
|
|
}
|
|
Ok(deleted)
|
|
}
|
|
|
|
pub fn refresh_status_from_state(
|
|
state: &StoredProviderCredentialRefreshState,
|
|
) -> ProviderCredentialRefreshStatus {
|
|
ProviderCredentialRefreshStatus {
|
|
provider: state.provider_name.clone(),
|
|
provider_id: state.provider_id.clone(),
|
|
credential_key: state.credential_key.clone(),
|
|
strategy: state.strategy,
|
|
status: state.status.clone(),
|
|
expiration_time: if refresh_has_expiration(state) {
|
|
openshell_core::time::timestamp_from_millis(state.expires_at_ms).ok()
|
|
} else {
|
|
None
|
|
},
|
|
next_refresh_time: if state.next_refresh_at_ms == i64::MAX {
|
|
None
|
|
} else {
|
|
openshell_core::time::optional_timestamp_from_legacy_millis(state.next_refresh_at_ms)
|
|
.ok()
|
|
.flatten()
|
|
},
|
|
last_refresh_time: openshell_core::time::optional_timestamp_from_legacy_millis(
|
|
state.last_refresh_at_ms,
|
|
)
|
|
.ok()
|
|
.flatten(),
|
|
last_error: state.last_error.clone(),
|
|
recovery_action: state.recovery_action,
|
|
failure_code: state.failure_code.clone(),
|
|
provider_error_subtype: state.provider_error_subtype.clone(),
|
|
last_error_time: openshell_core::time::optional_timestamp_from_legacy_millis(
|
|
state.last_error_at_ms,
|
|
)
|
|
.ok()
|
|
.flatten(),
|
|
}
|
|
}
|
|
|
|
/// Whether a refresh expiration was explicitly provided.
|
|
///
|
|
/// Legacy records infer presence from a nonzero millisecond value. New records
|
|
/// use a private metadata annotation only for the ambiguous Unix epoch value so
|
|
/// the frozen storage protobuf schema does not need to change.
|
|
pub fn refresh_has_expiration(state: &StoredProviderCredentialRefreshState) -> bool {
|
|
state.expires_at_ms != 0
|
|
|| state.metadata.as_ref().is_some_and(|metadata| {
|
|
metadata
|
|
.annotations
|
|
.get(EXPIRATION_PRESENT_ANNOTATION)
|
|
.is_some_and(|value| value == "true")
|
|
})
|
|
}
|
|
|
|
pub fn set_refresh_expiration_presence(
|
|
state: &mut StoredProviderCredentialRefreshState,
|
|
present: bool,
|
|
) {
|
|
let Some(metadata) = state.metadata.as_mut() else {
|
|
return;
|
|
};
|
|
if present && state.expires_at_ms == 0 {
|
|
metadata.annotations.insert(
|
|
EXPIRATION_PRESENT_ANNOTATION.to_string(),
|
|
"true".to_string(),
|
|
);
|
|
} else {
|
|
metadata.annotations.remove(EXPIRATION_PRESENT_ANNOTATION);
|
|
}
|
|
}
|
|
|
|
pub struct NewRefreshStateConfig {
|
|
pub strategy: ProviderCredentialRefreshStrategy,
|
|
pub material: HashMap<String, String>,
|
|
pub secret_material_keys: Vec<String>,
|
|
pub expires_at_ms: i64,
|
|
pub token_url: String,
|
|
pub scopes: Vec<String>,
|
|
pub refresh_before: Option<prost_types::Duration>,
|
|
pub max_lifetime: Option<prost_types::Duration>,
|
|
/// Resolved semantic output id -> concrete env key for credentials this
|
|
/// refresh co-mints beyond its primary. Pinned from the profile's
|
|
/// `additional_outputs` at configure time.
|
|
pub additional_output_keys: HashMap<String, String>,
|
|
}
|
|
|
|
#[allow(clippy::unnecessary_wraps)]
|
|
pub fn new_refresh_state(
|
|
provider: &Provider,
|
|
workspace: &str,
|
|
credential_key: &str,
|
|
config: NewRefreshStateConfig,
|
|
) -> Result<StoredProviderCredentialRefreshState, Status> {
|
|
if let Some(value) = config.refresh_before.as_ref() {
|
|
openshell_core::time::duration_to_std(value)
|
|
.map_err(|error| Status::invalid_argument(error.to_string()))?;
|
|
}
|
|
if let Some(value) = config.max_lifetime.as_ref() {
|
|
let duration = openshell_core::time::duration_to_std(value)
|
|
.map_err(|error| Status::invalid_argument(error.to_string()))?;
|
|
if duration.is_zero() {
|
|
return Err(Status::invalid_argument(
|
|
"max_lifetime must be greater than zero when present",
|
|
));
|
|
}
|
|
}
|
|
let provider_id = provider.object_id().to_string();
|
|
let provider_name = provider.object_name().to_string();
|
|
let now_ms = current_time_ms();
|
|
let next_refresh_at_ms =
|
|
next_refresh_at_ms(config.expires_at_ms, config.refresh_before.as_ref());
|
|
Ok(StoredProviderCredentialRefreshState {
|
|
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
|
id: uuid::Uuid::new_v4().to_string(),
|
|
name: refresh_state_name(&provider_id, credential_key),
|
|
created_time: openshell_core::time::timestamp_from_millis(now_ms).ok(),
|
|
labels: HashMap::new(),
|
|
resource_version: 0,
|
|
annotations: HashMap::new(),
|
|
workspace: workspace.to_string(),
|
|
deletion_time: None,
|
|
}),
|
|
provider_id,
|
|
provider_name,
|
|
credential_key: credential_key.to_string(),
|
|
strategy: config.strategy as i32,
|
|
material: config.material,
|
|
secret_material_keys: config.secret_material_keys,
|
|
expires_at_ms: config.expires_at_ms,
|
|
next_refresh_at_ms,
|
|
last_refresh_at_ms: 0,
|
|
status: "configured".to_string(),
|
|
last_error: String::new(),
|
|
token_url: config.token_url,
|
|
scopes: config.scopes,
|
|
refresh_before: config.refresh_before,
|
|
max_lifetime: config.max_lifetime,
|
|
additional_output_keys: config.additional_output_keys,
|
|
authorization_epoch: uuid::Uuid::new_v4().to_string(),
|
|
secret_material_handles: HashMap::new(),
|
|
pending_secret_deletions: Vec::new(),
|
|
recovery_action: ProviderCredentialRefreshRecoveryAction::Unspecified as i32,
|
|
failure_code: String::new(),
|
|
provider_error_subtype: String::new(),
|
|
last_error_at_ms: 0,
|
|
})
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct MintedCredential {
|
|
access_token: String,
|
|
expires_at_ms: i64,
|
|
refresh_token: Option<String>,
|
|
additional_credentials: HashMap<String, String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct TokenResponse {
|
|
access_token: String,
|
|
expires_in: Option<i64>,
|
|
refresh_token: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct OAuthErrorResponse {
|
|
error: String,
|
|
#[serde(default, deserialize_with = "deserialize_optional_oauth_string")]
|
|
error_subtype: Option<String>,
|
|
}
|
|
|
|
fn deserialize_optional_oauth_string<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
|
|
where
|
|
D: serde::Deserializer<'de>,
|
|
{
|
|
let value = serde_json::Value::deserialize(deserializer)?;
|
|
Ok(value.as_str().map(str::to_owned))
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
enum OAuthGrantKind {
|
|
UserRefreshToken,
|
|
NonInteractive,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct RefreshFailure {
|
|
status: Status,
|
|
recovery_action: ProviderCredentialRefreshRecoveryAction,
|
|
failure_code: &'static str,
|
|
provider_error_subtype: Option<&'static str>,
|
|
retry_schedule: RefreshRetrySchedule,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
enum RefreshRetrySchedule {
|
|
Short,
|
|
Configuration,
|
|
Parked,
|
|
}
|
|
|
|
impl RefreshFailure {
|
|
fn retryable(status: Status, failure_code: &'static str) -> Self {
|
|
Self {
|
|
status,
|
|
recovery_action: ProviderCredentialRefreshRecoveryAction::Retry,
|
|
failure_code,
|
|
provider_error_subtype: None,
|
|
retry_schedule: RefreshRetrySchedule::Short,
|
|
}
|
|
}
|
|
|
|
fn investigate(status: Status, failure_code: &'static str) -> Self {
|
|
Self {
|
|
status,
|
|
recovery_action: ProviderCredentialRefreshRecoveryAction::Investigate,
|
|
failure_code,
|
|
provider_error_subtype: None,
|
|
retry_schedule: RefreshRetrySchedule::Short,
|
|
}
|
|
}
|
|
|
|
fn reauthorize(
|
|
status: Status,
|
|
failure_code: &'static str,
|
|
provider_error_subtype: Option<&'static str>,
|
|
) -> Self {
|
|
Self {
|
|
status,
|
|
recovery_action: ProviderCredentialRefreshRecoveryAction::Reauthorize,
|
|
failure_code,
|
|
provider_error_subtype,
|
|
retry_schedule: RefreshRetrySchedule::Parked,
|
|
}
|
|
}
|
|
|
|
fn fix_configuration(status: Status, failure_code: &'static str) -> Self {
|
|
Self {
|
|
status,
|
|
recovery_action: ProviderCredentialRefreshRecoveryAction::FixConfiguration,
|
|
failure_code,
|
|
provider_error_subtype: None,
|
|
retry_schedule: RefreshRetrySchedule::Configuration,
|
|
}
|
|
}
|
|
|
|
fn fix_configuration_with_subtype(
|
|
status: Status,
|
|
failure_code: &'static str,
|
|
provider_error_subtype: &'static str,
|
|
) -> Self {
|
|
Self {
|
|
status,
|
|
recovery_action: ProviderCredentialRefreshRecoveryAction::FixConfiguration,
|
|
failure_code,
|
|
provider_error_subtype: Some(provider_error_subtype),
|
|
retry_schedule: RefreshRetrySchedule::Configuration,
|
|
}
|
|
}
|
|
|
|
fn into_status(self) -> Status {
|
|
self.status
|
|
}
|
|
|
|
fn from_status(status: &Status) -> Self {
|
|
Self::from(Status::new(status.code(), status.message().to_string()))
|
|
}
|
|
}
|
|
|
|
impl From<Status> for RefreshFailure {
|
|
fn from(status: Status) -> Self {
|
|
match status.code() {
|
|
Code::InvalidArgument
|
|
| Code::FailedPrecondition
|
|
| Code::PermissionDenied
|
|
| Code::Unauthenticated => {
|
|
Self::fix_configuration(status, "refresh_configuration_invalid")
|
|
}
|
|
Code::Unavailable
|
|
| Code::DeadlineExceeded
|
|
| Code::ResourceExhausted
|
|
| Code::Aborted
|
|
| Code::Internal => Self::retryable(status, "refresh_failed"),
|
|
_ => Self::investigate(status, "refresh_failed"),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct GoogleServiceAccountClaims<'a> {
|
|
iss: &'a str,
|
|
scope: String,
|
|
aud: &'a str,
|
|
iat: i64,
|
|
exp: i64,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
sub: Option<&'a str>,
|
|
}
|
|
|
|
pub fn next_refresh_at_ms(
|
|
expires_at_ms: i64,
|
|
refresh_before: Option<&prost_types::Duration>,
|
|
) -> i64 {
|
|
let refresh_before = refresh_before
|
|
.and_then(|value| openshell_core::time::duration_to_std(value).ok())
|
|
.unwrap_or_else(|| {
|
|
Duration::from_secs(u64::try_from(DEFAULT_REFRESH_BEFORE_SECONDS).unwrap_or(u64::MAX))
|
|
});
|
|
let refresh_before_ms = refresh_before.as_millis().saturating_add(u128::from(
|
|
!refresh_before.subsec_nanos().is_multiple_of(1_000_000),
|
|
));
|
|
let refresh_before_ms = i64::try_from(refresh_before_ms).unwrap_or(i64::MAX);
|
|
if expires_at_ms > 0 {
|
|
return expires_at_ms.saturating_sub(refresh_before_ms);
|
|
}
|
|
0
|
|
}
|
|
|
|
// OAuth/JWT expiry fields use whole seconds. Round an exact positive profile
|
|
// duration up only at that protocol boundary so a fractional lifetime never
|
|
// collapses into the absent/default sentinel.
|
|
fn max_lifetime_seconds(state: &StoredProviderCredentialRefreshState) -> i64 {
|
|
let Some(value) = state.max_lifetime.as_ref() else {
|
|
return DEFAULT_MAX_LIFETIME_SECONDS;
|
|
};
|
|
let Ok(duration) = openshell_core::time::duration_to_std(value) else {
|
|
return DEFAULT_MAX_LIFETIME_SECONDS;
|
|
};
|
|
let seconds = duration
|
|
.as_secs()
|
|
.saturating_add(u64::from(duration.subsec_nanos() != 0));
|
|
i64::try_from(seconds).unwrap_or(i64::MAX).max(1)
|
|
}
|
|
|
|
fn seconds_until_ms(now_ms: i64, target_ms: i64) -> i64 {
|
|
if target_ms <= 0 {
|
|
return 0;
|
|
}
|
|
target_ms.saturating_sub(now_ms).max(0) / 1000
|
|
}
|
|
|
|
pub fn refresh_strategy_name(strategy: i32) -> &'static str {
|
|
match ProviderCredentialRefreshStrategy::try_from(strategy)
|
|
.unwrap_or(ProviderCredentialRefreshStrategy::Unspecified)
|
|
{
|
|
ProviderCredentialRefreshStrategy::Static => "static",
|
|
ProviderCredentialRefreshStrategy::External => "external",
|
|
ProviderCredentialRefreshStrategy::Oauth2RefreshToken => "oauth2_refresh_token",
|
|
ProviderCredentialRefreshStrategy::Oauth2ClientCredentials => "oauth2_client_credentials",
|
|
ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt => "google_service_account_jwt",
|
|
ProviderCredentialRefreshStrategy::AwsStsAssumeRole => "aws_sts_assume_role",
|
|
ProviderCredentialRefreshStrategy::Unspecified => "unspecified",
|
|
}
|
|
}
|
|
|
|
pub use openshell_providers::is_gateway_mintable_strategy;
|
|
|
|
/// Secret source-material fields that are security-sensitive by strategy even
|
|
/// when a direct API caller omits `secret_material_keys`.
|
|
pub fn strategy_secret_material_keys(
|
|
strategy: ProviderCredentialRefreshStrategy,
|
|
) -> &'static [&'static str] {
|
|
match strategy {
|
|
ProviderCredentialRefreshStrategy::Oauth2RefreshToken => {
|
|
&["refresh_token", "client_secret"]
|
|
}
|
|
ProviderCredentialRefreshStrategy::Oauth2ClientCredentials => &["client_secret"],
|
|
ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt => &["private_key"],
|
|
ProviderCredentialRefreshStrategy::AwsStsAssumeRole => {
|
|
&["aws_secret_access_key", "aws_session_token"]
|
|
}
|
|
ProviderCredentialRefreshStrategy::Static
|
|
| ProviderCredentialRefreshStrategy::External
|
|
| ProviderCredentialRefreshStrategy::Unspecified => &[],
|
|
}
|
|
}
|
|
|
|
async fn resolve_refresh_material(
|
|
credentials: Option<&crate::credentials::CredentialRuntime>,
|
|
state: &StoredProviderCredentialRefreshState,
|
|
) -> Result<StoredProviderCredentialRefreshState, Status> {
|
|
if state.secret_material_handles.is_empty() {
|
|
return Ok(state.clone());
|
|
}
|
|
let credentials = credentials.ok_or_else(|| {
|
|
Status::failed_precondition(
|
|
"provider refresh material requires the configured credential runtime",
|
|
)
|
|
})?;
|
|
let resolved = credentials
|
|
.resolve_refresh_material(
|
|
refresh_material_scope(state),
|
|
&state.secret_material_handles,
|
|
)
|
|
.await?;
|
|
let mut transient = state.clone();
|
|
transient.material.extend(resolved);
|
|
Ok(transient)
|
|
}
|
|
|
|
pub fn enqueue_pending_secret_deletion(
|
|
state: &mut StoredProviderCredentialRefreshState,
|
|
material_key: &str,
|
|
handle: CredentialHandle,
|
|
) {
|
|
state
|
|
.pending_secret_deletions
|
|
.push(StoredRefreshMaterialDeletion {
|
|
material_key: material_key.to_string(),
|
|
handle: Some(handle),
|
|
});
|
|
}
|
|
|
|
async fn delete_pending_secret_handles(
|
|
credentials: &crate::credentials::CredentialRuntime,
|
|
state: &StoredProviderCredentialRefreshState,
|
|
) -> Result<(), Status> {
|
|
credentials
|
|
.delete_refresh_material_deletions(
|
|
refresh_material_scope(state),
|
|
&state.pending_secret_deletions,
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn cleanup_pending_secret_deletions(
|
|
store: &Store,
|
|
credentials: Option<&crate::credentials::CredentialRuntime>,
|
|
state: &mut StoredProviderCredentialRefreshState,
|
|
expected_version: u64,
|
|
) -> Result<u64, Status> {
|
|
if state.pending_secret_deletions.is_empty() {
|
|
return Ok(expected_version);
|
|
}
|
|
let credentials = credentials.ok_or_else(|| {
|
|
Status::failed_precondition(
|
|
"provider refresh cleanup requires the configured credential runtime",
|
|
)
|
|
})?;
|
|
delete_pending_secret_handles(credentials, state).await?;
|
|
// Keep the caller's in-memory state unchanged unless the CAS succeeds. A
|
|
// failed cleanup must not let the live refresh path persist a locally
|
|
// cleared tombstone list over the durable retry references.
|
|
let mut cleaned = state.clone();
|
|
cleaned.pending_secret_deletions.clear();
|
|
let new_version = persist_refresh_state_if_current(store, &cleaned, expected_version)
|
|
.await?
|
|
.ok_or_else(|| {
|
|
Status::aborted("provider refresh was deleted or superseded during secret cleanup")
|
|
})?;
|
|
if let Some(metadata) = cleaned.metadata.as_mut() {
|
|
metadata.resource_version = new_version;
|
|
}
|
|
*state = cleaned;
|
|
Ok(new_version)
|
|
}
|
|
|
|
async fn persist_retryable_refresh_error_state(
|
|
store: &Store,
|
|
state: &mut StoredProviderCredentialRefreshState,
|
|
expected_version: u64,
|
|
error: &Status,
|
|
) -> Result<u64, Status> {
|
|
let failure = RefreshFailure::retryable(
|
|
Status::new(error.code(), error.message().to_string()),
|
|
"refresh_failed",
|
|
);
|
|
persist_refresh_failure_state(store, state, expected_version, &failure).await
|
|
}
|
|
|
|
async fn persist_refresh_failure_state(
|
|
store: &Store,
|
|
state: &mut StoredProviderCredentialRefreshState,
|
|
expected_version: u64,
|
|
failure: &RefreshFailure,
|
|
) -> Result<u64, Status> {
|
|
let now_ms = current_time_ms();
|
|
state.status = match failure.recovery_action {
|
|
ProviderCredentialRefreshRecoveryAction::Retry
|
|
| ProviderCredentialRefreshRecoveryAction::Unspecified => "error",
|
|
ProviderCredentialRefreshRecoveryAction::Reauthorize => "reauthorization_required",
|
|
ProviderCredentialRefreshRecoveryAction::FixConfiguration => "configuration_required",
|
|
ProviderCredentialRefreshRecoveryAction::Investigate => "investigation_required",
|
|
}
|
|
.to_string();
|
|
state.last_error = failure.status.message().to_string();
|
|
state.recovery_action = failure.recovery_action as i32;
|
|
state.failure_code = failure.failure_code.to_string();
|
|
state.provider_error_subtype = failure
|
|
.provider_error_subtype
|
|
.unwrap_or_default()
|
|
.to_string();
|
|
state.last_error_at_ms = now_ms;
|
|
state.next_refresh_at_ms = match failure.retry_schedule {
|
|
RefreshRetrySchedule::Short => {
|
|
now_ms.saturating_add(REFRESH_ERROR_RETRY_SECONDS.saturating_mul(1000))
|
|
}
|
|
RefreshRetrySchedule::Configuration => {
|
|
now_ms.saturating_add(REFRESH_CONFIGURATION_RETRY_SECONDS.saturating_mul(1000))
|
|
}
|
|
RefreshRetrySchedule::Parked => i64::MAX,
|
|
};
|
|
let new_version = persist_refresh_state_if_current(store, state, expected_version)
|
|
.await?
|
|
.ok_or_else(|| {
|
|
Status::aborted(
|
|
"provider refresh was deleted or superseded while recording a refresh error",
|
|
)
|
|
})?;
|
|
if let Some(metadata) = state.metadata.as_mut() {
|
|
metadata.resource_version = new_version;
|
|
}
|
|
Ok(new_version)
|
|
}
|
|
|
|
fn validate_secret_material_references(
|
|
state: &StoredProviderCredentialRefreshState,
|
|
) -> Result<(), Status> {
|
|
let mut missing: Vec<_> = state
|
|
.secret_material_keys
|
|
.iter()
|
|
.filter(|key| {
|
|
!state.material.contains_key(*key) && !state.secret_material_handles.contains_key(*key)
|
|
})
|
|
.cloned()
|
|
.collect();
|
|
if missing.is_empty() {
|
|
return Ok(());
|
|
}
|
|
missing.sort();
|
|
missing.dedup();
|
|
Err(Status::failed_precondition(format!(
|
|
"provider refresh secret material is missing both inline values and credential handles for {}; a mixed-version gateway upgrade may have discarded the handles, so restore the refresh state or reconfigure the grant",
|
|
missing.join(", ")
|
|
)))
|
|
}
|
|
|
|
pub async fn refresh_provider_credential(
|
|
store: &Store,
|
|
workspace: &str,
|
|
credentials: &crate::credentials::CredentialRuntime,
|
|
compute: Option<&crate::compute::ComputeRuntime>,
|
|
provider_name: &str,
|
|
credential_key: &str,
|
|
) -> Result<StoredProviderCredentialRefreshState, Status> {
|
|
let provider = store
|
|
.get_message_by_name::<Provider>(workspace, provider_name)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("fetch provider failed: {e}")))?
|
|
.ok_or_else(|| Status::not_found("provider not found"))?;
|
|
let Some(state) =
|
|
get_refresh_state(store, workspace, provider.object_id(), credential_key).await?
|
|
else {
|
|
return Err(Status::not_found("provider refresh state not found"));
|
|
};
|
|
if state
|
|
.metadata
|
|
.as_ref()
|
|
.is_some_and(|metadata| metadata.deletion_time.is_some())
|
|
{
|
|
return Err(Status::failed_precondition(
|
|
"provider refresh is being deleted",
|
|
));
|
|
}
|
|
let mut state = state;
|
|
validate_secret_material_references(&state)?;
|
|
// Generation of the refresh at the start of the rotation. Terminal persists
|
|
// match on it so a concurrent delete or rotation is detected rather than
|
|
// clobbered, and a deleted refresh is never recreated (CWE-362).
|
|
let expected_version = state
|
|
.metadata
|
|
.as_ref()
|
|
.map_or(0, |meta| meta.resource_version);
|
|
let expected_version = match cleanup_pending_secret_deletions(
|
|
store,
|
|
Some(credentials),
|
|
&mut state,
|
|
expected_version,
|
|
)
|
|
.await
|
|
{
|
|
Ok(new_version) => new_version,
|
|
Err(err) => {
|
|
warn!(
|
|
provider = %state.provider_name,
|
|
credential_key = %state.credential_key,
|
|
error = %err,
|
|
"provider refresh material cleanup failed; continuing with live refresh"
|
|
);
|
|
expected_version
|
|
}
|
|
};
|
|
|
|
info!(
|
|
provider = %state.provider_name,
|
|
credential_key = %state.credential_key,
|
|
strategy = %refresh_strategy_name(state.strategy),
|
|
status = %state.status,
|
|
expires_at_ms = state.expires_at_ms,
|
|
next_refresh_at_ms = state.next_refresh_at_ms,
|
|
"provider credential refresh started"
|
|
);
|
|
|
|
let mint_result = match resolve_refresh_material(Some(credentials), &state).await {
|
|
Ok(transient_state) => mint_credential(&transient_state).await,
|
|
Err(err) => Err(err.into()),
|
|
};
|
|
match mint_result {
|
|
Ok(minted) => {
|
|
let now_ms = current_time_ms();
|
|
let mut staged_refresh_token_handles = HashMap::new();
|
|
if let Some(ref refresh_token) = minted.refresh_token {
|
|
if !state
|
|
.secret_material_keys
|
|
.iter()
|
|
.any(|key| key == "refresh_token")
|
|
{
|
|
state.secret_material_keys.push("refresh_token".to_string());
|
|
}
|
|
let material =
|
|
HashMap::from([("refresh_token".to_string(), refresh_token.clone())]);
|
|
let staging_id = format!(
|
|
"{}-refresh-material-{}",
|
|
state.object_id(),
|
|
uuid::Uuid::new_v4()
|
|
);
|
|
staged_refresh_token_handles = match credentials
|
|
.store_refresh_material_with_object_id(
|
|
refresh_material_scope(&state),
|
|
&staging_id,
|
|
&material,
|
|
&HashMap::new(),
|
|
)
|
|
.await
|
|
{
|
|
Ok(handles) => handles,
|
|
Err(store_err) => {
|
|
let failure = RefreshFailure::reauthorize(
|
|
Status::failed_precondition(format!(
|
|
"the OAuth provider rotated the refresh token, but the replacement could not be stored; the grant must be re-authorized: {}",
|
|
store_err.message()
|
|
)),
|
|
"oauth_rotated_refresh_token_store_failed",
|
|
None,
|
|
);
|
|
persist_refresh_failure_state(
|
|
store,
|
|
&mut state,
|
|
expected_version,
|
|
&failure,
|
|
)
|
|
.await?;
|
|
return Err(failure.into_status());
|
|
}
|
|
};
|
|
let Some(handle) = staged_refresh_token_handles.get("refresh_token").cloned()
|
|
else {
|
|
let failure = RefreshFailure::reauthorize(
|
|
Status::failed_precondition(
|
|
"the OAuth provider rotated the refresh token, but the credential driver returned no replacement handle; the grant must be re-authorized",
|
|
),
|
|
"oauth_rotated_refresh_token_handle_missing",
|
|
None,
|
|
);
|
|
cleanup_staged_refresh_material_handles(
|
|
credentials,
|
|
&state,
|
|
&staged_refresh_token_handles,
|
|
)
|
|
.await;
|
|
persist_refresh_failure_state(store, &mut state, expected_version, &failure)
|
|
.await?;
|
|
return Err(failure.into_status());
|
|
};
|
|
if let Some(previous) = state
|
|
.secret_material_handles
|
|
.insert("refresh_token".to_string(), handle)
|
|
{
|
|
enqueue_pending_secret_deletion(&mut state, "refresh_token", previous);
|
|
}
|
|
state.material.remove("refresh_token");
|
|
}
|
|
state.expires_at_ms = minted.expires_at_ms;
|
|
set_refresh_expiration_presence(&mut state, true);
|
|
state.next_refresh_at_ms =
|
|
next_refresh_at_ms(minted.expires_at_ms, state.refresh_before.as_ref());
|
|
state.last_refresh_at_ms = now_ms;
|
|
state.status = "refreshed".to_string();
|
|
state.last_error.clear();
|
|
state.recovery_action = ProviderCredentialRefreshRecoveryAction::Unspecified as i32;
|
|
state.failure_code.clear();
|
|
state.provider_error_subtype.clear();
|
|
state.last_error_at_ms = 0;
|
|
|
|
// Claim the refresh generation with a version-matched write BEFORE
|
|
// touching the provider. It succeeds only if the refresh still holds
|
|
// the generation we started from; if it was deleted, recreated, or a
|
|
// concurrent rotation won, it returns `None` and we leave both the
|
|
// refresh state and the provider unchanged — no credentials minted
|
|
// from a stale generation are written, and a deleted refresh is not
|
|
// resurrected (CWE-362). This makes generation ownership the gate on
|
|
// the provider credential write.
|
|
let new_version = match persist_refresh_state_if_current(
|
|
store,
|
|
&state,
|
|
expected_version,
|
|
)
|
|
.await
|
|
{
|
|
Ok(Some(new_version)) => new_version,
|
|
Ok(None) => {
|
|
if !staged_refresh_token_handles.is_empty() {
|
|
cleanup_staged_refresh_material_handles(
|
|
credentials,
|
|
&state,
|
|
&staged_refresh_token_handles,
|
|
)
|
|
.await;
|
|
}
|
|
warn!(
|
|
provider = %state.provider_name,
|
|
credential_key = %state.credential_key,
|
|
strategy = %refresh_strategy_name(state.strategy),
|
|
"provider credential refresh deleted or superseded during rotation; discarding minted credentials"
|
|
);
|
|
return Err(Status::aborted(
|
|
"provider refresh was deleted or superseded during rotation",
|
|
));
|
|
}
|
|
Err(err) => {
|
|
// The replacement refresh token is already in credential
|
|
// storage. Retry the same CAS with error/backoff state so a
|
|
// transient database failure does not discard the only
|
|
// upstream-valid grant. If that also fails, leave the
|
|
// staged object intact for operator recovery rather than
|
|
// deleting an irreplaceable rotated token.
|
|
persist_retryable_refresh_error_state(
|
|
store,
|
|
&mut state,
|
|
expected_version,
|
|
&err,
|
|
)
|
|
.await?;
|
|
return Err(err);
|
|
}
|
|
};
|
|
|
|
// Generation is ours; write the minted credentials into the provider.
|
|
if let Err(err) = apply_minted_credential(
|
|
store,
|
|
workspace,
|
|
Some(credentials),
|
|
compute,
|
|
&provider,
|
|
credential_key,
|
|
&minted,
|
|
)
|
|
.await
|
|
{
|
|
// Reflect the failure on the state we just wrote; skip silently
|
|
// if it was deleted concurrently (it is not recreated).
|
|
let failure = RefreshFailure::from_status(&err);
|
|
persist_refresh_failure_state(store, &mut state, new_version, &failure).await?;
|
|
warn!(
|
|
provider = %state.provider_name,
|
|
credential_key = %state.credential_key,
|
|
strategy = %refresh_strategy_name(state.strategy),
|
|
status = %state.status,
|
|
next_refresh_at_ms = state.next_refresh_at_ms,
|
|
seconds_until_refresh = seconds_until_ms(now_ms, state.next_refresh_at_ms),
|
|
error = %err,
|
|
"provider credential refresh errored"
|
|
);
|
|
return Err(err);
|
|
}
|
|
info!(
|
|
provider = %state.provider_name,
|
|
credential_key = %state.credential_key,
|
|
strategy = %refresh_strategy_name(state.strategy),
|
|
status = %state.status,
|
|
expires_at_ms = state.expires_at_ms,
|
|
next_refresh_at_ms = state.next_refresh_at_ms,
|
|
seconds_until_refresh = seconds_until_ms(now_ms, state.next_refresh_at_ms),
|
|
"provider credential refresh completed"
|
|
);
|
|
if !state.pending_secret_deletions.is_empty()
|
|
&& let Err(err) = cleanup_pending_secret_deletions(
|
|
store,
|
|
Some(credentials),
|
|
&mut state,
|
|
new_version,
|
|
)
|
|
.await
|
|
{
|
|
warn!(
|
|
provider = %state.provider_name,
|
|
credential_key = %state.credential_key,
|
|
error = %err,
|
|
"failed to clean up replaced refresh material; retrying on the next sweep"
|
|
);
|
|
}
|
|
Ok(state)
|
|
}
|
|
Err(failure) => {
|
|
let now_ms = current_time_ms();
|
|
persist_refresh_failure_state(store, &mut state, expected_version, &failure).await?;
|
|
warn!(
|
|
provider = %state.provider_name,
|
|
credential_key = %state.credential_key,
|
|
strategy = %refresh_strategy_name(state.strategy),
|
|
status = %state.status,
|
|
next_refresh_at_ms = state.next_refresh_at_ms,
|
|
seconds_until_refresh = seconds_until_ms(now_ms, state.next_refresh_at_ms),
|
|
recovery_action = ?failure.recovery_action,
|
|
failure_code = failure.failure_code,
|
|
error = %failure.status,
|
|
"provider credential refresh errored"
|
|
);
|
|
Err(failure.into_status())
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn cleanup_staged_refresh_material_handles(
|
|
credentials: &crate::credentials::CredentialRuntime,
|
|
state: &StoredProviderCredentialRefreshState,
|
|
handles: &HashMap<String, CredentialHandle>,
|
|
) {
|
|
if let Err(err) = credentials
|
|
.delete_refresh_material_handles(refresh_material_scope(state), handles)
|
|
.await
|
|
{
|
|
warn!(
|
|
provider = %state.provider_name,
|
|
credential_key = %state.credential_key,
|
|
error = %err,
|
|
"failed to clean up staged provider refresh material"
|
|
);
|
|
}
|
|
}
|
|
|
|
async fn apply_minted_credential(
|
|
store: &Store,
|
|
workspace: &str,
|
|
credentials: Option<&crate::credentials::CredentialRuntime>,
|
|
compute: Option<&crate::compute::ComputeRuntime>,
|
|
provider: &Provider,
|
|
credential_key: &str,
|
|
minted: &MintedCredential,
|
|
) -> Result<(), Status> {
|
|
let mut updated = provider.clone();
|
|
let staging_id = format!("{}-refresh-{}", provider.object_id(), uuid::Uuid::new_v4());
|
|
let staged_handles = if let Some(credentials) = credentials
|
|
&& credentials.stores_provider_credentials()
|
|
{
|
|
if let Some(compute) = compute {
|
|
compute.ensure_workspace(workspace).await?;
|
|
}
|
|
let mut creds_to_store =
|
|
HashMap::from([(credential_key.to_string(), minted.access_token.clone())]);
|
|
for (key, value) in &minted.additional_credentials {
|
|
creds_to_store.insert(key.clone(), value.clone());
|
|
}
|
|
// Stage under new handles with a unique staging ID to ensure we don't overwrite
|
|
// the still-committed values before validation/CAS succeeds
|
|
let staged = credentials
|
|
.store_provider_credentials_with_object_id(
|
|
provider.object_name(),
|
|
provider.object_workspace(),
|
|
provider.object_id(),
|
|
&staging_id,
|
|
&creds_to_store,
|
|
&HashMap::new(), // Empty map forces creation of new handles
|
|
)
|
|
.await?;
|
|
if !staged.contains_key(credential_key) {
|
|
cleanup_staged_refresh_handles(credentials, provider, &staged).await;
|
|
return Err(Status::internal(
|
|
"credential driver did not return refreshed credential handle",
|
|
));
|
|
}
|
|
for (key, handle) in &staged {
|
|
updated.credentials.remove(key);
|
|
updated
|
|
.credential_handles
|
|
.insert(key.clone(), handle.clone());
|
|
}
|
|
Some(staged)
|
|
} else {
|
|
updated
|
|
.credentials
|
|
.insert(credential_key.to_string(), minted.access_token.clone());
|
|
for (key, value) in &minted.additional_credentials {
|
|
updated.credentials.insert(key.clone(), value.clone());
|
|
}
|
|
None
|
|
};
|
|
let credential_expiration_time =
|
|
openshell_core::time::optional_timestamp_from_legacy_millis(minted.expires_at_ms)
|
|
.map_err(|error| Status::internal(error.to_string()))?;
|
|
if let Some(expiration_time) = credential_expiration_time.as_ref() {
|
|
updated
|
|
.credential_expiration_times
|
|
.insert(credential_key.to_string(), *expiration_time);
|
|
for key in minted.additional_credentials.keys() {
|
|
updated
|
|
.credential_expiration_times
|
|
.insert(key.clone(), *expiration_time);
|
|
}
|
|
} else {
|
|
updated.credential_expiration_times.remove(credential_key);
|
|
for key in minted.additional_credentials.keys() {
|
|
updated.credential_expiration_times.remove(key);
|
|
}
|
|
}
|
|
// Acquire the shared sandbox mutation boundary only around validation and
|
|
// persistence, after any remote minting or credential staging. This
|
|
// prevents route status from committing against the old provider revision
|
|
// after the rotation writes, without holding the guard across network I/O.
|
|
let _sandbox_sync_guard = if let Some(compute) = compute {
|
|
Some(compute.sandbox_sync_guard().await)
|
|
} else {
|
|
None
|
|
};
|
|
if let Err(err) = crate::grpc::provider::validate_provider_update_against_attached_sandboxes(
|
|
store, workspace, &updated,
|
|
)
|
|
.await
|
|
{
|
|
if let Some(credentials) = credentials
|
|
&& let Some(handles) = &staged_handles
|
|
{
|
|
cleanup_staged_refresh_handles(credentials, provider, handles).await;
|
|
}
|
|
return Err(err);
|
|
}
|
|
|
|
// Capture only handles actually replaced in the CAS snapshot. This avoids
|
|
// deleting unchanged sibling handles and remains correct if another refresh
|
|
// updated the provider after this refresh began.
|
|
let mut old_handles_to_delete = HashMap::new();
|
|
let cas_result = store
|
|
.update_message_cas::<Provider, _>(provider.object_id(), 0, |current| {
|
|
if let Some(handles) = staged_handles.clone() {
|
|
for (key, handle) in &handles {
|
|
current.credentials.remove(key);
|
|
if let Some(old_handle) = current
|
|
.credential_handles
|
|
.insert(key.clone(), handle.clone())
|
|
&& old_handle != *handle
|
|
{
|
|
old_handles_to_delete.insert(key.clone(), old_handle);
|
|
}
|
|
}
|
|
} else {
|
|
current
|
|
.credentials
|
|
.insert(credential_key.to_string(), minted.access_token.clone());
|
|
for (key, value) in &minted.additional_credentials {
|
|
current.credentials.insert(key.clone(), value.clone());
|
|
}
|
|
}
|
|
if let Some(expiration_time) = credential_expiration_time.as_ref() {
|
|
current
|
|
.credential_expiration_times
|
|
.insert(credential_key.to_string(), *expiration_time);
|
|
for key in minted.additional_credentials.keys() {
|
|
current
|
|
.credential_expiration_times
|
|
.insert(key.clone(), *expiration_time);
|
|
}
|
|
} else {
|
|
current.credential_expiration_times.remove(credential_key);
|
|
for key in minted.additional_credentials.keys() {
|
|
current.credential_expiration_times.remove(key);
|
|
}
|
|
}
|
|
})
|
|
.await
|
|
.map(|_| ())
|
|
.map_err(|e| {
|
|
Status::internal(format!("persist refreshed provider credential failed: {e}"))
|
|
});
|
|
if cas_result.is_err()
|
|
&& let Some(credentials) = credentials
|
|
&& let Some(ref handles) = staged_handles
|
|
{
|
|
cleanup_staged_refresh_handles(credentials, provider, handles).await;
|
|
}
|
|
|
|
// If CAS succeeded and we have old handles to delete, clean them up
|
|
if cas_result.is_ok()
|
|
&& !old_handles_to_delete.is_empty()
|
|
&& let Some(credentials) = credentials
|
|
&& let Err(cleanup_err) = credentials
|
|
.delete_provider_credential_handles(
|
|
provider.object_name(),
|
|
provider.object_workspace(),
|
|
provider.object_id(),
|
|
&old_handles_to_delete,
|
|
)
|
|
.await
|
|
{
|
|
warn!(
|
|
provider_name = %provider.object_name(),
|
|
error = %cleanup_err,
|
|
"failed to clean up old provider credential handles after successful refresh"
|
|
);
|
|
// Don't fail the operation - the refresh succeeded, this is just cleanup
|
|
}
|
|
|
|
cas_result
|
|
}
|
|
|
|
async fn cleanup_staged_refresh_handles(
|
|
credentials: &crate::credentials::CredentialRuntime,
|
|
provider: &Provider,
|
|
handles: &HashMap<String, CredentialHandle>,
|
|
) {
|
|
if let Err(cleanup_err) = credentials
|
|
.delete_provider_credential_handles(
|
|
provider.object_name(),
|
|
provider.object_workspace(),
|
|
provider.object_id(),
|
|
handles,
|
|
)
|
|
.await
|
|
{
|
|
warn!(
|
|
provider_name = %provider.object_name(),
|
|
error = %cleanup_err,
|
|
"failed to clean up staged provider credentials after refresh failure"
|
|
);
|
|
}
|
|
}
|
|
|
|
async fn mint_credential(
|
|
state: &StoredProviderCredentialRefreshState,
|
|
) -> Result<MintedCredential, RefreshFailure> {
|
|
let strategy = ProviderCredentialRefreshStrategy::try_from(state.strategy)
|
|
.unwrap_or(ProviderCredentialRefreshStrategy::Unspecified);
|
|
match strategy {
|
|
ProviderCredentialRefreshStrategy::Oauth2RefreshToken => {
|
|
mint_oauth2_refresh_token(state).await
|
|
}
|
|
ProviderCredentialRefreshStrategy::Oauth2ClientCredentials => {
|
|
mint_oauth2_client_credentials(state).await
|
|
}
|
|
ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt => {
|
|
mint_google_service_account_jwt(state).await
|
|
}
|
|
ProviderCredentialRefreshStrategy::AwsStsAssumeRole => {
|
|
mint_aws_sts_assume_role(state).await
|
|
}
|
|
ProviderCredentialRefreshStrategy::External
|
|
| ProviderCredentialRefreshStrategy::Static
|
|
| ProviderCredentialRefreshStrategy::Unspecified => Err(Status::failed_precondition(
|
|
format!("refresh strategy '{strategy:?}' cannot be minted by the gateway"),
|
|
)
|
|
.into()),
|
|
}
|
|
}
|
|
|
|
async fn mint_oauth2_refresh_token(
|
|
state: &StoredProviderCredentialRefreshState,
|
|
) -> Result<MintedCredential, RefreshFailure> {
|
|
let token_url = oauth2_token_url(state)?;
|
|
let client_id = required_material(&state.material, "client_id")?;
|
|
let refresh_token = required_material(&state.material, "refresh_token")?;
|
|
let mut form = vec![
|
|
("grant_type".to_string(), "refresh_token".to_string()),
|
|
("client_id".to_string(), client_id),
|
|
("refresh_token".to_string(), refresh_token),
|
|
];
|
|
if let Some(client_secret) = material_value(&state.material, &["client_secret"]) {
|
|
form.push(("client_secret".to_string(), client_secret));
|
|
}
|
|
let scope = refresh_scopes(state).join(" ");
|
|
if !scope.is_empty() {
|
|
form.push(("scope".to_string(), scope));
|
|
}
|
|
|
|
request_token(
|
|
&token_url,
|
|
&form,
|
|
max_lifetime_seconds(state),
|
|
OAuthGrantKind::UserRefreshToken,
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn mint_oauth2_client_credentials(
|
|
state: &StoredProviderCredentialRefreshState,
|
|
) -> Result<MintedCredential, RefreshFailure> {
|
|
let token_url = oauth2_token_url(state)?;
|
|
let client_id = required_material(&state.material, "client_id")?;
|
|
let client_secret = required_material(&state.material, "client_secret")?;
|
|
let mut form = vec![
|
|
("grant_type".to_string(), "client_credentials".to_string()),
|
|
("client_id".to_string(), client_id),
|
|
("client_secret".to_string(), client_secret),
|
|
];
|
|
let scope = refresh_scopes(state).join(" ");
|
|
if !scope.is_empty() {
|
|
form.push(("scope".to_string(), scope));
|
|
}
|
|
|
|
request_token(
|
|
&token_url,
|
|
&form,
|
|
max_lifetime_seconds(state),
|
|
OAuthGrantKind::NonInteractive,
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn mint_google_service_account_jwt(
|
|
state: &StoredProviderCredentialRefreshState,
|
|
) -> Result<MintedCredential, RefreshFailure> {
|
|
crate::install_jsonwebtoken_crypto_provider();
|
|
let token_url = google_token_url(state);
|
|
let client_email = required_material(&state.material, "client_email")?;
|
|
let private_key = required_material(&state.material, "private_key")?;
|
|
let scopes = refresh_scopes(state);
|
|
if scopes.is_empty() {
|
|
return Err(Status::invalid_argument(
|
|
"google_service_account_jwt requires at least one scope",
|
|
)
|
|
.into());
|
|
}
|
|
let now_ms = current_time_ms();
|
|
let now_secs = now_ms / 1000;
|
|
let lifetime_secs = max_lifetime_seconds(state).min(DEFAULT_MAX_LIFETIME_SECONDS);
|
|
let subject = material_value(&state.material, &["subject", "sub"]);
|
|
let claims = GoogleServiceAccountClaims {
|
|
iss: &client_email,
|
|
scope: scopes.join(" "),
|
|
aud: &token_url,
|
|
iat: now_secs,
|
|
exp: now_secs.saturating_add(lifetime_secs),
|
|
sub: subject.as_deref(),
|
|
};
|
|
let assertion = jsonwebtoken::encode(
|
|
&jsonwebtoken::Header::new(jsonwebtoken::Algorithm::RS256),
|
|
&claims,
|
|
&jsonwebtoken::EncodingKey::from_rsa_pem(private_key.as_bytes()).map_err(|_| {
|
|
Status::invalid_argument("google_service_account_jwt private_key must be RSA PEM")
|
|
})?,
|
|
)
|
|
.map_err(|_| Status::internal("sign google service account jwt failed"))?;
|
|
let form = vec![
|
|
(
|
|
"grant_type".to_string(),
|
|
"urn:ietf:params:oauth:grant-type:jwt-bearer".to_string(),
|
|
),
|
|
("assertion".to_string(), assertion),
|
|
];
|
|
request_token(
|
|
&token_url,
|
|
&form,
|
|
lifetime_secs,
|
|
OAuthGrantKind::NonInteractive,
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn mint_aws_sts_assume_role(
|
|
state: &StoredProviderCredentialRefreshState,
|
|
) -> Result<MintedCredential, RefreshFailure> {
|
|
let role_arn = required_material(&state.material, "role_arn")?;
|
|
let session_name = material_value(&state.material, &["session_name"])
|
|
.unwrap_or_else(|| "openshell-sandbox".to_string());
|
|
let external_id = material_value(&state.material, &["external_id"]);
|
|
let region =
|
|
material_value(&state.material, &["aws_region"]).unwrap_or_else(|| "us-east-1".to_string());
|
|
|
|
let region_provider = aws_sdk_sts::config::Region::new(region);
|
|
let mut config_loader =
|
|
aws_config::defaults(aws_config::BehaviorVersion::latest()).region(region_provider);
|
|
|
|
// Explicit source credentials are all-or-nothing. A lone key must not
|
|
// silently fall through to the gateway's ambient identity (CWE-20): the
|
|
// caller asked for a specific principal, so an incomplete pair is an error.
|
|
// An optional session token supports temporary source credentials (SSO or a
|
|
// prior AssumeRole); it requires the access/secret pair.
|
|
let session_token = material_value(&state.material, &["aws_session_token"]);
|
|
match (
|
|
material_value(&state.material, &["aws_access_key_id"]),
|
|
material_value(&state.material, &["aws_secret_access_key"]),
|
|
) {
|
|
(Some(access_key), Some(secret_key)) => {
|
|
let creds = aws_sdk_sts::config::Credentials::new(
|
|
access_key,
|
|
secret_key,
|
|
session_token,
|
|
None,
|
|
"openshell-provider-refresh",
|
|
);
|
|
config_loader = config_loader.credentials_provider(creds);
|
|
}
|
|
(None, None) if session_token.is_some() => {
|
|
return Err(Status::invalid_argument(
|
|
"aws_session_token requires aws_access_key_id and aws_secret_access_key",
|
|
)
|
|
.into());
|
|
}
|
|
(None, None) => {}
|
|
_ => {
|
|
return Err(Status::invalid_argument(
|
|
"aws_access_key_id and aws_secret_access_key must both be set or both omitted",
|
|
)
|
|
.into());
|
|
}
|
|
}
|
|
|
|
let sdk_config = config_loader.load().await;
|
|
let sts_config = {
|
|
let mut builder = aws_sdk_sts::config::Builder::from(&sdk_config);
|
|
// Endpoint overrides exist only to point tests at a local mock STS. In
|
|
// production the endpoint is always resolved from the region so a caller
|
|
// cannot redirect an AWS-signed AssumeRole request at an arbitrary
|
|
// service (CWE-918). See `test_sts_endpoint_override`.
|
|
if let Some(endpoint) = test_sts_endpoint_override(state) {
|
|
builder = builder.endpoint_url(endpoint);
|
|
}
|
|
builder.build()
|
|
};
|
|
let client = aws_sdk_sts::Client::from_conf(sts_config);
|
|
|
|
let max_lifetime_i64 = max_lifetime_seconds(state);
|
|
let max_lifetime = i32::try_from(max_lifetime_i64.min(i64::from(i32::MAX))).unwrap_or(i32::MAX);
|
|
let max_lifetime_ms = i64::from(max_lifetime).saturating_mul(1000);
|
|
|
|
let mut req = client
|
|
.assume_role()
|
|
.role_arn(&role_arn)
|
|
.role_session_name(&session_name)
|
|
.duration_seconds(max_lifetime);
|
|
|
|
if let Some(eid) = external_id {
|
|
req = req.external_id(eid);
|
|
}
|
|
|
|
let resp = req
|
|
.send()
|
|
.await
|
|
.map_err(|e| Status::internal(format!("STS AssumeRole failed: {e}")))?;
|
|
|
|
let creds = resp
|
|
.credentials()
|
|
.ok_or_else(|| Status::internal("STS AssumeRole response missing credentials"))?;
|
|
|
|
let access_key_id = creds.access_key_id().to_string();
|
|
let secret_access_key = creds.secret_access_key().to_string();
|
|
let session_token = creds.session_token().to_string();
|
|
|
|
let now_ms = current_time_ms();
|
|
let max_expires = now_ms.saturating_add(max_lifetime_ms);
|
|
let expires_at_ms = creds.expiration().to_millis().unwrap_or(max_expires);
|
|
let expires_at_ms = expires_at_ms.min(max_expires);
|
|
|
|
// Map STS response fields to the env keys the profile bound to each semantic
|
|
// output. Configure pins these from the profile's additional_outputs, so a
|
|
// missing mapping means the state was not configured against a valid AWS STS
|
|
// profile binding; refuse rather than guessing standard AWS names.
|
|
let output_values = [
|
|
("secret_access_key", secret_access_key),
|
|
("session_token", session_token),
|
|
];
|
|
let mut additional = HashMap::new();
|
|
for (output_id, value) in output_values {
|
|
let env_key = state.additional_output_keys.get(output_id).ok_or_else(|| {
|
|
Status::failed_precondition(format!(
|
|
"refresh state missing resolved output key for '{output_id}'; reconfigure the AWS STS refresh"
|
|
))
|
|
})?;
|
|
additional.insert(env_key.clone(), value);
|
|
}
|
|
|
|
Ok(MintedCredential {
|
|
access_token: access_key_id,
|
|
expires_at_ms,
|
|
refresh_token: None,
|
|
additional_credentials: additional,
|
|
})
|
|
}
|
|
|
|
async fn request_token(
|
|
token_url: &str,
|
|
form: &[(String, String)],
|
|
max_lifetime_seconds: i64,
|
|
grant_kind: OAuthGrantKind,
|
|
) -> Result<MintedCredential, RefreshFailure> {
|
|
let parsed = reqwest::Url::parse(token_url)
|
|
.map_err(|_| Status::invalid_argument("token_url must be an absolute URL"))?;
|
|
match parsed.scheme() {
|
|
"https" => {}
|
|
"http" if parsed.host_str().is_some_and(is_loopback_host) => {}
|
|
_ => {
|
|
return Err(Status::invalid_argument(
|
|
"token_url must use https, except loopback http for local tests",
|
|
)
|
|
.into());
|
|
}
|
|
}
|
|
|
|
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
|
let client = reqwest::Client::builder()
|
|
.timeout(Duration::from_secs(30))
|
|
.build()
|
|
.map_err(|e| Status::internal(format!("build refresh HTTP client failed: {e}")))?;
|
|
let response = client
|
|
.post(parsed)
|
|
.form(form)
|
|
.send()
|
|
.await
|
|
.map_err(|error| {
|
|
let error_kind = if error.is_timeout() {
|
|
"timeout"
|
|
} else if error.is_connect() {
|
|
"connect"
|
|
} else if error.is_request() {
|
|
"request"
|
|
} else {
|
|
"other"
|
|
};
|
|
warn!(
|
|
error = %error.without_url(),
|
|
error_kind,
|
|
"OAuth token endpoint request failed"
|
|
);
|
|
RefreshFailure::retryable(
|
|
Status::unavailable("token endpoint request failed"),
|
|
"oauth_token_endpoint_unavailable",
|
|
)
|
|
})?;
|
|
let status = response.status();
|
|
if !status.is_success() {
|
|
let body = read_bounded_oauth_error_body(response).await;
|
|
return Err(classify_oauth_token_error(status, &body, grant_kind));
|
|
}
|
|
let token = response.json::<TokenResponse>().await.map_err(|_| {
|
|
RefreshFailure::investigate(
|
|
Status::failed_precondition("token endpoint returned invalid JSON"),
|
|
"oauth_invalid_success_response",
|
|
)
|
|
})?;
|
|
if token.access_token.trim().is_empty() {
|
|
return Err(RefreshFailure::investigate(
|
|
Status::failed_precondition("token endpoint returned empty access_token"),
|
|
"oauth_empty_access_token",
|
|
));
|
|
}
|
|
let now_ms = current_time_ms();
|
|
let lifetime_cap_seconds = if max_lifetime_seconds > 0 {
|
|
max_lifetime_seconds
|
|
} else {
|
|
DEFAULT_MAX_LIFETIME_SECONDS
|
|
};
|
|
let lifetime_seconds = token
|
|
.expires_in
|
|
.filter(|value| *value > 0)
|
|
.unwrap_or(lifetime_cap_seconds);
|
|
let lifetime_seconds = lifetime_seconds.min(lifetime_cap_seconds);
|
|
Ok(MintedCredential {
|
|
access_token: token.access_token,
|
|
expires_at_ms: now_ms.saturating_add(lifetime_seconds.saturating_mul(1000)),
|
|
refresh_token: token
|
|
.refresh_token
|
|
.filter(|refresh_token| !refresh_token.trim().is_empty()),
|
|
additional_credentials: HashMap::new(),
|
|
})
|
|
}
|
|
|
|
async fn read_bounded_oauth_error_body(mut response: reqwest::Response) -> Vec<u8> {
|
|
let mut body = Vec::new();
|
|
while body.len() < MAX_OAUTH_ERROR_RESPONSE_BYTES {
|
|
let Ok(Some(chunk)) = response.chunk().await else {
|
|
break;
|
|
};
|
|
let remaining = MAX_OAUTH_ERROR_RESPONSE_BYTES.saturating_sub(body.len());
|
|
body.extend_from_slice(&chunk[..chunk.len().min(remaining)]);
|
|
if chunk.len() >= remaining {
|
|
break;
|
|
}
|
|
}
|
|
body
|
|
}
|
|
|
|
fn classify_oauth_token_error(
|
|
status: reqwest::StatusCode,
|
|
body: &[u8],
|
|
grant_kind: OAuthGrantKind,
|
|
) -> RefreshFailure {
|
|
if status == reqwest::StatusCode::TOO_MANY_REQUESTS || status.is_server_error() {
|
|
return RefreshFailure::retryable(
|
|
Status::unavailable(format!("token endpoint returned HTTP {status}")),
|
|
"oauth_token_endpoint_retryable",
|
|
);
|
|
}
|
|
|
|
let Ok(error_response) = serde_json::from_slice::<OAuthErrorResponse>(body) else {
|
|
return RefreshFailure::investigate(
|
|
Status::failed_precondition(format!(
|
|
"token endpoint returned HTTP {status} without a recognized OAuth error"
|
|
)),
|
|
"oauth_unrecognized_error_response",
|
|
);
|
|
};
|
|
|
|
match error_response.error.as_str() {
|
|
"invalid_grant" if grant_kind == OAuthGrantKind::UserRefreshToken => {
|
|
let subtype = error_response
|
|
.error_subtype
|
|
.as_deref()
|
|
.filter(|subtype| *subtype == "invalid_rapt")
|
|
.map(|_| "invalid_rapt");
|
|
let message = if subtype.is_some() {
|
|
"OAuth refresh grant requires interactive reauthorization (invalid_grant/invalid_rapt)"
|
|
} else {
|
|
"OAuth refresh grant is no longer usable (invalid_grant); user reauthorization is required"
|
|
};
|
|
RefreshFailure::reauthorize(
|
|
Status::failed_precondition(message),
|
|
"oauth_invalid_grant",
|
|
subtype,
|
|
)
|
|
}
|
|
"invalid_client" => RefreshFailure::fix_configuration(
|
|
Status::failed_precondition(
|
|
"OAuth token endpoint rejected the client configuration (invalid_client)",
|
|
),
|
|
"oauth_invalid_client",
|
|
),
|
|
"unauthorized_client" => RefreshFailure::fix_configuration(
|
|
Status::failed_precondition(
|
|
"OAuth token endpoint rejected the client grant (unauthorized_client)",
|
|
),
|
|
"oauth_unauthorized_client",
|
|
),
|
|
"invalid_scope" => RefreshFailure::fix_configuration(
|
|
Status::failed_precondition(
|
|
"OAuth token endpoint rejected the configured scopes (invalid_scope)",
|
|
),
|
|
"oauth_invalid_scope",
|
|
),
|
|
"unsupported_grant_type" => RefreshFailure::fix_configuration(
|
|
Status::failed_precondition(
|
|
"OAuth token endpoint rejected the configured grant type (unsupported_grant_type)",
|
|
),
|
|
"oauth_unsupported_grant_type",
|
|
),
|
|
"admin_policy_enforced" => RefreshFailure::fix_configuration(
|
|
Status::failed_precondition(
|
|
"OAuth access is blocked by an administrator policy (admin_policy_enforced)",
|
|
),
|
|
"oauth_admin_policy_enforced",
|
|
),
|
|
"access_denied"
|
|
if error_response.error_subtype.as_deref() == Some("admin_policy_enforced") =>
|
|
{
|
|
RefreshFailure::fix_configuration_with_subtype(
|
|
Status::failed_precondition(
|
|
"OAuth access is blocked by an administrator policy (access_denied/admin_policy_enforced)",
|
|
),
|
|
"oauth_admin_policy_enforced",
|
|
"admin_policy_enforced",
|
|
)
|
|
}
|
|
"access_denied" if grant_kind == OAuthGrantKind::UserRefreshToken => {
|
|
RefreshFailure::reauthorize(
|
|
Status::failed_precondition(
|
|
"OAuth access was denied; user reauthorization is required (access_denied)",
|
|
),
|
|
"oauth_access_denied",
|
|
None,
|
|
)
|
|
}
|
|
"access_denied" => RefreshFailure::fix_configuration(
|
|
Status::failed_precondition(
|
|
"OAuth access was denied for the non-interactive grant (access_denied)",
|
|
),
|
|
"oauth_access_denied",
|
|
),
|
|
"invalid_grant" => RefreshFailure::fix_configuration(
|
|
Status::failed_precondition(
|
|
"OAuth token endpoint rejected the non-interactive grant (invalid_grant)",
|
|
),
|
|
"oauth_invalid_grant",
|
|
),
|
|
"server_error" | "temporarily_unavailable" => RefreshFailure::retryable(
|
|
Status::unavailable("OAuth token endpoint reported a temporary failure"),
|
|
"oauth_token_endpoint_retryable",
|
|
),
|
|
_ => RefreshFailure::investigate(
|
|
Status::failed_precondition(format!(
|
|
"token endpoint returned HTTP {status} with an unrecognized OAuth error"
|
|
)),
|
|
"oauth_unrecognized_error",
|
|
),
|
|
}
|
|
}
|
|
|
|
pub fn refresh_scopes(state: &StoredProviderCredentialRefreshState) -> Vec<String> {
|
|
if !state.scopes.is_empty() {
|
|
return state.scopes.clone();
|
|
}
|
|
material_scopes(&state.material)
|
|
}
|
|
|
|
pub fn material_scopes(material: &HashMap<String, String>) -> Vec<String> {
|
|
material_value(material, &["scope", "scopes"])
|
|
.map(|raw| {
|
|
raw.split(|ch: char| ch == ',' || ch.is_ascii_whitespace())
|
|
.map(str::trim)
|
|
.filter(|scope| !scope.is_empty())
|
|
.map(ToString::to_string)
|
|
.collect()
|
|
})
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
pub fn parse_material_i64(
|
|
material: &HashMap<String, String>,
|
|
key: &str,
|
|
) -> Result<Option<i64>, Status> {
|
|
let Some(value) = material_value(material, &[key]) else {
|
|
return Ok(None);
|
|
};
|
|
value
|
|
.parse::<i64>()
|
|
.map(Some)
|
|
.map_err(|_| Status::invalid_argument(format!("{key} material must be a signed integer")))
|
|
}
|
|
|
|
fn oauth2_token_url(state: &StoredProviderCredentialRefreshState) -> Result<String, Status> {
|
|
if let Some(tenant_id) = material_value(&state.material, &["tenant_id"]) {
|
|
return Ok(format!(
|
|
"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token"
|
|
));
|
|
}
|
|
if !state.token_url.trim().is_empty() {
|
|
return Ok(state.token_url.clone());
|
|
}
|
|
Err(Status::invalid_argument(
|
|
"oauth2_client_credentials requires token_url or tenant_id material",
|
|
))
|
|
}
|
|
|
|
fn google_token_url(state: &StoredProviderCredentialRefreshState) -> String {
|
|
if state.token_url.trim().is_empty() {
|
|
"https://oauth2.googleapis.com/token".to_string()
|
|
} else {
|
|
state.token_url.clone()
|
|
}
|
|
}
|
|
|
|
fn required_material(material: &HashMap<String, String>, key: &str) -> Result<String, Status> {
|
|
material_value(material, &[key])
|
|
.ok_or_else(|| Status::invalid_argument(format!("{key} material is required")))
|
|
}
|
|
|
|
fn material_value(material: &HashMap<String, String>, keys: &[&str]) -> Option<String> {
|
|
for key in keys {
|
|
if let Some(value) = material.get(*key).map(|value| value.trim())
|
|
&& !value.is_empty()
|
|
{
|
|
return Some(value.to_string());
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn is_loopback_host(host: &str) -> bool {
|
|
matches!(host, "localhost" | "127.0.0.1" | "::1")
|
|
}
|
|
|
|
/// Test-only STS endpoint override. Reads the `sts_endpoint_url` material and
|
|
/// accepts it only when it is a loopback URL, so unit tests can target a local
|
|
/// mock STS. Compiled out of production builds entirely: the configure boundary
|
|
/// also rejects the `sts_endpoint_url` material key, so it can never reach a
|
|
/// stored refresh state outside tests.
|
|
#[cfg(test)]
|
|
fn test_sts_endpoint_override(state: &StoredProviderCredentialRefreshState) -> Option<String> {
|
|
material_value(&state.material, &["sts_endpoint_url"]).filter(|endpoint| {
|
|
reqwest::Url::parse(endpoint)
|
|
.ok()
|
|
.and_then(|parsed| parsed.host_str().map(is_loopback_host))
|
|
.unwrap_or(false)
|
|
})
|
|
}
|
|
|
|
#[cfg(not(test))]
|
|
#[allow(clippy::missing_const_for_fn)]
|
|
fn test_sts_endpoint_override(_state: &StoredProviderCredentialRefreshState) -> Option<String> {
|
|
None
|
|
}
|
|
|
|
pub fn spawn_refresh_worker(state: std::sync::Arc<crate::ServerState>, interval: Duration) {
|
|
info!(
|
|
interval_seconds = interval.as_secs(),
|
|
"provider credential refresh worker started"
|
|
);
|
|
tokio::spawn(async move {
|
|
let mut ticker = tokio::time::interval(interval);
|
|
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
|
loop {
|
|
ticker.tick().await;
|
|
match Box::pin(run_refresh_worker_tick(
|
|
state.store.as_ref(),
|
|
Some(&state.credentials),
|
|
Some(&state.compute),
|
|
))
|
|
.await
|
|
{
|
|
Ok(workspaces) => {
|
|
for workspace in workspaces {
|
|
crate::config_delivery::publish_workspace_components(
|
|
&state,
|
|
&workspace,
|
|
crate::config_delivery::ConfigComponents::ALL,
|
|
);
|
|
}
|
|
}
|
|
Err(err) => {
|
|
warn!(error = %err, "provider credential refresh worker tick failed");
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
#[tracing::instrument(
|
|
name = "refresh",
|
|
skip_all,
|
|
fields(
|
|
otel.name = "refresh.provider_credentials",
|
|
watched_count = tracing::field::Empty,
|
|
due_count = tracing::field::Empty,
|
|
)
|
|
)]
|
|
async fn run_refresh_worker_tick(
|
|
store: &Store,
|
|
credentials: Option<&crate::credentials::CredentialRuntime>,
|
|
compute: Option<&crate::compute::ComputeRuntime>,
|
|
) -> Result<std::collections::HashSet<String>, Status> {
|
|
let mut changed_workspaces = std::collections::HashSet::new();
|
|
let now_ms = current_time_ms();
|
|
let states = list_all_refresh_states(store).await.inspect_err(|_| {
|
|
crate::otel_tracing::mark_error(&tracing::Span::current());
|
|
})?;
|
|
let watched_count = states.len();
|
|
let due_count = states
|
|
.iter()
|
|
.filter(|state| state.next_refresh_at_ms <= 0 || state.next_refresh_at_ms <= now_ms)
|
|
.count();
|
|
let rotation_requested_count = states
|
|
.iter()
|
|
.filter(|state| state.status == "rotation_requested")
|
|
.count();
|
|
let span = tracing::Span::current();
|
|
span.record("watched_count", watched_count);
|
|
span.record("due_count", due_count);
|
|
info!(
|
|
watched_count,
|
|
due_count, rotation_requested_count, "provider credential refresh worker sweep"
|
|
);
|
|
for state in states {
|
|
if state
|
|
.metadata
|
|
.as_ref()
|
|
.is_some_and(|metadata| metadata.deletion_time.is_some())
|
|
{
|
|
let Some(credentials) = credentials else {
|
|
warn!(
|
|
provider = %state.provider_name,
|
|
credential_key = %state.credential_key,
|
|
"cannot finalize tombstoned provider refresh without credential runtime"
|
|
);
|
|
continue;
|
|
};
|
|
if let Err(err) = delete_refresh_state_with_credentials(
|
|
store,
|
|
credentials,
|
|
state.object_workspace(),
|
|
&state.provider_id,
|
|
&state.credential_key,
|
|
)
|
|
.await
|
|
{
|
|
warn!(
|
|
provider = %state.provider_name,
|
|
credential_key = %state.credential_key,
|
|
error = %err,
|
|
"failed to finalize tombstoned provider refresh; retrying on the next sweep"
|
|
);
|
|
} else {
|
|
changed_workspaces.insert(state.object_workspace().to_string());
|
|
}
|
|
continue;
|
|
}
|
|
let mut state = state;
|
|
let expected_version = state
|
|
.metadata
|
|
.as_ref()
|
|
.map_or(0, |metadata| metadata.resource_version);
|
|
if let Err(err) =
|
|
cleanup_pending_secret_deletions(store, credentials, &mut state, expected_version).await
|
|
{
|
|
warn!(
|
|
provider = %state.provider_name,
|
|
credential_key = %state.credential_key,
|
|
error = %err,
|
|
"provider refresh material cleanup failed; continuing with live refresh"
|
|
);
|
|
}
|
|
let strategy = ProviderCredentialRefreshStrategy::try_from(state.strategy)
|
|
.unwrap_or(ProviderCredentialRefreshStrategy::Unspecified);
|
|
if !is_gateway_mintable_strategy(strategy) {
|
|
warn!(
|
|
provider = %state.provider_name,
|
|
credential_key = %state.credential_key,
|
|
strategy = %refresh_strategy_name(state.strategy),
|
|
status = %state.status,
|
|
"skipping non-gateway-mintable provider credential refresh state"
|
|
);
|
|
continue;
|
|
}
|
|
let due = state.next_refresh_at_ms <= 0 || state.next_refresh_at_ms <= now_ms;
|
|
let rotation_requested = state.status == "rotation_requested";
|
|
info!(
|
|
provider = %state.provider_name,
|
|
credential_key = %state.credential_key,
|
|
strategy = %refresh_strategy_name(state.strategy),
|
|
status = %state.status,
|
|
expires_at_ms = state.expires_at_ms,
|
|
seconds_until_expiry = seconds_until_ms(now_ms, state.expires_at_ms),
|
|
next_refresh_at_ms = state.next_refresh_at_ms,
|
|
last_refresh_at_ms = state.last_refresh_at_ms,
|
|
seconds_until_refresh = seconds_until_ms(now_ms, state.next_refresh_at_ms),
|
|
due,
|
|
rotation_requested,
|
|
"provider credential refresh watch"
|
|
);
|
|
if !due && !rotation_requested {
|
|
continue;
|
|
}
|
|
let Some(credentials) = credentials else {
|
|
warn!(
|
|
provider = %state.provider_name,
|
|
credential_key = %state.credential_key,
|
|
"cannot refresh provider credential without credential runtime"
|
|
);
|
|
continue;
|
|
};
|
|
info!(
|
|
provider = %state.provider_name,
|
|
credential_key = %state.credential_key,
|
|
strategy = %refresh_strategy_name(state.strategy),
|
|
status = %state.status,
|
|
"refreshing provider credential"
|
|
);
|
|
if let Err(err) = refresh_provider_credential(
|
|
store,
|
|
state.object_workspace(),
|
|
credentials,
|
|
compute,
|
|
&state.provider_name,
|
|
&state.credential_key,
|
|
)
|
|
.await
|
|
{
|
|
warn!(
|
|
provider = %state.provider_name,
|
|
credential_key = %state.credential_key,
|
|
strategy = %refresh_strategy_name(state.strategy),
|
|
status = %state.status,
|
|
next_refresh_at_ms = state.next_refresh_at_ms,
|
|
error = %err,
|
|
"provider credential refresh failed"
|
|
);
|
|
} else {
|
|
changed_workspaces.insert(state.object_workspace().to_string());
|
|
}
|
|
}
|
|
Ok(changed_workspaces)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{
|
|
MAX_OAUTH_ERROR_RESPONSE_BYTES, NewRefreshStateConfig, OAuthGrantKind, RefreshFailure,
|
|
RefreshRetrySchedule, Status, classify_oauth_token_error,
|
|
delete_refresh_state_with_credentials, effective_authorization_epoch,
|
|
enqueue_pending_secret_deletion, get_refresh_state, list_all_refresh_states,
|
|
list_refresh_states_for_provider, max_lifetime_seconds, new_refresh_state,
|
|
next_refresh_at_ms, put_refresh_state, read_bounded_oauth_error_body,
|
|
refresh_has_expiration, refresh_material_scope, refresh_provider_credential,
|
|
refresh_state_name, refresh_status_from_state, refresh_strategy_name,
|
|
run_refresh_worker_tick, seconds_until_ms, set_refresh_expiration_presence,
|
|
validate_secret_material_references,
|
|
};
|
|
use crate::credentials::CredentialRuntime;
|
|
use crate::persistence::{current_time_ms, test_store};
|
|
use crate::storage_proto::StoredProviderCredentialRefreshStateV2 as StoredProviderCredentialRefreshState;
|
|
|
|
fn proto_duration(seconds: i64) -> prost_types::Duration {
|
|
prost_types::Duration { seconds, nanos: 0 }
|
|
}
|
|
|
|
#[test]
|
|
fn exact_refresh_durations_preserve_fractional_values_until_protocol_boundaries() {
|
|
let refresh_before = prost_types::Duration {
|
|
seconds: 0,
|
|
nanos: 500_000_000,
|
|
};
|
|
assert_eq!(next_refresh_at_ms(10_000, Some(&refresh_before)), 9_500);
|
|
let submillisecond = prost_types::Duration {
|
|
seconds: 0,
|
|
nanos: 1,
|
|
};
|
|
assert_eq!(next_refresh_at_ms(10_000, Some(&submillisecond)), 9_999);
|
|
|
|
let state = StoredProviderCredentialRefreshState {
|
|
max_lifetime: Some(refresh_before),
|
|
..Default::default()
|
|
};
|
|
assert_eq!(max_lifetime_seconds(&state), 1);
|
|
}
|
|
use openshell_core::Config;
|
|
use openshell_core::proto::datamodel::v1::ObjectMeta;
|
|
use openshell_core::proto::{
|
|
CredentialHandle, Provider, ProviderCredentialRefreshRecoveryAction,
|
|
ProviderCredentialRefreshStrategy, Sandbox, SandboxSpec,
|
|
};
|
|
use openshell_core::{ObjectId, ObjectName, ObjectWorkspace};
|
|
use std::collections::HashMap;
|
|
use wiremock::matchers::{body_string_contains, method, path};
|
|
use wiremock::{Mock, MockServer, ResponseTemplate};
|
|
|
|
fn ts(milliseconds: i64) -> prost_types::Timestamp {
|
|
openshell_core::time::timestamp_from_millis(milliseconds).unwrap()
|
|
}
|
|
|
|
fn test_credentials() -> CredentialRuntime {
|
|
CredentialRuntime::from_config(&Config::new(None).with_credential_drivers(["test-static"]))
|
|
.expect("test credential runtime")
|
|
}
|
|
|
|
#[test]
|
|
fn refresh_state_name_preserves_distinct_credential_keys() {
|
|
let provider_id = "provider-id";
|
|
|
|
assert_ne!(
|
|
refresh_state_name(provider_id, "API_KEY"),
|
|
refresh_state_name(provider_id, "api_key")
|
|
);
|
|
assert_ne!(
|
|
refresh_state_name(provider_id, " alex-api "),
|
|
refresh_state_name(provider_id, " alex_api")
|
|
);
|
|
assert_ne!(
|
|
refresh_state_name(provider_id, "Alex-API"),
|
|
refresh_state_name(provider_id, "alex-api")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn refresh_state_lists_hydrate_authoritative_resource_versions() {
|
|
let store = test_store().await;
|
|
let provider_id = "provider-id";
|
|
let state = StoredProviderCredentialRefreshState {
|
|
metadata: Some(ObjectMeta {
|
|
id: "refresh-id".to_string(),
|
|
name: refresh_state_name(provider_id, "ACCESS_TOKEN"),
|
|
workspace: "default".to_string(),
|
|
..Default::default()
|
|
}),
|
|
provider_id: provider_id.to_string(),
|
|
credential_key: "ACCESS_TOKEN".to_string(),
|
|
..Default::default()
|
|
};
|
|
put_refresh_state(&store, &state).await.unwrap();
|
|
|
|
let scoped = list_refresh_states_for_provider(&store, provider_id)
|
|
.await
|
|
.unwrap();
|
|
let all = list_all_refresh_states(&store).await.unwrap();
|
|
assert_eq!(scoped[0].metadata.as_ref().unwrap().resource_version, 1);
|
|
assert_eq!(all[0].metadata.as_ref().unwrap().resource_version, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn new_refresh_configuration_rotates_authorization_epoch_and_legacy_state_is_stable() {
|
|
let provider = Provider {
|
|
metadata: Some(ObjectMeta {
|
|
id: "provider-id".to_string(),
|
|
name: "provider".to_string(),
|
|
workspace: "default".to_string(),
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
let config = || NewRefreshStateConfig {
|
|
strategy: ProviderCredentialRefreshStrategy::Oauth2RefreshToken,
|
|
material: HashMap::new(),
|
|
secret_material_keys: Vec::new(),
|
|
expires_at_ms: 0,
|
|
token_url: "https://issuer.example/token".to_string(),
|
|
scopes: vec!["scope".to_string()],
|
|
refresh_before: Some(proto_duration(300)),
|
|
max_lifetime: Some(proto_duration(3600)),
|
|
additional_output_keys: HashMap::new(),
|
|
};
|
|
let first = new_refresh_state(&provider, "default", "ACCESS_TOKEN", config())
|
|
.expect("first refresh configuration");
|
|
let second = new_refresh_state(&provider, "default", "ACCESS_TOKEN", config())
|
|
.expect("second refresh configuration");
|
|
assert!(!first.authorization_epoch.is_empty());
|
|
assert_ne!(first.authorization_epoch, second.authorization_epoch);
|
|
|
|
let mut legacy = first;
|
|
legacy.authorization_epoch.clear();
|
|
assert_eq!(
|
|
effective_authorization_epoch(&legacy).expect("legacy migration epoch"),
|
|
legacy.metadata.as_ref().expect("metadata").id
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn refresh_log_helpers_format_safe_operational_fields() {
|
|
assert_eq!(seconds_until_ms(1_000, 61_000), 60);
|
|
assert_eq!(seconds_until_ms(61_000, 1_000), 0);
|
|
assert_eq!(seconds_until_ms(1_000, 0), 0);
|
|
assert_eq!(
|
|
refresh_strategy_name(ProviderCredentialRefreshStrategy::Oauth2RefreshToken as i32),
|
|
"oauth2_refresh_token"
|
|
);
|
|
assert_eq!(
|
|
refresh_strategy_name(
|
|
ProviderCredentialRefreshStrategy::Oauth2ClientCredentials as i32
|
|
),
|
|
"oauth2_client_credentials"
|
|
);
|
|
assert_eq!(
|
|
refresh_strategy_name(
|
|
ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt as i32
|
|
),
|
|
"google_service_account_jwt"
|
|
);
|
|
assert_eq!(refresh_strategy_name(i32::MAX), "unspecified");
|
|
}
|
|
|
|
#[test]
|
|
fn refresh_expiration_presence_distinguishes_absent_epoch_and_legacy_values() {
|
|
let mut state = StoredProviderCredentialRefreshState {
|
|
metadata: Some(ObjectMeta::default()),
|
|
..Default::default()
|
|
};
|
|
assert!(!refresh_has_expiration(&state));
|
|
assert!(refresh_status_from_state(&state).expiration_time.is_none());
|
|
|
|
set_refresh_expiration_presence(&mut state, true);
|
|
assert!(refresh_has_expiration(&state));
|
|
assert_eq!(
|
|
refresh_status_from_state(&state).expiration_time,
|
|
Some(ts(0))
|
|
);
|
|
|
|
set_refresh_expiration_presence(&mut state, false);
|
|
assert!(!refresh_has_expiration(&state));
|
|
|
|
state.expires_at_ms = 1;
|
|
assert!(refresh_has_expiration(&state));
|
|
assert_eq!(
|
|
refresh_status_from_state(&state).expiration_time,
|
|
Some(ts(1))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn local_refresh_statuses_default_to_safe_recovery_actions() {
|
|
for status in [
|
|
Status::invalid_argument("missing material"),
|
|
Status::failed_precondition("strategy cannot be minted"),
|
|
Status::permission_denied("client is not allowed"),
|
|
] {
|
|
let failure = RefreshFailure::from(status);
|
|
assert_eq!(
|
|
failure.recovery_action,
|
|
ProviderCredentialRefreshRecoveryAction::FixConfiguration
|
|
);
|
|
assert_eq!(failure.failure_code, "refresh_configuration_invalid");
|
|
assert_eq!(failure.retry_schedule, RefreshRetrySchedule::Configuration);
|
|
}
|
|
|
|
let retry = RefreshFailure::from(Status::unavailable("temporary backend outage"));
|
|
assert_eq!(
|
|
retry.recovery_action,
|
|
ProviderCredentialRefreshRecoveryAction::Retry
|
|
);
|
|
assert_eq!(retry.retry_schedule, RefreshRetrySchedule::Short);
|
|
|
|
let investigate = RefreshFailure::from(Status::unknown("unclassified failure"));
|
|
assert_eq!(
|
|
investigate.recovery_action,
|
|
ProviderCredentialRefreshRecoveryAction::Investigate
|
|
);
|
|
assert_eq!(investigate.retry_schedule, RefreshRetrySchedule::Short);
|
|
}
|
|
|
|
#[test]
|
|
fn pending_secret_deletions_preserve_multiple_generations_for_one_key() {
|
|
let mut state = StoredProviderCredentialRefreshState::default();
|
|
for handle in ["first", "second"] {
|
|
enqueue_pending_secret_deletion(
|
|
&mut state,
|
|
"refresh_token",
|
|
CredentialHandle {
|
|
driver: "test-static".to_string(),
|
|
handle: handle.to_string(),
|
|
metadata: HashMap::new(),
|
|
},
|
|
);
|
|
}
|
|
|
|
assert_eq!(state.pending_secret_deletions.len(), 2);
|
|
assert_eq!(
|
|
state.pending_secret_deletions[0]
|
|
.handle
|
|
.as_ref()
|
|
.unwrap()
|
|
.handle,
|
|
"first"
|
|
);
|
|
assert_eq!(
|
|
state.pending_secret_deletions[1]
|
|
.handle
|
|
.as_ref()
|
|
.unwrap()
|
|
.handle,
|
|
"second"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn refresh_rejects_secret_material_lost_by_mixed_version_gateway() {
|
|
let mut state = StoredProviderCredentialRefreshState {
|
|
secret_material_keys: vec!["refresh_token".to_string()],
|
|
..Default::default()
|
|
};
|
|
let err = validate_secret_material_references(&state).unwrap_err();
|
|
assert_eq!(err.code(), tonic::Code::FailedPrecondition);
|
|
assert!(err.message().contains("mixed-version gateway"));
|
|
assert!(err.message().contains("refresh_token"));
|
|
|
|
state.secret_material_handles.insert(
|
|
"refresh_token".to_string(),
|
|
CredentialHandle {
|
|
driver: "test-static".to_string(),
|
|
handle: "stored-token".to_string(),
|
|
metadata: HashMap::new(),
|
|
},
|
|
);
|
|
validate_secret_material_references(&state).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn oauth_invalid_grant_requires_user_reauthorization_without_exposing_description() {
|
|
let failure = classify_oauth_token_error(
|
|
reqwest::StatusCode::BAD_REQUEST,
|
|
br#"{"error":"invalid_grant","error_subtype":"invalid_rapt","error_description":"sensitive provider detail"}"#,
|
|
OAuthGrantKind::UserRefreshToken,
|
|
);
|
|
|
|
assert_eq!(
|
|
failure.recovery_action,
|
|
ProviderCredentialRefreshRecoveryAction::Reauthorize
|
|
);
|
|
assert_eq!(failure.failure_code, "oauth_invalid_grant");
|
|
assert_eq!(failure.provider_error_subtype, Some("invalid_rapt"));
|
|
assert_eq!(failure.retry_schedule, RefreshRetrySchedule::Parked);
|
|
assert!(
|
|
failure
|
|
.status
|
|
.message()
|
|
.contains("interactive reauthorization")
|
|
);
|
|
assert!(
|
|
!failure
|
|
.status
|
|
.message()
|
|
.contains("sensitive provider detail")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn oauth_invalid_grant_ignores_non_string_optional_subtype() {
|
|
let failure = classify_oauth_token_error(
|
|
reqwest::StatusCode::BAD_REQUEST,
|
|
br#"{"error":"invalid_grant","error_subtype":{"vendor":"value"}}"#,
|
|
OAuthGrantKind::UserRefreshToken,
|
|
);
|
|
|
|
assert_eq!(
|
|
failure.recovery_action,
|
|
ProviderCredentialRefreshRecoveryAction::Reauthorize
|
|
);
|
|
assert_eq!(failure.failure_code, "oauth_invalid_grant");
|
|
assert_eq!(failure.provider_error_subtype, None);
|
|
assert_eq!(failure.retry_schedule, RefreshRetrySchedule::Parked);
|
|
}
|
|
|
|
#[test]
|
|
fn oauth_noninteractive_invalid_grant_requires_configuration_fix() {
|
|
let failure = classify_oauth_token_error(
|
|
reqwest::StatusCode::BAD_REQUEST,
|
|
br#"{"error":"invalid_grant"}"#,
|
|
OAuthGrantKind::NonInteractive,
|
|
);
|
|
|
|
assert_eq!(
|
|
failure.recovery_action,
|
|
ProviderCredentialRefreshRecoveryAction::FixConfiguration
|
|
);
|
|
assert_eq!(failure.failure_code, "oauth_invalid_grant");
|
|
assert_eq!(failure.retry_schedule, RefreshRetrySchedule::Configuration);
|
|
}
|
|
|
|
#[test]
|
|
fn oauth_admin_policy_subtype_requires_configuration_fix() {
|
|
let failure = classify_oauth_token_error(
|
|
reqwest::StatusCode::BAD_REQUEST,
|
|
br#"{"error":"access_denied","error_subtype":"admin_policy_enforced"}"#,
|
|
OAuthGrantKind::UserRefreshToken,
|
|
);
|
|
|
|
assert_eq!(
|
|
failure.recovery_action,
|
|
ProviderCredentialRefreshRecoveryAction::FixConfiguration
|
|
);
|
|
assert_eq!(failure.failure_code, "oauth_admin_policy_enforced");
|
|
assert_eq!(
|
|
failure.provider_error_subtype,
|
|
Some("admin_policy_enforced")
|
|
);
|
|
assert_eq!(failure.retry_schedule, RefreshRetrySchedule::Configuration);
|
|
}
|
|
|
|
#[test]
|
|
fn oauth_access_denied_recovery_depends_on_grant_kind() {
|
|
let user_failure = classify_oauth_token_error(
|
|
reqwest::StatusCode::FORBIDDEN,
|
|
br#"{"error":"access_denied"}"#,
|
|
OAuthGrantKind::UserRefreshToken,
|
|
);
|
|
assert_eq!(
|
|
user_failure.recovery_action,
|
|
ProviderCredentialRefreshRecoveryAction::Reauthorize
|
|
);
|
|
assert_eq!(user_failure.failure_code, "oauth_access_denied");
|
|
assert_eq!(user_failure.retry_schedule, RefreshRetrySchedule::Parked);
|
|
|
|
let service_failure = classify_oauth_token_error(
|
|
reqwest::StatusCode::FORBIDDEN,
|
|
br#"{"error":"access_denied"}"#,
|
|
OAuthGrantKind::NonInteractive,
|
|
);
|
|
assert_eq!(
|
|
service_failure.recovery_action,
|
|
ProviderCredentialRefreshRecoveryAction::FixConfiguration
|
|
);
|
|
assert_eq!(service_failure.failure_code, "oauth_access_denied");
|
|
assert_eq!(
|
|
service_failure.retry_schedule,
|
|
RefreshRetrySchedule::Configuration
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn oauth_server_failure_remains_retryable() {
|
|
let failure = classify_oauth_token_error(
|
|
reqwest::StatusCode::SERVICE_UNAVAILABLE,
|
|
br#"{"error":"invalid_grant"}"#,
|
|
OAuthGrantKind::UserRefreshToken,
|
|
);
|
|
|
|
assert_eq!(
|
|
failure.recovery_action,
|
|
ProviderCredentialRefreshRecoveryAction::Retry
|
|
);
|
|
assert_eq!(failure.failure_code, "oauth_token_endpoint_retryable");
|
|
assert_eq!(failure.retry_schedule, RefreshRetrySchedule::Short);
|
|
}
|
|
|
|
#[test]
|
|
fn unrecognized_oauth_error_requests_investigation_without_echoing_body() {
|
|
let failure = classify_oauth_token_error(
|
|
reqwest::StatusCode::BAD_REQUEST,
|
|
br#"{"error":"vendor_secret_error","error_description":"do not expose me"}"#,
|
|
OAuthGrantKind::UserRefreshToken,
|
|
);
|
|
|
|
assert_eq!(
|
|
failure.recovery_action,
|
|
ProviderCredentialRefreshRecoveryAction::Investigate
|
|
);
|
|
assert_eq!(failure.failure_code, "oauth_unrecognized_error");
|
|
assert_eq!(failure.retry_schedule, RefreshRetrySchedule::Short);
|
|
assert!(!failure.status.message().contains("vendor_secret_error"));
|
|
assert!(!failure.status.message().contains("do not expose me"));
|
|
}
|
|
|
|
#[test]
|
|
fn html_and_malformed_oauth_errors_are_investigated_without_echoing_body() {
|
|
for body in [b"<html>issuer failure</html>".as_slice(), b"{".as_slice()] {
|
|
let failure = classify_oauth_token_error(
|
|
reqwest::StatusCode::BAD_REQUEST,
|
|
body,
|
|
OAuthGrantKind::UserRefreshToken,
|
|
);
|
|
|
|
assert_eq!(
|
|
failure.recovery_action,
|
|
ProviderCredentialRefreshRecoveryAction::Investigate
|
|
);
|
|
assert_eq!(failure.failure_code, "oauth_unrecognized_error_response");
|
|
assert!(!failure.status.message().contains("issuer failure"));
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn oversized_oauth_error_body_is_bounded_and_investigated() {
|
|
let mock_server = MockServer::start().await;
|
|
Mock::given(method("GET"))
|
|
.and(path("/oversized"))
|
|
.respond_with(ResponseTemplate::new(400).set_body_bytes(vec![
|
|
b'x';
|
|
MAX_OAUTH_ERROR_RESPONSE_BYTES
|
|
+ 512
|
|
]))
|
|
.mount(&mock_server)
|
|
.await;
|
|
|
|
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
|
let response = reqwest::get(format!("{}/oversized", mock_server.uri()))
|
|
.await
|
|
.unwrap();
|
|
let status = response.status();
|
|
let body = read_bounded_oauth_error_body(response).await;
|
|
assert_eq!(body.len(), MAX_OAUTH_ERROR_RESPONSE_BYTES);
|
|
|
|
let failure = classify_oauth_token_error(status, &body, OAuthGrantKind::UserRefreshToken);
|
|
assert_eq!(
|
|
failure.recovery_action,
|
|
ProviderCredentialRefreshRecoveryAction::Investigate
|
|
);
|
|
assert_eq!(failure.failure_code, "oauth_unrecognized_error_response");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn invalid_local_oauth_configuration_retries_hourly() {
|
|
let store = test_store().await;
|
|
let provider = provider("invalid-local-config", "outlook");
|
|
store.put_message(&provider).await.unwrap();
|
|
let state = new_refresh_state(
|
|
&provider,
|
|
"default",
|
|
"MS_GRAPH_ACCESS_TOKEN",
|
|
NewRefreshStateConfig {
|
|
strategy: ProviderCredentialRefreshStrategy::Oauth2RefreshToken,
|
|
material: HashMap::from([
|
|
("client_id".to_string(), "client-id".to_string()),
|
|
("refresh_token".to_string(), "refresh-token".to_string()),
|
|
]),
|
|
secret_material_keys: vec!["refresh_token".to_string()],
|
|
expires_at_ms: 0,
|
|
token_url: "not-an-absolute-url".to_string(),
|
|
scopes: Vec::new(),
|
|
refresh_before: Some(proto_duration(30)),
|
|
max_lifetime: Some(proto_duration(60)),
|
|
additional_output_keys: HashMap::new(),
|
|
},
|
|
)
|
|
.unwrap();
|
|
put_refresh_state(&store, &state).await.unwrap();
|
|
|
|
let err = refresh_provider_credential(
|
|
&store,
|
|
"default",
|
|
&test_credentials(),
|
|
None,
|
|
"invalid-local-config",
|
|
"MS_GRAPH_ACCESS_TOKEN",
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
|
|
assert_eq!(err.code(), tonic::Code::InvalidArgument);
|
|
let stored = get_refresh_state(
|
|
&store,
|
|
"default",
|
|
provider.object_id(),
|
|
"MS_GRAPH_ACCESS_TOKEN",
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(stored.status, "configuration_required");
|
|
assert_eq!(
|
|
stored.recovery_action,
|
|
ProviderCredentialRefreshRecoveryAction::FixConfiguration as i32
|
|
);
|
|
assert_eq!(stored.failure_code, "refresh_configuration_invalid");
|
|
assert_eq!(
|
|
stored.next_refresh_at_ms - stored.last_error_at_ms,
|
|
60 * 60 * 1000
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn oauth_invalid_grant_persists_terminal_reauthorization_status() {
|
|
let mock_server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/token"))
|
|
.respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
|
|
"error": "invalid_grant",
|
|
"error_subtype": "invalid_rapt",
|
|
"error_description": "provider-controlled detail"
|
|
})))
|
|
.mount(&mock_server)
|
|
.await;
|
|
|
|
let store = test_store().await;
|
|
let provider = provider("expired-grant", "outlook");
|
|
store.put_message(&provider).await.unwrap();
|
|
let state = new_refresh_state(
|
|
&provider,
|
|
"default",
|
|
"MS_GRAPH_ACCESS_TOKEN",
|
|
NewRefreshStateConfig {
|
|
strategy: ProviderCredentialRefreshStrategy::Oauth2RefreshToken,
|
|
material: HashMap::from([
|
|
("client_id".to_string(), "client-id".to_string()),
|
|
(
|
|
"refresh_token".to_string(),
|
|
"expired-refresh-token".to_string(),
|
|
),
|
|
]),
|
|
secret_material_keys: vec!["refresh_token".to_string()],
|
|
expires_at_ms: 0,
|
|
token_url: format!("{}/token", mock_server.uri()),
|
|
scopes: Vec::new(),
|
|
refresh_before: Some(proto_duration(30)),
|
|
max_lifetime: Some(proto_duration(60)),
|
|
additional_output_keys: HashMap::new(),
|
|
},
|
|
)
|
|
.unwrap();
|
|
put_refresh_state(&store, &state).await.unwrap();
|
|
|
|
let err = refresh_provider_credential(
|
|
&store,
|
|
"default",
|
|
&test_credentials(),
|
|
None,
|
|
"expired-grant",
|
|
"MS_GRAPH_ACCESS_TOKEN",
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
|
|
assert_eq!(err.code(), tonic::Code::FailedPrecondition);
|
|
let stored = get_refresh_state(
|
|
&store,
|
|
"default",
|
|
provider.object_id(),
|
|
"MS_GRAPH_ACCESS_TOKEN",
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(stored.status, "reauthorization_required");
|
|
assert_eq!(stored.next_refresh_at_ms, i64::MAX);
|
|
assert_eq!(
|
|
stored.recovery_action,
|
|
ProviderCredentialRefreshRecoveryAction::Reauthorize as i32
|
|
);
|
|
assert_eq!(stored.failure_code, "oauth_invalid_grant");
|
|
assert_eq!(stored.provider_error_subtype, "invalid_rapt");
|
|
assert!(stored.last_error_at_ms > 0);
|
|
assert!(!stored.last_error.contains("provider-controlled detail"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn oauth2_client_credentials_refresh_mints_and_persists_access_token() {
|
|
let mock_server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/token"))
|
|
.and(body_string_contains("grant_type=client_credentials"))
|
|
.and(body_string_contains("client_id=client-id"))
|
|
.and(body_string_contains(
|
|
"scope=https%3A%2F%2Fgraph.microsoft.com%2F.default",
|
|
))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
|
"access_token": "minted-graph-token",
|
|
"expires_in": 3600,
|
|
"token_type": "Bearer"
|
|
})))
|
|
.mount(&mock_server)
|
|
.await;
|
|
|
|
let store = test_store().await;
|
|
let provider = provider("my-graph", "outlook");
|
|
store.put_message(&provider).await.unwrap();
|
|
let before_refresh_ms = current_time_ms();
|
|
let mut state = new_refresh_state(
|
|
&provider,
|
|
"default",
|
|
"MS_GRAPH_ACCESS_TOKEN",
|
|
NewRefreshStateConfig {
|
|
additional_output_keys: HashMap::new(),
|
|
strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials,
|
|
material: HashMap::from([
|
|
("client_id".to_string(), "client-id".to_string()),
|
|
("client_secret".to_string(), "client-secret".to_string()),
|
|
]),
|
|
secret_material_keys: vec!["client_secret".to_string()],
|
|
expires_at_ms: 0,
|
|
token_url: format!("{}/token", mock_server.uri()),
|
|
scopes: vec!["https://graph.microsoft.com/.default".to_string()],
|
|
refresh_before: Some(proto_duration(30)),
|
|
max_lifetime: Some(proto_duration(60)),
|
|
},
|
|
)
|
|
.unwrap();
|
|
state.status = "investigation_required".to_string();
|
|
state.last_error = "safe prior error".to_string();
|
|
state.recovery_action = ProviderCredentialRefreshRecoveryAction::Investigate as i32;
|
|
state.failure_code = "oauth_unrecognized_error".to_string();
|
|
state.provider_error_subtype = "prior_subtype".to_string();
|
|
state.last_error_at_ms = current_time_ms();
|
|
put_refresh_state(&store, &state).await.unwrap();
|
|
let authorization_epoch = state.authorization_epoch.clone();
|
|
let credentials = test_credentials();
|
|
|
|
let refreshed = refresh_provider_credential(
|
|
&store,
|
|
"default",
|
|
&credentials,
|
|
None,
|
|
"my-graph",
|
|
"MS_GRAPH_ACCESS_TOKEN",
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(refreshed.authorization_epoch, authorization_epoch);
|
|
assert_eq!(refreshed.status, "refreshed");
|
|
assert!(refreshed.expires_at_ms > 0);
|
|
assert!(refreshed.next_refresh_at_ms > 0);
|
|
assert!(refreshed.expires_at_ms <= before_refresh_ms + 120_000);
|
|
assert!(refreshed.last_error.is_empty());
|
|
assert_eq!(
|
|
refreshed.recovery_action,
|
|
ProviderCredentialRefreshRecoveryAction::Unspecified as i32
|
|
);
|
|
assert!(refreshed.failure_code.is_empty());
|
|
assert!(refreshed.provider_error_subtype.is_empty());
|
|
assert_eq!(refreshed.last_error_at_ms, 0);
|
|
|
|
let stored = store
|
|
.get_message_by_name::<Provider>("default", "my-graph")
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
let resolved = credentials
|
|
.resolve_provider_handles(&stored, current_time_ms())
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
resolved.values.get("MS_GRAPH_ACCESS_TOKEN"),
|
|
Some(&"minted-graph-token".to_string())
|
|
);
|
|
assert_eq!(
|
|
stored
|
|
.credential_expiration_times
|
|
.get("MS_GRAPH_ACCESS_TOKEN"),
|
|
Some(&ts(refreshed.expires_at_ms))
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn oauth2_client_credentials_refresh_stores_access_token_with_credential_runtime() {
|
|
let mock_server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/token"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
|
"access_token": "stored-graph-token",
|
|
"expires_in": 3600,
|
|
"token_type": "Bearer"
|
|
})))
|
|
.mount(&mock_server)
|
|
.await;
|
|
|
|
let store = test_store().await;
|
|
let provider = provider("my-stored-graph", "outlook");
|
|
store.put_message(&provider).await.unwrap();
|
|
let mut state = new_refresh_state(
|
|
&provider,
|
|
"default",
|
|
"MS_GRAPH_ACCESS_TOKEN",
|
|
NewRefreshStateConfig {
|
|
strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials,
|
|
material: HashMap::from([
|
|
("client_id".to_string(), "client-id".to_string()),
|
|
("client_secret".to_string(), "client-secret".to_string()),
|
|
]),
|
|
secret_material_keys: vec!["client_secret".to_string()],
|
|
expires_at_ms: 0,
|
|
token_url: format!("{}/token", mock_server.uri()),
|
|
scopes: Vec::new(),
|
|
refresh_before: Some(proto_duration(30)),
|
|
max_lifetime: Some(proto_duration(60)),
|
|
additional_output_keys: HashMap::new(),
|
|
},
|
|
)
|
|
.unwrap();
|
|
let config = Config::new(None).with_credential_drivers(["test-static"]);
|
|
let credentials = CredentialRuntime::from_config(&config).unwrap();
|
|
state.secret_material_handles = credentials
|
|
.store_refresh_material_with_object_id(
|
|
refresh_material_scope(&state),
|
|
"configured-client-secret",
|
|
&HashMap::from([("client_secret".to_string(), "client-secret".to_string())]),
|
|
&HashMap::new(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
state.material.remove("client_secret");
|
|
put_refresh_state(&store, &state).await.unwrap();
|
|
let authorization_epoch = state.authorization_epoch.clone();
|
|
|
|
let refreshed = refresh_provider_credential(
|
|
&store,
|
|
"default",
|
|
&credentials,
|
|
None,
|
|
"my-stored-graph",
|
|
"MS_GRAPH_ACCESS_TOKEN",
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(refreshed.authorization_epoch, authorization_epoch);
|
|
let stored_refresh = get_refresh_state(
|
|
&store,
|
|
"default",
|
|
provider.object_id(),
|
|
"MS_GRAPH_ACCESS_TOKEN",
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert!(!stored_refresh.material.contains_key("client_secret"));
|
|
assert!(
|
|
stored_refresh
|
|
.secret_material_handles
|
|
.contains_key("client_secret")
|
|
);
|
|
|
|
let stored = store
|
|
.get_message_by_name::<Provider>("default", "my-stored-graph")
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert!(!stored.credentials.contains_key("MS_GRAPH_ACCESS_TOKEN"));
|
|
let handle = stored
|
|
.credential_handles
|
|
.get("MS_GRAPH_ACCESS_TOKEN")
|
|
.unwrap();
|
|
assert_eq!(handle.driver, "test-static");
|
|
assert_eq!(
|
|
stored
|
|
.credential_expiration_times
|
|
.get("MS_GRAPH_ACCESS_TOKEN"),
|
|
Some(&ts(refreshed.expires_at_ms))
|
|
);
|
|
|
|
let resolved = credentials
|
|
.resolve_provider_handles(&stored, current_time_ms())
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
resolved.values.get("MS_GRAPH_ACCESS_TOKEN"),
|
|
Some(&"stored-graph-token".to_string())
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn refresh_rejects_minted_credential_key_collision_for_attached_sandbox() {
|
|
let mock_server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/token"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
|
"access_token": "minted-graph-token",
|
|
"expires_in": 3600,
|
|
"token_type": "Bearer"
|
|
})))
|
|
.mount(&mock_server)
|
|
.await;
|
|
|
|
let store = test_store().await;
|
|
let mut provider_a = provider("existing-graph", "outlook");
|
|
provider_a.credentials.insert(
|
|
"MS_GRAPH_ACCESS_TOKEN".to_string(),
|
|
"existing-token".to_string(),
|
|
);
|
|
store.put_message(&provider_a).await.unwrap();
|
|
let provider_b = provider("refreshing-graph", "outlook");
|
|
store.put_message(&provider_b).await.unwrap();
|
|
store
|
|
.put_message(&Sandbox {
|
|
metadata: Some(ObjectMeta {
|
|
id: "sandbox-collision".to_string(),
|
|
name: "collision".to_string(),
|
|
created_time: openshell_core::time::timestamp_from_millis(1).ok(),
|
|
labels: HashMap::new(),
|
|
resource_version: 0,
|
|
annotations: HashMap::new(),
|
|
workspace: "default".to_string(),
|
|
deletion_time: None,
|
|
}),
|
|
spec: Some(SandboxSpec {
|
|
providers: vec!["existing-graph".to_string(), "refreshing-graph".to_string()],
|
|
..SandboxSpec::default()
|
|
}),
|
|
..Default::default()
|
|
})
|
|
.await
|
|
.unwrap();
|
|
let state = new_refresh_state(
|
|
&provider_b,
|
|
"default",
|
|
"MS_GRAPH_ACCESS_TOKEN",
|
|
NewRefreshStateConfig {
|
|
additional_output_keys: HashMap::new(),
|
|
strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials,
|
|
material: HashMap::from([
|
|
("client_id".to_string(), "client-id".to_string()),
|
|
("client_secret".to_string(), "client-secret".to_string()),
|
|
]),
|
|
secret_material_keys: vec!["client_secret".to_string()],
|
|
expires_at_ms: 0,
|
|
token_url: format!("{}/token", mock_server.uri()),
|
|
scopes: Vec::new(),
|
|
refresh_before: Some(proto_duration(30)),
|
|
max_lifetime: Some(proto_duration(60)),
|
|
},
|
|
)
|
|
.unwrap();
|
|
put_refresh_state(&store, &state).await.unwrap();
|
|
let credentials = test_credentials();
|
|
|
|
let err = refresh_provider_credential(
|
|
&store,
|
|
"default",
|
|
&credentials,
|
|
None,
|
|
"refreshing-graph",
|
|
"MS_GRAPH_ACCESS_TOKEN",
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
|
|
assert_eq!(err.code(), tonic::Code::FailedPrecondition);
|
|
assert!(err.message().contains("MS_GRAPH_ACCESS_TOKEN"));
|
|
let stored_state = get_refresh_state(
|
|
&store,
|
|
"default",
|
|
provider_b.object_id(),
|
|
"MS_GRAPH_ACCESS_TOKEN",
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(stored_state.status, "configuration_required");
|
|
assert_eq!(
|
|
stored_state.recovery_action,
|
|
ProviderCredentialRefreshRecoveryAction::FixConfiguration as i32
|
|
);
|
|
assert_eq!(stored_state.failure_code, "refresh_configuration_invalid");
|
|
assert_eq!(
|
|
stored_state.next_refresh_at_ms - stored_state.last_error_at_ms,
|
|
60 * 60 * 1000
|
|
);
|
|
assert!(stored_state.last_error.contains("MS_GRAPH_ACCESS_TOKEN"));
|
|
let stored_provider = store
|
|
.get_message_by_name::<Provider>("default", "refreshing-graph")
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert!(
|
|
!stored_provider
|
|
.credentials
|
|
.contains_key("MS_GRAPH_ACCESS_TOKEN")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn oauth2_refresh_token_refresh_mints_access_token_and_persists_rotated_refresh_token() {
|
|
let mock_server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/token"))
|
|
.and(body_string_contains("grant_type=refresh_token"))
|
|
.and(body_string_contains("client_id=client-id"))
|
|
.and(body_string_contains("refresh_token=old-refresh-token"))
|
|
.and(body_string_contains(
|
|
"scope=https%3A%2F%2Fgraph.microsoft.com%2F.default",
|
|
))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
|
"access_token": "delegated-graph-token",
|
|
"refresh_token": "rotated-refresh-token",
|
|
"expires_in": 3600,
|
|
"token_type": "Bearer"
|
|
})))
|
|
.mount(&mock_server)
|
|
.await;
|
|
|
|
let store = test_store().await;
|
|
let provider = provider("my-delegated-graph", "outlook");
|
|
store.put_message(&provider).await.unwrap();
|
|
let state = new_refresh_state(
|
|
&provider,
|
|
"default",
|
|
"MS_GRAPH_ACCESS_TOKEN",
|
|
NewRefreshStateConfig {
|
|
additional_output_keys: HashMap::new(),
|
|
strategy: ProviderCredentialRefreshStrategy::Oauth2RefreshToken,
|
|
material: HashMap::from([
|
|
("client_id".to_string(), "client-id".to_string()),
|
|
("refresh_token".to_string(), "old-refresh-token".to_string()),
|
|
]),
|
|
secret_material_keys: vec!["refresh_token".to_string()],
|
|
expires_at_ms: 0,
|
|
token_url: format!("{}/token", mock_server.uri()),
|
|
scopes: vec!["https://graph.microsoft.com/.default".to_string()],
|
|
refresh_before: Some(proto_duration(30)),
|
|
max_lifetime: Some(proto_duration(60)),
|
|
},
|
|
)
|
|
.unwrap();
|
|
put_refresh_state(&store, &state).await.unwrap();
|
|
let credentials = CredentialRuntime::from_config(
|
|
&Config::new(None).with_credential_drivers(["test-static"]),
|
|
)
|
|
.unwrap();
|
|
|
|
let refreshed = refresh_provider_credential(
|
|
&store,
|
|
"default",
|
|
&credentials,
|
|
None,
|
|
"my-delegated-graph",
|
|
"MS_GRAPH_ACCESS_TOKEN",
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(refreshed.status, "refreshed");
|
|
assert!(refreshed.expires_at_ms > 0);
|
|
|
|
let stored_provider = store
|
|
.get_message_by_name::<Provider>("default", "my-delegated-graph")
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert!(
|
|
!stored_provider
|
|
.credentials
|
|
.contains_key("MS_GRAPH_ACCESS_TOKEN")
|
|
);
|
|
assert!(
|
|
stored_provider
|
|
.credential_handles
|
|
.contains_key("MS_GRAPH_ACCESS_TOKEN")
|
|
);
|
|
assert_eq!(
|
|
stored_provider
|
|
.credential_expiration_times
|
|
.get("MS_GRAPH_ACCESS_TOKEN"),
|
|
Some(&ts(refreshed.expires_at_ms))
|
|
);
|
|
|
|
let stored_state = get_refresh_state(
|
|
&store,
|
|
"default",
|
|
provider.object_id(),
|
|
"MS_GRAPH_ACCESS_TOKEN",
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert!(!stored_state.material.contains_key("refresh_token"));
|
|
assert!(
|
|
stored_state
|
|
.secret_material_handles
|
|
.contains_key("refresh_token")
|
|
);
|
|
assert!(stored_state.pending_secret_deletions.is_empty());
|
|
assert_eq!(
|
|
credentials
|
|
.resolve_refresh_material(
|
|
refresh_material_scope(&stored_state),
|
|
&stored_state.secret_material_handles,
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.get("refresh_token"),
|
|
Some(&"rotated-refresh-token".to_string())
|
|
);
|
|
assert!(
|
|
stored_state
|
|
.secret_material_keys
|
|
.iter()
|
|
.any(|key| key == "refresh_token")
|
|
);
|
|
assert_eq!(
|
|
credentials.stored_credential_count(),
|
|
Some(2),
|
|
"only the access token and current refresh token remain"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn refresh_continues_when_pending_secret_cleanup_temporarily_fails() {
|
|
let mock_server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/token"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
|
"access_token": "minted-after-cleanup-failure",
|
|
"expires_in": 3600,
|
|
"token_type": "Bearer"
|
|
})))
|
|
.mount(&mock_server)
|
|
.await;
|
|
|
|
let store = test_store().await;
|
|
let provider = provider("cleanup-retry", "outlook");
|
|
store.put_message(&provider).await.unwrap();
|
|
let credentials = test_credentials();
|
|
let mut state = new_refresh_state(
|
|
&provider,
|
|
"default",
|
|
"MS_GRAPH_ACCESS_TOKEN",
|
|
NewRefreshStateConfig {
|
|
strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials,
|
|
material: HashMap::from([
|
|
("client_id".to_string(), "client-id".to_string()),
|
|
("client_secret".to_string(), "client-secret".to_string()),
|
|
]),
|
|
secret_material_keys: vec!["client_secret".to_string()],
|
|
expires_at_ms: 0,
|
|
token_url: format!("{}/token", mock_server.uri()),
|
|
scopes: Vec::new(),
|
|
refresh_before: Some(proto_duration(30)),
|
|
max_lifetime: Some(proto_duration(60)),
|
|
additional_output_keys: HashMap::new(),
|
|
},
|
|
)
|
|
.unwrap();
|
|
state.secret_material_handles = credentials
|
|
.store_refresh_material_with_object_id(
|
|
refresh_material_scope(&state),
|
|
"current-refresh-object",
|
|
&HashMap::from([("client_secret".to_string(), "client-secret".to_string())]),
|
|
&HashMap::new(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
state.material.remove("client_secret");
|
|
let old = credentials
|
|
.store_refresh_material_with_object_id(
|
|
refresh_material_scope(&state),
|
|
"old-refresh-object",
|
|
&HashMap::from([("client_secret".to_string(), "obsolete".to_string())]),
|
|
&HashMap::new(),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.remove("client_secret")
|
|
.unwrap();
|
|
enqueue_pending_secret_deletion(&mut state, "client_secret", old);
|
|
put_refresh_state(&store, &state).await.unwrap();
|
|
credentials.fail_next_delete();
|
|
|
|
let refreshed = refresh_provider_credential(
|
|
&store,
|
|
"default",
|
|
&credentials,
|
|
None,
|
|
"cleanup-retry",
|
|
"MS_GRAPH_ACCESS_TOKEN",
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(refreshed.status, "refreshed");
|
|
assert!(refreshed.pending_secret_deletions.is_empty());
|
|
let stored = get_refresh_state(
|
|
&store,
|
|
"default",
|
|
provider.object_id(),
|
|
"MS_GRAPH_ACCESS_TOKEN",
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert!(stored.pending_secret_deletions.is_empty());
|
|
assert_eq!(
|
|
credentials.stored_credential_count(),
|
|
Some(2),
|
|
"the current client secret and minted access token remain"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rotated_refresh_token_store_failure_requires_reauthorization() {
|
|
let mock_server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/token"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
|
"access_token": "access-token-that-must-not-be-applied",
|
|
"refresh_token": "replacement-refresh-token",
|
|
"expires_in": 3600,
|
|
"token_type": "Bearer"
|
|
})))
|
|
.mount(&mock_server)
|
|
.await;
|
|
|
|
let store = test_store().await;
|
|
let provider = provider("rotation-store-failure", "outlook");
|
|
store.put_message(&provider).await.unwrap();
|
|
let credentials = test_credentials();
|
|
let mut state = new_refresh_state(
|
|
&provider,
|
|
"default",
|
|
"MS_GRAPH_ACCESS_TOKEN",
|
|
NewRefreshStateConfig {
|
|
strategy: ProviderCredentialRefreshStrategy::Oauth2RefreshToken,
|
|
material: HashMap::from([
|
|
("client_id".to_string(), "client-id".to_string()),
|
|
("refresh_token".to_string(), "old-refresh-token".to_string()),
|
|
]),
|
|
secret_material_keys: vec!["refresh_token".to_string()],
|
|
expires_at_ms: 0,
|
|
token_url: format!("{}/token", mock_server.uri()),
|
|
scopes: Vec::new(),
|
|
refresh_before: Some(proto_duration(30)),
|
|
max_lifetime: Some(proto_duration(60)),
|
|
additional_output_keys: HashMap::new(),
|
|
},
|
|
)
|
|
.unwrap();
|
|
state.secret_material_handles = credentials
|
|
.store_refresh_material_with_object_id(
|
|
refresh_material_scope(&state),
|
|
"original-grant",
|
|
&HashMap::from([("refresh_token".to_string(), "old-refresh-token".to_string())]),
|
|
&HashMap::new(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
state.material.remove("refresh_token");
|
|
put_refresh_state(&store, &state).await.unwrap();
|
|
credentials.fail_next_store();
|
|
|
|
let err = refresh_provider_credential(
|
|
&store,
|
|
"default",
|
|
&credentials,
|
|
None,
|
|
"rotation-store-failure",
|
|
"MS_GRAPH_ACCESS_TOKEN",
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
|
|
assert_eq!(err.code(), tonic::Code::FailedPrecondition);
|
|
assert!(err.message().contains("must be re-authorized"));
|
|
let stored = get_refresh_state(
|
|
&store,
|
|
"default",
|
|
provider.object_id(),
|
|
"MS_GRAPH_ACCESS_TOKEN",
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(stored.status, "reauthorization_required");
|
|
assert!(stored.last_error.contains("must be re-authorized"));
|
|
assert_eq!(stored.next_refresh_at_ms, i64::MAX);
|
|
assert_eq!(
|
|
stored.recovery_action,
|
|
ProviderCredentialRefreshRecoveryAction::Reauthorize as i32
|
|
);
|
|
assert_eq!(
|
|
stored.failure_code,
|
|
"oauth_rotated_refresh_token_store_failed"
|
|
);
|
|
assert_eq!(credentials.stored_credential_count(), Some(1));
|
|
assert_eq!(
|
|
credentials
|
|
.resolve_refresh_material(
|
|
refresh_material_scope(&stored),
|
|
&stored.secret_material_handles,
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.get("refresh_token"),
|
|
Some(&"old-refresh-token".to_string())
|
|
);
|
|
let stored_provider = store
|
|
.get_message_by_name::<Provider>("default", "rotation-store-failure")
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert!(stored_provider.credential_handles.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn google_service_account_refresh_mints_and_persists_access_token() {
|
|
let mock_server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/token"))
|
|
.and(body_string_contains(
|
|
"grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer",
|
|
))
|
|
.and(body_string_contains("assertion="))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
|
"access_token": "minted-drive-token",
|
|
"expires_in": 1800,
|
|
"token_type": "Bearer"
|
|
})))
|
|
.mount(&mock_server)
|
|
.await;
|
|
|
|
let store = test_store().await;
|
|
let provider = provider("my-drive", "google-drive");
|
|
store.put_message(&provider).await.unwrap();
|
|
let state = new_refresh_state(
|
|
&provider,
|
|
"default",
|
|
"GOOGLE_DRIVE_ACCESS_TOKEN",
|
|
NewRefreshStateConfig {
|
|
additional_output_keys: HashMap::new(),
|
|
strategy: ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt,
|
|
material: HashMap::from([
|
|
(
|
|
"client_email".to_string(),
|
|
"svc@example.iam.gserviceaccount.com".to_string(),
|
|
),
|
|
("private_key".to_string(), TEST_RSA_PRIVATE_KEY.to_string()),
|
|
]),
|
|
secret_material_keys: vec!["private_key".to_string()],
|
|
expires_at_ms: 0,
|
|
token_url: format!("{}/token", mock_server.uri()),
|
|
scopes: vec!["https://www.googleapis.com/auth/drive.readonly".to_string()],
|
|
refresh_before: Some(proto_duration(300)),
|
|
max_lifetime: Some(proto_duration(3600)),
|
|
},
|
|
)
|
|
.unwrap();
|
|
put_refresh_state(&store, &state).await.unwrap();
|
|
let credentials = test_credentials();
|
|
|
|
let refreshed = refresh_provider_credential(
|
|
&store,
|
|
"default",
|
|
&credentials,
|
|
None,
|
|
"my-drive",
|
|
"GOOGLE_DRIVE_ACCESS_TOKEN",
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(refreshed.status, "refreshed");
|
|
assert!(refreshed.expires_at_ms > 0);
|
|
|
|
let stored = store
|
|
.get_message_by_name::<Provider>("default", "my-drive")
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
let resolved = credentials
|
|
.resolve_provider_handles(&stored, current_time_ms())
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
resolved.values.get("GOOGLE_DRIVE_ACCESS_TOKEN"),
|
|
Some(&"minted-drive-token".to_string())
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn refresh_worker_skips_non_gateway_mintable_strategies() {
|
|
let store = test_store().await;
|
|
let provider = provider("my-external", "outlook");
|
|
store.put_message(&provider).await.unwrap();
|
|
let state = new_refresh_state(
|
|
&provider,
|
|
"default",
|
|
"MS_GRAPH_ACCESS_TOKEN",
|
|
NewRefreshStateConfig {
|
|
additional_output_keys: HashMap::new(),
|
|
strategy: ProviderCredentialRefreshStrategy::External,
|
|
material: HashMap::new(),
|
|
secret_material_keys: Vec::new(),
|
|
expires_at_ms: 0,
|
|
token_url: String::new(),
|
|
scopes: Vec::new(),
|
|
refresh_before: None,
|
|
max_lifetime: None,
|
|
},
|
|
)
|
|
.unwrap();
|
|
put_refresh_state(&store, &state).await.unwrap();
|
|
|
|
Box::pin(run_refresh_worker_tick(&store, None, None))
|
|
.await
|
|
.unwrap();
|
|
|
|
let stored_state = get_refresh_state(
|
|
&store,
|
|
"default",
|
|
provider.object_id(),
|
|
"MS_GRAPH_ACCESS_TOKEN",
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_ne!(stored_state.status, "error");
|
|
assert!(stored_state.last_error.is_empty());
|
|
|
|
let stored_provider = store
|
|
.get_message_by_name::<Provider>("default", "my-external")
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert!(
|
|
!stored_provider
|
|
.credentials
|
|
.contains_key("MS_GRAPH_ACCESS_TOKEN")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn refresh_worker_skips_parked_reauthorization_state() {
|
|
let store = test_store().await;
|
|
let provider = provider("parked-refresh", "outlook");
|
|
store.put_message(&provider).await.unwrap();
|
|
let mut state = new_refresh_state(
|
|
&provider,
|
|
"default",
|
|
"MS_GRAPH_ACCESS_TOKEN",
|
|
NewRefreshStateConfig {
|
|
strategy: ProviderCredentialRefreshStrategy::Oauth2RefreshToken,
|
|
material: HashMap::new(),
|
|
secret_material_keys: Vec::new(),
|
|
expires_at_ms: 0,
|
|
token_url: "https://issuer.example/token".to_string(),
|
|
scopes: Vec::new(),
|
|
refresh_before: Some(proto_duration(30)),
|
|
max_lifetime: Some(proto_duration(60)),
|
|
additional_output_keys: HashMap::new(),
|
|
},
|
|
)
|
|
.unwrap();
|
|
state.status = "reauthorization_required".to_string();
|
|
state.last_error = "OAuth refresh grant is no longer usable".to_string();
|
|
state.recovery_action = ProviderCredentialRefreshRecoveryAction::Reauthorize as i32;
|
|
state.failure_code = "oauth_invalid_grant".to_string();
|
|
state.last_error_at_ms = current_time_ms();
|
|
state.next_refresh_at_ms = i64::MAX;
|
|
put_refresh_state(&store, &state).await.unwrap();
|
|
|
|
Box::pin(run_refresh_worker_tick(
|
|
&store,
|
|
Some(&test_credentials()),
|
|
None,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
|
|
let stored = get_refresh_state(
|
|
&store,
|
|
"default",
|
|
provider.object_id(),
|
|
"MS_GRAPH_ACCESS_TOKEN",
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(stored.status, "reauthorization_required");
|
|
assert_eq!(stored.next_refresh_at_ms, i64::MAX);
|
|
assert_eq!(stored.failure_code, "oauth_invalid_grant");
|
|
assert_eq!(
|
|
stored.recovery_action,
|
|
ProviderCredentialRefreshRecoveryAction::Reauthorize as i32
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn refresh_worker_finalizes_tombstoned_refresh_material() {
|
|
let store = test_store().await;
|
|
let provider = provider("tombstoned-refresh", "outlook");
|
|
store.put_message(&provider).await.unwrap();
|
|
let credentials = test_credentials();
|
|
let mut state = new_refresh_state(
|
|
&provider,
|
|
"default",
|
|
"MS_GRAPH_ACCESS_TOKEN",
|
|
NewRefreshStateConfig {
|
|
strategy: ProviderCredentialRefreshStrategy::Oauth2RefreshToken,
|
|
material: HashMap::from([("refresh_token".to_string(), "delete-me".to_string())]),
|
|
secret_material_keys: vec!["refresh_token".to_string()],
|
|
expires_at_ms: 0,
|
|
token_url: "https://issuer.example/token".to_string(),
|
|
scopes: Vec::new(),
|
|
refresh_before: Some(proto_duration(30)),
|
|
max_lifetime: Some(proto_duration(60)),
|
|
additional_output_keys: HashMap::new(),
|
|
},
|
|
)
|
|
.unwrap();
|
|
state.secret_material_handles = credentials
|
|
.store_refresh_material_with_object_id(
|
|
refresh_material_scope(&state),
|
|
"tombstoned-grant",
|
|
&state.material,
|
|
&HashMap::new(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
state.material.clear();
|
|
state.metadata.as_mut().unwrap().deletion_time =
|
|
openshell_core::time::timestamp_from_millis(current_time_ms()).ok();
|
|
state.status = "deleting".to_string();
|
|
put_refresh_state(&store, &state).await.unwrap();
|
|
assert_eq!(credentials.stored_credential_count(), Some(1));
|
|
|
|
Box::pin(run_refresh_worker_tick(&store, Some(&credentials), None))
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(
|
|
get_refresh_state(
|
|
&store,
|
|
"default",
|
|
provider.object_id(),
|
|
"MS_GRAPH_ACCESS_TOKEN",
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.is_none()
|
|
);
|
|
assert_eq!(credentials.stored_credential_count(), Some(0));
|
|
}
|
|
|
|
/// The worker ticks on a timer with no inbound request, so without a span
|
|
/// of its own its store reads export as anonymous single-span traces.
|
|
#[tokio::test]
|
|
#[ignore = "flaky under concurrent test execution"]
|
|
async fn refresh_worker_ticks_are_roots_and_store_operations_have_parents() {
|
|
use crate::otel_tracing::test_exporter;
|
|
|
|
let store = test_store().await;
|
|
|
|
let traced = test_exporter::install_traced();
|
|
Box::pin(run_refresh_worker_tick(&store, None, None))
|
|
.await
|
|
.unwrap();
|
|
|
|
let spans = traced.finished_spans();
|
|
let root = spans
|
|
.iter()
|
|
.find(|s| s.name == "refresh.provider_credentials")
|
|
.unwrap_or_else(|| {
|
|
panic!(
|
|
"the tick records a span of its own, got {:?}",
|
|
spans.iter().map(|s| &s.name).collect::<Vec<_>>()
|
|
)
|
|
});
|
|
|
|
test_exporter::assert_is_root(root);
|
|
let store_span = spans
|
|
.iter()
|
|
.find(|span| {
|
|
span.name.starts_with("store.")
|
|
&& span.span_context.trace_id() == root.span_context.trace_id()
|
|
})
|
|
.expect("the tick records its store operation");
|
|
test_exporter::assert_has_parent(store_span);
|
|
}
|
|
|
|
#[test]
|
|
fn refresh_strategy_name_includes_aws_sts() {
|
|
assert_eq!(
|
|
refresh_strategy_name(ProviderCredentialRefreshStrategy::AwsStsAssumeRole as i32),
|
|
"aws_sts_assume_role"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn aws_sts_max_expires_saturates_on_saturated_clock() {
|
|
assert_eq!(i64::MAX.saturating_add(3_600_000), i64::MAX);
|
|
let now_ms = i64::MAX - 1_000;
|
|
let max_lifetime_ms = 3_600_000;
|
|
let max_expires = now_ms.saturating_add(max_lifetime_ms);
|
|
assert_eq!(max_expires, i64::MAX);
|
|
assert!(max_expires >= now_ms);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn aws_sts_assume_role_mints_three_credentials_from_mock_endpoint() {
|
|
let mock_server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(body_string_contains("Action=AssumeRole"))
|
|
.and(body_string_contains("RoleArn=arn"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_string(
|
|
r#"<AssumeRoleResponse xmlns="https://sts.amazonaws.com/doc/2011-06-15/">
|
|
<AssumeRoleResult>
|
|
<AssumedRoleUser>
|
|
<AssumedRoleId>AROA3XFRBF23:test-session</AssumedRoleId>
|
|
<Arn>arn:aws:sts::123456789012:assumed-role/TestRole/test-session</Arn>
|
|
</AssumedRoleUser>
|
|
<Credentials>
|
|
<AccessKeyId>ASIAMOCKKEY</AccessKeyId>
|
|
<SecretAccessKey>MockSecretAccessKey123</SecretAccessKey>
|
|
<SessionToken>MockSessionTokenXYZ</SessionToken>
|
|
<Expiration>2099-01-01T00:00:00Z</Expiration>
|
|
</Credentials>
|
|
</AssumeRoleResult>
|
|
<ResponseMetadata>
|
|
<RequestId>01234567-89ab-cdef-0123-456789abcdef</RequestId>
|
|
</ResponseMetadata>
|
|
</AssumeRoleResponse>"#,
|
|
))
|
|
.mount(&mock_server)
|
|
.await;
|
|
|
|
let store = test_store().await;
|
|
let prov = provider("aws-sts-test", "aws");
|
|
store.put_message(&prov).await.unwrap();
|
|
|
|
let state = new_refresh_state(
|
|
&prov,
|
|
"default",
|
|
"AWS_ACCESS_KEY_ID",
|
|
NewRefreshStateConfig {
|
|
additional_output_keys: HashMap::from([
|
|
(
|
|
"secret_access_key".to_string(),
|
|
"AWS_SECRET_ACCESS_KEY".to_string(),
|
|
),
|
|
("session_token".to_string(), "AWS_SESSION_TOKEN".to_string()),
|
|
]),
|
|
strategy: ProviderCredentialRefreshStrategy::AwsStsAssumeRole,
|
|
material: HashMap::from([
|
|
(
|
|
"role_arn".to_string(),
|
|
"arn:aws:iam::123456789012:role/TestRole".to_string(),
|
|
),
|
|
("session_name".to_string(), "test-session".to_string()),
|
|
("aws_access_key_id".to_string(), "AKIATESTKEY".to_string()),
|
|
(
|
|
"aws_secret_access_key".to_string(),
|
|
"TestSecretKey".to_string(),
|
|
),
|
|
("sts_endpoint_url".to_string(), mock_server.uri()),
|
|
]),
|
|
secret_material_keys: vec!["aws_secret_access_key".to_string()],
|
|
expires_at_ms: 0,
|
|
token_url: String::new(),
|
|
scopes: Vec::new(),
|
|
refresh_before: Some(proto_duration(300)),
|
|
max_lifetime: Some(proto_duration(3600)),
|
|
},
|
|
)
|
|
.unwrap();
|
|
put_refresh_state(&store, &state).await.unwrap();
|
|
let credentials = test_credentials();
|
|
|
|
let refreshed = refresh_provider_credential(
|
|
&store,
|
|
"default",
|
|
&credentials,
|
|
None,
|
|
"aws-sts-test",
|
|
"AWS_ACCESS_KEY_ID",
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(refreshed.status, "refreshed");
|
|
assert!(refreshed.expires_at_ms > 0);
|
|
|
|
let stored = store
|
|
.get_message_by_name::<Provider>("default", "aws-sts-test")
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
let resolved = credentials
|
|
.resolve_provider_handles(&stored, current_time_ms())
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
resolved.values.get("AWS_ACCESS_KEY_ID"),
|
|
Some(&"ASIAMOCKKEY".to_string())
|
|
);
|
|
assert_eq!(
|
|
resolved.values.get("AWS_SECRET_ACCESS_KEY"),
|
|
Some(&"MockSecretAccessKey123".to_string())
|
|
);
|
|
assert_eq!(
|
|
resolved.values.get("AWS_SESSION_TOKEN"),
|
|
Some(&"MockSessionTokenXYZ".to_string())
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn aws_sts_mint_writes_to_resolved_additional_output_keys() {
|
|
let mock_server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(body_string_contains("Action=AssumeRole"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_string(
|
|
r#"<AssumeRoleResponse xmlns="https://sts.amazonaws.com/doc/2011-06-15/">
|
|
<AssumeRoleResult>
|
|
<Credentials>
|
|
<AccessKeyId>ASIAMOCKKEY</AccessKeyId>
|
|
<SecretAccessKey>MockSecretAccessKey123</SecretAccessKey>
|
|
<SessionToken>MockSessionTokenXYZ</SessionToken>
|
|
<Expiration>2099-01-01T00:00:00Z</Expiration>
|
|
</Credentials>
|
|
</AssumeRoleResult>
|
|
</AssumeRoleResponse>"#,
|
|
))
|
|
.mount(&mock_server)
|
|
.await;
|
|
|
|
let store = test_store().await;
|
|
let prov = provider("aws-sts-custom", "aws");
|
|
store.put_message(&prov).await.unwrap();
|
|
|
|
// The minter honors the resolved output->env-key map from state, not
|
|
// hardcoded AWS names.
|
|
let state = new_refresh_state(
|
|
&prov,
|
|
"default",
|
|
"AWS_ACCESS_KEY_ID",
|
|
NewRefreshStateConfig {
|
|
additional_output_keys: HashMap::from([
|
|
("secret_access_key".to_string(), "CUSTOM_SECRET".to_string()),
|
|
("session_token".to_string(), "CUSTOM_SESSION".to_string()),
|
|
]),
|
|
strategy: ProviderCredentialRefreshStrategy::AwsStsAssumeRole,
|
|
material: HashMap::from([
|
|
(
|
|
"role_arn".to_string(),
|
|
"arn:aws:iam::123456789012:role/TestRole".to_string(),
|
|
),
|
|
("aws_access_key_id".to_string(), "AKIATESTKEY".to_string()),
|
|
(
|
|
"aws_secret_access_key".to_string(),
|
|
"TestSecretKey".to_string(),
|
|
),
|
|
("sts_endpoint_url".to_string(), mock_server.uri()),
|
|
]),
|
|
secret_material_keys: vec!["aws_secret_access_key".to_string()],
|
|
expires_at_ms: 0,
|
|
token_url: String::new(),
|
|
scopes: Vec::new(),
|
|
refresh_before: Some(proto_duration(300)),
|
|
max_lifetime: Some(proto_duration(3600)),
|
|
},
|
|
)
|
|
.unwrap();
|
|
put_refresh_state(&store, &state).await.unwrap();
|
|
let credentials = test_credentials();
|
|
|
|
refresh_provider_credential(
|
|
&store,
|
|
"default",
|
|
&credentials,
|
|
None,
|
|
"aws-sts-custom",
|
|
"AWS_ACCESS_KEY_ID",
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let stored = store
|
|
.get_message_by_name::<Provider>("default", "aws-sts-custom")
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
let resolved = credentials
|
|
.resolve_provider_handles(&stored, current_time_ms())
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
resolved.values.get("AWS_ACCESS_KEY_ID"),
|
|
Some(&"ASIAMOCKKEY".to_string())
|
|
);
|
|
assert_eq!(
|
|
resolved.values.get("CUSTOM_SECRET"),
|
|
Some(&"MockSecretAccessKey123".to_string())
|
|
);
|
|
assert_eq!(
|
|
resolved.values.get("CUSTOM_SESSION"),
|
|
Some(&"MockSessionTokenXYZ".to_string())
|
|
);
|
|
assert!(!resolved.values.contains_key("AWS_SECRET_ACCESS_KEY"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn aws_sts_mint_rejects_partial_source_credentials() {
|
|
let store = test_store().await;
|
|
let prov = provider("aws-sts-partial", "aws");
|
|
store.put_message(&prov).await.unwrap();
|
|
|
|
// Only the access key half of the explicit source pair is present. The
|
|
// mint must fail rather than fall back to the gateway's ambient identity.
|
|
let state = new_refresh_state(
|
|
&prov,
|
|
"default",
|
|
"AWS_ACCESS_KEY_ID",
|
|
NewRefreshStateConfig {
|
|
additional_output_keys: HashMap::from([
|
|
(
|
|
"secret_access_key".to_string(),
|
|
"AWS_SECRET_ACCESS_KEY".to_string(),
|
|
),
|
|
("session_token".to_string(), "AWS_SESSION_TOKEN".to_string()),
|
|
]),
|
|
strategy: ProviderCredentialRefreshStrategy::AwsStsAssumeRole,
|
|
material: HashMap::from([
|
|
(
|
|
"role_arn".to_string(),
|
|
"arn:aws:iam::123456789012:role/TestRole".to_string(),
|
|
),
|
|
("aws_access_key_id".to_string(), "AKIATESTKEY".to_string()),
|
|
]),
|
|
secret_material_keys: Vec::new(),
|
|
expires_at_ms: 0,
|
|
token_url: String::new(),
|
|
scopes: Vec::new(),
|
|
refresh_before: Some(proto_duration(300)),
|
|
max_lifetime: Some(proto_duration(3600)),
|
|
},
|
|
)
|
|
.unwrap();
|
|
put_refresh_state(&store, &state).await.unwrap();
|
|
let credentials = test_credentials();
|
|
|
|
let err = refresh_provider_credential(
|
|
&store,
|
|
"default",
|
|
&credentials,
|
|
None,
|
|
"aws-sts-partial",
|
|
"AWS_ACCESS_KEY_ID",
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert_eq!(err.code(), tonic::Code::InvalidArgument);
|
|
assert!(err.message().contains("both be set or both omitted"));
|
|
|
|
let stored = store
|
|
.get_message_by_name::<Provider>("default", "aws-sts-partial")
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert!(!stored.credentials.contains_key("AWS_ACCESS_KEY_ID"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn apply_minted_credential_writes_additional_keys() {
|
|
use super::apply_minted_credential;
|
|
|
|
let store = test_store().await;
|
|
let mut prov = provider("aws-test", "aws");
|
|
prov.credentials
|
|
.insert("AWS_ACCESS_KEY_ID".to_string(), "old-key".to_string());
|
|
store.put_message(&prov).await.unwrap();
|
|
|
|
let minted = super::MintedCredential {
|
|
access_token: "AKIAIOSFODNN7EXAMPLE".to_string(),
|
|
expires_at_ms: 4_000_000_000_000,
|
|
refresh_token: None,
|
|
additional_credentials: HashMap::from([
|
|
(
|
|
"AWS_SECRET_ACCESS_KEY".to_string(),
|
|
"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".to_string(),
|
|
),
|
|
(
|
|
"AWS_SESSION_TOKEN".to_string(),
|
|
"FwoGZXIvYXdzEBYaDH...EXAMPLETOKEN".to_string(),
|
|
),
|
|
]),
|
|
};
|
|
|
|
apply_minted_credential(
|
|
&store,
|
|
"default",
|
|
None,
|
|
None,
|
|
&prov,
|
|
"AWS_ACCESS_KEY_ID",
|
|
&minted,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let stored = store
|
|
.get_message_by_name::<Provider>("default", "aws-test")
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(
|
|
stored.credentials.get("AWS_ACCESS_KEY_ID"),
|
|
Some(&"AKIAIOSFODNN7EXAMPLE".to_string())
|
|
);
|
|
assert_eq!(
|
|
stored.credentials.get("AWS_SECRET_ACCESS_KEY"),
|
|
Some(&"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".to_string())
|
|
);
|
|
assert_eq!(
|
|
stored.credentials.get("AWS_SESSION_TOKEN"),
|
|
Some(&"FwoGZXIvYXdzEBYaDH...EXAMPLETOKEN".to_string())
|
|
);
|
|
assert_eq!(
|
|
stored.credential_expiration_times.get("AWS_ACCESS_KEY_ID"),
|
|
Some(&ts(4_000_000_000_000))
|
|
);
|
|
assert_eq!(
|
|
stored
|
|
.credential_expiration_times
|
|
.get("AWS_SECRET_ACCESS_KEY"),
|
|
Some(&ts(4_000_000_000_000))
|
|
);
|
|
assert_eq!(
|
|
stored.credential_expiration_times.get("AWS_SESSION_TOKEN"),
|
|
Some(&ts(4_000_000_000_000))
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn apply_minted_credential_replaces_only_refreshed_handles() {
|
|
use super::apply_minted_credential;
|
|
|
|
let store = test_store().await;
|
|
let credentials = CredentialRuntime::from_config(
|
|
&Config::new(None).with_credential_drivers(["test-static"]),
|
|
)
|
|
.unwrap();
|
|
let mut prov = provider("stored-aws", "aws");
|
|
let original_handles = credentials
|
|
.store_provider_credentials(
|
|
prov.object_name(),
|
|
prov.object_workspace(),
|
|
prov.object_id(),
|
|
&HashMap::from([
|
|
("AWS_ACCESS_KEY_ID".to_string(), "old-key".to_string()),
|
|
(
|
|
"AWS_SECRET_ACCESS_KEY".to_string(),
|
|
"unchanged-secret".to_string(),
|
|
),
|
|
]),
|
|
&HashMap::new(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
prov.credential_handles.clone_from(&original_handles);
|
|
store.put_message(&prov).await.unwrap();
|
|
|
|
let minted = super::MintedCredential {
|
|
access_token: "new-key".to_string(),
|
|
expires_at_ms: 4_000_000_000_000,
|
|
refresh_token: None,
|
|
additional_credentials: HashMap::new(),
|
|
};
|
|
|
|
apply_minted_credential(
|
|
&store,
|
|
"default",
|
|
Some(&credentials),
|
|
None,
|
|
&prov,
|
|
"AWS_ACCESS_KEY_ID",
|
|
&minted,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let stored = store
|
|
.get_message_by_name::<Provider>("default", "stored-aws")
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_ne!(
|
|
stored.credential_handles.get("AWS_ACCESS_KEY_ID"),
|
|
original_handles.get("AWS_ACCESS_KEY_ID")
|
|
);
|
|
assert_eq!(
|
|
stored.credential_handles.get("AWS_SECRET_ACCESS_KEY"),
|
|
original_handles.get("AWS_SECRET_ACCESS_KEY")
|
|
);
|
|
let resolved = credentials
|
|
.resolve_provider_handles(&stored, current_time_ms())
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
resolved.values.get("AWS_ACCESS_KEY_ID"),
|
|
Some(&"new-key".to_string())
|
|
);
|
|
assert_eq!(
|
|
resolved.values.get("AWS_SECRET_ACCESS_KEY"),
|
|
Some(&"unchanged-secret".to_string())
|
|
);
|
|
|
|
let old_handle_provider = Provider {
|
|
credential_handles: HashMap::from([(
|
|
"AWS_ACCESS_KEY_ID".to_string(),
|
|
original_handles["AWS_ACCESS_KEY_ID"].clone(),
|
|
)]),
|
|
..prov
|
|
};
|
|
let err = credentials
|
|
.resolve_provider_handles(&old_handle_provider, current_time_ms())
|
|
.await
|
|
.unwrap_err();
|
|
assert_eq!(err.code(), tonic::Code::NotFound);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn apply_minted_credential_validates_additional_keys_against_sandboxes() {
|
|
use super::apply_minted_credential;
|
|
|
|
let store = test_store().await;
|
|
let mut existing_provider = provider("existing-aws", "aws");
|
|
existing_provider
|
|
.credentials
|
|
.insert("AWS_SECRET_ACCESS_KEY".to_string(), "existing".to_string());
|
|
store.put_message(&existing_provider).await.unwrap();
|
|
|
|
let refreshing_provider = provider("refreshing-aws", "aws");
|
|
store.put_message(&refreshing_provider).await.unwrap();
|
|
|
|
store
|
|
.put_message(&Sandbox {
|
|
metadata: Some(ObjectMeta {
|
|
id: "sandbox-aws-collision".to_string(),
|
|
name: "aws-collision".to_string(),
|
|
created_time: openshell_core::time::timestamp_from_millis(1).ok(),
|
|
labels: HashMap::new(),
|
|
resource_version: 0,
|
|
annotations: HashMap::new(),
|
|
workspace: "default".to_string(),
|
|
deletion_time: None,
|
|
}),
|
|
spec: Some(SandboxSpec {
|
|
providers: vec!["existing-aws".to_string(), "refreshing-aws".to_string()],
|
|
..SandboxSpec::default()
|
|
}),
|
|
..Default::default()
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let minted = super::MintedCredential {
|
|
access_token: "AKIAIOSFODNN7EXAMPLE".to_string(),
|
|
expires_at_ms: 4_000_000_000_000,
|
|
refresh_token: None,
|
|
additional_credentials: HashMap::from([
|
|
(
|
|
"AWS_SECRET_ACCESS_KEY".to_string(),
|
|
"secret-key".to_string(),
|
|
),
|
|
("AWS_SESSION_TOKEN".to_string(), "session-token".to_string()),
|
|
]),
|
|
};
|
|
let credentials = CredentialRuntime::from_config(
|
|
&Config::new(None).with_credential_drivers(["test-static"]),
|
|
)
|
|
.unwrap();
|
|
|
|
let err = apply_minted_credential(
|
|
&store,
|
|
"default",
|
|
Some(&credentials),
|
|
None,
|
|
&refreshing_provider,
|
|
"AWS_ACCESS_KEY_ID",
|
|
&minted,
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert_eq!(err.code(), tonic::Code::FailedPrecondition);
|
|
assert!(err.message().contains("AWS_SECRET_ACCESS_KEY"));
|
|
assert_eq!(credentials.stored_credential_count(), Some(0));
|
|
}
|
|
|
|
// A wiremock responder that blocks the STS response until the test releases
|
|
// it, so a delete-refresh can be interleaved deterministically while the
|
|
// rotation is parked awaiting STS.
|
|
struct GatedStsResponder {
|
|
hit: std::sync::Mutex<Option<tokio::sync::oneshot::Sender<()>>>,
|
|
release: std::sync::Mutex<std::sync::mpsc::Receiver<()>>,
|
|
body: String,
|
|
}
|
|
|
|
impl wiremock::Respond for GatedStsResponder {
|
|
fn respond(&self, _request: &wiremock::Request) -> ResponseTemplate {
|
|
let hit = self.hit.lock().unwrap().take();
|
|
if let Some(hit) = hit {
|
|
let _ = hit.send(());
|
|
}
|
|
let _ = self.release.lock().unwrap().recv();
|
|
ResponseTemplate::new(200).set_body_string(self.body.clone())
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn aws_sts_mint_accepts_session_token_with_source_pair() {
|
|
let mock_server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(body_string_contains("Action=AssumeRole"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_string(
|
|
r#"<AssumeRoleResponse xmlns="https://sts.amazonaws.com/doc/2011-06-15/">
|
|
<AssumeRoleResult>
|
|
<Credentials>
|
|
<AccessKeyId>ASIAMOCKKEY</AccessKeyId>
|
|
<SecretAccessKey>MockSecretAccessKey123</SecretAccessKey>
|
|
<SessionToken>MockSessionTokenXYZ</SessionToken>
|
|
<Expiration>2099-01-01T00:00:00Z</Expiration>
|
|
</Credentials>
|
|
</AssumeRoleResult>
|
|
</AssumeRoleResponse>"#,
|
|
))
|
|
.mount(&mock_server)
|
|
.await;
|
|
|
|
let store = test_store().await;
|
|
let prov = provider("aws-sts-session", "aws");
|
|
store.put_message(&prov).await.unwrap();
|
|
|
|
// Temporary source credentials: access key + secret + session token.
|
|
let state = new_refresh_state(
|
|
&prov,
|
|
"default",
|
|
"AWS_ACCESS_KEY_ID",
|
|
NewRefreshStateConfig {
|
|
additional_output_keys: HashMap::from([
|
|
(
|
|
"secret_access_key".to_string(),
|
|
"AWS_SECRET_ACCESS_KEY".to_string(),
|
|
),
|
|
("session_token".to_string(), "AWS_SESSION_TOKEN".to_string()),
|
|
]),
|
|
strategy: ProviderCredentialRefreshStrategy::AwsStsAssumeRole,
|
|
material: HashMap::from([
|
|
(
|
|
"role_arn".to_string(),
|
|
"arn:aws:iam::123456789012:role/TestRole".to_string(),
|
|
),
|
|
("aws_access_key_id".to_string(), "ASIASOURCEKEY".to_string()),
|
|
(
|
|
"aws_secret_access_key".to_string(),
|
|
"SourceSecretKey".to_string(),
|
|
),
|
|
(
|
|
"aws_session_token".to_string(),
|
|
"SourceSessionToken".to_string(),
|
|
),
|
|
("sts_endpoint_url".to_string(), mock_server.uri()),
|
|
]),
|
|
secret_material_keys: vec![
|
|
"aws_secret_access_key".to_string(),
|
|
"aws_session_token".to_string(),
|
|
],
|
|
expires_at_ms: 0,
|
|
token_url: String::new(),
|
|
scopes: Vec::new(),
|
|
refresh_before: Some(proto_duration(300)),
|
|
max_lifetime: Some(proto_duration(3600)),
|
|
},
|
|
)
|
|
.unwrap();
|
|
put_refresh_state(&store, &state).await.unwrap();
|
|
let credentials = test_credentials();
|
|
|
|
let refreshed = refresh_provider_credential(
|
|
&store,
|
|
"default",
|
|
&credentials,
|
|
None,
|
|
"aws-sts-session",
|
|
"AWS_ACCESS_KEY_ID",
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(refreshed.status, "refreshed");
|
|
let stored = store
|
|
.get_message_by_name::<Provider>("default", "aws-sts-session")
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
let resolved = credentials
|
|
.resolve_provider_handles(&stored, current_time_ms())
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
resolved.values.get("AWS_ACCESS_KEY_ID"),
|
|
Some(&"ASIAMOCKKEY".to_string())
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn aws_sts_mint_rejects_session_token_without_source_pair() {
|
|
let store = test_store().await;
|
|
let prov = provider("aws-sts-lonesession", "aws");
|
|
store.put_message(&prov).await.unwrap();
|
|
|
|
let state = new_refresh_state(
|
|
&prov,
|
|
"default",
|
|
"AWS_ACCESS_KEY_ID",
|
|
NewRefreshStateConfig {
|
|
additional_output_keys: HashMap::from([
|
|
(
|
|
"secret_access_key".to_string(),
|
|
"AWS_SECRET_ACCESS_KEY".to_string(),
|
|
),
|
|
("session_token".to_string(), "AWS_SESSION_TOKEN".to_string()),
|
|
]),
|
|
strategy: ProviderCredentialRefreshStrategy::AwsStsAssumeRole,
|
|
material: HashMap::from([
|
|
(
|
|
"role_arn".to_string(),
|
|
"arn:aws:iam::123456789012:role/TestRole".to_string(),
|
|
),
|
|
(
|
|
"aws_session_token".to_string(),
|
|
"SourceSessionToken".to_string(),
|
|
),
|
|
]),
|
|
secret_material_keys: vec!["aws_session_token".to_string()],
|
|
expires_at_ms: 0,
|
|
token_url: String::new(),
|
|
scopes: Vec::new(),
|
|
refresh_before: Some(proto_duration(300)),
|
|
max_lifetime: Some(proto_duration(3600)),
|
|
},
|
|
)
|
|
.unwrap();
|
|
put_refresh_state(&store, &state).await.unwrap();
|
|
let credentials = test_credentials();
|
|
|
|
let err = refresh_provider_credential(
|
|
&store,
|
|
"default",
|
|
&credentials,
|
|
None,
|
|
"aws-sts-lonesession",
|
|
"AWS_ACCESS_KEY_ID",
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert_eq!(err.code(), tonic::Code::InvalidArgument);
|
|
assert!(err.message().contains("aws_session_token requires"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn deletion_of_observed_refresh_preserves_same_name_replacement() {
|
|
use super::delete_observed_refresh_state_with_credentials;
|
|
use crate::persistence::ObjectType;
|
|
let store = test_store().await;
|
|
let original = StoredProviderCredentialRefreshState {
|
|
metadata: Some(ObjectMeta {
|
|
id: "original-id".into(),
|
|
name: "refresh-name".into(),
|
|
workspace: "default".into(),
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
store.put_message(&original).await.unwrap();
|
|
let observed = store
|
|
.get_message::<StoredProviderCredentialRefreshState>("original-id")
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
store
|
|
.delete(
|
|
StoredProviderCredentialRefreshState::object_type(),
|
|
"original-id",
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let mut replacement = original;
|
|
replacement.metadata.as_mut().unwrap().id = "replacement-id".into();
|
|
store.put_message(&replacement).await.unwrap();
|
|
let replacement = store
|
|
.get_message::<StoredProviderCredentialRefreshState>("replacement-id")
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
|
|
delete_observed_refresh_state_with_credentials(&store, &test_credentials(), observed)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
store
|
|
.get_message::<StoredProviderCredentialRefreshState>("replacement-id")
|
|
.await
|
|
.unwrap()
|
|
.unwrap(),
|
|
replacement
|
|
);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
async fn rotation_does_not_resurrect_refresh_deleted_mid_flight() {
|
|
let mock_server = MockServer::start().await;
|
|
let (hit_tx, hit_rx) = tokio::sync::oneshot::channel::<()>();
|
|
let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
|
|
Mock::given(method("POST"))
|
|
.and(body_string_contains("Action=AssumeRole"))
|
|
.respond_with(GatedStsResponder {
|
|
hit: std::sync::Mutex::new(Some(hit_tx)),
|
|
release: std::sync::Mutex::new(release_rx),
|
|
body: r#"<AssumeRoleResponse xmlns="https://sts.amazonaws.com/doc/2011-06-15/">
|
|
<AssumeRoleResult>
|
|
<Credentials>
|
|
<AccessKeyId>ASIAMOCKKEY</AccessKeyId>
|
|
<SecretAccessKey>MockSecretAccessKey123</SecretAccessKey>
|
|
<SessionToken>MockSessionTokenXYZ</SessionToken>
|
|
<Expiration>2099-01-01T00:00:00Z</Expiration>
|
|
</Credentials>
|
|
</AssumeRoleResult>
|
|
</AssumeRoleResponse>"#
|
|
.to_string(),
|
|
})
|
|
.mount(&mock_server)
|
|
.await;
|
|
|
|
let store = test_store().await;
|
|
let prov = provider("aws-race", "aws");
|
|
store.put_message(&prov).await.unwrap();
|
|
let provider_id = prov.object_id().to_string();
|
|
|
|
let state = new_refresh_state(
|
|
&prov,
|
|
"default",
|
|
"AWS_ACCESS_KEY_ID",
|
|
NewRefreshStateConfig {
|
|
additional_output_keys: HashMap::from([
|
|
(
|
|
"secret_access_key".to_string(),
|
|
"AWS_SECRET_ACCESS_KEY".to_string(),
|
|
),
|
|
("session_token".to_string(), "AWS_SESSION_TOKEN".to_string()),
|
|
]),
|
|
strategy: ProviderCredentialRefreshStrategy::AwsStsAssumeRole,
|
|
material: HashMap::from([
|
|
(
|
|
"role_arn".to_string(),
|
|
"arn:aws:iam::123456789012:role/TestRole".to_string(),
|
|
),
|
|
("aws_access_key_id".to_string(), "AKIATESTKEY".to_string()),
|
|
(
|
|
"aws_secret_access_key".to_string(),
|
|
"TestSecretKey".to_string(),
|
|
),
|
|
("sts_endpoint_url".to_string(), mock_server.uri()),
|
|
]),
|
|
secret_material_keys: vec!["aws_secret_access_key".to_string()],
|
|
expires_at_ms: 0,
|
|
token_url: String::new(),
|
|
scopes: Vec::new(),
|
|
refresh_before: Some(proto_duration(300)),
|
|
max_lifetime: Some(proto_duration(3600)),
|
|
},
|
|
)
|
|
.unwrap();
|
|
put_refresh_state(&store, &state).await.unwrap();
|
|
let credentials = test_credentials();
|
|
|
|
let rotate = refresh_provider_credential(
|
|
&store,
|
|
"default",
|
|
&credentials,
|
|
None,
|
|
"aws-race",
|
|
"AWS_ACCESS_KEY_ID",
|
|
);
|
|
let interfere = async {
|
|
// Wait until the rotation is inside the STS call (its state read has
|
|
// already happened), then delete the refresh and release STS.
|
|
if tokio::time::timeout(std::time::Duration::from_secs(15), hit_rx)
|
|
.await
|
|
.is_err()
|
|
{
|
|
return;
|
|
}
|
|
delete_refresh_state_with_credentials(
|
|
&store,
|
|
&credentials,
|
|
"default",
|
|
&provider_id,
|
|
"AWS_ACCESS_KEY_ID",
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let _ = release_tx.send(());
|
|
};
|
|
let (rotate_result, ()) = tokio::join!(rotate, interfere);
|
|
|
|
// The rotation must fail rather than complete against a deleted refresh.
|
|
assert!(
|
|
rotate_result.is_err(),
|
|
"rotation should abort when its refresh is deleted mid-flight"
|
|
);
|
|
// The deleted refresh state must not be resurrected.
|
|
assert!(
|
|
get_refresh_state(&store, "default", &provider_id, "AWS_ACCESS_KEY_ID")
|
|
.await
|
|
.unwrap()
|
|
.is_none(),
|
|
"deleted refresh state must not be recreated"
|
|
);
|
|
// No credentials were minted into the provider.
|
|
let stored = store
|
|
.get_message_by_name::<Provider>("default", "aws-race")
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert!(!stored.credentials.contains_key("AWS_ACCESS_KEY_ID"));
|
|
assert!(!stored.credentials.contains_key("AWS_SECRET_ACCESS_KEY"));
|
|
assert!(!stored.credentials.contains_key("AWS_SESSION_TOKEN"));
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
async fn rotation_superseded_mid_flight_discards_credentials() {
|
|
let mock_server = MockServer::start().await;
|
|
let (hit_tx, hit_rx) = tokio::sync::oneshot::channel::<()>();
|
|
let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
|
|
Mock::given(method("POST"))
|
|
.and(body_string_contains("Action=AssumeRole"))
|
|
.respond_with(GatedStsResponder {
|
|
hit: std::sync::Mutex::new(Some(hit_tx)),
|
|
release: std::sync::Mutex::new(release_rx),
|
|
body: r#"<AssumeRoleResponse xmlns="https://sts.amazonaws.com/doc/2011-06-15/">
|
|
<AssumeRoleResult>
|
|
<Credentials>
|
|
<AccessKeyId>ASIAMOCKKEY</AccessKeyId>
|
|
<SecretAccessKey>MockSecretAccessKey123</SecretAccessKey>
|
|
<SessionToken>MockSessionTokenXYZ</SessionToken>
|
|
<Expiration>2099-01-01T00:00:00Z</Expiration>
|
|
</Credentials>
|
|
</AssumeRoleResult>
|
|
</AssumeRoleResponse>"#
|
|
.to_string(),
|
|
})
|
|
.mount(&mock_server)
|
|
.await;
|
|
|
|
let store = test_store().await;
|
|
let prov = provider("aws-superseded", "aws");
|
|
store.put_message(&prov).await.unwrap();
|
|
let provider_id = prov.object_id().to_string();
|
|
|
|
let state = new_refresh_state(
|
|
&prov,
|
|
"default",
|
|
"AWS_ACCESS_KEY_ID",
|
|
NewRefreshStateConfig {
|
|
additional_output_keys: HashMap::from([
|
|
(
|
|
"secret_access_key".to_string(),
|
|
"AWS_SECRET_ACCESS_KEY".to_string(),
|
|
),
|
|
("session_token".to_string(), "AWS_SESSION_TOKEN".to_string()),
|
|
]),
|
|
strategy: ProviderCredentialRefreshStrategy::AwsStsAssumeRole,
|
|
material: HashMap::from([
|
|
(
|
|
"role_arn".to_string(),
|
|
"arn:aws:iam::123456789012:role/TestRole".to_string(),
|
|
),
|
|
("aws_access_key_id".to_string(), "AKIATESTKEY".to_string()),
|
|
(
|
|
"aws_secret_access_key".to_string(),
|
|
"TestSecretKey".to_string(),
|
|
),
|
|
("sts_endpoint_url".to_string(), mock_server.uri()),
|
|
]),
|
|
secret_material_keys: vec!["aws_secret_access_key".to_string()],
|
|
expires_at_ms: 0,
|
|
token_url: String::new(),
|
|
scopes: Vec::new(),
|
|
refresh_before: Some(proto_duration(300)),
|
|
max_lifetime: Some(proto_duration(3600)),
|
|
},
|
|
)
|
|
.unwrap();
|
|
put_refresh_state(&store, &state).await.unwrap();
|
|
let credentials = test_credentials();
|
|
|
|
let rotate = refresh_provider_credential(
|
|
&store,
|
|
"default",
|
|
&credentials,
|
|
None,
|
|
"aws-superseded",
|
|
"AWS_ACCESS_KEY_ID",
|
|
);
|
|
let interfere = async {
|
|
if tokio::time::timeout(std::time::Duration::from_secs(15), hit_rx)
|
|
.await
|
|
.is_err()
|
|
{
|
|
return;
|
|
}
|
|
// Simulate a concurrent rotation or reconfigure winning the
|
|
// generation: any write to the refresh state bumps its version, so
|
|
// the in-flight rotation's version-matched persist will lose.
|
|
let mut winner =
|
|
get_refresh_state(&store, "default", &provider_id, "AWS_ACCESS_KEY_ID")
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
winner.last_error = "won-by-concurrent-writer".to_string();
|
|
put_refresh_state(&store, &winner).await.unwrap();
|
|
let _ = release_tx.send(());
|
|
};
|
|
let (rotate_result, ()) = tokio::join!(rotate, interfere);
|
|
|
|
// The superseded (losing) rotation must abort rather than complete.
|
|
assert!(
|
|
rotate_result.is_err(),
|
|
"a rotation whose generation was superseded must abort"
|
|
);
|
|
// It must not write its stale-generation credentials into the provider.
|
|
let stored = store
|
|
.get_message_by_name::<Provider>("default", "aws-superseded")
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert!(!stored.credentials.contains_key("AWS_ACCESS_KEY_ID"));
|
|
assert!(!stored.credentials.contains_key("AWS_SECRET_ACCESS_KEY"));
|
|
assert!(!stored.credentials.contains_key("AWS_SESSION_TOKEN"));
|
|
// The concurrent writer's refresh state must survive untouched.
|
|
let refresh = get_refresh_state(&store, "default", &provider_id, "AWS_ACCESS_KEY_ID")
|
|
.await
|
|
.unwrap()
|
|
.expect("refresh state should still exist");
|
|
assert_eq!(refresh.last_error, "won-by-concurrent-writer");
|
|
assert_ne!(refresh.status, "refreshed");
|
|
}
|
|
|
|
fn provider(name: &str, provider_type: &str) -> Provider {
|
|
Provider {
|
|
metadata: Some(ObjectMeta {
|
|
id: format!("{name}-id"),
|
|
name: name.to_string(),
|
|
created_time: openshell_core::time::timestamp_from_millis(1).ok(),
|
|
labels: HashMap::new(),
|
|
resource_version: 0,
|
|
annotations: HashMap::new(),
|
|
workspace: "default".to_string(),
|
|
deletion_time: None,
|
|
}),
|
|
r#type: provider_type.to_string(),
|
|
credentials: HashMap::new(),
|
|
config: HashMap::new(),
|
|
credential_expiration_times: HashMap::new(),
|
|
profile_workspace: "default".to_string(),
|
|
credential_handles: HashMap::new(),
|
|
}
|
|
}
|
|
|
|
const TEST_RSA_PRIVATE_KEY: &str = r"-----BEGIN PRIVATE KEY-----
|
|
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCvCoZ0mVHpCHsF
|
|
zeeqw2caNIe/eb4BQUccFPhZfRnF7sCfyB84zTBmuwG2umRBdjFnVsfIIZRp2HcD
|
|
OESrRYYiE1RGfjBXImGVg2Wtza0HYhL1sLyX1eaEefylxoilmApAgWDh9p36h8J2
|
|
s5YHwyXPTttx4DpdWDnxju1iNmwoIB8uVE/5amWgbNvlETMBOcB1RxDHtnVy+xJz
|
|
jjjrzK4Qz9WsUTHAvngdi4Yyxvci+yKpjYTg5+UWxmAN6iW522TpLe32MDb5Ug1d
|
|
trBvvepWmdQ6CBwPhBHCt/sMoSJAYSO4RKeBnBjeLQBXFTxaOv5iTGIsRTX3K471
|
|
epHp3cT5AgMBAAECggEASQlRv/4nZN5SgsH/K8v7zb3kdHsmUly8AJYpaCGgauvr
|
|
uN/mUyueyga2uNl+MqhQBef6VWHZjO6y/gdw86v/Q2GgVQebQQhKAnpAp2w+Ceoc
|
|
siKMFqi8VkOWLU+xPbM6d97kH3TpRxt1g1T8wYFmWeF0BEiE4eUJzGaQW14M9BJ+
|
|
G0QxmP/zjX9cNpVeApKTjBWKiH4CXG3DuI3pJ93VOMpUlOsrdLXvKGTze0e01itr
|
|
MX/MHHTE+VXB4FB+/zKSA4c36egi676OSXrGC/GDmM8ntJ4CUGeD5uZsMSADiAUn
|
|
iccv5iGRWVMIKxUS5Q4k0jy8uWuK+QVP4Y6cQWYArwKBgQDhuSNORBNpIGRfsKGN
|
|
iJo/h+qinz6pEIpa3D3oVl7rpkyvgIyaTwfXvC1vfdS9V5VIel2gV2Cx0OrI8yrr
|
|
nQu1JuNV/rLmtvqX321fgBLRdoiqF3pAy1gbmdUz1elerAIYL578gXQ6jg1bbdic
|
|
kJpn0MsoDUJGwvJnXcgLqG7q3wKBgQDGhRIa4oJsj1vqICc8zt8YsCAcot3vjWLH
|
|
588X7JdBGOWJdWxfdmGXQRn5Zw9UhMQnYa3uyTBPeVcXopThlPotYeuFhLSU856T
|
|
IJzfpzCJzC4zIQayoyvJFrKe7N70iUQ986dewYy9oxQhHvFKd/qe4ylbzZJXpthX
|
|
eWEuuBSjJwKBgGkqXt6qLPj/1IQYwUw15tfOtW0LEKCoSi3HCzjidNsJ4hSqqdeD
|
|
Fr5WuDyHvcRxt+XKzTBVRYHTOnBhiw+3XasK8UQxpJyFh/+WY1jpTNs2hLnqslTZ
|
|
6LUDWSgLc+1d6qPmHAa9Ma/OWz7L0O4xGR9hUiXY95YMYe/y668yzGq1AoGBAJyU
|
|
Gsqfu7U6gYmxoKEine6QBFPx1dD7GF2KJdq93jMXGvyHZFoLOkAdtgnz0rCcI0bY
|
|
kWKUxwj4MMxQjNM8OPMQl75xBCmz2XA8Od9htDQLmqjzNKAzePabc3lMZTJFDlE6
|
|
29kuGf79IIRbLn/JECDAFT/2baW60Ep2T0OVJ5njAoGAfaCaQ4aVgjI027q7Y5qP
|
|
KfNSI8uuA8PLqmUY30I9KFWzN6VDLu00eKa90F4w3CeWRRQWXW1+007tTz3V1mNw
|
|
20A24Fi3HGQmXc7NyuLDODTJsWBICuOemCnRkvcxIlxb+ec7jp+XRmzDwKkzSnVN
|
|
pM2zFU8SeVkvHKlEuoHaP0s=
|
|
-----END PRIVATE KEY-----";
|
|
}
|