Help us shape the future of Go! Take the 2026 Go Developer Survey

gitlab

package
v3.3.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 20, 2026 License: MIT Imports: 33 Imported by: 0

Documentation

Overview

Package gitlab provides a wrapper around the GitLab REST API v4 client. Some domains additionally use the GitLab GraphQL API for endpoints not covered by client-go service wrappers (see ADR-0006).

The package also detects Personal Access Token scopes so HTTP server entries can register tools according to the authenticated token's capabilities.

Resilience

Client initialization is lazy and recoverable. When GitLab is temporarily unavailable at startup, the server can enter degraded mode and retry initialization on later API calls with rate-limited health checks. This keeps local MCP startup responsive while still surfacing actionable errors to tool handlers.

Edition and Scope Detection

The wrapper tracks GitLab.com and Premium/Ultimate detection separately from token-scope detection. HTTP mode uses that information to register the correct tool catalog for each token and GitLab URL pair.

Index

Constants

View Source
const (
	GrantMaxBytes  int64 = 1 << 20
	GrantMaxScopes       = 1000
)

GrantMaxBytes is the most of one grant read this server takes in, and GrantMaxScopes the most scopes it evaluates (register row RQB-011).

A grant's size is its minter's choice. Each scope may carry every one of the 857 assignable permissions GitLab 19.4 names, up to 64 KB of them, and every project or group id a creation request lists becomes a scope of its own, needing only membership, so a user in a few thousand namespaces can mint a grant of tens of MiB. The read is bounded where the body is read (WithResponseLimit), under the response capture and client-go's decoder alike, rather than after either has taken the whole of it in, and the client keeps its own ceiling for every other call. A mebibyte holds a thousand scopes of a few dozen permissions each with room to spare, which is past any grant a person writes by hand; a grant past either bound is read as too large and leaves the session in phase A, saying so.

View Source
const AllowPrivateInstancesEnv = config.EnvPrefix + "ALLOW_PRIVATE_INSTANCES"

AllowPrivateInstancesEnv names the setting that permits a destination a caller or a redirect chose to resolve to a private, loopback, CGNAT, link-local, unique-local or unspecified address.

It is the opt-out for tier B and only tier B: the cloud metadata addresses are refused whatever it says, because no value of this setting is a claim that a GitLab API or a presigned object store is served from one.

The `--allow-private-instances` flag writes this variable, so there stays one reader; see cmd/server/env_flags.go for why that indirection exists.

View Source
const DefaultMaxResponseBytes int64 = 64 << 20

DefaultMaxResponseBytes is how much of one GitLab response body a client will read before giving up on it.

The number bounds the *decompressed* stream, which is the only place it can do any good: net/http gunzips a Content-Encoding: gzip response inside the base transport, so a cap applied to wire bytes bounds the compressed size and misses the amplification entirely. A 528 KB gzip that inflates to 67 MB is an ordinary artifact of a hostile or compromised upstream, and the SDK decodes whatever it is handed with no ceiling of its own.

64 MiB is chosen to sit above every response this server asks for in practice — the largest are job traces, which the jobs package truncates to 100 KB of its own accord, and artifact and export downloads, which are base64-encoded into a JSON-RPC response and are already unusable long before this — while being far below the multi-gigabyte working set an unbounded decode reaches. Raise it with Client.SetMaxResponseBytes if a deployment genuinely streams more than that through a tool.

View Source
const DestinationRefusedHint = allowPrivateAdvice

DestinationRefusedHint is the next step to offer a model or an operator who has just been told a tier B destination was refused.

It names the flag rather than describing the policy: the policy is already in the error, and what the reader does not have is the one string that changes it. A tier A refusal gets MetadataRefusedHint instead (DestinationRefusedHintFor), since no string changes that one.

View Source
const GitLabDotComHost = "gitlab.com"

GitLabDotComHost is the canonical host for GitLab SaaS-only features.

View Source
const GranularNotFound = "404 Not Found"

GranularNotFound is the fourth answer of GitLab's fine-grained authorization, its NOT_FOUND_MESSAGE: the boundary the operation names did not resolve, or resolved to one the token may not see.

Over REST GitLab turns it into its ordinary 404 (not_found! in lib/api/helpers.rb), which says nothing a 404 does not already say. Over GraphQL a mutation raises it as the message of one errors[] entry and runs nothing, and a read discards it and answers null. The literal is written by nothing else in GitLab's GraphQL layer at v19.4.1-ee, so an errors[] entry that is exactly this is the service's, whichever token was refused; the same text anywhere else, client-go's own 404 sentinel among them, is not.

View Source
const GranularScopeRefusalCode = "insufficient_granular_scope"

GranularScopeRefusalCode is the RFC 6750 error code GitLab's API guard writes into a 403 that refuses a call a permission a fine-grained token was not granted (lib/api/api_guard.rb at v19.4.1-ee, which renders every Gitlab::Auth::GranularPermissionsError as rack-oauth2's Forbidden with this code and the refusing service's sentence as error_description).

It is exported for the one other reader of such a 403, the OAuth verifier, so the spelling the two doors act on lives here once.

View Source
const MembershipTierQuery = `` /* 376-byte string literal not displayed */

MembershipTierQuery asks GitLab.com for the top-level groups the caller reaches and, for each, whether its plan carries a Premium and an Ultimate feature (issue 1224). It is exported for one reader, the test in internal/testutil that holds its cost to what GitLab.com measured: the estimate lives there, testutil imports this package so this package's tests cannot import it, and an external test package here would cost the package its condition gate (gobco cannot instrument one).

**Features rather than the plan.** Group.plan is authorized for :admin_namespace, so a member who does not administer the group reads null, the same wall GET /namespaces puts up. licensedFeatureAvailability is open to any member, and on GitLab.com it answers from the plan of the group's root ancestor, trials and the open source program included, which is the check GitLab itself makes before serving a licensed feature. EPICS is a Premium feature and SECURITY_DASHBOARD an Ultimate one in GitlabSubscriptions:: Features, and neither is global, which GitLab refuses to answer for.

**Which groups.** allAvailable: false is GroupsFinder's authorized groups: direct memberships, the groups shared with them, and the groups of the projects the caller is a member of, each with its ancestors, so topLevelOnly: true returns the root of every group the caller works in, a developer of a subgroup included. GitLab.com answered this for the maintainer's account on 2026-10-08, a Developer of a subgroup of a licensed group who administers only free namespaces: gitlab-community available for both features, where the namespace step had answered Free. A role is not filtered on, because the page offers no role at a root a caller reaches only through a subgroup; a member of any role is served a licensed feature GitLab's own policy lets that role use, and the role decides the rest.

**A fine-grained token gets no answer from it.** GitLab declares no fine-grained permission for LicensedFeatureAvailability (it is on the authorization to-do list of the 19.4.1 record), so it refuses that answer to every fine-grained token. The field and its available are both non-null, so the refusal nulls the whole node: such a token is answered a page of null groups beside an errors array, no group answers, and it keeps the tier the namespace step gave it.

It costs 22 at a page of a hundred, measured on GitLab.com, against the 250 GitLab refuses a query above.

View Source
const MetadataRefusedHint = "" /* 176-byte string literal not displayed */

MetadataRefusedHint is the next step offered with a tier A refusal. It names no flag, because none changes the outcome, and sends the reader to where the address came from instead.

One literal rather than two joined with +, so the mutation gate has no operator in a constant initializer to report as not covered.

View Source
const ScopeAPI = "api"

ScopeAPI is the GitLab scope that permits writes.

Only the two scopes this package's questions turn on are named here: this one, which answers whether a token can mutate GitLab, and ScopeReadAPI, which with it answers whether a token reaches the API at all (MeetsMinimum). internal/oauth owns the rest of the scope vocabulary for the authorization layer, and names these two as the same strings.

View Source
const ScopeGranular = "granular"

ScopeGranular is the one legacy scope GitLab gives a fine-grained personal access token. Its authority is not a scope at all but a grant of named permissions per namespace, which the scope list does not carry.

It is named here, beside the write scope, for the same reason: it changes the answer to whether a token can write, and to which scopes the catalog filter may read.

View Source
const ScopeReadAPI = "read_api"

