Anchor online self-managed Secrets Manager grace on license end

What does this MR do and why?

The problem. A paying online self-managed instance can have its Premium or Ultimate subscription end without a renewal. Today it gets no 14-day read-only grace window for Secrets Manager. On day 0 the entitlement goes to BLOCKED / SUBSCRIPTION_GRACE_PERIOD_EXPIRED. CI secret reads are dropped with secrets_manager_access_denied. The Secrets pages lock.

The cause. CDot can answer /consumers/resolve with no_billable_source_error. In that case SecretsManagement::Entitlement::Resolver starts the self-managed window from the instance-level secrets_manager AddOnPurchase mirror. CustomersDot only creates that add-on for offline cloud licenses (AddOns::ParamsGenerator::SelfManaged::SecretsManager#eligible?). A GitLab Credits purchase has no Zuora Secrets Manager charge at all. So online installs never have the row. The start point is nil, and the resolver answers "expired".

The fix. This is option 1 from the issue, and it changes GitLab only. The resolver now also reads the end of the online cloud license term (License.current.expires_at) and starts the window from the later of that date and the purchase mirror. The license sync already mirrors this date as the subscription end. It is the self-managed counterpart of the gitlab_subscription.end_date anchor that GitLab.com uses. It also follows the https://gitlab.com/gitlab-org/gitlab/-/work_items/612697 decision to "start the timer after the subscription ends".

  • The later date wins. An active mirror that lags behind the CDot lapse is later, so it still decides. A stale mirror loses to the license: a license sync without the add-on only expires the row (ExpireService sets expires_on to yesterday), so an install that moved from an offline license with the add-on to an online one keeps a row dated the switch. Anchoring on that row first would lock it out on day 0 again (raised in review).
  • expires_at is exclusive, like the mirror's expires_on. So the same -1 day applies. The window runs through expires_at - 1 + 14 days.
  • The license date only counts for an online cloud license. The Gitlab.com? staging route can reach the online branch with another license shape. There, the license term says nothing about billing.
  • The anchor is only used on a no_billable_source_error answer. It never opens a window while CDot still finds a billing source for the instance.

Cost and caveats

  • There are no extra queries on the hot path. License.current is memoized per request. resolve_uncached! already loads it. The anchor is only computed on the blocked no_billable_source_error branch, next to the existing AddOnPurchase MAXIMUM.
  • An online license with no expires_at keeps the old behavior (expired).
  • CDot also answers no_billable_source_error when it cannot match an instance to its current subscription, for example after an instance UUID change or a seat link that never synced. Those instances were fully locked out before. Now they get read-only grace until 14 days after the license term ends. Writes stay blocked.

Overlap with !257032 (merged): that MR exposes gracePeriodEndDate from the same grace_anchor_date. Both MRs touch the comment above self_managed_grace_anchor_date and the online self-managed License stub in resolver_spec.rb, so whichever lands second needs a rebase. After both land, gracePeriodEndDate for an online install with no add-on row is expires_at - 1 + 14 days.

Out of scope: option 2 (CDot returns the subscription end date with the 402), and the E2E harness scenario that runs Stage 6 without Cloud.set_grace_addon_row.

What the specs cover. The specs are in ee/spec/lib/secrets_management/entitlement/resolver_spec.rb, in the online self-managed no_billable_source_error block:

  • No purchase mirror and the license term is inside the window: :grace. This replaces the old "no row is expired" expectation for the online case.
  • Last day of the window: :grace. Window elapsed: :subscription_grace_period_expired.
  • License term still running (out of sync with CDot): :grace, reads allowed, writes denied.
  • License without expires_at: expired.
  • License that is not an online cloud license: the term is ignored.
  • Purchase mirror and license term both present: the later one wins, both ways (stale mirror older than the license, and mirror later than the license).

I reverted each production line separately and confirmed that a spec fails for each one.

Resolves https://gitlab.com/gitlab-org/gitlab/-/issues/632211

References

Screenshots or screen recordings

Not applicable, backend only.

How to set up and validate locally

  1. Use an online cloud license on the GDK, enable secrets_manager_paid_experience, and enroll the instance in Secrets Manager.
  2. Point the CustomersDot stub's /api/v1/consumers/resolve at 402 no_billable_source_error. Make sure there is no secrets_manager AddOnPurchase row:
    GitlabSubscriptions::AddOnPurchase.for_secrets_manager.for_self_managed.delete_all
  3. In the Rails console, make the license term end a few days ago and resolve. Run both inside one request store block. Outside a request, License.current returns a fresh copy from its cache on every call, so the change would be lost:
    Gitlab::SafeRequestStore.ensure_request_store do
      License.current.license.expires_at = 5.days.ago.to_date
      SecretsManagement::Entitlement::Resolver.new(nil, fresh: true).resolve.blocked_reason
    end
    Before: :subscription_grace_period_expired. After: :grace.
  4. Run the same block with expires_at = 14.days.ago.to_date. The answer is :subscription_grace_period_expired again.

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.

🤖 Generated with Claude Code

Edited by Dmytro Biryukov

Merge request reports

Loading
Loading