Add SecretsManagerEnableAddOn GraphQL mutation

What does this MR do and why?

Adds the backend for the "Enable add-on" flow for GitLab Secrets Manager (work item 612843). It lets a group Owner of a trial-eligible top-level group on gitlab.com enable the paid Secrets Manager add-on directly, without starting a trial.

The flow is only supported when the customer's on-demand billing is already accepted (onDemandEnabled true from CustomersDot). When on-demand is off, the mutation rejects the request — no "pending" state is introduced. No CustomersDot changes are needed: CustomersDot has no per-product paid signal, so the paid intent is stored in GitLab, and billability stays CustomersDot's live /consumers/resolve answer.

Changes (two commits):

  • DB migration: adds a nullable timestamptz column add_on_requested_at to secrets_manager_namespace_enrollments, because existing enrollment records cannot distinguish paid add-on intent from trial-chain or free-beta enrollment.
  • SecretsManagement::NamespaceEnrollment: new scope with_add_on_requested and class method add_on_requested?(namespace).
  • SecretsManagement::Entitlement::Resolver: maps CustomersDot trial state trial_eligible to entitlement state paid when add-on intent is recorded and /consumers/resolve allows access; if resolve blocks (for example, on-demand acceptance reset by a subscription renewal), it falls back to trial_eligible. Also adds Resolver.clear_cache(namespace) so a caller that records intent mid-request can re-resolve.
  • New GraphQL mutation secretsManagerEnableAddOn (Mutations::SecretsManagement::EnableAddOn, experiment, milestone 19.4). Owner-only (admin_group), top-level groups only, gitlab.com only, behind the default-off feature flag secrets_manager_paid_experience. The mutation expires cached CustomersDot answers and validates billability live (state trial_eligible plus onDemandEnabled), enrolls the namespace and stamps add_on_requested_at, then re-resolves; if the result is not paid, it restores the enrollment to its pre-click shape (including a previous opt-out) and returns an error. Otherwise it provisions the secrets manager via the existing GroupSecretsManagers::InitializeService (the "already initialized" answer is treated as benign, so a re-click after a partial failure retries provisioning) and returns the entitlement.
  • SecretsManagement::NamespaceEnrollmentService#unenroll now clears add_on_requested_at, so an opt-out cannot leave behind a stale intent that would silently restore the paid state on a later re-enroll.
  • Two internal event definitions: secrets_manager_add_on_enabled and secrets_manager_add_on_enable_failed (failure reason in the label property).
  • Regenerated GraphQL reference docs, introspection schema, and permissions GraphQL docs.

Testing: five spec files (model, entitlement resolver, enrollment service, mutation unit spec, GraphQL request spec), all passing. The request spec stubs only the CustomersDot HTTP client; enrollment, the resolver mapping, and provisioning run for real.

References

Screenshots or screen recordings

Result Screenshot
Happy path: entitlement returned as PAID, onDemandEnabled: true, empty errors enable_add_on_success_paid
On-demand billing off: mutation rejected with the Customers Portal message, no DB writes enable_add_on_on_demand_off_rejected
Ineligible state: mutation rejected enable_add_on_ineligible_rejected

How to set up and validate locally

Setup (roll-in)

  1. Run GDK with SaaS simulation (GITLAB_SIMULATE_SAAS=1) and start OpenBao: gdk start openbao.

  2. Stub CustomersDot entitlement responses with a dev-only initializer at config/initializers/gsm_entitlement_dev_stub.rb, then run gdk restart rails-web (initializers do not hot-reload). The stub reads tmp/gsm_entitlement_stub on every request, so switching states after this needs no restart.

    config/initializers/gsm_entitlement_dev_stub.rb
    # frozen_string_literal: true
    
    # DEV-ONLY, LOCAL GDK FILE -- never commit this initializer.
    #
    # Overrides SecretsManagement::Entitlement resolution for ALL groups based on
    # tmp/gsm_entitlement_stub. First word is the state; append "on_demand" to
    # enable on-demand billing:
    #
    #   echo trial_eligible        > tmp/gsm_entitlement_stub
    #   echo trial                 > tmp/gsm_entitlement_stub
    #   echo "trial on_demand"     > tmp/gsm_entitlement_stub
    #   echo paid                  > tmp/gsm_entitlement_stub
    #   echo "paid on_demand"      > tmp/gsm_entitlement_stub
    #   echo ineligible            > tmp/gsm_entitlement_stub
    #   echo trial_expired         > tmp/gsm_entitlement_stub
    #   echo grace                 > tmp/gsm_entitlement_stub
    #   echo grace_expired         > tmp/gsm_entitlement_stub
    #   rm tmp/gsm_entitlement_stub   # back to real resolution
    #
    # The file is read on every call and checked before all cache layers, so
    # switching takes effect on the next request without restarting Rails.
    #
    # The stub only replaces the CDot-backed state. The resolver's real beta
    # attributes still run on top (NamespaceEnrollment lookup + the
    # end_secrets_manager_beta_program flag), so the beta window is simulated
    # with real local data:
    #
    #   SecretsManagement::NamespaceEnrollment.create!(namespace: group, beta: true)
    #   Feature.disable(:end_secrets_manager_beta_program)         # window open
    #   Feature.enable(:end_secrets_manager_beta_program, group)   # window closed
    #
    # The add-on remap also runs on real local data: with the stub set to
    # "trial_eligible on_demand", a namespace whose enrollment has
    # add_on_requested_at set resolves to :paid, mirroring production.
    if Rails.env.development?
      # Top-level module on purpose: constants inside the Zeitwerk-managed
      # SecretsManagement namespace are wiped on every dev code reload.
      module GsmEntitlementDevStub
        STUB_FILE = 'tmp/gsm_entitlement_stub'
    
        BUILDERS = {
          'trial_eligible' => ->(entitlement, on_demand) {
            entitlement.new(state: :trial_eligible, on_demand_enabled: on_demand)
          },
          'trial' => ->(entitlement, on_demand) {
            entitlement.new(
              state: :trial,
              trial_started_at: 5.days.ago,
              trial_expires_at: 25.days.from_now,
              credits_remaining: 900,
              credits_total: 1000,
              on_demand_enabled: on_demand
            )
          },
          'paid' => ->(entitlement, on_demand) {
            entitlement.new(
              state: :paid,
              trial_started_at: 35.days.ago,
              trial_expires_at: 5.days.ago,
              on_demand_enabled: on_demand
            )
          },
          'ineligible' => ->(entitlement, _on_demand) {
            entitlement.new(state: :ineligible)
          },
          'trial_expired' => ->(entitlement, _on_demand) {
            entitlement.new(
              state: :blocked,
              blocked_reason: :trial_expired,
              trial_started_at: 35.days.ago,
              trial_expires_at: 5.days.ago,
              on_demand_enabled: false
            )
          },
          'grace' => ->(entitlement, _on_demand) {
            entitlement.new(state: :blocked, blocked_reason: :grace)
          },
          'grace_expired' => ->(entitlement, _on_demand) {
            entitlement.new(state: :blocked, blocked_reason: :subscription_grace_period_expired)
          }
        }.freeze
    
        def self.read_stub
          tokens = Rails.root.join(STUB_FILE).read.split
          BUILDERS[tokens.first]&.call(::SecretsManagement::Entitlement, tokens.include?('on_demand'))
        rescue Errno::ENOENT
          nil
        end
    
        # Wrap the stubbed state with the resolver's real beta logic so the
        # enrollment lookup and beta flag behave exactly as in production.
        def resolve
          stubbed = GsmEntitlementDevStub.read_stub
          stubbed ? gsm_stub_attach_beta(gsm_stub_apply_add_on_remap(stubbed)) : super
        end
    
        def resolve!
          stubbed = GsmEntitlementDevStub.read_stub
          stubbed ? gsm_stub_attach_beta(gsm_stub_apply_add_on_remap(stubbed)) : super
        end
    
        private
    
        # Mirror Resolver#add_on_converted?: trial_eligible + local add-on intent
        # maps to paid (stubbed CDot answers are never blocked). Without this the
        # EnableAddOn mutation's post-intent re-resolve never sees :paid and
        # reverts the intent.
        def gsm_stub_apply_add_on_remap(stubbed)
          return stubbed unless stubbed.state == :trial_eligible && @namespace &&
            ::SecretsManagement::NamespaceEnrollment.add_on_requested?(@namespace)
    
          ::SecretsManagement::Entitlement.new(state: :paid, on_demand_enabled: stubbed.on_demand_enabled)
        end
    
        # Branch-aware: the beta-window branch adds with_beta_attributes
        # (beta_enrolled + beta_program_ended); master only carries
        # beta_program_ended.
        def gsm_stub_attach_beta(stubbed)
          if respond_to?(:with_beta_attributes, true)
            with_beta_attributes(stubbed)
          else
            ::SecretsManagement::Entitlement.new(**stubbed.to_h, beta_program_ended: beta_program_ended?)
          end
        end
      end
    
      # to_prepare: the resolver is reloadable, so re-prepend after every reload.
      Rails.application.config.to_prepare do
        SecretsManagement::Entitlement::Resolver.prepend(GsmEntitlementDevStub)
      end
    end
  3. In a Rails console, prepare a top-level group you own (example uses flightjs):

    group = Group.find_by_full_path('flightjs')
    plan = Plan.find_by(name: 'ultimate')
    sub = group.gitlab_subscription
    sub ? sub.update!(hosted_plan: plan) : GitlabSubscription.create!(namespace: group, hosted_plan: plan)
    Feature.enable(:secrets_manager_paid_experience, group)
    Feature.enable(:secrets_manager_namespace_enrollment, group)
  4. Set the stub to the billable case:

    echo "trial_eligible on_demand" > tmp/gsm_entitlement_stub