ScopeReadAPI is the GitLab scope that permits reading the API, and the minimum every door admits a classic token at (ADR-0018, issue 952).

Variables

View Source
var ErrDestinationRefused = errors.New("outbound destination refused")

ErrDestinationRefused reports that this server declined to open a connection to an address, before any packet was sent to it.

It is a refusal by this server rather than an answer from anything upstream, which is the distinction a reader of the message needs: nothing was reached, nothing was disclosed, and the fix is configuration rather than credentials.

View Source
var ErrMetadataDestinationRefused error = metadataRefusalError{}

ErrMetadataDestinationRefused is the refusal of tier A: a cloud metadata address, which no configuration permits.

It is a sentinel of its own so that the next step offered with the refusal can differ from tier B's, which names the flag that permits a private address; naming that flag here only invites a reader to try it and find it did nothing. It unwraps to ErrDestinationRefused, so everything that recognizes a refusal still does, and its text is that error's, so the message a reader sees is the one every refusal always had.

View Source
var ErrNoResponseCaptured = errors.New("no response was captured")

ErrNoResponseCaptured is what ResponseCapture.Decode returns when no response passed through the transport under the capture's context: the request was never made, or was made with another context.

View Source
var ErrResponseTooLarge = errors.New("gitlab response exceeded the maximum size")

ErrResponseTooLarge reports that a GitLab response body exceeded the client's size ceiling and was abandoned rather than buffered.

It surfaces from whatever was reading the body — the SDK's JSON decode, an io.Copy into a caller's writer — so a tool handler sees a failed call rather than a truncated success.

View Source
var ErrUnboundClient = errors.New("gitlab client is unbound: the handler belongs to a shared catalog and was not bound to a credential")

ErrUnboundClient is what every request through an unbound client fails with. A handler that reports it was called through the shared, unbound copy of a catalog instead of through a copy bound to a credential, which is a wiring defect and never a user error.

Functions

func AuthorityFrom added in v3.2.0

func AuthorityFrom(ctx context.Context) *finegrained.Authority

AuthorityFrom returns the authority of the client bound to ctx, or nil when none is bound or the bound one carries none.

It reads the binding alone, never a fallback client: the callers are the layers that decide what a fine-grained session is shown, and they hold no client of their own. Both transports bind one, HTTP the request's pool entry and stdio the process's single client, so a request with no binding is one nothing could attribute to a credential, which is served as unknown authority and refused further in by the unbound client.

func BelowMinimum added in v3.2.0

func BelowMinimum(facts TokenFacts) bool

BelowMinimum reports whether what is known of a token puts it below the admission minimum: GitLab answered with its scopes and they do not meet it (MeetsMinimum). A token whose scopes nothing answered for (nil) is not below it, for the reason WriteCapable gives unknown scopes.

func CatalogScopes added in v3.2.0

func CatalogScopes(scopes []string) []string

CatalogScopes returns the scope list the catalog's scope filter and its cache key read for a token: the token's own list, or nil when that list says nothing about what the token may reach.

A fine-grained token is the one case that maps to nil. Its list carries no legacy scope a group requirement can name, so reading it literally would remove every group that requires one (the admin_mode groups) on the strength of a scope the token cannot carry, and would key its catalog exactly as an empty list keys one while it must be filtered as unknown. Mapped to nil, it shares the filter and the key of a classic token whose scopes are unknown, which is what both are: served the unfiltered catalog, with GitLab deciding each call.

It is one function because the filter and the key must read the same list: a key that told the two cases apart while the filter did not, or the reverse, would serve one of them a catalog it did not earn.

func CheckCallerNamedInstance

func CheckCallerNamedInstance(rawURL string) error

CheckCallerNamedInstance refuses an instance URL a caller named that this server would decline to dial anyway.

It exists to answer at the door instead of deep inside a tool call. The dialer is still the authority — this consults the same predicate and the same opt-out — and this is the friendlier half: an operator who pointed the header at their own private GitLab gets one 400 naming the flag, rather than a server that starts, admits the credential and fails every action.

It resolves nothing. A host spelled as a literal address is judged here; a host spelled as a name is left entirely to the dialer, which sees what the name resolved to and is the only check that can. Asking DNS a question per request at the door would put a resolver timeout in front of every caller and would still not be authoritative. A spelling is read as net/http will dial it ([dialHostname]), so an address written in fullwidth digits is the address it reaches rather than a name, and a host that ends in a number is read as the address a libc proxy would make of it ([numericHostAddress]).

func DestinationRefusedHintFor added in v3.3.0

func DestinationRefusedHintFor(err error) string

DestinationRefusedHintFor returns the next step to offer with err: MetadataRefusedHint for a tier A refusal, and DestinationRefusedHint, which names the flag, for every other.

func FineGrained added in v3.2.0

func FineGrained(scopes []string) bool

FineGrained reports whether a scope list is the one a fine-grained personal access token presents: exactly ScopeGranular and nothing else.

GitLab creates such a token with that single legacy scope (app/services/authn/personal_access_tokens/create_granular_service.rb at v19.4.1-ee, the one service both the REST and the GraphQL creation paths call), and the list is the only place the kind shows in what client-go decodes of the PAT self endpoint this server already asks. GitLab sends the kind there too, as granular: true on every token (lib/api/entities/personal_access_token.rb), but client-go's PersonalAccessToken does not model that field (row 32 of docs/development/upstream-bugs.md); a reader that needs it takes it from the captured response (ADR-0021). For every token GitLab can mint the two agree, since the service that sets granular also sets this list. The RFC 6750 code insufficient_granular_scope does not prove the kind: GitLab answers a classic token with the same code under a root namespace that enforces fine-grained tokens. A list that carries the scope beside others is not this shape, and is read as the classic scopes it spells.

The list reaches this predicate from the self endpoint only when the token may read itself: that endpoint requires Personal Access Token: Read of a fine-grained token, and without it GitLab answers 403 insufficient_granular_scope, which DetectToken reads as a fine-grained token with no id whose grant cannot be read, reporting this list for it without asking this predicate.

func FoldHostCase added in v3.3.0

func FoldHostCase(host string) string

FoldHostCase returns host with its ASCII letters in lower case and every other byte as it was written, which is the form two spellings of a host are compared in wherever this server decides whether they name the same one.

Why not strings.ToLower

strings.ToLower folds Unicode, and two runes outside ASCII fold into an ASCII letter: U+0130, the dotted capital I, becomes "i", and U+212A, the Kelvin sign, becomes "k". net/http dials "gİtlab.example.com" as xn--gitlab-qyd.example.com, while strings.ToLower makes it "gitlab.example.com". It dials the Kelvin sign as "k", but a plain-http request through an HTTP proxy names its host in the request line under the Punycode profile, which maps neither rune, so "Kube.example.com" is asked for as xn--ube-xk1a.example.com. A comparison through strings.ToLower therefore treated hosts somebody else can register as the configured instance, and let a redirect to them keep PRIVATE-TOKEN.

Why not the IDNA form either

There is no single IDNA form to compare. net/http dials, names in a CONNECT and hands a SOCKS proxy the host's form under the Lookup profile, and writes its Host header, and the request line of a plain-http request through an HTTP proxy, under the Punycode profile, and the two differ for exactly these spellings: xn--gitlab-qyd against xn--gtlab-h4a. Folding ASCII alone can only be stricter than either. An ASCII host is dialed as written and DNS ignores its case; a name outside ASCII matches only the same bytes, which both profiles map alike; and an ASCII parent after a dot maps to itself, so a subdomain written outside ASCII is still a subdomain. Where it costs anything the cost is paid in the safe direction: the xn-- spelling of an instance configured in Unicode counts as another host, and so does the Kelvin-sign spelling, which a direct dial resolves to the instance and a proxied plain-http request does not.

Byte by byte rather than rune by rune, so a host carrying bytes that are not UTF-8 keeps them: a rune-wise mapping would turn every such byte into U+FFFD and make two different hosts compare equal. No byte of a multi-byte UTF-8 sequence falls in the ASCII range, so none of them is touched.

The copy is made at the first upper-case letter and not before, so a host already in lower case, which is nearly every one, is handed back as it came. One test of the byte decides both whether to copy and what to fold, since a separate scan for the first letter would ask the same question twice.

