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.
- 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.)
- Security-policy document ownership — full-replace. The role owns the
entire
policy.ymland 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 namedAPPEND,REPLACE, andREMOVEoperations. 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). - 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). - MR approval policy vs. classic approval rules — both, with a boundary.
Classic per-project rules stay on REST (
project_approval_rule); the GraphQLapproval_policy(which generatesreport_approverrules) 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:
- 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. - 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 aniid; the nested rotation resolves inside its schedule read. - No HTTP status semantics. GraphQL returns HTTP 200 even on error, so
failure is read from two JSON channels — the top-level
errorsarray and each mutation payload'serrorsfield. A separate transport (gitlab_graphql.py) parses both, treats a populated payloaderrorsas a failed write, and never retries a 200-with-errors. - Cursor pagination in-body. The transport walks
pageInfo{ hasNextPage endCursor }and re-issues the same document withafter:. - 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_documentkind 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.py—GitLabGraphQLReconcilerwith thegraphql_singleton,graphql_collection,graphql_child_collection, andpolicy_documentkinds.module_utils/gitlab_graphql_policy.py— the GraphQL analog ofPOLICIES.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.
organizationUpdateis 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_environmentpolicies carry them but keepapproval_rulesincompare_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_rulesingleton.