Compute Secrets Manager grace period for self-managed installs
What does this MR do and why?
SecretsManagement::Entitlement::Resolver computes a read-only :grace blocked-reason for lapsed paid Secrets Manager customers when CustomersDot's /api/v1/consumers/resolve returns a 402 block with no_billable_source_error. Within GRACE_DAYS (14 days, derived from GitlabSubscription::SUBSCRIPTION_GRACE_PERIOD) after the paid term ends, the customer keeps read-only access so CI reads continue to work; past that, the reason becomes :subscription_grace_period_expired and access is fully locked out.
Until now the grace anchor was GitLab.com-only: it read group.gitlab_subscription.end_date. Self-managed installs do not populate gitlab_subscriptions rows, so a lapsed paid self-managed (online cloud licensed) customer failed closed to full lockout on day 0, while the same customer on GitLab.com got 14 read-only days. This gap was raised during review of the merge request that added the GitLab.com computation.
This MR routes the grace anchor by realm using Gitlab::Saas.feature_available?(:gitlab_com_subscriptions), the same split the resolver's cache_key and the billable-events emitter already use:
- On GitLab.com the anchor is unchanged: the namespace's paid subscription
end_date. - On self-managed it becomes the local Secrets Manager add-on purchase mirror —
GitlabSubscriptions::AddOnPurchase.for_secrets_manager.for_self_managed.non_trial.maximum(:expires_on)— the same record the resolver's offline branch already trusts.
AddOnPurchase#expires_on is exclusive (a purchase is active while today < expires_on), whereas gitlab_subscription.end_date is inclusive, so the self-managed anchor subtracts one day to give an identical 14-day window on both platforms.
The anchor is deliberately not the platform license (License.current): an active GitLab license proves nothing about a Secrets Manager purchase, and using it would leak an open-ended grace window to instances that never paid for Secrets Manager.
Behavior that does not change:
- With no purchase mirror record (or a trial-only record), the resolver still fails closed to
:subscription_grace_period_expiredon day 0. Secrets Manager trial purchases are excluded vianon_trial, so expired trials remain a day-0 hard cutoff. - The offline (air-gapped) branch is untouched.
The whole code path is gated behind the secrets_manager_paid_experience feature flag (default off), so there is no user-facing change until rollout.
Specs: the self-managed no_billable_source_error contexts now cover the window boundaries (inside the window, last day, elapsed), a still-active mirror (local sync lagging behind the CustomersDot lapse), trial-only exclusion, namespace-scoped purchases being ignored, the no-mirror fail-closed case, and a guard that the GitLab.com path never consults the purchase mirror.
References
- Resolves https://gitlab.com/gitlab-org/gitlab/-/work_items/612697 (confidential work item). Product confirmed the approach (same 14-day window as GitLab.com, anchored on the purchase expiry) in https://gitlab.com/gitlab-org/gitlab/-/work_items/612697#note_3691711279.
- The GitLab.com grace computation was added in !249308 (merged).
- Review threads that raised the self-managed gap: !249308 (comment 3676871305) and !249308 (comment 3671766454).
Screenshots or screen recordings
Not applicable — this is a backend-only change with no user interface impact.
How to set up and validate locally
GDK runs as self-managed, so the new branch is exercised directly.
-
Run the updated spec file:
bundle exec rspec ee/spec/lib/secrets_management/entitlement/resolver_spec.rb -
Or, in a Rails console, create a lapsed instance-wide Secrets Manager purchase mirror and check the resolver's grace computation directly:
add_on = GitlabSubscriptions::AddOn.find_or_create_by!(name: 'secrets_manager') { |a| a.description = 'Secrets Manager' } GitlabSubscriptions::AddOnPurchase.create!( add_on: add_on, namespace: nil, quantity: 1, trial: false, started_at: 1.year.ago.to_date, expires_on: 5.days.ago.to_date, purchase_xid: SecureRandom.hex(8), organization: Organizations::Organization.first ) resolver = SecretsManagement::Entitlement::Resolver.new(nil) resolver.send(:grace_window_reason) # => :grace (within 14 days of expiry)Setting
expires_onfurther back (more than 14 days ago) returns:subscription_grace_period_expiredinstead.
MR acceptance checklist
This MR has been evaluated against the MR acceptance checklist.
Database: raw SQL and query plan
The new query is GitlabSubscriptions::AddOnPurchase.for_secrets_manager.for_self_managed.non_trial.maximum(:expires_on) on the subscription_add_on_purchases table; it runs only on self-managed instances, when CustomersDot answers the consumer-resolve call with a no_billable_source_error block.
SELECT MAX(expires_on) FROM "subscription_add_on_purchases" WHERE "subscription_add_on_purchases"."subscription_add_on_uid" = 8 AND "subscription_add_on_purchases"."namespace_id" IS NULL AND "subscription_add_on_purchases"."trial" = FALSEThe plan below is from a local GDK database. A postgres.ai Database Lab plan against GitLab.com data would not add signal here: the unique index caps the number of matching rows at one regardless of table size.
Aggregate (cost=2.17..2.18 rows=1 width=4) (actual time=0.009..0.009 rows=1 loops=1)
Buffers: shared hit=1
-> Index Scan using index_add_on_purchases_on_add_on_uid_and_namespace_id_not_null on subscription_add_on_purchases (cost=0.15..2.17 rows=1 width=4) (actual time=0.003..0.003 rows=0 loops=1)
Index Cond: ((subscription_add_on_uid = 8) AND (namespace_id IS NULL))
Filter: (NOT trial)
Buffers: shared hit=1
Planning:
Buffers: shared hit=285
Planning Time: 3.378 ms
Execution Time: 0.020 ms- Index backing: the query is served by
index_add_on_purchases_on_add_on_uid_and_namespace_id_not_null, a unique btree on(subscription_add_on_uid, namespace_id) NULLS NOT DISTINCT. Because ofNULLS NOT DISTINCT, at most one row withnamespace_id IS NULLcan exist per add-on uid, so the aggregate runs over at most one row. This is a schema guarantee, not a data-distribution assumption — it holds at any table size, and no sequential scan is possible for this access path. - Execution realm: the query only runs on self-managed instances. The SaaS branch anchors on
gitlab_subscriptionand never consults the purchase mirror (there is a spec guarding this), so on GitLab.com — the only deployment where this table is large — the query never executes. A self-managed install's table only contains that instance's own mirrored add-on purchases. - Frequency: it runs only for lapsed customers (when CustomersDot returns a
no_billable_source_errorblock), and the resolution is memoized per request viaGitlab::SafeRequestStore.