-
-
Notifications
You must be signed in to change notification settings - Fork 1.8k
feat(libstore): add AWS SSO support for S3 authentication #14645
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
c63600d
f8034e6
078ef24
35f59f1
1a2e90b
c1a1b43
2e45b88
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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,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<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; | ||
| 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<Aws::Crt::Auth::ICredentialsProvider> 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(); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Shouldn't this be a member of the class? I'm not sure about the lifetime requirements of the sdk for the allocator. |
||
| 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; | ||
| } | ||
| } | ||
|
|
||
| AwsCredentials getCredentialsRaw(const std::string & profile); | ||
|
|
@@ -111,6 +163,7 @@ class AwsCredentialProviderImpl : public AwsCredentialProvider | |
|
|
||
| private: | ||
| Aws::Crt::ApiHandle apiHandle; | ||
| std::shared_ptr<Aws::Crt::Io::TlsContext> tlsContext; | ||
| boost::concurrent_flat_map<std::string, std::shared_ptr<Aws::Crt::Auth::ICredentialsProvider>> | ||
| 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"); | ||
| } | ||
|
Comment on lines
+179
to
182
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The bootstrap could also probably be a member and not rely on the singleton 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(); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The default is special-cases by the SDK? |
||
|
|
||
| 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) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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): | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. BTW. It would make sense to start adding types here at some point. mypy is enabled in NixOS tests. |
||
| """ | ||
| Decorator that creates/destroys a unique bucket for each test. | ||
| Optionally pre-populates bucket with specified packages. | ||
|
|
@@ -157,23 +157,48 @@ 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: | ||
| if public: | ||
| 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: | ||
| server.succeed(f"{ENV_WITH_CREDS} nix copy --to '{store_url}' {pkg}") | ||
| 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!") | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This seems unnecessary, considering the
createWrapperProviderals does the nullptr check.