Expose the instance plan in the metadata API
What does this MR do and why?
Adds a plan field to the instance metadata already served to every authenticated caller, in both its REST form (GET /api/v4/metadata, and its GET /api/v4/version alias) and its GraphQL form (the Metadata type the REST endpoint is implemented on top of).
Today GET /api/v4/license is the only endpoint that reports which plan an instance is licensed for, and it requires an administrator. Every client that adapts to what the instance supports therefore has to be configured by hand, or infer the plan by probing licensed endpoints, which each one does slightly differently. The plan is already inferable that way by any authenticated user, so what the current restriction protects is not the information, only the convenience of reading it.
The value is the plan of the current license (starter, premium, ultimate). An active trial license reports its plan the same way as a paid license, because that is the plan whose features the instance serves while the trial runs. It is free on an instance with no license, on an instance whose trial license has expired (License#feature_available? withholds every feature of an expired trial, while License.current can still return it), and on CE where there is no license to read. It is null where subscriptions are scoped for each namespace rather than for an instance. That last case is GitLab.com: the instance license says nothing there about what any given caller may do, so reporting it would be worse than reporting nothing, because a caller would read it as their own entitlement. Callers there already have the per-namespace plan that ee/lib/ee/api/entities/namespace.rb exposes under can_admin_namespace || has_gitlab_subscription.
An expired paid license keeps its plan, because License#feature_available? still grants its features. Whether that is the right answer is an open question in the review.
Nothing commercial is exposed. The licensee name and email, the seat count, the expiry date, the subscription identifier and the historical seat usage all stay behind the administrator check on /license, which this MR does not touch. One string is added, and it is the one every integration is already guessing.
The CE and EE split follows Gitlab::Tracking::StandardContext, which resolves a plan exactly this way, down to its 'free' # GitLab CE edition is always free base case. The EE override asks Gitlab::Saas.feature_available?(:gitlab_com_subscriptions) rather than Gitlab.com?, so it does not add a new entry to the Gitlab/AvoidGitlabInstanceChecks todo list.
Why this is safe to expose
- It sits behind authentication.
/api/v4/metadataalready hasbefore { authenticate! }and the GraphQL type already requires:read_instance_metadata. Anonymous callers learn nothing new. - No new permission is introduced. The field is served to exactly the callers who can read this endpoint today.
- It is the field next door to
enterprise, which already discloses EE versus CE to the same callers.
References
Closes #630305
That issue carries the measurements behind this change (a licensed EE 19.3.1 and GitLab.com, each asked with administrator and ordinary credentials), the prior proposals, and why this one is shaped differently from them.
Related: #247915 (expose the tier to all users on Self-Managed, open since 2020) and #219732 (read license information as a non-admin, open since 2020, linked to two customer tickets). Neither thread records a design objection. This MR deliberately does not do what #219732 asks, which is to open /api/v4/license itself; that endpoint carries contractual data a non-admin has no business reading, and that is likely why it never moved.
Screenshots or screen recordings
Not applicable, this is an API field with no UI surface.
How to set up and validate locally
On a licensed instance, as a user who is not an administrator:
curl --header "PRIVATE-TOKEN: <non_admin_token>" \
--url "https://gitlab.example.com/api/v4/metadata"{
"version": "19.5.0-ee",
"revision": "ceb07b24cb0",
"kas": { "enabled": true, "externalUrl": "grpc://gitlab.example.com:8150", "externalK8sProxyUrl": "https://gitlab.example.com:8150/k8s-proxy", "version": "19.5.0" },
"enterprise": true,
"plan": "ultimate"
}The same through GraphQL:
{ metadata { version enterprise plan } }The specs covering each case:
bundle exec rspec spec/models/app_config/instance_metadata_spec.rb
bundle exec rspec spec/requests/api/metadata_spec.rb
bundle exec rspec spec/graphql/types/app_config/instance_metadata_type_spec.rb
bundle exec rspec spec/requests/api/graphql/metadata_query_spec.rb
bundle exec rspec ee/spec/models/ee/app_config/instance_metadata_spec.rb
bundle exec rspec ee/spec/requests/api/metadata_spec.rbee/spec/requests/api/metadata_spec.rb is the one that covers the point of the change: an ordinary user, on an instance licensed Ultimate, reading ultimate from both routes. ee/spec/models/ee/app_config/instance_metadata_spec.rb covers the license states one by one: a paid license, a license naming no plan, an active trial, an expired trial, an expired paid license, no license, and subscriptions scoped for each namespace.
These were run, against a development environment on this branch (Ruby 3.3.11, PostgreSQL 17, Redis 7.2, Gitaly built), all six together: 75 examples, 0 failures, 7 pending. The pending ones are the granular-token shared examples excusing themselves ("namespace has no top-level group", "only meaningful on Project/Group boundaries"), not anything of this change. The frontend is not built in that environment, and spec/requests/api/graphql/metadata_query_spec.rb goes through ApplicationController, which asks sprockets for the emoji_sprites stylesheet; an empty app/assets/builds/emoji_sprites.css, which git ignores, stood in for the build.
The first run was not green, and both things it caught are worth naming rather than quietly fixing:
ee/spec/models/ee/app_config/instance_metadata_spec.rbfailed on theModel disables STIshared example this directory injects into every model spec. That example runs in the root group, where mybeforeblock read alicensethat only existed as aletinside each context, so it raisedundefined local variable or method 'license'. Fixed with a root-level default; the spec is now proof against any other example injected there.graphql-verifyfailed because a new field changes what the schema introspects to, and the two committed introspection schemas had not been regenerated. They are regenerated rather than edited, now throughgitlab:graphql:update_all, and the diff is the lines of this field in each.
What that first run missed, the review caught: spec/requests/api/graphql/metadata_query_spec.rb selects every field of the type and compares the whole response, so it failed on the new key. I had not run it; it is in the list above now, and it failed on exactly that key before the fix.
bundle exec rake gitlab:graphql:check_docs reports "GraphQL documentation is up to date" and gitlab:graphql:check_introspection_sync reports "All GraphQL introspection schemas are up to date", so the doc/api/graphql/reference/_index.md row and both introspection schemas are byte-for-byte what the generators produce. Both OpenAPI documents were updated by hand to the shape the Grape entity produces; if bin/rake gitlab:openapi:v2:generate disagrees in any detail, the generated output is correct and mine should be replaced by it.
What this does not cover is the rest of the suite: only these six files were run locally, and the pipeline is what speaks for everything else.
MR acceptance checklist
- I have evaluated the MR acceptance checklist for this MR.
- Tests added for the new behaviour, covering CE, a licensed instance, an unlicensed instance, an active trial, an expired trial, an expired paid license, and the per-namespace case.
- Documentation updated:
doc/api/metadata.md, the GraphQL reference, and both OpenAPI documents. -
Changelog: addedtrailer on the commit. - Specs executed locally: 75 examples, 0 failures, 7 pending across the six files above. See the notes above for what the first run caught and what it missed.
- Generated artifacts regenerated rather than hand-written where a generator exists (the GraphQL reference and the two introspection schemas, verified with
check_docsandcheck_introspection_sync).