Skip to content

Credential operations

How to declare a GitLab credential, verify it before a play changes anything, run a managed OAuth session, and clear every state the role can report.

This is the operator's manual for the authentication layer. For the field-level contract see Configuring the GitLab target; for the reasoning behind the design see ADR-0012.

Contents

Two layers of checking

Registry validation is mandatory and free. Before any request is built, the role checks the declared credential against the operation it is about to perform: whether that credential family is accepted for that endpoint, whether a bounded token can prove authority over the identity being managed, whether sudo is permitted there. This runs inside every task, always, and costs no network request. A refusal here names the resource, the operation, and the transport.

Metadata verification is optional and costs one request. GitLab alone knows whether a token is active, what scopes it carries, and when it expires. The gitlab_credential_check task asks, once, and compares the answer with what your play requires. You never have to run it; it exists so a credential problem surfaces as one clear failure instead of an opaque 401 partway through a loop, possibly after a partial change.

Both layers are evidence-based. Neither guesses, and neither falls back to a weaker credential.

1. Declaring a credential

There is one shape. target.auth names the kind of credential, because every decision downstream — authority boundaries, endpoint acceptance, scope requirements, token-metadata probes — depends on knowing which kind it is.

gitlab_settings_target:
  url: https://gitlab.example.com
  auth:
    type: personal_access_token
    token: "{{ vault_gitlab_token }}"

Credential types

auth.type Required fields Request form Notes
anonymous none no credential sent Accepted only where GitLab documents public access. Never a fallback.
oauth2 access_token Authorization: Bearer Add refresh for a managed session.
personal_access_token token PRIVATE-TOKEN
project_access_token token PRIVATE-TOKEN Requires bound_to.
group_access_token token PRIVATE-TOKEN Requires bound_to.
impersonation_token token PRIVATE-TOKEN
session_cookie session_cookie _gitlab_session cookie

Bounded credentials

A project or group access token carries authority over exactly one project or group. Declare that boundary so the role can refuse out-of-scope work before sending anything:

    auth:
      type: project_access_token
      token: "{{ vault_project_token }}"
      bound_to:
        resource: project
        identifier: platform/service

A bounded credential cannot perform an operation that names no identity. If you see cannot prove authority for unscoped <resource> operation, the task — or the credential check — needs an identifier or parent, not a different token.

Administrator delegation

    auth:
      type: personal_access_token
      token: "{{ vault_admin_token }}"
      sudo:
        username: root      # or: id: 1

sudo is accepted only where the registry records that GitLab honours it. target.auth.sudo is unsupported for <resource> <operation> through <transport> means that endpoint ignores or rejects delegation — drop sudo for that work, or split it into a separate task with a credential that owns the authority directly.

Credentials this role does not accept

ci_job_token and deploy_token are not declarable. Declaring either fails immediately with target.auth.type must be one of: and the supported list.

They authenticate GitLab's data plane — package and container registries, and git over HTTP. This role manages control plane: configuration of how GitLab behaves. GitLab accepts neither credential for a configuration operation, so there is nothing here for one to do.

Managing them is a different thing from authenticating with them

Configuring job-token policy and deploy keys is fully supported, as ordinary managed resources — project_job_token_scope, project_job_token_allowlist_entry, and project_deploy_key. That is control plane, and it uses whichever supported credential you declared.

2. Verifying before you converge

gitlab_credential_check compares GitLab's own token metadata with what the work ahead requires. Run it once, at the top of a play:

- name: Verify the credential can reconcile everything this play manages
  primetheus.gitlab_settings.gitlab_credential_check:
    target: "{{ gitlab_settings_target }}"
    resources:
      - group
      - project
      - member
    write: true

List the whole set in one task. Requirements are resolved locally from the registry, so the entire set is compared against a single metadata response — one request, not one per resource. Do not loop this task: each iteration is a separate module invocation and therefore a separate request.

Entry forms

An entry is either a resource name, which uses the task-level write and backend, or a mapping that overrides them and can supply the context a real task resolves:

    resources:
      - branch                        # read-only, task-level defaults
      - resource: group
        write: true
      - resource: project             # a bounded token must name its identity
        identifier: platform/service
        write: true
