Skip to content

GraphQL-Served Settings

Companion to Supported settings. Some settings-relevant GitLab surfaces have no REST endpoint for management and are reachable only through GitLab's GraphQL API. The role reconciles them over a committed-document GraphQL backend (ADR-0008), routed by the capability model (ADR-0006). This document lists resources served wholly or partly by GraphQL, how the backend reconciles them, and the operational limits that apply.

Originally audited 2026-07-16 against live GitLab documentation (16 candidate surfaces investigated, each verdict grounded in cited docs); implemented and last revised 2026-07-25.

Surface map

Fourteen policy resources are served over GraphQL. The five security-policy types form one subsystem: they are arrays inside a single .gitlab/security-policies/policy.yml in one linked Security Policy Project, but GitLab's generic scanExecutionPolicyCommit mutation accepts one named policy per call despite its name.

Surface Scope Key mutations Identity Shape
Audit event streaming destinations instance, group instanceAuditEventStreamingDestinationsCreate/Delete, groupAuditEventStreamingDestinationsCreate/Delete name create/delete collection; destination_url private and fingerprinted, secret_token write-only; migrated off the REST endpoints live GitLab 19 removed
Shared group task settings (name, visibility, Duo enable/lock) group groupUpdate group fullPath update-only singleton; also REST/Rails capable
Compliance frameworks group createComplianceFramework / update / destroy framework name; default is single-per-group collection
Custom emoji group createCustomEmoji, destroyCustomEmoji (no update) (groupPath, name) → gid collection; url change = recreate
Value streams group / namespace valueStreamCreate / Update / Destroy (namespace, name) → id; nested stages full-replace collection with nested replace
Dependency proxy settings + TTL policy group updateDependencyProxySettings, updateDependencyProxyImageTtlGroupPolicy groupPath (two objects) two update-only singletons
Work item type configuration namespace workItemTypeCreate / Update (archive for absent) (namespace, name) → gid; built-ins lock-guarded collection
Escalation policies project escalationPolicyCreate / Update / Destroy (projectPath, name) → gid; ordered replace-all rules collection
On-call schedules + rotations project oncallSchedule*, oncallRotation* (projectPath, schedule name) → iid; (schedule, rotation name) → id nested collection
Security policies (scan execution, pipeline execution, scheduled pipeline execution, vulnerability management, MR approval) project (+ linked SPP) one scanExecutionPolicyCommit per named policy with APPEND, REPLACE, or REMOVE policy name within each array of policy.yml full-owned document applied as per-policy mutations
Organization settings organization organizationUpdate (converge only) org path → GlobalID converge-only singleton

Per-surface contracts (fields, caveats, examples) are in docs/schema/; the implemented surface list is in supported-settings.md.

Scope decisions

The analysis front-loaded four scope questions; they are resolved and reflected in the implementation.

  1. SAML / SCIM provider configuration — out (no execution path). GitLab exposes no mutation for the group SAML provider config or SCIM tokens in either protocol; a GraphQL backend does not unblock it. SCIM tokens are also structurally un-reconcilable (write-once, unreadable), which conflicts with the converge contract regardless of transport. (Note: the group SAML SSO provider — enable, SSO URL, fingerprint — is reconciled over the Rails backend as an ActiveRecord model; see supported-settings.md. That is distinct from SAML/SCIM identity/token management, which has no API.)
  2. Security-policy document ownership — full-replace. The role owns the entire policy.yml and computes a converge-and-prune target, consistent with the declared-state-wins model. GitLab only accepts one policy per commit mutation, so the engine applies the target as named APPEND, REPLACE, and REMOVE operations. Externally-authored policies in the same document are pruned; a read-merge-write mode that preserves them is a documented deferred TODO (module_utils/gitlab_security_policy.py).
  3. Structural vs. settings boundary — per resource, not blanket. On the GraphQL side, organizations and security-policy projects are converge-only: no organizationCreate / securityPolicyProjectCreate; an unlinked security-policy target skips with a link-first reason. But GraphQL collections (custom emoji, compliance frameworks, streaming destinations, value streams) do create and delete their entries, and on the REST side groups, projects, users, and runners are fully provisionable from their declarations (creatable/deletable).
  4. MR approval policy vs. classic approval rules — both, with a boundary. Classic per-project rules stay on REST (project_approval_rule); the GraphQL approval_policy (which generates report_approver rules) rides the security-policy document. The schema documents the overlap so the two paths are not pointed at the same approval configuration.

