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
- 1. Declaring a credential
- 2. Verifying before you converge
- 3. Managed OAuth sessions
- 4. Check mode
- 5. State reference
- 6. Working with the durable store
- 7. What the role never does
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¶
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:
— 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:
- Write the replacement to a protected recovery record, and prove it durable.
- Replace the active document.
- 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 a403. - 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--checkrun.