Expose Secrets Manager grace period end date in entitlement

What does this MR do and why?

When a customer's Premium or Ultimate subscription lapses, the Secrets Manager entitlement moves to a 14-day read-only grace window before it expires. The frontend wants to show the user the date this window ends, but the GraphQL entitlement response did not include that date. The backend already computed it internally, in SecretsManagement::Entitlement::Resolver#grace_window_reason, but only used it to pick a reason and then discarded it.

This MR adds the date to the entitlement so it can be exposed over GraphQL.

Changes:

  • ee/lib/secrets_management/entitlement.rb: adds an optional grace_period_end_date field to the Entitlement value object. It raises ArgumentError if this is set while blocked_reason is not :grace, because no other blocked reason has a grace window, and a date under those states would mislead the caller.
  • ee/lib/secrets_management/entitlement/resolver.rb: blocked_entitlement now sets grace_period_end_date to anchor + GRACE_DAYS when blocked_reason is :grace, and to nil otherwise. grace_anchor_date is now memoized with strong_memoize_attr, so this reuses the lookup the reason check already made instead of running a second query. This matters on self-managed, where the anchor comes from a .maximum(:expires_on) query on GitlabSubscriptions::AddOnPurchase.
  • ee/lib/secrets_management/entitlement/resolver.rb: the Rails.cache entry for the attributes hash is now keyed by a digest of the Entitlement field list. See the rollout section below for why.
  • ee/app/graphql/types/secrets_management/entitlement_type.rb: adds a new field gracePeriodEndDate, type Date, nullable. Description: "Last day of the read-only grace period, inclusive. Set only when blockedReason is GRACE; null otherwise."
  • GraphQL reference docs and introspection JSON, regenerated with bundle exec rake gitlab:graphql:update_all.

Design notes:

  • The date is the last day of the window (inclusive), not the first blocked day. This mirrors the <= comparison already used in the resolver, so the field can never disagree with blockedReason. It also matches the inclusive convention of GitlabSubscription#end_date, which anchors it. The field is named end_date, not ends_at, because it is a calendar date, not a timestamp.
  • Data sources: on GitLab.com, the date comes from the top-level group's gitlab_subscription.end_date. On self-managed with an online license, it comes from the local AddOnPurchase mirror's expires_on minus one day, since expires_on is exclusive. On self-managed offline (air-gapped) instances, the entitlement never enters GRACE, it goes straight to SUBSCRIPTION_GRACE_PERIOD_EXPIRED, so the field stays null there.
  • The field is not marked experiment. The sibling fields carry that tag because of the secrets_manager_paid_experience flag, and that flag is removed in 19.5 for GA, so a new field would only pick up the tag to lose it in the same milestone.

Backward compatibility and rollout:

  • This change is purely additive. No existing field changes behavior.
  • The CI job pickup path caches Entitlement#to_h in Rails.cache for one minute and rebuilds it with Entitlement.new(**hash), which rejects unknown keys. Since !256197 (merged), Ci::BuildRunnerPresenter also reads that cache and drops the build with secrets_manager_access_denied when the entitlement denies. Without care, a rolling deploy would let an old node read a hash written by a new node, fail closed on the unknown key, and wrongly fail the job for up to one minute per cache key.
  • To prevent that, the Rails.cache key now carries a short digest of the Entitlement field list (Resolver::ATTRIBUTES_CACHE_VERSION). Each code version reads and writes its own entry, so old and new nodes never see each other's hash. The cost is one cold CustomersDot resolve per namespace during the deploy window. The request store and the last-known-good store keep their keys, since neither holds this hash.
  • clear_cache and refresh! both delete the versioned key and the legacy unversioned key. clear_cache runs after a local intent change, such as enabling the add-on. refresh! runs after a billing consent change in the Customers Portal. This way, an invalidation on a new node also reaches old nodes that still read the unversioned key. The opposite case, an old node missing the new key, cannot be fixed from this side. It lasts at most one minute and only during the deploy. The legacy delete can be removed in 19.6, because zero-downtime upgrades must go one minor release at a time, so no 19.5-to-19.6 mixed deployment has a node reading the unversioned key.
  • The same invalidation gap between digest keys comes back on every future field addition, since each version only knows its own digest. It is bounded by the one-minute TTL and only happens during deploys. A version-independent fix is tracked in #630675.
  • Rollback is safe for the same reason: the old code reads its own unversioned key, and entries written by the new code expire on their own within the TTL.

Follow-up (out of scope for this MR): wiring the frontend query and banner to use this field. The value is a plain Date string, for example 2026-10-06, so the frontend needs to parse it as a local date to avoid an off-by-one error in negative UTC offsets.

References

Related work item: #629113 (closed)

Screenshots or screen recordings

Backend only, no UI change.

Example query:

query {
  group(fullPath: "my-group") {
    secretsManagerEntitlement {
      state
      blockedReason
      gracePeriodEndDate
    }
  }
}

Example response, for a group in the GRACE state:

{
  "data": {
    "group": {
      "secretsManagerEntitlement": {
        "state": "BLOCKED",
        "blockedReason": "GRACE",
        "gracePeriodEndDate": "2026-10-06"
      }
    }
  }
}

How to set up and validate locally

CDot is an external service, so the simplest way to validate this locally is to stub the client in a Rails console. Run GDK in SaaS mode (GITLAB_SIMULATE_SAAS=1) so the group path is used.

  1. Enable the feature flag for a top-level group and give it a lapsed paid subscription:
    group = Group.find_by_full_path('my-group')
    Feature.enable(:secrets_manager_paid_experience, group)
    GitlabSubscription.create!(namespace: group, hosted_plan: Plan.find_by(name: 'premium'), end_date: 5.days.ago.to_date)
  2. Stub the two CDot calls in the same console:
    client = Gitlab::SubscriptionPortal::Client
    client.define_singleton_method(:secrets_manager_consumer_resolve) do |**|
      Gitlab::SubscriptionPortal::SecretsManagerConsumerResolveResponse.new(blocked: true, blocked_reason: :no_billable_source_error)
    end
    client.define_singleton_method(:secrets_manager_trial) do |**|
      Gitlab::SubscriptionPortal::SecretsManagerTrialResponse.new(state: :expired)
    end
  3. Resolve the entitlement and check the date:
    entitlement = SecretsManagement::Entitlement.for(group)
    entitlement.blocked_reason          # => :grace
    entitlement.grace_period_end_date   # => end_date + 14 days
  4. Run the GraphQL query from the same console, since the stubs only exist in this process:
    query = 'query($p: ID!) { group(fullPath: $p) { secretsManagerEntitlement { state blockedReason gracePeriodEndDate } } }'
    GitlabSchema.execute(query, variables: { p: group.full_path }, context: { current_user: group.first_owner }).to_h
    gracePeriodEndDate should match the date from step 3.

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.

Edited by Erick Bajao

Merge request reports

Loading
Loading