Case 1: happy path

  1. As the group Owner, open /-/graphql-explorer and run:

    mutation {
      secretsManagerEnableAddOn(input: { groupPath: "flightjs" }) {
        entitlement { state onDemandEnabled }
        errors
      }
    }
  2. Expected: state: "PAID", onDemandEnabled: true, errors: [] (first screenshot).

  3. Console checks:

    SecretsManagement::NamespaceEnrollment.find_by_namespace_id(group.id).add_on_requested_at # stamped
    group.reload.secrets_manager.status                                                      # "active"
  4. Re-running the mutation is idempotent: it returns PAID again and does not re-stamp the timestamp.

Case 2: on-demand billing off

  1. Reset the enrollment in the console. Do not destroy group.secrets_manager to reset: that enqueues a real OpenBao deprovision task and the mutation refuses to run until it completes.

    SecretsManagement::NamespaceEnrollment.find_by_namespace_id(group.id)&.destroy!
  2. Switch the stub (no on_demand): echo trial_eligible > tmp/gsm_entitlement_stub

  3. Run the mutation. Expected: error On-demand billing must be enabled for this namespace before the Secrets Manager add-on can be enabled. The billing account owner can enable it in the Customers Portal., and no enrollment row is written (second screenshot).

Case 3: ineligible

  1. echo ineligible > tmp/gsm_entitlement_stub, then run the mutation. Expected: error This group is not eligible to enable the Secrets Manager add-on. (third screenshot).

Case 4: provisioning failure, then retry

  1. Set the stub back to trial_eligible on_demand, reset the enrollment (as in case 2), then gdk stop openbao.
  2. Run the mutation: it returns a provisioning error, but the intent survives (add_on_requested_at stays stamped), because eligibility and enrollment succeeded and only provisioning failed.
  3. gdk start openbao, then run the mutation again: it returns PAID, the timestamp is unchanged (original conversion time preserved), and only the provisioning step is retried.

Cleanup (roll-out)

rm tmp/gsm_entitlement_stub
rm config/initializers/gsm_entitlement_dev_stub.rb
gdk restart rails-web
SecretsManagement::NamespaceEnrollment.find_by_namespace_id(group.id)&.destroy!
Feature.disable(:secrets_manager_paid_experience, group)
Feature.disable(:secrets_manager_namespace_enrollment, group)

MR acceptance checklist

Evaluated against the MR acceptance checklist: backend-only, behind a default-off feature flag, with a DB migration needing database review.

Edited by Dmytro Biryukov

Merge request reports

Loading
Loading