diff --git a/src/libstore/aws-creds.cc b/src/libstore/aws-creds.cc index dfdd81abbc4..a7753d705b3 100644 --- a/src/libstore/aws-creds.cc +++ b/src/libstore/aws-creds.cc @@ -4,15 +4,15 @@ # include # 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 # include # include +// C library headers for SSO provider support +# include + # include # include @@ -30,6 +30,48 @@ 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 createWrappedProvider( + aws_credentials_provider * rawProvider, Aws::Crt::Allocator * allocator = Aws::Crt::ApiAllocator()) +{ + if (rawProvider == nullptr) { + return nullptr; + } + + auto provider = Aws::Crt::MakeShared(allocator, rawProvider, allocator); + return std::static_pointer_cast(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 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; + 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 + auto * rawProvider = aws_credentials_provider_new_sso(allocator, &options); + if (!rawProvider) { + return nullptr; + } + + return createWrappedProvider(rawProvider, allocator); +} + static AwsCredentials getCredentialsFromProvider(std::shared_ptr provider) { if (!provider || !provider->IsValid()) { @@ -91,6 +133,16 @@ class AwsCredentialProviderImpl : public AwsCredentialProvider 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(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; + } } AwsCredentials getCredentialsRaw(const std::string & profile); @@ -111,6 +163,7 @@ class AwsCredentialProviderImpl : public AwsCredentialProvider private: Aws::Crt::ApiHandle apiHandle; + std::shared_ptr tlsContext; boost::concurrent_flat_map> credentialProviderCache; }; @@ -123,18 +176,58 @@ AwsCredentialProviderImpl::createProviderForProfile(const std::string & profile) getpid(), 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); + auto bootstrap = Aws::Crt::ApiHandle::GetOrCreateStaticDefaultClientBootstrap(); + if (!bootstrap) { + throw AwsAuthError("failed to create AWS client bootstrap"); } - 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); + // 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(); + const char * profileName = profile.empty() ? "(default)" : profile.c_str(); + + 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, profileName); + } else { + debug("Skipped AWS %s Credential Provider for profile '%s'", name, profileName); + } + }; + + // 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", profileName); + } + + // 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) diff --git a/src/libstore/meson.build b/src/libstore/meson.build index d8927c3a6c3..088d8bcbb59 100644 --- a/src/libstore/meson.build +++ b/src/libstore/meson.build @@ -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()) diff --git a/tests/nixos/s3-binary-cache-store.nix b/tests/nixos/s3-binary-cache-store.nix index a2ba1dae6c7..154c1fb1b8b 100644 --- a/tests/nixos/s3-binary-cache-store.nix +++ b/tests/nixos/s3-binary-cache-store.nix @@ -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!")