Refresh STS credentials early for Pages deployment URLs
What does this MR do and why?
GitLab Pages caches the presigned deployment URL returned by the internal API for up to 10 minutes (gitlab-cache-expiry, 600s by default). When object storage uses use_iam_profile, that URL is only valid while the temporary STS session token that signed it is valid, regardless of the 24h expiry Rails puts on the URL itself. fog-aws refreshes the token only when fewer than 15 seconds remain (aws_credentials_refresh_threshold_seconds default), so a URL signed in the last minutes of a token's life keeps being served from the Pages cache after S3 has started answering 400 ExpiredToken. A visitor of a rarely visited Pages site then gets a 500, with httprange: new resource 400 in the gitlab-pages log, until the cache entry is refreshed.
This MR defaults aws_credentials_refresh_threshold_seconds to 900 for Pages::DeploymentUploader only, and only when the provider is AWS and use_iam_profile is set. It is applied in Pages::DeploymentUploader.object_store_credentials, which is what CarrierWave hands to Fog::Storage.new, so no other uploader is affected. An explicit aws_credentials_refresh_threshold_seconds in the connection settings still takes precedence (the default is merged underneath the configured hash).
Why 900: it has to exceed the Pages cache lifetime (600s) plus the zip VFS cache (~60s) so that no URL handed to Pages can outlive the token that signed it.
What it does and does not guarantee: fog-aws refreshes by calling fetch_credentials again. Where that mints a new token (STS AssumeRoleWithWebIdentity, i.e. IRSA on EKS, which is the setup this was diagnosed on) the window is closed, at the cost of rotating the Pages connection's token at 45 minutes instead of 59m45s. With an EC2 instance profile, IMDS returns the same credentials until AWS rotates them, which it does at least five minutes before expiry; there the window shrinks from about 10 minutes to about 5 and fog re-reads IMDS on each Pages request during that time. The code comment and the docs say so.
Why Pages only: Pages is the only consumer that parks a presigned URL in another daemon's cache. Browsers and runners follow their presigned URLs immediately, so the token lifetime never matters to them. Rails already allows a per-type connection for Pages (ObjectStoreSettings::WORKHORSE_ACCELERATED_TYPES excludes it), and CarrierWave caches Fog connections per credentials hash, so Pages simply gets its own connection.
References
- #588183 (closed)
- https://gitlab.com/gitlab-com/gl-infra/gitlab-dedicated/team/-/issues/9954 (internal)
- fog-aws refresh threshold: https://github.com/fog/fog-aws#controlling-credential-refresh-time-with-iam-authentication
- AWS: a presigned URL signed with temporary credentials expires when the session token expires, regardless of the URL's own expiry: https://docs.aws.amazon.com/AmazonS3/latest/userguide/ShareObjectPreSignedURL.html
Screenshots or screen recordings
| Before | After |
|---|---|
How to set up and validate locally
The failure itself needs a real STS token to expire (minimum DurationSeconds is 900s), so it cannot be reproduced against MinIO. The change can be validated at the credentials level:
-
In a Rails console, check that nothing changes for the current (static key) configuration:
Pages::DeploymentUploader.object_store_credentials == Pages::DeploymentUploader.object_store_options.connection.to_hash.deep_symbolize_keys # => true -
Simulate an IAM profile connection and check that fog-aws receives the threshold and would refresh a token with 10 minutes left (which the default of 15s would not):
opts = Gitlab::Configs.build_options('enabled' => true, 'remote_directory' => 'pages', 'connection' => { 'provider' => 'AWS', 'use_iam_profile' => true, 'region' => 'eu-central-1' }) Pages::DeploymentUploader.define_singleton_method(:object_store_options) { opts } creds = Pages::DeploymentUploader.object_store_credentials # => {:aws_credentials_refresh_threshold_seconds=>900, :provider=>"AWS", :use_iam_profile=>true, :region=>"eu-central-1"} fake = { aws_access_key_id: 'AKIAFAKE', aws_secret_access_key: 'x' * 40, aws_session_token: 'tok', aws_credentials_expire_at: Time.now.utc + 600 } Fog::AWS::Storage.define_singleton_method(:fetch_credentials) { |_ = {}| fake } storage = Fog::Storage.new(creds) storage.send(:credentials_refresh_threshold) # => 900 storage.send(:credentials_expired?) # => true (refreshes before signing) storage.instance_variable_set(:@aws_credentials_refresh_threshold_seconds, nil) storage.send(:credentials_expired?) # => false (the bug: signs with a token that dies in 10 minutes) -
Check that an explicit setting wins by adding
'aws_credentials_refresh_threshold_seconds' => 120to the connection above:object_store_credentials[:aws_credentials_refresh_threshold_seconds]returns120. -
Check that
provider: 'Google',provider: 'AWS'with static keys, anduse_iam_profile: falsereturn the connection hash unchanged.
Verified on a GDK with MinIO (rerun after the follow-up commit for the target spec, spec/uploaders/object_storage_spec.rb and spec/lib/object_storage/config_spec.rb): spec/uploaders, spec/lib/object_storage, spec/models/pages*, spec/requests/api/internal/pages_spec.rb, spec/lib/gitlab/pages, spec/services/pages, spec/workers/pages (1545 examples, 0 failures) and ee/spec/uploaders (107 examples, 0 failures). All 39 uploaders that include ObjectStorage::Concern return credentials identical to their connection YAML with static keys. A real Pages deployment stored through the uploader still yields a working 24h presigned URL from Pages::LookupPath#source.
MR acceptance checklist
Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.