How the backend reconciles them

The GraphQL backend honours the same contract the REST reconciler exposes — get → compare managed fields → set only on drift, with real check/diff, secret separation, and the same result envelope (changed/found/ resource_id/diff/actions/skipped/skip_reason) — so the composing filters and tasks/apply-*.yml treat it identically. Where GraphQL breaks REST assumptions it forks; where the logic is transport-agnostic it reuses.

Shared unchanged from gitlab_resource.py (transport-free): values_equal, drift, parse_iso_datetime, strip_userinfo, and the module-level build_result / unsupported_result the REST reconciler was refactored to delegate to. The GraphQL reconciler imports them directly, so comparison, diff, and result semantics are byte-identical across backends.

Forked, because GraphQL violates five REST assumptions:

  1. No path catalog / no OpenAPI. GitLab publishes no OpenAPI for these surfaces and GraphQL has no per-endpoint catalog; runtime introspection would reintroduce the air-gapped/mutable-upstream failure ADR-0004 killed. The analog is a committed set of hand-authored query + mutation documents, a compressed schema generated from the immutable GitLab source commit recorded in metadata.yml, and that provenance metadata. Runtime loads only the documents. Required CI validates them against the artifact, verifies that the recorded release tag resolves to the recorded commit, and regenerates the artifact from a commit-addressed source archive.
  2. Identity ≠ global ID. Every update/delete needs the node's gid://gitlab/…, which is not the natural key, so the read-to-resolve-gid step is mandatory and folds into the single read query ({ id name …fields } builds name→gid in one round trip). Schedules resolve to an iid; the nested rotation resolves inside its schedule read.
  3. No HTTP status semantics. GraphQL returns HTTP 200 even on error, so failure is read from two JSON channels — the top-level errors array and each mutation payload's errors field. A separate transport (gitlab_graphql.py) parses both, treats a populated payload errors as a failed write, and never retries a 200-with-errors.
  4. Cursor pagination in-body. The transport walks pageInfo{ hasNextPage endCursor } and re-issues the same document with after:.
  5. Collection-inside-a-document. The security-policy surfaces are named policies inside one YAML file behind a commit mutation, which no existing reconciler kind expresses; they get the policy_document kind and a dedicated engine (gitlab_security_policy.py).

The op envelope stayed transport-neutral, so gitlab_settings_plan remains pure and network-free (gid resolution is reconcile-time). tasks/apply.yml partitions operations by chosen backend and routes the GraphQL partition to tasks/apply-graphql.yml, which produces the same gitlab_settings_apply_result shape as apply-rest.yml.

Secret separation carries over intact — sensitive values ride the mutation variables map, never compared or shown in a diff — though none of the twelve implemented surfaces currently declare a secret field (the mechanism exists for a future surface that needs it). Check mode works because queries are side-effect-free (read + diff, no write), with the security-policy async caveat below.