Entry option Purpose
resource Registry resource name. Required.
write Check reconciliation scopes, not only read scopes.
backend Transport preference for this entry.
identifier The identity the play will manage. Required for bounded credentials.
parent Semantic parent for a nested resource.
fields Desired fields, for field-level capability gates.
settings The desired state a task would apply. Plans the entry exactly — see checking the exact work.
state present (default) or absent, for a planned entry.

Checking the exact work

A resource name is not the unit of work. One public task expands into several operations, and routing can send a child through a different transport than its parent — where the credential decision may differ.

Checking resources: [group] verifies group read and group update through REST. It does not verify a group_custom_emoji operation that will route to GraphQL. A credential can therefore pass the check and still be refused by a later task, after earlier tasks have already changed something.

Declare settings to close that gap. The entry is then planned and routed exactly as the reconciliation task would be, and every operation it expands into is checked against the transport it will really use:

    resources:
      - resource: group
        identifier: platform
        settings:
          group:
            description: Platform engineering
          custom_emoji:
            - name: shipit
              url: https://example.com/shipit.png

With a sudo credential, that reports:

target.auth.sudo is unsupported for group_custom_emoji read through graphql

— which is the message the real task would produce, from the same gate.

Which form should I use?

Use settings when you want certainty that a specific task will succeed; it is the only form that catches a cross-transport child. Use the plain resource name for a cheaper, coarser check that still catches scope, boundary, and credential-state problems.

settings and write cannot be combined in one entry, because a planned operation is always a write. Declared settings are never echoed back in the result.

mode (strict or compatible) governs routing for planned entries, matching the reconciliation task this check stands in for.

Verdicts

The result reports authentication.preflight:

Verdict Meaning
satisfied Every requirement checked was met by GitLab's reported metadata.
skipped GitLab exposed nothing usable to compare. Not a failure — absence of evidence is never evidence of insufficiency.

authentication.preflight_reason explains a skip:

Reason Cause
no safe metadata endpoint This credential family exposes none: anonymous or session-cookie. No request was made.
metadata unavailable The probe was attempted and did not return a usable document.
no reported scopes The response carried no scope set to compare.

checked.requests reports how many token-metadata requests were actually performed — never more than one, and zero when the credential has no probe endpoint or the registry refused before any request.

Failures

These are raised only when metadata positively contradicts a requirement. The safe classification is in authentication.classification.

Classification What GitLab reported What to do
insufficient_scope The token's scopes satisfy none of the recorded alternatives for a named operation. The message names the resource and operation, e.g. the group update operation, which requires api. Issue a token with the required scope, or narrow the work (write: false for read-only resources).
token_inactive The credential is inactive or revoked. Issue a new credential.
token_expired Its remaining lifetime has passed. Supply a current credential, or enable managed refresh.
token_type_mismatch It is a different kind than auth.type declares. Correct auth.type to match the token you hold.
oauth_application_mismatch The OAuth token was issued by a different application than auth.client_id names. Point at the right application, or use the token that application issued.

3. Managed OAuth sessions

A caller-managed OAuth target needs only access_token. Managed refresh is opt-in, because rotating a refresh token is a real credential mutation: GitLab invalidates the previous pair on every exchange.

    auth:
      type: oauth2
      access_token: "{{ vault_oauth_access_token }}"
      refresh_token: "{{ vault_oauth_refresh_token }}"
      client_id: "{{ vault_oauth_client_id }}"
      client_secret: "{{ vault_oauth_client_secret }}"   # omit for public PKCE
      redirect_uri: https://automation.example.com/oauth/callback
      refresh:
        enabled: true
        before_expiry: 300
        store:
          type: controller_file
          path: /secure/ansible/oauth/gitlab-primary.json

Refresh happens on the Ansible controller, before each task's module runs. Playbooks never call an OAuth endpoint, compute expiry, or pass credential facts between tasks.

When a refresh happens

refresh_reason Trigger
current Nothing was needed; the stored pair is valid.
initialized The store was empty and was seeded from the inline pair.
expiring The stored pair falls inside before_expiry.
authentication_rejected GitLab returned 401; one controlled refresh is attempted. A 403 never triggers refresh.
recovered An interrupted rotation was completed. See state reference.
reinitialized An operator replaced the session deliberately.

