Audit successful CI/CD variable value access in GraphQL

What does this MR do?

Emits a streamed audit event, variable_viewed_graphql, recording which CI/CD variable values a GraphQL query accessed, and for which project or group.

This replaces the structured-log approach the MR originally took (a query_analysis.successfully_accessed_vars field in the GraphQL log), following the discussion from !240847 (comment 3627156051) onwards. Two reasons for the change:

  1. Audience. Structured logs are only visible to instance administrators. Audit events can be streamed to external SIEM destinations.
  2. Abstraction level. The log approach wrote domain-specific data into context[:gl_analysis], which otherwise carries only generic query instrumentation: complexity, depth, and field usage.

A comparable event, variable_viewed_api, already exists for the REST API. A new event type is added rather than extending that one, because streaming destinations filter by event name: folding GraphQL access into variable_viewed_api would change what existing subscribers receive and make filtering for REST-only access impossible. This event carries an api_type detail so that the two can be queried together once the REST event carries it as well, which is part of https://gitlab.com/gitlab-org/gitlab/-/work_items/628040 (confidential).

How it works

Variable values resolve per node, so accesses are accumulated during execution and audited once per query and variable owner:

  • EE::Types::Ci::ProjectVariableType records each resolved variable into a Gitlab::Ci::Variables::AccessCollector held in the query context. EE::Types::Ci::GroupVariableType overrides #variable_scope so group variables resolve their owner from the group, which is needed because the group type inherits from the project type.
  • Gitlab::Graphql::Tracers::CiVariableAuditTracer emits the audit events from execute_multiplex, the only hook that runs after query results are materialized.
  • Keys are deduplicated per owner, so aliased value selections are recorded once.
  • Hidden variables are recorded separately in hidden_keys, because their value is withheld. variable_viewed_api audits access to hidden variables too, so this keeps the two events consistent while still letting consumers filter on values that were actually returned.
  • The number of keys per owner is capped (MAX_KEYS_PER_SCOPE) so a query over many variables cannot produce an unbounded payload. The truncated detail is set only when a key is actually dropped.
  • Auditing one owner cannot fail the query or suppress the events for the remaining owners: failures are tracked instead of raised, per owner.
  • Values read by a query that later fails are still audited, because they were already decrypted in process.
  • Gated behind the audit_ci_variable_value_access feature flag (gitlab_com_derisk), renamed from log_ci_variable_value_access.

CE and EE

Audit events are an EE feature, and this one is unreachable from CE: the event type is defined in ee/config, so Gitlab::Audit::Auditor raises when that definition is not loaded, and the CE gate rejects a Project or Group scope in any case.

Everything therefore lives in ee/: the collector, the tracer, the trace_with registration, the feature flag definition, the recording itself, and the specs. CE keeps only two prepend_mod calls, so ProjectVariableType#value is unchanged from before this MR. FOSS does no work for this feature.

Event payload

variable_viewed_graphql is stream-only (saved_to_database: false), matching variable_viewed_api after !200323 (merged). It is delivered only to configured streaming destinations, so it does not appear in the audit events UI or in audit_json.log.

Field Value
scope / target The project or group owning the variables, which yields entity_path
target_details Comma-separated list of the keys whose values were returned, or the auditor's default when only hidden variables were requested
ip_address Populated from the request context
additional_details.api_type graphql
additional_details.accessed_keys Keys whose values were returned
additional_details.hidden_keys Keys requested but withheld because the variable is hidden
additional_details.truncated Whether any key was dropped from the lists above

A query reading variables from more than one owner produces one event per owner.

Follow-ups

Access token details, tracked in https://gitlab.com/gitlab-org/gitlab/-/work_items/628040 (confidential). Recording access_token_id and access_token_name was deferred by agreement with the Security team. Neither is captured automatically today, and variable_viewed_api does not record token details either, so one change should improve both events.

Instance-level CI/CD variables. Types::Ci::InstanceVariableType also exposes value and is deliberately not covered here. Instance variables are unaudited on every read path today, including REST (/admin/ci/variables), GraphQL, and the admin UI, and variable_viewed_api is scoped to Project and Group only. Covering only the GraphQL half would be inconsistent, and doing it properly pulls in Gitlab::Audit::InstanceScope and the admin_audit_log licensing path. Note that InstanceVariableType inherits from BaseObject, not ProjectVariableType, so it is not incidentally covered by the recording added here.

References

https://gitlab.com/gitlab-org/gitlab/-/work_items/602520 (confidential)

Edited by Oleg Yakovenko

Merge request reports

Loading
Loading