Bridge UI
The bridge is a backend-for-frontend HTTP server embedded in the operator. It exposes CRUD over the GitLab custom resource and serves a single-page application (SPA) to configure GitLab instances. The bridge is disabled by default.
This page describes how to enable the bridge, create a service account for a caller, mint a token, and reach the UI. For the internal architecture and how to work on the code, see internal/bridge/CLAUDE.md.
Authentication model
The bridge uses caller-identity delegation, like the old Kubernetes Dashboard. The bridge does not
use the operator service account for API calls. Instead, every request to /api must carry a bearer
token:
This applies to the bridge running in the cluster. The kubectl bridge plugin runs the bridge on
your machine and authenticates with your kubeconfig instead, so it needs no bearer token. For more
information, see Run the bridge as a kubectl plugin.
Authorization: Bearer <token>The bridge builds a per-request Kubernetes client from that token. It then forwards the call to the
Kubernetes API server, so the API server handles authentication and authorization. The token’s own
RBAC decides what the caller can do. A caller with no token receives a 401 response. An action the
caller cannot perform receives a 403 response.
As a result, each caller needs their own RBAC on the GitLab custom resource
(gitlabs.apps.gitlab.com). The following sections create a service account with those permissions
and mint a token for it.
Build the bridge
The bridge is gated behind the bridge Go build tag, so it is absent from public operator
images. Only a build produced with -tags bridge contains the bridge server and its SPA. CI
publishes these as separate images with a -bridge tag suffix: <branch-ref-slug>-bridge on branch
and merge request pipelines, and latest-bridge on the default branch. Release tags never produce a
bridge image.
Build a bridge image locally with the dedicated task or Dockerfile:
CONTAINER_CLI=docker task docker-build-bridge # tags <image>:<TAG>-bridge
# or: docker build -f Dockerfile.bridge -t <image>:<tag>-bridge .For a local operator process, build with the tag:
go build -tags bridge -o bin/manager ./cmd/managerEnable the bridge
Enabling requires a bridge build (above); ENABLE_BRIDGE has no effect in a public image because
the bridge is not compiled in. Set ENABLE_BRIDGE=true, which the operator reads in
controllers/settings/settings.go. The bind address
defaults to :8090. To change it, set BRIDGE_BIND_ADDRESS.
To enable the bridge in-cluster, deploy a
-bridgeimage and set the chart valuebridge.enabled=true. The manager Deployment then injectsENABLE_BRIDGEandBRIDGE_BIND_ADDRESSand opens the container port.export HELM_CHARTS=$(pwd)/charts CHART_VERSION=$(head -n1 CHART_VERSIONS) ARGS='--set bridge.enabled=true --set image.tag=latest-bridge' task deploy_operatorTo enable the bridge locally, set the variable when you run the tagged binary:
export HELM_CHARTS=$(pwd)/charts CHART_VERSION=$(head -n1 CHART_VERSIONS) ENABLE_BRIDGE=true go run -tags bridge ./cmd/manager
Confirm the server started:
kubectl -n gitlab-system logs deploy/gitlab-controller-manager | grep bridge
# -> "starting bridge server","addr":":8090"The chart exposes only a container port for the bridge, with no Service or Ingress. Reach it
with kubectl port-forward. Do not expose it publicly while it is a proof of concept.
Install the v2alpha1 custom resources
The apps.gitlab.com/v2alpha1 resources that
ADR 26 designs are not part of the Helm chart.
The chart is what produces the release manifests and the OLM bundle, so leaving them out keeps them
out of everything a user installs.
Install them in a development cluster:
task install_v2alpha1_crdsThe task acts on the current kubectl context. Check it first, because a development cluster can
hold a real GitLab instance. It reads $NAMESPACE and $NAME_OVERRIDE to find the webhook Service,
so use the same values you deployed the Operator with.
The task adds three definitions, each with v2alpha1 as its only version:
| Definition | Kind |
|---|---|
gitlabcores.apps.gitlab.com | GitLabCore |
orbits.apps.gitlab.com | Orbit |
datainsightplatforms.apps.gitlab.com | DataInsightPlatform |
GitLabCore is a definition of its own, not a second version of GitLab. A definition carries one
kind across all of its versions, so a differently named kind needs a definition of its own. The
existing gitlabs.apps.gitlab.com definition is untouched, keeps v1beta1 as its only served and
stored version, and needs no conversion webhook.
Nothing converts a GitLab into a GitLabCore. Kubernetes converts only between versions of one
definition, so an instance created through GitLab stays there. To work with the new resource,
start from the
GitLabCore sample.
No controller reconciles GitLabCore, Orbit, or DataInsightPlatform yet. Creating one stores
the object and nothing else happens.
Create a service account and grant access
Create a service account and bind it to a role with the verbs the caller needs on GitLab resources.
This example grants full CRUD. For a read-only caller, drop create, update, patch, and
delete.
kubectl -n gitlab-system create serviceaccount bridge-user
kubectl create clusterrole gitlab-editor \
--verb=get,list,watch,create,update,patch,delete \
--resource=gitlabs.apps.gitlab.com
kubectl create clusterrolebinding bridge-user \
--clusterrole=gitlab-editor \
--serviceaccount=gitlab-system:bridge-userTo limit the caller to a single namespace, use a Role and RoleBinding instead of the
cluster-scoped variants.
Get a token
Mint a short-lived token for the service account with the TokenRequest API (Kubernetes 1.24 and later):
TOKEN=$(kubectl -n gitlab-system create token bridge-user --duration=1h)A kubeconfig that authenticates with a client certificate or an exec or OIDC plugin cannot be
reduced to a bearer token. In that case, use kubectl create token <service_account> or your OIDC
ID token instead.
Access the bridge
Forward the port, then use the token:
kubectl -n gitlab-system port-forward deploy/gitlab-controller-manager 8090:8090Use
curlwith the token:curl -H "Authorization: Bearer $TOKEN" localhost:8090/api/v1/gitlabsIn the SPA, open http://localhost:8090/, paste the token into the header token field, and select Save token. The SPA attaches the token to every API request and stores it in the browser
localStorage.For the API documentation, open http://localhost:8090/docs, select Authorize, and paste the token to try requests from the documentation UI.
The SPA stores the token in localStorage, which any script on the page can read. This is
acceptable for the current proof of concept with short-lived tokens. Do not treat it as a
production credential store.
Verify the RBAC delegation
To confirm the bridge uses the caller identity rather than the operator identity, use a token whose
service account lacks a verb. For example, a read-only account that attempts a create receives a
403 response:
# A read succeeds.
curl -H "Authorization: Bearer $TOKEN" localhost:8090/api/v1/gitlabs
# A create the caller cannot perform returns HTTP 403.Run the bridge as a kubectl plugin
The kubectl bridge plugin runs the bridge on your own machine instead of in the cluster. The plugin
builds its Kubernetes client from your kubeconfig, so it authenticates the same way kubectl does.
Client certificate, exec, OIDC, and token kubeconfigs all work. You do not create a service account,
mint a token, or paste anything into the UI.
Use the plugin when you want the UI without deploying a bridge image, or when your kubeconfig cannot be reduced to a bearer token.
The plugin performs no authentication of its own. Anyone who reaches the port acts with your kubeconfig permissions. It binds loopback by default. You can bind a routable address, for example to run the plugin in a container, but the plugin prints a warning because that exposes full cluster access to the network.
Loopback keeps other machines out, but not other tabs in your browser: with no token to guess, a page
on any site you have open could otherwise fetch http://127.0.0.1:8090/api/... with your
kubeconfig permissions. As kubectl proxy --accept-hosts does for the same problem, the plugin
answers 403 to /api requests that either:
- Carry a
Hostthat is neither a loopback name (localhost,127.0.0.1,[::1], …) nor one you passed to--accept-hosts. This blocks DNS rebinding, where a name the attacker controls resolves to127.0.0.1so that their page counts as same-origin with the plugin. - Look like a browser fetch made for another origin, judged by
Sec-Fetch-Site,Origin, andReferer.
The SPA’s own requests, the docs UI, and non-browser clients such as curl are unaffected. A
routable address passed to --address is accepted as a host too; reaching the plugin under some
other name (a DNS entry, a container host name) needs --accept-hosts.
Install the plugin
Build and install the binary. The task builds the SPA first, then runs go install, which compiles
the SPA into the binary with go:embed:
task install-kubectl-pluginThe binary goes where go install puts it: $GOBIN when set, otherwise $(go env GOPATH)/bin. The
task prints the resolved path.
That directory must be on your PATH, because kubectl discovers plugins by searching PATH for
executables named kubectl-<name>. Add it to your shell profile if needed:
export PATH="$(go env GOPATH)/bin:$PATH"Confirm kubectl found the plugin:
kubectl plugin list | grep kubectl-bridgeTo build the binary into bin/kubectl-bridge without installing it, use task build-kubectl-plugin.
Start the plugin
Run the plugin against your current kubeconfig context:
kubectl bridgeThe plugin prints the identity it acts as and the URL, then opens the URL in your browser. The header shows Local — kubeconfig identity instead of the token field. Stop the plugin with Control+C.
Plugin flags
| Flag | Default | Description |
|---|---|---|
--port, -p | 8090 | Local port to bind. Falls back to a random free port when the port is busy. |
--address | 127.0.0.1 | Address to bind. IPv6 literals such as ::1 work. A non-loopback address is allowed, with a warning. |
--accept-hosts | Loopback names | Comma-separated Host header names to accept on /api besides loopback ones and the bind address. Needed to reach the plugin under another name. |
--context | Current context | Kubeconfig context to use. |
--kubeconfig | Standard loading rules | Path to the kubeconfig file. |
--no-open | Off | Print the URL instead of opening a browser. |
--verbose, -v | Off | Log startup details and every served request at debug level. |
For example, to use a different context on another port without opening a browser:
kubectl bridge --context staging --port 9000 --no-openFrontend development
To work on the SPA with hot-module reload, run the bridge so :8090 is reachable. Use task run or
a port-forward. Then start the Vite dev server:
task frontend-dev # http://localhost:5173, proxies /api, /openapi*, and /docs to :8090For more information about the frontend workflow and regenerating the OpenAPI document and typed client, see internal/bridge/CLAUDE.md.