func HTTPTransport

func HTTPTransport(skipTLSVerify bool) http.RoundTripper

HTTPTransport returns the GitLab HTTP transport configured with the same TLS policy used by authenticated GitLab clients.

A request made through it carries no destination policy, so the dialer applies tier A and nothing else, as it always has: a caller that borrows the transport chose its own destination (ADR-0022). Without skipped verification it is the permissive half of [sharedDestinationPools], which is the pool such a request belongs in: a connection it finds there leads to an address tier A allows, and one it opens is one any request routed there would have been allowed to dial as well.

func IsGitLabDotComURL

func IsGitLabDotComURL(rawURL string) bool

IsGitLabDotComURL reports whether rawURL targets the canonical GitLab.com host.

func MeetsMinimum added in v3.2.0

func MeetsMinimum(scopes []string) bool

MeetsMinimum reports whether a scope list GitLab answered with meets the admission minimum, read_api, which api covers. A fine-grained token's list meets it: its one scope names no authority, so it is unknown authority, and GitLab judges its grant per call (ADR-0024).

It judges an answer and nothing else, so a nil or empty list does not meet it. A caller that holds no answer, because the token's scopes could not be read, admits the token without asking (BelowMinimum), since unknown scopes count as capable (ADR-0018); the OAuth door, whose verifier reports an introspection that answered with no scope as an empty answer, asks it of that answer and refuses.

Every door asks this one predicate, so a scope that reaches the API is the same scope at each of them.

func MissingClassicScopes added in v3.2.0

func MissingClassicScopes(need finegrained.ClassicScope, groupScopes, tokenScopes []string, readAPIOnly bool) (byGitLab, byServer []string)

MissingClassicScopes names the scopes a token lacks for an action, in the two halves the answer to a withheld action words apart, since only the first is GitLab's rule. byGitLab is api when GitLab requires it for what the action sends and the token was narrowed to what read_api reaches (readAPIOnly, which NarrowToTokenScope sets). byServer is the scopes of groupScopes the token does not carry, sorted and each once: admin_mode, which this server demands before it serves a catalog group, whatever GitLab asks of each action in it. The API half is read from the narrowing rather than from the list, so the answer depends only on what keys the shared catalog it is cached beside (internal/tools.CatalogFilterKey). A token whose scopes are unknown, or a fine-grained one, lacks nothing it can be told about, since GitLab judges it per call.

func NarrowToTokenScope

func NarrowToTokenScope(cfg *config.ServerConfig) bool

NarrowToTokenScope marks a server configuration as built for a token carrying read_api without api, and reports whether it did. Both transports call it once the scopes are known: the HTTP pool per entry, since an entry is per token, and stdio once at startup for its single token. The catalog built from the configuration then withholds every action GitLab refuses read_api, which the requests each action sends decide (finegrained.ClassicScope), and reports it as withheld by the token scope, rather than listing actions GitLab would refuse one by one with its own 403 (ADR-0018, ADR-0026).

It marks the configuration whatever the operator set: read-only mode is a second narrowing on top of this one, applied first and reported as the operator's, so a write both remove is never blamed on the credential, and a read GitLab refuses read_api is still withheld from such a token in a read-only deployment. The line it logs names both narrowings then ([readAPINarrowingMessage]). Unknown scopes narrow nothing, and neither does a fine-grained token, for the reason WriteCapable gives.

func PermissionRefusal added in v3.2.0

func PermissionRefusal(body []byte) (string, bool)

PermissionRefusal reports whether body is GitLab's refusal of a permission a fine-grained grant lacks, and returns the sentence GitLab gave for it.

The answer says the credential was accepted: GitLab authenticates the token first and judges the grant after (lib/api/helpers.rb, current_user then authorize_granular_token_scopes!), so a body carrying this code was written for a token the instance knows. It does not say the token is fine-grained: GitLab answers a classic token with the same code under a root namespace that enforces fine-grained tokens. On GET /api/v4/user, whose boundary is the user and names no root namespace, only a fine-grained token receives it.

The sentence is GitLab's text as sent, which under --allow-any-gitlab-url is whatever the caller's own instance chose to write. A reader that quotes it to a caller or writes it to a log bounds and filters it first.

The body is decoded strictly, refusing an object that names a member twice, for the reason decodeInstanceJSON in internal/oauth gives: this decides how a door answers a credential, and a body two readers could read two ways is not taken as proof of anything. A body that does not decode reports false, which every caller reads as the refusal it was before this code was recognized.

func ReadGrant added in v3.2.0

ReadGrant reads the grant of the fine-grained personal access token with the given id, which must be the token the client authenticates with (DetectToken read it from the self endpoint).

client-go does not model the grant (row 32 of docs/development/upstream-bugs.md), and the self endpoint does not present it, so it is decoded from the captured response of GET /personal_access_tokens/:id (ADR-0021), read under GrantMaxBytes and decoded with at most GrantMaxScopes scopes (finegrained.DecodeGrant).

What comes back says which of three things happened. A grant and finegrained.FallbackNone: it was read and can be evaluated. A reason and no error: GitLab answered, and the answer cannot be evaluated: refused (the token may not read its grant), too large, or holding something the decoder would have to guess at. An error: GitLab did not answer the question (a transport failure, a timeout, a status that is no verdict on the read), which a caller holding an evaluated grant reads as no news rather than as phase A, since the grant cannot have changed (nothing edits one after creation).

Nothing of the grant is logged here, and the body is dropped once decoded.

func RefusalMayBePermission added in v3.1.0

func RefusalMayBePermission(status int, req *http.Request, body []byte) bool

RefusalMayBePermission reports whether a refusal GitLab answered can be a valid credential refused a permission, which is the one refusal a handler's hint about a role, a license or an owner is written for. status is the answered status, req the request GitLab answered and body the answer's body, either of which may be nil.

Only a REST 401 or 403 whose body carries no RFC 6750 error code, and whose message does not refuse the account, qualifies. GitLab's API helpers unauthorized!, forbidden! and render_api_error!, which every permission refusal goes through (the 401 ones are entry 55 of docs/development/upstream-bugs.md), render {"message": ...} and nothing else. GitLab's API guard refuses the credential rather than the call in two ways, and neither qualifies. Its token and scope refusals carry a code: invalid_token, dpop_error and restricted_language_server_client_error on a 401, insufficient_scope and insufficient_granular_scope on a 403 (lib/api/api_guard.rb). An account the API will not serve (blocked, deactivated, pending approval, an internal user, the Terms of Service not accepted, the primary email unconfirmed, the password expired) it refuses through those same helpers, with a plain 403 forbidden! whose reason is one of the fixed sentences of lib/gitlab/auth/user_access_denied_reason.rb, so that answer is told apart by its message, [accountRefusalReasons]. A role or license hint after either sends the reader to fix something that is not wrong. The GraphQL endpoint answers 401 and 403 only from its own authentication and access checks, and refuses a field the caller may not see with a 200, so none of its answers qualifies.

A request carrying no token at all does qualify, and it is harmless here. The guard maps the error for a missing token to rack-oauth2's default code unauthorized, but nothing in GitLab raises that error: such a request is answered by authenticate!'s plain 401, byte for byte a permission refusal, and this server never sends a request without a token.

The answer is "may" and not "is": a token GitLab has no record of is answered through unauthorized! with the same bytes as a permission refusal, which is why the description in front of a hint names both causes. What it guarantees is the other direction. Every 401 UnauthorizedNamesCredential says names the credential carries a code or came from GraphQL, so a hint keyed on this never follows the verdict that the token itself was refused.

func ScopeSatisfied

func ScopeSatisfied(tokenScopes, requiredScopes []string) bool

ScopeSatisfied checks whether requiredScopes are all present in the detected tokenScopes. If tokenScopes is nil (detection failed or disabled), returns true (allow all). If requiredScopes is empty, returns true (no requirement). A fine-grained token's list is read through CatalogScopes, so it is unknown authority here too and satisfies every requirement.

func UnauthorizedNamesCredential added in v3.1.0

func UnauthorizedNamesCredential(req *http.Request, body []byte) bool

