mirror of
https://github.com/NixOS/nix.git
synced 2025-12-22 17:01:08 +01:00
Merge pull request #14645 from lovesegfault/s3-sts
feat(libstore): add AWS SSO support for S3 authentication
This commit is contained in:
commit
11f5a3124b
3 changed files with 243 additions and 18 deletions
|
|
@ -4,15 +4,15 @@
|
|||
|
||||
# include <aws/crt/Types.h>
|
||||
# include "nix/store/s3-url.hh"
|
||||
# include "nix/util/finally.hh"
|
||||
# include "nix/util/logging.hh"
|
||||
# include "nix/util/url.hh"
|
||||
# include "nix/util/util.hh"
|
||||
|
||||
# include <aws/crt/Api.h>
|
||||
# include <aws/crt/auth/Credentials.h>
|
||||
# include <aws/crt/io/Bootstrap.h>
|
||||
|
||||
// C library headers for SSO provider support
|
||||
# include <aws/auth/credentials.h>
|
||||
|
||||
# include <boost/unordered/concurrent_flat_map.hpp>
|
||||
|
||||
# include <chrono>
|
||||
|
|
@ -30,6 +30,46 @@ AwsAuthError::AwsAuthError(int errorCode)
|
|||
|
||||
namespace {
|
||||
|
||||
/**
|
||||
* Helper function to wrap a C credentials provider in the C++ interface.
|
||||
* This replicates the static s_CreateWrappedProvider from aws-crt-cpp.
|
||||
*/
|
||||
static std::shared_ptr<Aws::Crt::Auth::ICredentialsProvider> createWrappedProvider(
|
||||
aws_credentials_provider * rawProvider, Aws::Crt::Allocator * allocator = Aws::Crt::ApiAllocator())
|
||||
{
|
||||
if (rawProvider == nullptr) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
auto provider = Aws::Crt::MakeShared<Aws::Crt::Auth::CredentialsProvider>(allocator, rawProvider, allocator);
|
||||
return std::static_pointer_cast<Aws::Crt::Auth::ICredentialsProvider>(provider);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an SSO credentials provider using the C library directly.
|
||||
* The C++ wrapper doesn't expose SSO, so we call the C library and wrap the result.
|
||||
* Returns nullptr if SSO provider creation fails (e.g., profile doesn't have SSO config).
|
||||
*/
|
||||
static std::shared_ptr<Aws::Crt::Auth::ICredentialsProvider> createSSOProvider(
|
||||
const std::string & profileName,
|
||||
Aws::Crt::Io::ClientBootstrap * bootstrap,
|
||||
Aws::Crt::Io::TlsContext * tlsContext,
|
||||
Aws::Crt::Allocator * allocator = Aws::Crt::ApiAllocator())
|
||||
{
|
||||
aws_credentials_provider_sso_options options;
|
||||
AWS_ZERO_STRUCT(options);
|
||||
|
||||
options.bootstrap = bootstrap->GetUnderlyingHandle();
|
||||
options.tls_ctx = tlsContext ? tlsContext->GetUnderlyingHandle() : nullptr;
|
||||
if (!profileName.empty()) {
|
||||
options.profile_name_override = aws_byte_cursor_from_c_str(profileName.c_str());
|
||||
}
|
||||
|
||||
// Create the SSO provider - will return nullptr if SSO isn't configured for this profile
|
||||
// createWrappedProvider handles nullptr gracefully
|
||||
return createWrappedProvider(aws_credentials_provider_new_sso(allocator, &options), allocator);
|
||||
}
|
||||
|
||||
static AwsCredentials getCredentialsFromProvider(std::shared_ptr<Aws::Crt::Auth::ICredentialsProvider> provider)
|
||||
{
|
||||
if (!provider || !provider->IsValid()) {
|
||||
|
|
@ -91,6 +131,22 @@ public:
|
|||
logLevel = Aws::Crt::LogLevel::Warn;
|
||||
}
|
||||
apiHandle.InitializeLogging(logLevel, stderr);
|
||||
|
||||
// Create a shared TLS context for SSO (required for HTTPS connections)
|
||||
auto allocator = Aws::Crt::ApiAllocator();
|
||||
auto tlsCtxOptions = Aws::Crt::Io::TlsContextOptions::InitDefaultClient(allocator);
|
||||
tlsContext =
|
||||
std::make_shared<Aws::Crt::Io::TlsContext>(tlsCtxOptions, Aws::Crt::Io::TlsMode::CLIENT, allocator);
|
||||
if (!tlsContext || !*tlsContext) {
|
||||
warn("failed to create TLS context for AWS SSO; SSO authentication will be unavailable");
|
||||
tlsContext = nullptr;
|
||||
}
|
||||
|
||||
// Get bootstrap (lives as long as apiHandle)
|
||||
bootstrap = Aws::Crt::ApiHandle::GetOrCreateStaticDefaultClientBootstrap();
|
||||
if (!bootstrap) {
|
||||
throw AwsAuthError("failed to create AWS client bootstrap");
|
||||
}
|
||||
}
|
||||
|
||||
AwsCredentials getCredentialsRaw(const std::string & profile);
|
||||
|
|
@ -111,6 +167,8 @@ public:
|
|||
|
||||
private:
|
||||
Aws::Crt::ApiHandle apiHandle;
|
||||
std::shared_ptr<Aws::Crt::Io::TlsContext> tlsContext;
|
||||
Aws::Crt::Io::ClientBootstrap * bootstrap;
|
||||
boost::concurrent_flat_map<std::string, std::shared_ptr<Aws::Crt::Auth::ICredentialsProvider>>
|
||||
credentialProviderCache;
|
||||
};
|
||||
|
|
@ -118,23 +176,58 @@ private:
|
|||
std::shared_ptr<Aws::Crt::Auth::ICredentialsProvider>
|
||||
AwsCredentialProviderImpl::createProviderForProfile(const std::string & profile)
|
||||
{
|
||||
debug(
|
||||
"[pid=%d] creating new AWS credential provider for profile '%s'",
|
||||
getpid(),
|
||||
profile.empty() ? "(default)" : profile.c_str());
|
||||
// profileDisplayName is only used for debug logging - SDK uses its default profile
|
||||
// when ProfileNameOverride is not set
|
||||
const char * profileDisplayName = profile.empty() ? "(default)" : profile.c_str();
|
||||
|
||||
if (profile.empty()) {
|
||||
Aws::Crt::Auth::CredentialsProviderChainDefaultConfig config;
|
||||
config.Bootstrap = Aws::Crt::ApiHandle::GetOrCreateStaticDefaultClientBootstrap();
|
||||
return Aws::Crt::Auth::CredentialsProvider::CreateCredentialsProviderChainDefault(config);
|
||||
debug("[pid=%d] creating new AWS credential provider for profile '%s'", getpid(), profileDisplayName);
|
||||
|
||||
// Build a custom credential chain: Environment → SSO → Profile → IMDS
|
||||
// This works for both default and named profiles, ensuring consistent behavior
|
||||
// including SSO support and proper TLS context for STS-based role assumption.
|
||||
Aws::Crt::Auth::CredentialsProviderChainConfig chainConfig;
|
||||
auto allocator = Aws::Crt::ApiAllocator();
|
||||
|
||||
auto addProviderToChain = [&](std::string_view name, auto createProvider) {
|
||||
if (auto provider = createProvider()) {
|
||||
chainConfig.Providers.push_back(provider);
|
||||
debug("Added AWS %s Credential Provider to chain for profile '%s'", name, profileDisplayName);
|
||||
} else {
|
||||
debug("Skipped AWS %s Credential Provider for profile '%s'", name, profileDisplayName);
|
||||
}
|
||||
};
|
||||
|
||||
// 1. Environment variables (highest priority)
|
||||
addProviderToChain("Environment", [&]() {
|
||||
return Aws::Crt::Auth::CredentialsProvider::CreateCredentialsProviderEnvironment(allocator);
|
||||
});
|
||||
|
||||
// 2. SSO provider (try it, will fail gracefully if not configured)
|
||||
if (tlsContext) {
|
||||
addProviderToChain("SSO", [&]() { return createSSOProvider(profile, bootstrap, tlsContext.get(), allocator); });
|
||||
} else {
|
||||
debug("Skipped AWS SSO Credential Provider for profile '%s': TLS context unavailable", profileDisplayName);
|
||||
}
|
||||
|
||||
Aws::Crt::Auth::CredentialsProviderProfileConfig config;
|
||||
config.Bootstrap = Aws::Crt::ApiHandle::GetOrCreateStaticDefaultClientBootstrap();
|
||||
// This is safe because the underlying C library will copy this string
|
||||
// c.f. https://github.com/awslabs/aws-c-auth/blob/main/source/credentials_provider_profile.c#L220
|
||||
config.ProfileNameOverride = Aws::Crt::ByteCursorFromCString(profile.c_str());
|
||||
return Aws::Crt::Auth::CredentialsProvider::CreateCredentialsProviderProfile(config);
|
||||
// 3. Profile provider (for static credentials and role_arn/source_profile with STS)
|
||||
addProviderToChain("Profile", [&]() {
|
||||
Aws::Crt::Auth::CredentialsProviderProfileConfig profileConfig;
|
||||
profileConfig.Bootstrap = bootstrap;
|
||||
profileConfig.TlsContext = tlsContext.get();
|
||||
if (!profile.empty()) {
|
||||
profileConfig.ProfileNameOverride = Aws::Crt::ByteCursorFromCString(profile.c_str());
|
||||
}
|
||||
return Aws::Crt::Auth::CredentialsProvider::CreateCredentialsProviderProfile(profileConfig, allocator);
|
||||
});
|
||||
|
||||
// 4. IMDS provider (for EC2 instances, lowest priority)
|
||||
addProviderToChain("IMDS", [&]() {
|
||||
Aws::Crt::Auth::CredentialsProviderImdsConfig imdsConfig;
|
||||
imdsConfig.Bootstrap = bootstrap;
|
||||
return Aws::Crt::Auth::CredentialsProvider::CreateCredentialsProviderImds(imdsConfig, allocator);
|
||||
});
|
||||
|
||||
return Aws::Crt::Auth::CredentialsProvider::CreateCredentialsProviderChain(chainConfig, allocator);
|
||||
}
|
||||
|
||||
AwsCredentials AwsCredentialProviderImpl::getCredentialsRaw(const std::string & profile)
|
||||
|
|
|
|||
|
|
@ -160,6 +160,8 @@ if s3_aws_auth.enabled()
|
|||
deps_other += aws_crt_cpp
|
||||
aws_c_common = cxx.find_library('aws-c-common', required : true)
|
||||
deps_other += aws_c_common
|
||||
aws_c_auth = cxx.find_library('aws-c-auth', required : true)
|
||||
deps_other += aws_c_auth
|
||||
endif
|
||||
|
||||
configdata_pub.set('NIX_WITH_AWS_AUTH', s3_aws_auth.enabled().to_int())
|
||||
|
|
|
|||
|
|
@ -147,7 +147,7 @@ in
|
|||
else:
|
||||
machine.fail(f"nix path-info {pkg}")
|
||||
|
||||
def setup_s3(populate_bucket=[], public=False, versioned=False):
|
||||
def setup_s3(populate_bucket=[], public=False, versioned=False, profiles=None):
|
||||
"""
|
||||
Decorator that creates/destroys a unique bucket for each test.
|
||||
Optionally pre-populates bucket with specified packages.
|
||||
|
|
@ -157,9 +157,22 @@ in
|
|||
populate_bucket: List of packages to upload before test runs
|
||||
public: If True, make the bucket publicly accessible
|
||||
versioned: If True, enable versioning on the bucket before populating
|
||||
profiles: Dict of AWS profiles to create, e.g.:
|
||||
{"valid": {"access_key": "...", "secret_key": "..."},
|
||||
"invalid": {"access_key": "WRONG", "secret_key": "WRONG"}}
|
||||
Profiles are created on the client machine at /root/.aws/credentials
|
||||
"""
|
||||
def decorator(test_func):
|
||||
def wrapper():
|
||||
# Restart nix-daemon on both machines to clear the credential provider cache.
|
||||
# The AwsCredentialProviderImpl singleton persists in the daemon process,
|
||||
# and its cache can cause credentials from previous tests to be reused.
|
||||
# We reset-failed first to avoid systemd's start rate limiting.
|
||||
server.succeed("systemctl reset-failed nix-daemon.service nix-daemon.socket")
|
||||
server.succeed("systemctl restart nix-daemon")
|
||||
client.succeed("systemctl reset-failed nix-daemon.service nix-daemon.socket")
|
||||
client.succeed("systemctl restart nix-daemon")
|
||||
|
||||
bucket = str(uuid.uuid4())
|
||||
server.succeed(f"mc mb minio/{bucket}")
|
||||
try:
|
||||
|
|
@ -167,6 +180,15 @@ in
|
|||
server.succeed(f"mc anonymous set download minio/{bucket}")
|
||||
if versioned:
|
||||
server.succeed(f"mc version enable minio/{bucket}")
|
||||
if profiles:
|
||||
# Build credentials file content
|
||||
creds_content = ""
|
||||
for name, creds in profiles.items():
|
||||
creds_content += f"[{name}]\n"
|
||||
creds_content += f"aws_access_key_id = {creds['access_key']}\n"
|
||||
creds_content += f"aws_secret_access_key = {creds['secret_key']}\n\n"
|
||||
client.succeed("mkdir -p /root/.aws")
|
||||
client.succeed(f"cat > /root/.aws/credentials << 'AWSCREDS'\n{creds_content}AWSCREDS")
|
||||
if populate_bucket:
|
||||
store_url = make_s3_url(bucket)
|
||||
for pkg in populate_bucket:
|
||||
|
|
@ -174,6 +196,9 @@ in
|
|||
test_func(bucket)
|
||||
finally:
|
||||
server.succeed(f"mc rb --force minio/{bucket}")
|
||||
# Clean up AWS profiles if created
|
||||
if profiles:
|
||||
client.succeed("rm -rf /root/.aws")
|
||||
# Clean up client store - only delete if path exists
|
||||
for pkg in PKGS.values():
|
||||
client.succeed(f"[ ! -e {pkg} ] || nix store delete --ignore-liveness {pkg}")
|
||||
|
|
@ -764,6 +789,108 @@ in
|
|||
|
||||
print(" ✓ Compressed log uploaded with multipart")
|
||||
|
||||
@setup_s3(
|
||||
populate_bucket=[PKGS['A']],
|
||||
profiles={
|
||||
"valid": {"access_key": ACCESS_KEY, "secret_key": SECRET_KEY},
|
||||
"invalid": {"access_key": "INVALIDKEY", "secret_key": "INVALIDSECRET"},
|
||||
}
|
||||
)
|
||||
def test_profile_credentials(bucket):
|
||||
"""Test that profile-based credentials work without environment variables"""
|
||||
print("\n=== Testing Profile-Based Credentials ===")
|
||||
|
||||
store_url = make_s3_url(bucket, profile="valid")
|
||||
|
||||
# Verify store info works with profile credentials (no env vars)
|
||||
client.succeed(f"HOME=/root nix store info --store '{store_url}' >&2")
|
||||
print(" ✓ nix store info works with profile credentials")
|
||||
|
||||
# Verify we can copy from the store using profile
|
||||
verify_packages_in_store(client, PKGS['A'], should_exist=False)
|
||||
client.succeed(f"HOME=/root nix copy --no-check-sigs --from '{store_url}' {PKGS['A']}")
|
||||
verify_packages_in_store(client, PKGS['A'])
|
||||
print(" ✓ nix copy works with profile credentials")
|
||||
|
||||
# Clean up the package we just copied so we can test invalid profile
|
||||
client.succeed(f"nix store delete --ignore-liveness {PKGS['A']}")
|
||||
verify_packages_in_store(client, PKGS['A'], should_exist=False)
|
||||
|
||||
# Verify invalid profile fails when trying to copy
|
||||
invalid_url = make_s3_url(bucket, profile="invalid")
|
||||
client.fail(f"HOME=/root nix copy --no-check-sigs --from '{invalid_url}' {PKGS['A']} 2>&1")
|
||||
print(" ✓ Invalid profile credentials correctly rejected")
|
||||
|
||||
@setup_s3(
|
||||
populate_bucket=[PKGS['A']],
|
||||
profiles={
|
||||
"wrong": {"access_key": "WRONGKEY", "secret_key": "WRONGSECRET"},
|
||||
}
|
||||
)
|
||||
def test_env_vars_precedence(bucket):
|
||||
"""Test that environment variables take precedence over profile credentials"""
|
||||
print("\n=== Testing Environment Variables Precedence ===")
|
||||
|
||||
# Use profile with wrong credentials, but provide correct creds via env vars
|
||||
store_url = make_s3_url(bucket, profile="wrong")
|
||||
|
||||
# Ensure package is not in client store
|
||||
verify_packages_in_store(client, PKGS['A'], should_exist=False)
|
||||
|
||||
# This should succeed because env vars (correct) override profile (wrong)
|
||||
output = client.succeed(
|
||||
f"HOME=/root {ENV_WITH_CREDS} nix copy --no-check-sigs --debug --from '{store_url}' {PKGS['A']} 2>&1"
|
||||
)
|
||||
print(" ✓ nix copy succeeded with env vars overriding wrong profile")
|
||||
|
||||
# Verify the credential chain shows Environment provider was added
|
||||
if "Added AWS Environment Credential Provider" not in output:
|
||||
print("Debug output:")
|
||||
print(output)
|
||||
raise Exception("Expected Environment provider to be added to chain")
|
||||
print(" ✓ Environment provider added to credential chain")
|
||||
|
||||
# Clean up the package so we can test again without env vars
|
||||
client.succeed(f"nix store delete --ignore-liveness {PKGS['A']}")
|
||||
verify_packages_in_store(client, PKGS['A'], should_exist=False)
|
||||
|
||||
# Without env vars, same URL should fail (proving profile creds are actually wrong)
|
||||
client.fail(f"HOME=/root nix copy --no-check-sigs --from '{store_url}' {PKGS['A']} 2>&1")
|
||||
print(" ✓ Without env vars, wrong profile credentials correctly fail")
|
||||
|
||||
@setup_s3(
|
||||
populate_bucket=[PKGS['A']],
|
||||
profiles={
|
||||
"testprofile": {"access_key": ACCESS_KEY, "secret_key": SECRET_KEY},
|
||||
}
|
||||
)
|
||||
def test_credential_provider_chain(bucket):
|
||||
"""Test that debug logging shows which providers are added to the chain"""
|
||||
print("\n=== Testing Credential Provider Chain Logging ===")
|
||||
|
||||
store_url = make_s3_url(bucket, profile="testprofile")
|
||||
|
||||
output = client.succeed(
|
||||
f"HOME=/root nix store info --debug --store '{store_url}' 2>&1"
|
||||
)
|
||||
|
||||
# For a named profile, we expect to see these providers in the chain
|
||||
expected_providers = ["Environment", "Profile", "IMDS"]
|
||||
for provider in expected_providers:
|
||||
msg = f"Added AWS {provider} Credential Provider to chain for profile 'testprofile'"
|
||||
if msg not in output:
|
||||
print("Debug output:")
|
||||
print(output)
|
||||
raise Exception(f"Expected to find: {msg}")
|
||||
print(f" ✓ {provider} provider added to chain")
|
||||
|
||||
# SSO should be skipped (no SSO config for this profile)
|
||||
if "Skipped AWS SSO Credential Provider for profile 'testprofile'" not in output:
|
||||
print("Debug output:")
|
||||
print(output)
|
||||
raise Exception("Expected SSO provider to be skipped")
|
||||
print(" ✓ SSO provider correctly skipped (not configured)")
|
||||
|
||||
# ============================================================================
|
||||
# Main Test Execution
|
||||
# ============================================================================
|
||||
|
|
@ -797,6 +924,9 @@ in
|
|||
test_multipart_upload_basic()
|
||||
test_multipart_threshold()
|
||||
test_multipart_with_log_compression()
|
||||
test_profile_credentials()
|
||||
test_env_vars_precedence()
|
||||
test_credential_provider_chain()
|
||||
|
||||
print("\n" + "="*80)
|
||||
print("✓ All S3 Binary Cache Store Tests Passed!")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue