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_attosecrets_manager_namespace_enrollments, because existing enrollment records cannot distinguish paid add-on intent from trial-chain or free-beta enrollment. SecretsManagement::NamespaceEnrollment: new scopewith_add_on_requestedand class methodadd_on_requested?(namespace).SecretsManagement::Entitlement::Resolver: maps CustomersDot trial statetrial_eligibleto entitlement statepaidwhen add-on intent is recorded and/consumers/resolveallows access; if resolve blocks (for example, on-demand acceptance reset by a subscription renewal), it falls back totrial_eligible. Also addsResolver.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 flagsecrets_manager_paid_experience. The mutation expires cached CustomersDot answers and validates billability live (statetrial_eligibleplusonDemandEnabled), enrolls the namespace and stampsadd_on_requested_at, then re-resolves; if the result is notpaid, 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 existingGroupSecretsManagers::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#unenrollnow clearsadd_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_enabledandsecrets_manager_add_on_enable_failed(failure reason in thelabelproperty). - 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
How to set up and validate locally
Setup (roll-in)
-
Run GDK with SaaS simulation (
GITLAB_SIMULATE_SAAS=1) and start OpenBao:gdk start openbao. -
Stub CustomersDot entitlement responses with a dev-only initializer at
config/initializers/gsm_entitlement_dev_stub.rb, then rungdk restart rails-web(initializers do not hot-reload). The stub readstmp/gsm_entitlement_stubon 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 -
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) -
Set the stub to the billable case:
echo "trial_eligible on_demand" > tmp/gsm_entitlement_stub
Case 1: happy path
-
As the group Owner, open
/-/graphql-explorerand run:mutation { secretsManagerEnableAddOn(input: { groupPath: "flightjs" }) { entitlement { state onDemandEnabled } errors } } -
Expected:
state: "PAID",onDemandEnabled: true,errors: [](first screenshot). -
Console checks:
SecretsManagement::NamespaceEnrollment.find_by_namespace_id(group.id).add_on_requested_at # stamped group.reload.secrets_manager.status # "active" -
Re-running the mutation is idempotent: it returns
PAIDagain and does not re-stamp the timestamp.
Case 2: on-demand billing off
-
Reset the enrollment in the console. Do not destroy
group.secrets_managerto 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! -
Switch the stub (no
on_demand):echo trial_eligible > tmp/gsm_entitlement_stub -
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
echo ineligible > tmp/gsm_entitlement_stub, then run the mutation. Expected: errorThis group is not eligible to enable the Secrets Manager add-on.(third screenshot).
Case 4: provisioning failure, then retry
- Set the stub back to
trial_eligible on_demand, reset the enrollment (as in case 2), thengdk stop openbao. - Run the mutation: it returns a provisioning error, but the intent survives (
add_on_requested_atstays stamped), because eligibility and enrollment succeeded and only provisioning failed. gdk start openbao, then run the mutation again: it returnsPAID, 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-webSecretsManagement::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.