Module files

  • module_utils/gitlab_graphql.py — the transport (dual error channels, cursor pagination, retry classification).
  • module_utils/gitlab_graphql_resource.pyGitLabGraphQLReconciler with the graphql_singleton, graphql_collection, graphql_child_collection, and policy_document kinds.
  • module_utils/gitlab_graphql_policy.py — the GraphQL analog of POLICIES.
  • module_utils/gitlab_graphql_operations.py + files/graphql/*.graphql — the runtime operation documents and loader.
  • files/graphql/gitlab_schema.json.gz + files/graphql/metadata.yml + tools/project_graphql_schema.py — the repository-only operation-specific validation contract, its immutable-source provenance, and its deterministic projector. The full GitLab EE introspection dump is neither committed nor packaged in the public collection.
  • module_utils/gitlab_security_policy.py — the full-replace policy-document engine.
  • library/gitlab_settings_graphql_resource.py — the Ansible module wrapper.
  • tests/integration/fixtures/fake_gitlab_graphql.py — the offline fake endpoint (dispatches on operation name, mints gids, exercises both error channels).

Operational caveats and risks

These are inherent to the GraphQL surfaces and remain true in production:

  • Operation semantics remain hand-authored. Unlike the REST catalog, a GraphQL schema cannot derive resource ownership or identity. CI validates every document against the committed artifact, rejects actual obsolete fields, verifies that the recorded release tag resolves to the immutable source commit, regenerates the schema from that commit, requires semantic equality, and runs GitLab's own document validator. GitLab encodes experiment status through GraphQL deprecation metadata; an exact status-only marker remains eligible, while replacement/removal deprecations fail. These checks do not prove permissions, feature flags, or live behavior.
  • Security-policy convergence is asynchronous. A commit lands as a change in the SPP and can take ~10 minutes to enforce, so an immediate second run against a live instance may still report drift. Offline idempotence is proven against the synchronous fake; treat live second-run no-ops as best-effort.
  • Security-policy multi-change writes are not transactional. GitLab accepts only one named policy per mutation. The engine validates the complete desired plan before writing and verifies ambiguous outcomes, but a definitive failure after an earlier successful mutation can leave a partially applied document; rerun after correcting the failure to converge the remainder.
  • Experiment/flagged mutations can change without deprecation. organizationUpdate is experiment-flagged and several policy mutations are young; the committed-document approach concentrates that stability risk in one reviewable place rather than hiding it.
  • Global-ID resolution is a hard-failure surface. Every update/delete makes a mandatory extra read, and a read query selecting a field GitLab later removes is a hard query error (REST tolerates unknown response fields; GraphQL does not).
  • Permission/token divergence. These surfaces need Owner / manage_security_policy_link / specific scopes, not the instance-admin PAT the REST path assumes. A mixed REST + GraphQL run may need a token that covers both; GitLab RBAC (not the role) enforces this.
  • Full-replace is destructive by design. The security-policy engine prunes any policy in the document it was not told to keep, and fails loudly on a malformed declaration rather than committing an unintended empty array. Declare every policy you want to keep.

Schema-contract validation

The required offline gate validates all committed GraphQL documents against the operation-specific projection in files/graphql/gitlab_schema.json.gz on every pipeline. The graphql-contract job verifies that schema_ref in metadata.yml resolves to schema_commit, verifies the exact published source-dump digest, downloads the source archive by that immutable commit, dumps its schema, reproduces the committed projection, and runs GitLab's validator. The projection contains only reachable schema coordinates and input contracts; it omits upstream descriptions and reduces deprecation prose to the status distinction enforced by the role. The job is required for GraphQL contract changes and runs on schedule; GitLab's generated IDL and full JSON remain CI artifacts for review rather than release inputs.

This is deliberately separate from the offline fake-server integration suite: the contract gate detects schema drift, while the fake validates the role's reconciliation flow. Neither is a live GitLab acceptance test; a live test also needs a provisioned instance, a suitably privileged token, and any relevant licensed or feature-flagged surfaces enabled.

Out of scope / handled elsewhere

  • Analytics dashboards — out of scope: the dashboards are authored YAML committed to a repo (repository content, not a setting), and the configuration-project pointer has no public execution path in either protocol.
  • Protected-environment deployment approval rules — REST, not GraphQL. The project_protected_environment / group_protected_environment policies carry them but keep approval_rules in compare_ignore (the access arrays do not round-trip cleanly); documented as apply-on-create-only in the schema.
  • Group push rules — REST, fully covered by the group_push_rule singleton.