UnauthorizedNamesCredential reports whether a 401 GitLab answered says the credential itself was refused, as opposed to a valid credential refused a permission. req is the request GitLab answered and body the answer's body, either of which may be nil. The caller has already established that the status is 401: neither signal means anything on another status.

Two answers say so:

  • A REST body carrying the RFC 6750 code invalid_token. GitLab's API guard answers an expired, revoked or impersonation-disabled token with rack-oauth2's body carrying that code (lib/api/api_guard.rb, pinned by spec/requests/api/api_guard_spec.rb), and nothing else in the REST API writes it. GitLab's API helper unauthorized! (lib/api/helpers.rb), which the permission refusals call, renders {"message":"401 Unauthorized"} instead.
  • Any 401 from the GraphQL endpoint. It answers 401 only from its authentication checks (GraphqlController#authorize_access_api! renders {"errors":[{"message":"Invalid token"}]} for a token it could not use, and the two authentication errors the controller rescues are the others), and it refuses a field the caller may not see with a 200 carrying errors, never with a 401. So the permission refusal a REST 401 can be has no GraphQL counterpart to be confused with.

The absence of both decides nothing. A token GitLab cannot find at all is answered through unauthorized! too, byte for byte like a permission refusal (auth_finders.rb raises UnauthorizedError, which the API helpers turn into unauthorized!), so a plain 401 may be either.

It is one rule with two readers, and they must not disagree. The transport reads it to decide whether a refused call ends a pooled credential at once or is confirmed first, and internal/toolutil reads it to decide what a 401 is described as to a model. A copy in either would let the server end a credential while telling the model a permission was missing, or the reverse.

It is not the rule the credential probe's answer is read by, [credentialVerdictFor], which is the opposite and is right where it is used: GET /user asks about the credential and nothing else, so any 401 or 403 there is about the credential, but for the one 403 that says the credential was accepted, PermissionRefusal's.

func VersionRefusedForScope added in v3.2.0

func VersionRefusedForScope(err error) bool

VersionRefusedForScope reports whether err is Client.Initialize's failure for a token whose scopes do not reach the version endpoint, which a stdio start reads as an instance that answered rather than one it could not reach.

func WithClient

func WithClient(ctx context.Context, c *Client) context.Context

WithClient returns ctx carrying the client every handler running under it must use.

It is the channel that lets one github.com/modelcontextprotocol/go-sdk/mcp.Server serve many credentials. A server built once per configuration shape registers handlers that capture the credential-less client from NewUnboundClient; the HTTP layer resolves the pool entry for each request and installs that entry's client here, and Client.For is what every handler reads it back through.

A nil client is not stored. "Bound to nothing" and "not bound" would then be indistinguishable to Client.For, and the difference decides whether a handler runs under the caller's credential or under the one that refuses every request.

func WithResponseLimit added in v3.2.0

func WithResponseLimit(ctx context.Context, n int64) context.Context

WithResponseLimit returns a context under which a request this client makes reads at most n bytes of its response body, or fewer when the client's own ceiling is lower, and fails with ErrResponseTooLarge past it. Pass the context to the SDK call with gl.WithContext, as with WithResponseCapture.

It exists for a response whose size is the caller's to choose rather than the instance's, which a ceiling set on the whole client cannot bound without bounding every other call the client makes: a fine-grained token's grant (ReadGrant) is as large as its minter made it. The ceiling applies where the body is read, under the response capture and the SDK's decoder alike, so neither holds more of it than the bound, and a value of zero or less sets none.

func WriteCapable

func WriteCapable(scopes []string) bool

WriteCapable reports whether a token's scopes permit mutating GitLab.

Unknown scopes (nil: detection failed, was disabled, or the instance is too old to answer) count as write-capable. Assuming otherwise would silently strip every mutating tool from a deployment whose token is perfectly able to use them, and a wrong "no" is invisible — the tools are simply not there — while a wrong "yes" surfaces as GitLab's own 403 on the call that actually tried to write.

A fine-grained token counts as write-capable for the same reason: its scope list is the single value ScopeGranular, which says nothing about what it may do, so it is unknown authority and not a read-only token. GitLab judges each call against the permissions the token was granted, and a write outside them is refused there, with GitLab's own 403 naming the missing permission.

Types

type Client

type Client struct {
	// contains filtered or unexported fields
}

Client wraps the official GitLab API client with project-specific configuration. It includes connection resilience: when GitLab is unreachable at startup, the server enters degraded mode and automatically recovers when connectivity is restored.

func ClientFrom

func ClientFrom(ctx context.Context) (*Client, bool)

ClientFrom returns the client bound to ctx, and whether there was one.

Client.For is what handlers use; this is for the few callers that must distinguish "no credential is bound" from "the fallback was returned", such as a middleware deciding whether it has anything to install.

func NewClient

func NewClient(cfg *config.Config) (*Client, error)

NewClient creates an authenticated GitLab client from the provided configuration. When cfg.SkipTLSVerify is true, TLS certificate verification is disabled (for self-signed certs). The client includes a resilience transport that enables automatic recovery when GitLab becomes available after being unreachable at startup.

func NewClientWithToken

func NewClientWithToken(baseURL, token string, skipTLSVerify bool) (*Client, error)

NewClientWithToken creates an authenticated GitLab client with explicit parameters. Unlike NewClient, it does not require a full config.Config and is designed for use in the server pool where each client has a unique token but shares the same base URL and TLS settings. The client includes a resilience transport that enables automatic recovery when GitLab becomes available after being unreachable.

func NewClientWithTokenRetries

func NewClientWithTokenRetries(baseURL, token string, skipTLSVerify, disableRetries bool) (*Client, error)

NewClientWithTokenRetries is NewClientWithToken with the retry policy exposed: disableRetries turns off client-go's retryablehttp wrapper (RetryMax 5, linear backoff), which unit tests need — a pooled client probing a mock that answers 5xx otherwise sleeps through the whole credential-check deadline instead of reading the answer it already has.

func NewOAuthClientWithToken

func NewOAuthClientWithToken(baseURL, token string, skipTLSVerify bool) (*Client, error)

NewOAuthClientWithToken creates a GitLab client that authenticates with "Authorization: Bearer" instead of the PRIVATE-TOKEN header. The server pool uses it in oauth HTTP mode, where every credential arrives as a Bearer token: an OAuth access token (gloas-...) is ONLY valid as Bearer — GitLab rejects it in PRIVATE-TOKEN — while personal access tokens are valid in both schemes, so forwarding exactly as received is correct for every token kind the mode admits.

func NewUnboundClient

func NewUnboundClient(baseURL string) *Client

NewUnboundClient returns a client for baseURL that carries no credential and refuses every request with ErrUnboundClient.

It exists for the catalog cached per configuration and shared by every pooled server. The catalog is built once, through the same ActionSpecs functions a bound client would be handed, and those inspect the client only for the instance class (GitLab.com or self-managed), which the URL answers. Building it with a real client would capture that client's credential in a process-wide cache; building it with this one captures nothing that can reach GitLab, so a handler served from the shared copy by mistake fails closed instead of running under someone else's token.

It panics when baseURL does not parse: the URL is a constant chosen by the caller, so a bad one is a programming error rather than a condition to handle.

func (*Client) AtLeast added in v3.3.0

func (c *Client) AtLeast(ctx context.Context, major, minor int) bool

AtLeast reports whether the instance is known to run release major.minor or a later one: true only when it reported a version that is. A prerelease counts as the minor it names, so GitLab.com's 19.6.0-pre is past 19.5.

It is for a handler that sends a route GitLab added in a known release and keeps the older route for an instance that may predate it, so the answer errs towards false: a version the instance refused this token (Metadata: Read, Client.VersionRefusal), one it did not answer, and one that is not a version are all "not known", which is false.

It reads Client.Version, and asks Client.ReadVersion when no read has answered yet, since an empty version is the common case rather than an old instance: an HTTP pool entry asks only to read a fine-grained token's authority or when its tier fell back to Free unpinned. An answer is kept, so a client asks at most once while the instance answers, a refusal is not asked again (a token's grant cannot change after it is created), and an instance that did not answer is asked again by the next call. ctx bounds the read.