The durable store

controller_file holds one JSON object, mode 0600, containing schema_version, generation, access_token, refresh_token, expires_at, and a binding identifying the target and OAuth application. It protects the document with filesystem permissions rather than encryption, and coordinates concurrent forks on one controller only.

generation is a compare-and-swap counter. A task that read generation N may only install N+1; if another fork installed a newer pair first, this one adopts it instead of overwriting.

How a rotation is made durable

This is the part worth understanding, because its failure states are the ones you may have to clear.

GitLab invalidates the previous pair during the exchange, so the replacement exists only in memory until it is written. The role therefore writes ahead:

  1. Write the replacement to a protected recovery record, and prove it durable.
  2. Replace the active document.
  3. Clear the recovery record — only once step 2 is proven durable.

"Proven durable" is stricter than "written". os.replace makes a replacement visible to the next reader; flushing the containing directory is what makes it survive a crash. When only that flush fails, the role reports the pair as installed but unproven — it does not claim the file is unchanged, because the next reader would see the new pair either way — and it keeps the recovery record. That record then carries the same generation as the active document, which is precisely the marker of an incomplete commit.

The next ordinary run completes it by re-proving durability, reports refresh_reason: recovered, and only then retires the record.

4. Check mode

Ordinary discovery and reconciliation never perform credential maintenance in check mode. Nothing is rotated, seeded, or written.

That means some states cannot be resolved during a --check run, and the role says so rather than proceeding on a credential it has not established:

Classification Clear it with
credential_initialization_required_in_check_mode Run gitlab_oauth_session once outside check mode.
credential_refresh_required_in_check_mode Run gitlab_oauth_session once outside check mode.
credential_recovery_required Run gitlab_oauth_session once outside check mode.

To permit maintenance during a --check run deliberately, set check_mode: false on the session task itself:

- name: Ensure the OAuth session is valid
  primetheus.gitlab_settings.gitlab_oauth_session:
    target: "{{ gitlab_settings_target }}"
  check_mode: false

5. State reference

Every classification the authentication layer can report, what it means, and how to clear it. Each recovery step in this table has been exercised against the implementation.

Request-time rejections

Classification Meaning Safe to retry?
authentication_rejected GitLab returned 401. For a managed session this triggers at most one controlled refresh; otherwise the credential is not valid for this target. Only after fixing the credential.
authorization_denied GitLab returned 403. RBAC or token scope denied access. This never triggers a refresh, because it is not evidence of expiry. Not without more permission.

Session and store states

Classification Meaning How to clear
credential_store_unavailable The store cannot be used: wrong permissions, a symlink, unreadable, oversized, or not a JSON object. The message names the specific problem. Fix what the message names. For accessible to other users, chmod 0600 the file.
credential_store_binding_mismatch The stored document belongs to a different GitLab target, OAuth application, or redirect URI. Point at the correct store path, or reinitialize deliberately.
credential_initialization_required_in_check_mode The store is not initialized and check mode must not create it. Run gitlab_oauth_session outside check mode.
credential_refresh_required_in_check_mode A refresh is due and check mode must not perform it. Run gitlab_oauth_session outside check mode.
credential_recovery_required A rotated pair is outstanding — either staged from an interrupted rotation, or installed without proven durability. Run gitlab_oauth_session outside check mode. An ordinary run outside check mode also completes it.
credential_store_commit_failed GitLab rotated the pair but it could not be committed durably. The pair was not used. Read the message: it distinguishes installed but unproven from not recorded anywhere durable. See recovering a rotation.
missing_refresh_material The session holds no refresh token, or an initialization was attempted without a complete inline pair. Reauthorize with GitLab, then reinitialize.
missing_refresh_configuration reinitialize was requested without refresh.enabled and a durable store. Add the refresh block, or drop reinitialize.
refresh_budget_exhausted One refresh has already been attempted for this operation. A second exchange is not safe, because the first may have been processed. Investigate why the fresh pair was also rejected; do not retry blindly.
token_endpoint_rate_limited GitLab rate-limited the token endpoint beyond the bounded wait. Retry later.

Token-exchange outcomes

