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 (
ExpireServicesetsexpires_onto 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_atis exclusive, like the mirror'sexpires_on. So the same-1 dayapplies. The window runs throughexpires_at - 1 + 14days.- 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_erroranswer. 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.currentis memoized per request.resolve_uncached!already loads it. The anchor is only computed on the blockedno_billable_source_errorbranch, next to the existingAddOnPurchaseMAXIMUM. - An online license with no
expires_atkeeps the old behavior (expired). - CDot also answers
no_billable_source_errorwhen 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
- https://gitlab.com/gitlab-org/gitlab/-/work_items/612697 (decision) and !253303 (merged) (original implementation)
- !257032 (merged) and #632190 (grace end date in the entitlement; it reads the same anchor)
- https://gitlab.com/gitlab-org/gitlab/-/work_items/628023 (E2E plan, Stage 6)
Screenshots or screen recordings
Not applicable, backend only.
How to set up and validate locally
- Use an online cloud license on the GDK, enable
secrets_manager_paid_experience, and enroll the instance in Secrets Manager. - Point the CustomersDot stub's
/api/v1/consumers/resolveat402 no_billable_source_error. Make sure there is nosecrets_managerAddOnPurchaserow:GitlabSubscriptions::AddOnPurchase.for_secrets_manager.for_self_managed.delete_all - 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.currentreturns a fresh copy from its cache on every call, so the change would be lost:Before: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:subscription_grace_period_expired. After::grace. - Run the same block with
expires_at = 14.days.ago.to_date. The answer is:subscription_grace_period_expiredagain.
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.