func (*Client) Authority added in v3.2.0

func (c *Client) Authority() *finegrained.Authority

Authority returns what Client.SetAuthority attached, or nil when nothing was, which is every credential that is not a fine-grained token. A nil client has none either, so a caller holding no client asks without a guard.

func (*Client) BearerToken added in v3.3.0

func (c *Client) BearerToken() gl.RequestOptionFunc

BearerToken returns a request option that sends this client's token as "Authorization: Bearer" on the one request it is handed.

It is for a GitLab route that authenticates through that header alone. The ML model package files route declares its token types sent through the Bearer header and reads no other, so the PRIVATE-TOKEN header a personal access token client sends everywhere else leaves the request unauthenticated there, and a private project answers 404 (see "The ML model package route accepts neither PRIVATE-TOKEN nor a GitLab OAuth access token" in docs/development/upstream-bugs.md). The option adds the header beside the client's own rather than replacing it, since client-go applies the client's header after the request options and only when that header is absent; an OAuth client already sends the same Bearer value, so on one it changes nothing.

func (*Client) CheckCredential added in v3.1.0

func (c *Client) CheckCredential(ctx context.Context) CredentialVerdict

CheckCredential asks GitLab whether it accepts this credential, and reports which of the five answers it got.

Five and not two, because the callers need different parts of them. Admission needs to know whether GitLab refused, and admits on no verdict so that an instance outage is not a total denial of service; it also needs the two accepting 403s apart, because a credential GitLab accepted and refused the probe's permission, or the probe for want of a scope, is neither a refusal to charge nor a credential to serve (Client.CheckCredentialDetail carries GitLab's sentence for the first). The pool's confirmation of a 401 that named no cause needs to know whether GitLab accepted, and ends an entry whose credential is below the minimum: keeping an entry and recording that its credential was just checked is only honest on a real answer, and a 500 read as "accepted" would push back the credential-age ceiling on the strength of a question GitLab never answered. The pool's periodic revalidation reads them all, through Client.CheckCredentialDetail so it can log what each was read from: it evicts on a refusal or a credential below the minimum, stamps the entry on an acceptance, and counts anything else as no verdict, since treating a briefly unreachable GitLab as a refusal would evict every tenant at once.

It issues GET /api/v4/user through the raw health client rather than the SDK on purpose: client-go wraps requests in retryablehttp with RetryMax 5 and a linear backoff, which turns a refused connection or a struggling instance into seconds of stalling. A liveness question about a credential should be asked once and answered fast. The health client also does not report its 401s to the unauthorized hook, which is what lets the pool ask this question about a 401, or on its revalidation sweep, without the answer raising another.

The probe URL is built from the client's normalized base URL, as [Client.versionDirect]'s is: an instance the operator configured or published, or, under --allow-any-gitlab-url, one a caller named, which the destination guard judges on every hop (ADR-0022).

func (*Client) CheckCredentialDetail added in v3.1.0

func (c *Client) CheckCredentialDetail(ctx context.Context) CredentialCheck

CheckCredentialDetail is Client.CheckCredential with the cause kept.

The verdict alone serves admission and the pool's confirmation of a 401, which act on it and log nothing about the probe. The pool's periodic revalidation logs every round that reaches no verdict, and a warning that cannot say whether GitLab answered 500, 429 or a redirect, or never answered at all, gives an operator who sees it on every round nothing to go on.

func (*Client) CurrentUser

func (c *Client) CurrentUser(ctx context.Context) (*CurrentUserInfo, error)

CurrentUser returns the identity of the authenticated GitLab user. It calls the /user API endpoint and returns both the numeric ID and username.

func (*Client) CurrentUsername deprecated

func (c *Client) CurrentUsername(ctx context.Context) (string, error)

CurrentUsername returns the username of the authenticated GitLab user.

Deprecated: Use Client.CurrentUser which returns both ID and username.

func (*Client) DetectEnterprise

func (c *Client) DetectEnterprise(ctx context.Context, fallback bool) bool

DetectEnterprise updates the client edition flag from /api/v4/version when GitLab exposes it, returning fallback when the field is absent or detection fails.