These come from the OAuth token endpoint itself, during a refresh.

Classification Meaning Safe to retry?
invalid_refresh_token GitLab rejected the refresh token as invalid or already used. The previous pair is gone. No. Reauthorize and reinitialize.
token_endpoint_rate_limited Rate-limited beyond the bounded wait. Yes, later.
tls_failure The TLS connection to the token endpoint could not be established or verified. Yes, once the trust problem is fixed. Nothing was sent.
network_failure The token endpoint could not be reached at all. Yes. Nothing was sent.
malformed_token_response The endpoint answered, but with something unusable: not JSON, not an object, oversized, or missing a usable token in the replacement pair. No — see ambiguous outcomes.
ambiguous_refresh_outcome The exchange may or may not have been processed: a timeout, a mid-flight failure, an unreadable response, an off-origin redirect, or a 5xx. Never. See ambiguous outcomes.

Verification verdicts

See failures in section 2 for insufficient_scope, token_inactive, token_expired, token_type_mismatch, and oauth_application_mismatch.

Ambiguous outcomes

A timeout, a dropped connection, an unreadable response, or an uncertain 5xx during a token exchange is never retried. GitLab may have processed it, which would mean the refresh token you still hold is already dead — and a second exchange with a dead token destroys nothing but tells you nothing either.

The role reports ambiguous_refresh_outcome and stops. Reauthorize with GitLab and reinitialize. Only a definitive rejection that proves the exchange was not processed — tls_failure, network_failure — may be attempted again.

6. Working with the durable store

Inspecting it

The document is plain JSON, mode 0600. Read it as the user the controller runs as. generation, expires_at, and binding are safe to inspect and to quote in a ticket; access_token and refresh_token are not.

STORE=/secure/ansible/oauth/gitlab-primary.json
sudo -u ansible jq '{generation, expires_at, binding}' "$STORE"

A file named <store>.recovery beside it means a rotation is outstanding.

Recovering a rotation

credential_store_commit_failed has two variants, and the message tells you which:

"…and it is installed, but its durability could not be proven" — the pair is in the active document and a recovery record is retained. Run the session task once outside check mode; it re-proves durability, retires the record, and reports refresh_reason: recovered. An ordinary task outside check mode does the same thing.

- name: Complete the outstanding rotation
  primetheus.gitlab_settings.gitlab_oauth_session:
    target: "{{ gitlab_settings_target }}"
  check_mode: false

"…but it could not be recorded anywhere durable, so the pair is lost" — the write-ahead record could not be written at all. GitLab has already invalidated the previous pair, so nothing recoverable remains. Fix the underlying storage problem, reauthorize with GitLab, and reinitialize.

If directory flushing never succeeds on that filesystem — some network mounts — rotation will fail closed on every run rather than appearing to work. Move the store to local storage.

Reinitializing a session

Reinitialization atomically replaces an existing managed session with a pair you supply. Ordinary tasks never infer it, because inline seed values are also what a first-time initialization uses.

- name: Install a reauthorized OAuth pair
  primetheus.gitlab_settings.gitlab_oauth_session:
    target: "{{ gitlab_settings_target }}"     # with a complete inline pair
    reinitialize: true
  check_mode: false

The target must carry a complete access_token and refresh_token, and refresh.enabled with a durable store. Any staged recovery record belongs to the session being replaced and is discarded.

Permissions

The store file must be 0600 and its directory must not be writable by other users. Neither may be a symbolic link. The role refuses to read a credential that other users can read, rather than treating it as usable.

7. What the role never does

  • No silent fallback. A refused credential is never downgraded to anonymous, and anonymous access is never a retry after a failure.
  • No guessed acceptance. Where the pinned catalogue carries no evidence that a credential family is accepted, the decision is unknown and the request is refused, naming what is missing.
  • No second refresh. One controlled exchange per operation, only after a 401, never after a 403.
  • No retry of an ambiguous exchange. See ambiguous outcomes.
  • No credential in output. Diagnostics carry the credential type, expiry, reason, and provider — never a token, cookie, secret, or store path.
  • No maintenance in ordinary check mode. Only the focused session task, with check_mode: false, performs credential maintenance during a --check run.