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_expired on day 0. Secrets Manager trial purchases are excluded via non_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

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.

  1. Run the updated spec file:

    bundle exec rspec ee/spec/lib/secrets_management/entitlement/resolver_spec.rb
  2. 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_on further back (more than 14 days ago) returns :subscription_grace_period_expired instead.

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" = FALSE

The 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 of NULLS NOT DISTINCT, at most one row with namespace_id IS NULL can 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_subscription and 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_error block), and the resolution is memoized per request via Gitlab::SafeRequestStore.
Edited by Dmytro Biryukov

Merge request reports

Loading
Loading