A token GitLab refuses the version endpoint for a fine-grained permission (Client.VersionRefusal) is not asked again, and its refusal is logged at debug rather than as a failed detection: the refusal is a fact about the token's grant, which cannot change. Who reports it depends on the mode. A stdio start warns once, since its token is the operator's own (cmd/server's warnVersionRefused, from Client.VersionRefusal). An HTTP pool entry reaches this through Client.DetectTier with a caller's token, and there nothing above the debug line reports it: the refusal describes that caller's grant rather than the deployment, and the entry takes the fallback edition.

func (*Client) DetectTier

func (c *Client) DetectTier(ctx context.Context) edition.Tier

DetectTier resolves the GitLab licensing tier and stores it on the client.

It asks up to three questions, because no one of them answers for every deployment:

  • GET /license, which carries the instance's own plan and is **admin-only on self-managed**. It is the authoritative answer where it is available.
  • GET /namespaces, which carries a plan per namespace and is readable by any token, for the namespaces the caller administers. On GitLab.com that plan is the subscription; on self-managed it is "default" whatever the instance is licensed for, since a subscription is a GitLab.com concept.
  • On GitLab.com alone, the top-level groups the caller is a member of, each with whether its plan carries a Premium and an Ultimate feature ([Client.tierFromMembership]), asked unless the namespaces already answered Ultimate.

Measured on 2026-09-22 against a licensed EE 19.3.1 and against GitLab.com: a non-admin token is refused /license with 403 on both, /namespaces reports "default" for admin and non-admin alike on self-managed, and reports the real plan on GitLab.com for each namespace the caller administers. So the second question rescues the GitLab.com caller who owns a paid group, and rescues nobody on self-managed. It does not rescue a member: GitLab.com shows a namespace's plan only to a caller who may administer it, while it serves a licensed feature to every member of a group whose top-level group's plan carries it, which is most of the people working in a paid group (issue 1224). The third question is that check, and GitLab.com answered it for the maintainer's account, a Developer of a subgroup of a licensed group, on 2026-10-08.

The highest answer of the second and third wins, on ADR-0018's asymmetry, and a third that fails keeps the second's: a failed read is no evidence that the caller holds less than it was already shown to hold.

Where none answers, the tier is edition.Free as before. That is right on a CE instance and wrong on a licensed one the caller cannot read the license of, and the two are told apart by the edition rather than guessed at: an enterprise build that could not be resolved gets a warning naming the flag that settles it, and a CE build stays silent because Free is the truth there.

func (*Client) EnableLazyInit

func (c *Client) EnableLazyInit()

EnableLazyInit enables lazy re-initialization on subsequent API calls. Called when startup Initialize() fails so that the server can recover automatically when GitLab becomes available again.

func (*Client) EnsureInitialized

func (c *Client) EnsureInitialized(ctx context.Context)

EnsureInitialized attempts lazy re-initialization if the client was not initialized at startup (e.g. GitLab was down). This allows automatic recovery when GitLab becomes available again. Thread-safe via initMu. Includes a 30-second cooldown between attempts to avoid hammering GitLab. Called automatically by [resilienceTransport] on every SDK request.

func (*Client) For

func (c *Client) For(ctx context.Context) *Client

For returns the client bound to ctx, or the receiver when ctx carries none.

The receiver is the fallback rather than the answer, which is what makes the shared-server arrangement fail closed: on a shape server the captured client is the unbound one, so a handler reached without a bound context refuses with ErrUnboundClient instead of running under whichever credential happened to build the shape. On stdio, and in every test that registers with a real client, no context ever carries one and the receiver is simply returned, so the call is invisible.

A nil receiver is allowed and returns whatever the context carries, so a caller holding no fallback can still resolve one.

func (*Client) GL

func (c *Client) GL() *gl.Client

GL returns the underlying gitlab client for use in tool handlers.

func (*Client) Initialize

func (c *Client) Initialize(ctx context.Context) (string, error)

Initialize validates GitLab connectivity via a direct HTTP health check (bypassing the SDK transport chain to avoid recursion). On success it marks the client as initialized and returns the GitLab version string.

One refusal is a success too: GitLab accepting the credential and refusing it /api/v4/version for a fine-grained permission its grant lacks, which at v19.4.1-ee is Metadata: Read (lib/api/metadata.rb declares read_metadata at the instance boundary). The instance answered and authenticated the token, so it is reachable, and the client is marked initialized with the version and the edition unknown and the empty string returned: Client.VersionRefusal carries GitLab's sentence for the caller that has to say so. Read as a failure, it put the client into lazy re-initialization for good, since a token's grant cannot be changed after it is created: every SDK request then paid a refused /api/v4/version once per [initCooldown], and a caller that only runs identity and tier detection after a successful start never ran them at all.

func (*Client) IsEnterprise

func (c *Client) IsEnterprise() bool

IsEnterprise reports whether the GitLab instance is Premium/Ultimate, derived from the resolved tier (tier >= Premium).

func (*Client) IsGitLabDotCom

func (c *Client) IsGitLabDotCom() bool

IsGitLabDotCom reports whether the client is configured for GitLab.com.

func (*Client) IsInitialized

func (c *Client) IsInitialized() bool

IsInitialized returns true if Initialize() completed successfully.

func (*Client) IsUnbound

func (c *Client) IsUnbound() bool

IsUnbound reports whether this client is the credential-less one, which refuses every request with ErrUnboundClient.

It exists so a surface can refuse early and say why. A resource read or a completion resolved to this client has not failed at GitLab and never will reach it: the request could not be attributed to a credential, which is a wiring defect on this side. Asked after Client.For, it is the question "did this request bring a credential", and answering it before the call is what keeps the caller from being told their token lacks access to something.

A nil client is not unbound: it is no client at all, which every reader already handles.

func (*Client) MarkInitialized

func (c *Client) MarkInitialized()

MarkInitialized sets the initialized flag without running the full Initialize flow. Intended for test setups where the client is preconfigured with a token or mock credentials.

func (*Client) MarkInstanceCallerNamed

func (c *Client) MarkInstanceCallerNamed()

MarkInstanceCallerNamed records that this client's instance was named by a caller rather than by the operator, which is the --allow-any-gitlab-url hatch, and puts its very first hop under tier B.

It is called by the server pool, which is the only place that knows: a client is built from a URL string, and the string looks identical whether --gitlab-url published it or a GITLAB-URL header did.

The default is the other way round because the alternative breaks every ordinary deployment: a client whose instance nobody marked is one the operator configured, and refusing those would refuse GitLab on localhost.

func (*Client) PinTier added in v3.1.0

func (c *Client) PinTier(t edition.Tier)

PinTier records a tier the operator named, and keeps it: nothing the client learns about the instance afterwards replaces it, a lazy re-initialization included. The catalog a process registers is built for this tier, so a handler reading Client.IsEnterprise must answer for the same one.

func (*Client) Ping

func (c *Client) Ping(ctx context.Context) (string, error)

Ping validates connectivity and authentication by calling the GitLab version endpoint. Returns the GitLab version string on success. Callers should wrap ctx with context.WithTimeout to bound the network round-trip.

func (*Client) ReadFineGrained added in v3.2.0

func (c *Client) ReadFineGrained(ctx context.Context, facts TokenFacts) finegrained.Reading

ReadFineGrained reads what a fine-grained token's authority is judged from (finegrained.Judge): the version the instance reports, and the token's grant, for the token the client authenticates with as DetectToken described it.

A token that may not read its own grant is not asked anything, since no answer could lift that: its reading says why and nothing else, and its authority is phase A. Otherwise it asks GET /api/v4/version through the health client, and then GET /personal_access_tokens/:id only when that left a version to judge the grant at, since without one no grant is evaluated. A version read the instance did not answer is finegrained.FallbackVersionUnanswered, and one it answered with no version this server can read (a refusal of Metadata: Read, or a string that does not validate) finegrained.FallbackVersionUnreadable; a grant read it did not answer is finegrained.FallbackGrantUnanswered. The two unanswered reasons are the ones a later read can lift without anything about the token changing. Nothing is logged here: the error of an unanswered read names the route, and the route names the token's id.

func (*Client) ReadFineGrainedAt added in v3.2.0

func (c *Client) ReadFineGrainedAt(ctx context.Context, facts TokenFacts, version string) finegrained.Reading

ReadFineGrainedAt is Client.ReadFineGrained with the version already read and answered, which is a stdio start's: Client.Initialize asked it moments ago, and asking again would cost the instance a request and a token without Metadata: Read a second refusal for nothing. An empty version is one the instance answered with none this server can read, and the grant is not asked for.

func (*Client) ReadVersion added in v3.2.0

func (c *Client) ReadVersion(ctx context.Context) (string, bool)

ReadVersion asks /api/v4/version now, through the health client, and returns the version the instance reported when it is one (Client.Version's rule), with whether the instance answered at all.

An answer is a 200 naming a version, readable or not, or GitLab's refusal of the fine-grained permission the endpoint needs (Metadata: Read): both say what this token will be told, and both leave the version "" when it is not one this server can read. A transport failure, a timeout, any other status and a body naming no version are no answer, which a caller holding a version from an earlier read keeps, rather than reading an instance that did not answer as one with no version.

It is the read a fine-grained token's authority is chosen by (the bucket of the recorded table it is matched against), made when a pool entry is built and again on each accepted revalidation, and on stdio by the same timer.

func (*Client) RefreshAuthority added in v3.2.0

func (c *Client) RefreshAuthority(ctx context.Context, facts TokenFacts, table *finegrained.Table) (moved *finegrained.Authority, kept string)

RefreshAuthority re-reads a fine-grained token's grant and the instance version and replaces the authority this client carries when the reads answered, judging them against table (finegrained.Rejudge).

It returns the new authority when the replacement moved the token to another verdict (finegrained.Moved: its phase, its reason or the release it was judged at), which the caller logs with its finegrained.Authority.LogArgs as an entry build logs one in phase A, so an instance upgraded to a release no table records leaves a line saying the sessions on it fell back. A replacement that moved nothing returns neither, since a re-read that answered replaces the authority on every round. When it kept the authority it returns the reason, which the caller logs once rather than on every round: a version that was not answered or not readable, or why the grant was not usable. A token that may not read its grant is not asked anything, and keeps its authority.

It is what an accepted revalidation of an HTTP pool entry does, and what the stdio timer does for the process, so an instance upgraded under a running session moves it to that release's verdict without its credential being rebuilt.

func (*Client) SendsBearer added in v3.3.0

func (c *Client) SendsBearer() bool

SendsBearer reports whether every request this client makes carries its token as "Authorization: Bearer", which is what the client of an oauth HTTP mode session does (NewOAuthClientWithToken). A handler asks it when a route refuses some Bearer tokens and not others, so that a refusal there can name the cause GitLab's answer does not.

func (*Client) SetAuthority added in v3.2.0

func (c *Client) SetAuthority(authority *finegrained.Authority)

SetAuthority attaches what this client's credential may do as a fine-grained personal access token, and nil for any other credential.

The client is where it lives because the client is already the per-request carrier of the credential: an HTTP request binds its pool entry's client to the context (WithClient) and every handler reads it back through Client.For, and on stdio the one client serves every request. So every place that decides what a fine-grained session is shown, the listing, the call check, the dispatchers, find and the manifest, reads one mechanism on both transports, and a classic credential's client carries nothing and pays nothing. It is replaced whole, never edited: an finegrained.Authority is immutable, so a reader holds a consistent one however a revalidation races it.

func (*Client) SetEnterprise

func (c *Client) SetEnterprise(v bool)

SetEnterprise marks the client as connected to a Premium/Ultimate instance. It sets the tier to Premium when v is true and Free when v is false; callers needing the Premium/Ultimate distinction should use Client.SetTier. A tier set with Client.PinTier is left as it is, since the edition says less than the pin does.

func (*Client) SetMaxResponseBytes

func (c *Client) SetMaxResponseBytes(n int64)

SetMaxResponseBytes sets how much of a single response body this client will read. A value of zero or less removes the ceiling.

The ceiling applies from the next request onwards; a body already being read keeps the limit it started with.

func (*Client) SetOnRecovered added in v3.2.0

func (c *Client) SetOnRecovered(fn func())

SetOnRecovered registers fn to run each time a lazy re-initialization succeeds (Client.EnsureInitialized); a nil fn clears it. fn runs on the goroutine whose SDK request recovered the client, holding the initialization lock and before that request is sent, so it must be cheap and must not block: a stdio start hands it a send that does not wait, and asks GitLab what it could not ask at startup on a goroutine of its own.

func (*Client) SetOnUnauthorized

func (c *Client) SetOnUnauthorized(fn func(UnauthorizedAnswer))

SetOnUnauthorized registers fn to run when GitLab answers a call made with this client's credential with 401, and says what the 401 named. fn runs on the goroutine that made the call, so it must be cheap and must not block; a nil fn clears it.

The two answers arrive differently, because they mean different things. A 401 naming the credential (UnauthorizedCredential) is GitLab's verdict on it, and reaches fn once for the client's lifetime. A 401 naming nothing (UnauthorizedUnexplained) reaches fn every time: it may be a permission refusal of one call, which says nothing about the next, so it must neither stand for the credential's verdict nor use up the one delivery that verdict gets. Deciding how often to act on it is fn's business.

Only the SDK's requests are reported. The raw probes this client makes outside the SDK are not, since their callers act on the answer themselves.

func (*Client) SetTier

func (c *Client) SetTier(t edition.Tier)

SetTier records the resolved GitLab licensing tier for this client.

func (*Client) Tier

func (c *Client) Tier() edition.Tier

Tier returns the resolved GitLab licensing tier for this client.

func (*Client) TierPinned added in v3.1.0

func (c *Client) TierPinned() bool

TierPinned reports whether the tier was named by the operator rather than detected.

func (*Client) Version added in v3.2.0

func (c *Client) Version() string

Version returns the GitLab version the instance reported the last time /api/v4/version answered this client, and the empty string when it has not answered, refused the credential the permission to ask, or reported something that is not a version.

Only a version [validVersion] accepts is returned, because the string is the instance's: under --allow-any-gitlab-url the instance is one a caller named, and a version a reader prints or matches against a recorded release has to be one, of a bounded length, whoever sent it.

func (*Client) VersionRefusal added in v3.2.0

func (c *Client) VersionRefusal() (string, bool)

VersionRefusal returns GitLab's sentence and true when the instance accepted this client's credential and refused it /api/v4/version for a fine-grained permission the token's grant lacks, and false otherwise. The sentence is the instance's text as sent, so a reader that quotes it bounds and filters it first (PermissionRefusal).

Once seen, the refusal stands until a later read of the version endpoint is answered: Client.DetectEnterprise does not ask while it stands, because a token's grant cannot be changed after it is created. A read that is answered clears it, which is what GitLab's one refusal that can change while a process runs, fine-grained tokens not yet enabled for the token's user, needs once an administrator enables them.

type CredentialCheck added in v3.1.0

type CredentialCheck struct {
	// Verdict is what [Client.CheckCredential] reports.
	Verdict CredentialVerdict
	// Status is the HTTP status GitLab answered the probe with, and 0 when no
	// response arrived.
	Status int
	// Description is GitLab's own sentence for a
	// [CredentialAcceptedPermissionMissing] verdict, as the instance sent it,
	// and empty for every other verdict. It is the instance's text, so a door
	// that quotes it bounds and filters it first.
	Description string
	// Err is nil when GitLab answered with a verdict. Otherwise it says why
	// there was none: the error that stopped the request from being built or
	// answered (a refused connection, a TLS failure, a timeout), or, when a
	// response arrived with a status that is neither verdict, that status.
	Err error
}

CredentialCheck is one credential probe's verdict together with what it was read from, for a caller that has to say why no verdict was reached.

type CredentialVerdict added in v3.1.0

type CredentialVerdict uint8

CredentialVerdict is what GitLab answered when asked whether it accepts a credential. See Client.CheckCredential.

const (
	// CredentialUnanswered means no verdict was obtained: a transport error, a
	// timeout, a 404 from a stubbed instance, a 5xx. It is the zero value,
	// because a question nobody answered must never read as either answer.
	CredentialUnanswered CredentialVerdict = iota
	// CredentialAccepted means GitLab answered the probe with a 2xx.
	CredentialAccepted
	// CredentialRefused means GitLab answered the probe with 401 or 403, other
	// than the two 403s [CredentialAcceptedPermissionMissing] and
	// [CredentialAcceptedBelowMinimum] read.
	CredentialRefused
	// CredentialAcceptedPermissionMissing means GitLab accepted the credential
	// and refused the probe a permission its fine-grained grant lacks: a 403
	// whose body carries [GranularScopeRefusalCode] ([PermissionRefusal]). The
	// token is genuine, since GitLab judges a grant only after it has
	// authenticated the token, and on GET /api/v4/user the grant it lacks is
	// User: Read.
	CredentialAcceptedPermissionMissing
	// CredentialAcceptedBelowMinimum means GitLab accepted the credential and
	// refused the probe for want of a scope: a 403 whose body carries the RFC
	// 6750 code insufficient_scope. The token is genuine, since GitLab's API
	// guard checks a token's scopes only after it has found the token, and on
	// GET /api/v4/user it carries none of api, read_api and read_user, so it
	// is below the read_api minimum every door admits at ([MeetsMinimum]).
	CredentialAcceptedBelowMinimum
)

type CurrentUserInfo

type CurrentUserInfo struct {
	UserID   int
	Username string
}

CurrentUserInfo holds the identity of the authenticated GitLab user.

type GranularRefusal added in v3.2.0

type GranularRefusal struct {
	// Kind is which sentence it is.
	Kind GranularRefusalKind
	// TokenType is the token class as the sentence names it: singular in a
	// [GranularRefusalMissingPermissions] sentence ("personal access token")
	// and plural in the other two ("personal access tokens"), as GitLab
	// writes it.
	TokenType string
	// Boundary is the kind of boundary a [GranularRefusalMissingPermissions]
	// sentence names the permissions at ("project", "group", "user",
	// "instance", "personal projects"), and empty for the other kinds.
	Boundary string
	// Permissions are the permissions a [GranularRefusalMissingPermissions]
	// sentence lists, as GitLab wrote each ("Merge Request: Approve"): the
	// resource and action of the first assignable permission that expands to
	// the raw one refused, in GitLab's file order and deprecated ones
	// included, so a name here is not always one the token creation page
	// offers. Empty for the other kinds.
	Permissions []string
}

GranularRefusal is one of GitLab's fine-grained refusal sentences, read into its parts.

func ParseGranularRefusal added in v3.2.0

func ParseGranularRefusal(sentence string) GranularRefusal

ParseGranularRefusal reads sentence as one of GitLab's fine-grained refusals and reports which, with its parts. A sentence that is not one of them in full answers GranularRefusalUnrecognized and nothing else.

It reads a whole message and never searches inside one: a sentence quoted within a longer text, a validation error echoing a title among them, is not GitLab refusing the call. Leading and trailing white space is ignored. The token type and the boundary must each be lower-case words separated by single spaces, which is what ActiveModel's human name and GitLab's boundary labels are, and a listed permission may be anything but empty.

Nothing here bounds what it returns beyond the two labels: the permissions are the instance's text, and a reader that quotes them bounds and filters them first.

func ParseGraphQLGranularRefusal added in v3.2.0

func ParseGraphQLGranularRefusal(message string) GranularRefusal

ParseGraphQLGranularRefusal is ParseGranularRefusal for the message of one GraphQL errors[] entry, which is where the service's fourth answer can be read too: a message that is exactly GranularNotFound answers GranularRefusalNotFound. The caller has established that message is one entry's whole message and nothing else.

type GranularRefusalKind added in v3.2.0

type GranularRefusalKind uint8

GranularRefusalKind names which of the sentences GitLab's fine-grained authorization writes a refusal is.

GitLab refuses a fine-grained token in one service, Authz::Tokens::AuthorizeGranularScopesService (app/services/authz/tokens/authorize_granular_scopes_service.rb at v19.4.1-ee), and the service has four answers. Three are sentences, which the REST API guard sends as the error_description of a 403 carrying GranularScopeRefusalCode (lib/api/api_guard.rb) and a GraphQL mutation sends as one errors[] entry (lib/gitlab/graphql/authz/ granular_scope_authorization.rb, authorize!). They differ in what the caller can do about them, which is why a reader tells them apart rather than answering all three with one sentence. The fourth is GranularNotFound.

const (
	// GranularRefusalUnrecognized is a sentence none of the three below is.
	// It is the zero value, so a sentence nobody parsed is never read as one
	// GitLab wrote.
	GranularRefusalUnrecognized GranularRefusalKind = iota
	// GranularRefusalMissingPermissions is access_denied_error: "Access
	// denied: This operation requires a fine-grained <token type> with the
	// following <boundary> permissions: [<Resource>: <Action>, ...]." The
	// token lacks the permissions it lists, and a token's grant cannot be
	// changed after it is created.
	GranularRefusalMissingPermissions
	// GranularRefusalUnsupported is missing_inputs_error: "Access denied: This
	// operation doesn't support fine-grained <token type>s." GitLab declares
	// no fine-grained permission for the operation, which is what every route
	// without a declaration, and every one GitLab marks as still to do,
	// answers (lib/api/helpers.rb resolves no permission for either), so no
	// fine-grained token reaches it on this instance.
	GranularRefusalUnsupported
	// GranularRefusalDisabled is disabled_error: "Access denied: Fine-grained
	// <token type>s are not yet supported." The feature flag
	// granular_personal_access_tokens is off for the token's user, so every
	// call the token makes is refused.
	GranularRefusalDisabled
	// GranularRefusalNotFound is the service's fourth answer,
	// [GranularNotFound], read from a GraphQL errors[] entry: the object the
	// operation names was not found, or is outside what the token may see.
	// [ParseGranularRefusal] never answers it, because the text is GitLab's
	// ordinary 404 everywhere else; [ParseGraphQLGranularRefusal] does.
	GranularRefusalNotFound
)

type ResponseCapture

type ResponseCapture struct {
	// contains filtered or unexported fields
}

ResponseCapture holds the body of the last response a request made under WithResponseCapture received, so that a handler can read from it a field GitLab sends and client-go's struct does not model, from the same bytes the SDK decoded, without a second request and without spelling the route itself.

It exists because the 1:1 surface has no room for a field GitLab sends and this server drops, and client-go models most of the API but not all of it: a note's `imported_from`, a member's `membership_state`, a key's `expires_at`. Before this the answer was a request of the handler's own, which duplicates the route client-go already spells and, for a list, its pagination too. A capture leaves every request as it was and adds one decode of bytes already in memory.

Safe for use from one goroutine at a time, which is what a handler is; the transport records under a lock because a retried request answers more than once, and the last answer is the one the SDK decoded.

func CapturedBody

func CapturedBody(body []byte) *ResponseCapture

CapturedBody returns a capture already holding body, as if a request made under it had been answered with it. It is for a test of a reader that decodes captures, which would otherwise need a transport to hand it one.

func WithResponseCapture

func WithResponseCapture(ctx context.Context) (context.Context, *ResponseCapture)

WithResponseCapture returns a context under which the SDK client records the body of every response it receives, and the capture to read it from. Pass the context to the SDK call with gl.WithContext.

func (*ResponseCapture) Decode

func (c *ResponseCapture) Decode(v any) error

Decode unmarshals the captured body into v, the way the SDK unmarshalled it into its own struct. A body that does not decode is an error a handler reports rather than swallows: the SDK decoded the same bytes, so the fault is in the type v names.

type TokenFacts added in v3.2.0

type TokenFacts struct {
	// Scopes are the token's scopes, nil when they are not known: the endpoint
	// failed, or does not answer for this kind of token (an OAuth access token,
	// an instance older than 16.0). A fine-grained token's list is the single
	// value [ScopeGranular].
	Scopes []string
	// ID is the token's own id, 0 when it is not known. It is held to read the
	// token's grant ([ReadGrant]) and is never logged.
	ID int64
	// FineGrained is set for a fine-grained personal access token.
	FineGrained bool
	// GrantReadable is set when the token may read its own description, which
	// is the permission (Personal Access Token: Read) its grant is read with
	// too, and its id is known.
	GrantReadable bool
	// KindUnknown is set when nothing has said whether the token is a
	// fine-grained one: the self endpoint gave neither a description nor
	// GitLab's refusal of Personal Access Token: Read ([DetectToken]), it was
	// not asked, or the OAuth verifier's scopes are its own assumption rather
	// than an answer. Such a token is served as a classic one whose scopes are
	// what the rest of these facts say, and its kind is asked again
	// ([RedetectToken]) until GitLab answers, because a fine-grained token
	// served as a classic one is served every action, the ones no grant reaches
	// among them.
	KindUnknown bool
}

TokenFacts is what the personal access token self endpoint says about the token a client authenticates with.

func DescribeToken added in v3.2.0

func DescribeToken(ctx context.Context, client *gl.Client) TokenFacts

DescribeToken is DetectToken for a caller whose verdict an unanswered description cannot change, which therefore logs nothing about it: a stdio start whose version GitLab refused for want of a scope refuses the catalog whatever the description says, and asks it only to name the token's scopes, so DetectToken's warning that every tool will be registered would say the opposite of what the process does.

func DetectToken added in v3.2.0

func DetectToken(ctx context.Context, client *gl.Client) TokenFacts

DetectToken asks the GitLab personal access token self endpoint what the token the client authenticates with is: its scopes, its id, and whether it is a fine-grained token whose grant it may read.

It is one request, the one scope detection has always made. A 403 carrying insufficient_granular_scope is an answer and not a failure: the route's boundary is the user and names no root namespace, so no enforcement of fine-grained tokens applies to it, and only a fine-grained token is refused a grant there. Such a token lacks Personal Access Token: Read, so its grant cannot be read, and it is reported with its one scope and no id. Any other failure is reported as nothing known, its kind included (TokenFacts.KindUnknown), which serves every tool, as a failed scope detection always has (ADR-0018), until the kind is asked again.

The log line names the scopes and whether the token is fine-grained, never its id.

func FactsFromScopes added in v3.2.0

func FactsFromScopes(scopes []string, id int64) TokenFacts

FactsFromScopes builds the token facts of a token whose scopes and id were read elsewhere, by the OAuth verifier's introspection of the same token on the same instance, with the one rule DetectToken applies: the kind from the scope list, and the grant readable when the token is fine-grained and its id is known.

func RedetectToken added in v3.2.0

func RedetectToken(ctx context.Context, client *gl.Client) TokenFacts

RedetectToken is DetectToken for a token whose kind an earlier read left unknown, which a caller asks again on every revalidation round, on the stdio timer and once a degraded stdio start recovers, until GitLab answers. Its failure is logged at DEBUG rather than WARN, since the first one was already a warning and the rounds can repeat for as long as the instance does not answer. A round whose context was cancelled is not logged at all: the process ended it, which says nothing about whether GitLab answers, and a line written then would outlive whatever ended it.

type UnauthorizedAnswer added in v3.1.0

type UnauthorizedAnswer uint8

UnauthorizedAnswer is what a 401 GitLab answered says about the credential it refused.

GitLab answers 401 for two different things, and they must not be acted on alike. Its API guard answers it for a credential it cannot use, and at a family of REST routes GitLab's API helper unauthorized! (lib/api/helpers.rb) answers it for a valid credential that lacks a permission (entry 55 of docs/development/upstream-bugs.md). The first is a reason to stop serving the credential; the second is an ordinary refusal of one call, and ending the caller's subscriptions over it tells them to re-authenticate a token that works.

const (
	// UnauthorizedUnexplained is a 401 that names no cause. GitLab answers a
	// permission refusal and a token it no longer finds with the same bytes,
	// so the answer alone cannot say which it was, and whoever acts on it has
	// to ask again. It is the zero value on purpose: an answer nobody
	// classified is read the way that asks rather than the way that acts.
	UnauthorizedUnexplained UnauthorizedAnswer = iota
	// UnauthorizedCredential is a 401 GitLab said was about the credential
	// itself, which [UnauthorizedNamesCredential] decides.
	UnauthorizedCredential
)

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL