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 optionalgrace_period_end_datefield to theEntitlementvalue object. It raisesArgumentErrorif this is set whileblocked_reasonis 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_entitlementnow setsgrace_period_end_datetoanchor + GRACE_DAYSwhenblocked_reasonis:grace, and tonilotherwise.grace_anchor_dateis now memoized withstrong_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 onGitlabSubscriptions::AddOnPurchase.ee/lib/secrets_management/entitlement/resolver.rb: theRails.cacheentry for the attributes hash is now keyed by a digest of theEntitlementfield list. See the rollout section below for why.ee/app/graphql/types/secrets_management/entitlement_type.rb: adds a new fieldgracePeriodEndDate, typeDate, 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 withblockedReason. It also matches the inclusive convention ofGitlabSubscription#end_date, which anchors it. The field is namedend_date, notends_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 localAddOnPurchasemirror'sexpires_onminus one day, sinceexpires_onis exclusive. On self-managed offline (air-gapped) instances, the entitlement never enters GRACE, it goes straight toSUBSCRIPTION_GRACE_PERIOD_EXPIRED, so the field stays null there. - The field is not marked
experiment. The sibling fields carry that tag because of thesecrets_manager_paid_experienceflag, 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_hinRails.cachefor one minute and rebuilds it withEntitlement.new(**hash), which rejects unknown keys. Since !256197 (merged),Ci::BuildRunnerPresenteralso reads that cache and drops the build withsecrets_manager_access_deniedwhen 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.cachekey now carries a short digest of theEntitlementfield 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_cacheandrefresh!both delete the versioned key and the legacy unversioned key.clear_cacheruns 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.
- 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) - 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 - 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 - 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_hgracePeriodEndDateshould 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.