Skip to content

Web console (kopiur-ui)

kopiur-ui is an optional in-cluster web console for Kopiur: a fleet view of your repositories, policies, schedules, snapshots, restores, maintenance and replication, plus the actions you would otherwise run with kubectl kopiur — take a snapshot now, start a restore, suspend a schedule, request maintenance, browse the files inside a snapshot.

It is off by default. Turn it on with ui.enabled: true.

It is a view onto Kopiur’s Kubernetes objects, rendered for people who would rather not read YAML, and a small set of actions that create or patch those same objects.

It is not an authentication system. It has no login page, no user database, no password, no session cookie of its own. It learns who you are from a header that your authenticating proxy sets, and then it asks Kubernetes what you are allowed to do. Everything it shows you and everything it does on your behalf is a request the apiserver authorized as you.

It is not a second permission model, either. There is no admin flag, no role dropdown, no per-user config inside the console. Access is Kubernetes RBAC and nothing else, so the same bindings that govern kubectl govern the console.

Three sentences, and every other rule on this page follows from them.

  1. Your proxy is the trust boundary. It authenticates the person and tells the console who they are, in a header.
  2. The console impersonates that person. Every apiserver call it makes on your behalf carries Impersonate-User and Impersonate-Group for you — it never reads the cluster “as itself” on your behalf.
  3. Kubernetes decides. The apiserver authorizes the impersonated request. If you are not bound to a role, you get a refusal; the console cannot overrule it and does not try.

The consequence is worth stating on its own, because it is the thing people get wrong: anything that can reach the console’s port and set the identity header is whoever that header says. That is why the console requires a shared secret from the proxy, why the chart renders no Ingress, and why the Service is ClusterIP.

flowchart LR
  U[Person in a browser] -->|TLS| I[Your Ingress / HTTPRoute]
  I --> P[Your auth proxy<br/>oauth2-proxy, Envoy Gateway, ...]
  P -->|user + groups headers<br/>X-Kopiur-Proxy-Token| C[kopiur-ui<br/>ClusterIP :8090]
  C -->|Impersonate-User<br/>Impersonate-Group| A[kube-apiserver]
  A -->|allowed / forbidden| C

This is the part that takes the most care to set up, so it gets the most care here. Getting someone from “signed in to my IdP” to “allowed to take a snapshot in the console” involves five hops, and each one is controlled by exactly one setting:

flowchart TB
  IdP["1. Your IdP<br/>(Authelia, Keycloak, Google, ...)<br/>issues a token with the user's groups"]
  P["2. Your proxy<br/>(oauth2-proxy, Envoy Gateway, ...)<br/>copies user + groups into headers"]
  R["3. kopiur-ui reads the headers<br/>ui.auth.userHeader / groupsHeader<br/>ui.auth.groupsFormat"]
  F["4. kopiur-ui filters the groups<br/>ui.auth.allowedGroups (optional)"]
  K["5. Kubernetes RBAC decides<br/>ui.rbac.bindings, or your own ClusterRoleBinding"]
  IdP --> P --> R --> F --> K

If a hop is misconfigured, everything after it gets the wrong input, and the symptom usually shows up at hop 5 as Forbidden. So when access is wrong, work through the hops in order — Check it worked does exactly that.

Most confusion comes from mixing these up:

Job Setting What it does What it does not do
Read the groups ui.auth.groupsHeader, ui.auth.groupsFormat (+ groupsSeparator) Tells the console which header holds the groups and how that header is encoded. Grant or restrict anything.
Restrict the groups ui.auth.allowedGroups Optional. Drops every group not on the list before it reaches Kubernetes. Use it when your IdP sends dozens of groups and only a few matter. Grant access. Putting Admins here gives Admins nothing.
Grant access ui.rbac.bindings, or a ClusterRoleBinding you write Says what a group (or user) may do: view, operate, or both. — this is the only setting that grants anything.

So “how do I let my Admins group use the console?” is answered by the third row, not the second:

ui:
rbac:
bindings:
- role: user # view + operate backups
groups: [Admins]

Step 1: tell the console how your proxy sends identity

Section titled “Step 1: tell the console how your proxy sends identity”

Set ui.auth.userHeader and ui.auth.groupsHeader to the header names your proxy uses, and ui.auth.groupsFormat to how it encodes the group list:

Your proxy Typical headers groupsFormat What the groups header looks like
oauth2-proxy X-Forwarded-User, X-Forwarded-Groups delimited (the default), groupsSeparator: "," Admins,Domain Users
Envoy Gateway SecurityPolicy (claimToHeaders) whatever you name them, e.g. x-user-username, x-user-groups base64Json WyJBZG1pbnMiLCJEb21haW4gVXNlcnMiXQ==
A proxy that sends a raw JSON array — json ["Admins","Domain Users"]
A proxy that uses another separator — delimited, groupsSeparator: ";" Admins;Domain Users

Group names are used exactly as sent: case-sensitive, and spaces inside a name (Domain Users) are kept. Surrounding whitespace is trimmed.

The header names must also be ones only your proxy can set. That is what the shared token is for — see The proxy shared secret.

Complete, working wiring for both common proxies is in Deploying it.

Step 2: optionally, restrict which groups count

Section titled “Step 2: optionally, restrict which groups count”

Leave ui.auth.allowedGroups empty unless you have a reason. Set it, and:

  • every group not on the list is dropped before the console talks to Kubernetes (the user menu has a collapsed N groups not forwarded line that opens to their names, so it is never silent);
  • the same list is pinned into the console’s own RBAC, so Kubernetes refuses any other group even if the console’s parsing were somehow fooled.

Every group you bind in step 3 must also be on this list, or it is dropped before Kubernetes can match the binding. The chart refuses to render a ui.rbac.bindings entry that would be dropped this way.

Nobody can use the console until you bind a role — see Enabling it grants nobody anything. Do it in your values:

ui:
rbac:
bindings:
- role: user # view + operate: snapshots, restores, suspend, maintenance
groups: [Admins]
- role: viewer # read-only
groups: [Domain Users]
users: [alice] # one person, by the name your proxy puts in userHeader

Each entry becomes one ClusterRoleBinding to the role <release>-ui-<role> — kopiur-ui-user when the Helm release is named kopiur, as everywhere on this page. The roles are described in The four roles. browse is deliberately not offered here — it grants repository credentials, so you bind it by hand, per namespace.

Or write the ClusterRoleBinding yourself, if you keep RBAC outside the Kopiur release — The four roles has the manifest.

Open the console and click your name at the bottom of the sidebar. The panel shows exactly what the console read and what Kubernetes allows:

What you see What it means Fix
Groups lists your IdP groups, and actions are permitted Everything works. —
Groups shows one long unreadable value, e.g. WyJEb21h… The groups header is base64 JSON but is being read as delimited. ui.auth.groupsFormat: base64Json (Envoy Gateway).
Groups shows only system:authenticated No groups arrived. Either the proxy is not sending the groups header, or ui.auth.groupsHeader names a different header. Check the header name; request the groups scope from your IdP.
N groups not forwarded (open it) names the group you expected It arrived, but ui.auth.allowedGroups dropped it. Add it to allowedGroups, or empty allowedGroups.
Groups look right, but every screen says Forbidden No role is bound to those groups — or one was just added: permission answers are cached for up to ui.limits.sarTtl (60s). Add a ui.rbac.bindings entry (step 3), wait a minute, reload.
Every request fails with invalid-header-value A header is not in the format configured. The error names the header and the format. Set groupsFormat to what your proxy sends (step 1).

To check the Kubernetes side without the console, ask the apiserver directly as the identity the console will impersonate (substitute your user and groups; system:authenticated is always added):

Terminal window
$ kubectl auth can-i create snapshots.kopiur.home-operations.com -A \
--as=jane --as-group=Admins --as-group=system:authenticated
yes
$ kubectl get clusterrolebindings -l app.kubernetes.io/component=ui

allowedGroups never blocks system:authenticated: the console always adds it, and the chart always allows it.

This is the first surprise, and it is deliberate.

Installing the console creates the roles; it binds nobody to them unless you list people in ui.rbac.bindings. Who may operate your backups is not a chart default. So without that, the first person to sign in sees a console that loads and then reports Forbidden on every screen.

That is the correct first experience, not a broken install. Fix it by binding a role — Step 3. A console that worked for the first person through the door would be a console that worked for everyone who could reach the proxy.

The chart renders the first three roles while ui.enabled and ui.rbac.userRoles are both true, and kopiur-ui-browse only with ui.rbac.browseRole: true. Bind the first three with ui.rbac.bindings, or by hand; kopiur-ui-browse is always bound by hand.

The names assume the Helm release is called kopiur. For another release name they are <release>-ui-viewer, <release>-ui-editor, <release>-ui-user and <release>-ui-browse — check with kubectl get clusterroles | grep ui-.

Role What it grants Bind it
kopiur-ui-viewer get/list/watch on the nine Kopiur CRDs, get/list on CRDs, list on events. Read-only. ClusterRoleBinding
kopiur-ui-editor The action buttons: create/delete Snapshot, create Restore, patch on the seven patched kinds, list PVCs. ClusterRoleBinding
kopiur-ui-user Both of the above, by aggregation. This is the one to bind for an operator. ClusterRoleBinding
kopiur-ui-browse The in-snapshot file browser. Not aggregated into kopiur-ui-user — see the warning below. RoleBinding, per namespace

There is also a small namespaced kopiur-ui-doctor Role in the operator’s own namespace, which lets doctor’s “controller running” check list the operator Deployment. Without it that one check degrades to a warning; nothing else changes.

Bind kopiur-ui-user cluster-wide, not per namespace: the console is a fleet view, and a viewer scoped to one namespace would report a healthy cluster while another namespace burned.

---
# WHO MAY USE THE CONSOLE. Until one of these exists, everyone who signs in gets
# "Forbidden" on every screen — the console grants nothing by itself, and that
# first experience is correct, not a bug.
#
# `kopiur-ui-user` cluster-wide: the console is a fleet view, and a viewer who
# could see only one namespace would report a healthy cluster while another
# namespace burned.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: kopiur-ui-user
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: kopiur-ui-user
subjects:
# Matched against X-Forwarded-Groups, which your proxy fills from the IdP's
# `groups` claim. Swap for `kind: User` to name one person.
- apiGroup: rbac.authorization.k8s.io
kind: Group
name: platform
---
# THE FILE BROWSER, AND WHAT IT REALLY GRANTS.
#
# Browsing a snapshot runs a session pod and `pods/exec`s kopia into it. That
# pod loads the repository credentials from its own environment, and RBAC cannot
# narrow `pods/exec create` to one pod. So binding this role in a namespace
# gives the subject that namespace's repository credentials: they can exec into
# the session pod and print them.
#
# Bind it per namespace, to people you would hand those credentials to anyway.
# Requires `ui.rbac.browseRole: true` in the values above.
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: kopiur-ui-browse
# ONE namespace. Never a ClusterRoleBinding: that would be every namespace's
# repository credentials at once.
namespace: media
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: kopiur-ui-browse
subjects:
- apiGroup: rbac.authorization.k8s.io
kind: Group
name: media-admins

Granting browse grants that namespace’s repository credentials

Section titled “Granting browse grants that namespace’s repository credentials”

This is why the role is shipped separately instead of being folded into kopiur-ui-user: a grant with that consequence has to be a deliberate act, not something that arrives with the read-only role.

The same is true of kubectl kopiur ls/cat/download — see Inspecting & browsing snapshots. It is the same session-pod mechanism and the same blast radius.

ui.rbac.execPolicy.enabled: true renders a ValidatingAdmissionPolicy that narrows pods/exec CONNECT, for the subjects you list, to pods named kopiur-browse-* running /usr/local/bin/kopia. That turns the console’s closed command set into one the apiserver enforces.

It stops env and a shell. It does not stop someone from running kopia commands against that repository, and it does not un-grant anything above. Treat it as a useful narrowing, not as permission to bind browse more widely.

In header mode the console additionally requires a shared token, sent by the proxy as X-Kopiur-Proxy-Token and compared in constant time against ui.auth.proxySecret.

The reason is the trust model’s consequence again. Identity headers are only trustworthy if nothing else can set them, and in a cluster plenty of things can: any pod that can reach the Service, and anyone who can use the apiserver’s services/proxy subresource. Without a token, such a caller sends X-Forwarded-User: alice and is authorized as Alice — including as an Alice bound to kopiur-ui-editor.

You create the Secret and configure the proxy to send it; the chart only mounts it.

---
# The shared token. YOU create it; the chart only mounts it. Generate something
# long and random, e.g. `openssl rand -hex 32`.
apiVersion: v1
kind: Secret
metadata:
name: kopiur-ui-proxy-token
namespace: kopiur-system
type: Opaque
stringData:
token: REPLACE_ME
---
# The same value, in the proxy's namespace, so the proxy can send it. One value,
# two namespaces: the console reads it from a mounted file, the proxy from an
# environment variable.
apiVersion: v1
kind: Secret
metadata:
name: kopiur-ui-proxy-token
namespace: sso
type: Opaque
stringData:
token: REPLACE_ME

Each of these renders a console that works, and quietly lets the wrong person act as someone else. So the chart fails at render time rather than shipping them, and each message names the value to set:

  1. ui.auth.userHeader set, ui.auth.proxySecret.existingSecret empty — header mode with nothing proving the headers came from your proxy. Override with ui.auth.acknowledgeNoProxySecret: true only when something else (a mesh with mTLS, a NetworkPolicy) genuinely closes that hole.
  2. No identity source at all — neither a header nor an anonymous identity. Every request would then run as the console’s own ServiceAccount, which can impersonate anyone.
  3. ui.auth.anonymous.enabled with no ui.auth.anonymous.user — anonymous mode with nobody to be.
  4. ui.auth.anonymous.fallback with no anonymous identity configured — a header-less request would have nobody to fall back to.
  5. A published Service plus the acknowledgement — ui.service.type is not ClusterIP while ui.auth.acknowledgeNoProxySecret is true and a user header is configured. That is an identity-header console on the network with nothing to prove a request came from your proxy.

The chart also refuses three configurations that would be inert rather than unsafe: ui.enabled with installScope: namespaced (impersonation and the console’s cluster-wide reads cannot be expressed in a namespaced Role, so it would 403 on its first request), ui.networkPolicy.enabled with an empty proxySelector (a policy admitting no peer), and ui.rbac.execPolicy.enabled with no subjects (a policy matching nobody while appearing to restrict exec).

Set ui.auth.anonymous.enabled: true with a user (and optionally groups) and every request runs as that one fixed identity, with no header read at all. The console’s impersonate rule is then pinned by name to exactly that identity, so it cannot become anyone else even if header parsing were fooled.

This is legitimate in two situations:

  • An outer layer has already authenticated everyone identically — a single-user homelab behind an authenticating reverse proxy that does not forward identity, say. Everyone who gets through is the same person anyway.
  • A read-only demo or kiosk, where the fixed identity is bound only to kopiur-ui-viewer.

It is not legitimate as a shortcut past configuring your proxy: bind that identity to kopiur-ui-editor and everyone who reaches the console can delete snapshots as one anonymous user, with nothing in the audit trail to tell them apart.

ui.auth.anonymous.fallback: true is the third mode: header mode that serves a header-less request as the anonymous identity instead of answering 401. It is off by default and never implicit, because a silent downgrade from “the proxy said who you are” to “everyone is this fixed user” is precisely the failure the flag exists to make deliberate. The kopiur_ui_requests_total{identity_source=...} metric makes downgrades visible.

How a browse session works, and what it costs

Section titled “How a browse session works, and what it costs”
  1. You open a snapshot’s file browser and press start session. That is a POST, because it writes to your cluster.
  2. The console creates a Job named kopiur-browse-<repo>-<hash> in the snapshot’s namespace, running the same mover image the operator uses, which connects to the repository read-only and idles.
  3. Listing a directory or downloading a file pods/execs kopia inside that pod — as you, not as the console.
  4. The session is keyed by repository and shared: a second browser tab, or another person browsing the same repository, attaches to the same pod rather than starting a second one.
  5. It expires after ui.session.ttl (default 15m) idle, and the cluster garbage-collects the finished Job shortly after. Stop session ends it immediately.

The costs, so nothing is a surprise: one pod and one repository connection per repository being browsed, for as long as someone is browsing plus the TTL; a Job object in the snapshot’s namespace; and a first-request delay of a few seconds while the pod schedules and connects (ui.session.readyTimeout, default 300s, bounds that wait).

Reads never start a session. GET …/tree on a snapshot with no session answers 409 and tells you to start one — creating a pod is a real cost and takes a deliberate action.

Browsing a ClusterRepository from another namespace

Section titled “Browsing a ClusterRepository from another namespace”

The session pod loads the repository’s credentials from its own namespace, because a pod cannot load a Secret from anywhere else. A ClusterRepository usually keeps its Secret in the operator’s namespace, so for a snapshot in an app namespace the session asks for a copy, and the operator writes it. The console and the person browsing never touch the Secret.

The operator copies it only under the gates a backup’s credential projection passes, checked again against the live objects:

  • the ClusterRepository sets spec.credentialProjection.allowed: true;
  • the run that took the snapshot opted in, through its SnapshotPolicy’s spec.credentialProjection.enabled: true (a snapshot taken before that stays unbrowsable this way: browsing never widens what the backup was allowed);
  • the snapshot’s namespace is in the repository’s allowedNamespaces;
  • the chart grants the operator Secret writes (features.credentialProjection.enabled).

The copy is named <session job>-creds-<n> and belongs to the session Job, so it is deleted with the session: when you stop it, when it expires, or with the repository. When a gate is closed the console says which, and the session Job carries a Warning Event saying the same. The operator checks a refused session again every 15 seconds while it lives, so fixing the gate and retrying lets it start without ending the session first. If you would rather not copy credentials at all, kubectl kopiur browse --local reads from your own machine instead.

Caps, and what a user sees when they hit one

Section titled “Caps, and what a user sees when they hit one”

The console is bounded everywhere it fans out, so one person cannot exhaust it for everyone. Each cap answers with a specific problem document rather than a timeout or a crash.

Value Default What happens at the limit
ui.session.maxExecPerIdentity 4 429 too-many-requests naming the per-identity cap.
ui.session.maxExecGlobal 64 429 too-many-requests naming the process-wide cap.
ui.session.maxStarts 4 Concurrent session creations; further starts queue.
ui.download.maxBytes 1 GiB 413 download-too-large, checked before streaming starts.
ui.download.chunkTimeout 60s A transfer that makes no progress for this long is abandoned. Not a total-transfer budget — a real restore outlives any fixed deadline.
ui.limits.maxManifestBytes 64 MiB 422 directory-too-large (one directory) or catalog-too-large (the snapshot’s catalog). A million-entry directory must fail as a refusal, not an OOM kill.
ui.limits.snapshotListCap 5000 422 list-too-large when a listing’s filter matched more rows than this. A cap rather than a silent truncation — a snapshots table that quietly dropped rows would be read as “these are all my backups”.

ui.limits.sarTtl (default 60s) is how long a SubjectAccessReview answer is reused. Short by design: a revoked binding must stop hiding behind the cache quickly.

  • It never reads Secrets. Its ClusterRole has no secrets rule at all. That is the point of impersonation: credentials reach a session pod through the Job’s environment, never through the console. A copy a session needs is written by the operator, never by the console.
  • It never execs outside a session pod. pods/exec is not in the console’s own ClusterRole; the exec is performed as you, under kopiur-ui-browse.
  • It never creates an Ingress, in any mode. Your edge is yours.
  • It never reads inbound Impersonate-* headers. A caller cannot ask to be impersonated as someone else; only the configured identity headers are read.
  • It never impersonates a system: principal. system:masters is refused unconditionally, and every other system: user or group is rejected except system:authenticated.
  • It never logs credentials. Identity middleware strips Cookie, Authorization and X-Forwarded-Access-Token before any handler sees them, request tracing records no headers, and pod log tails quoted in error responses are redacted of AWS_/KEY-like tokens.

The full worked example is deploy/examples/45-web-ui.yaml. It holds two complete variants — pick the one that matches your proxy:

  • With oauth2-proxy — a separate proxy Deployment in front of the console; groups arrive comma-separated.
  • With Envoy Gateway — Envoy’s own OIDC login, no extra Deployment; groups arrive base64-encoded.

Either way, the proxy is your wiring. Kopiur owns the console Deployment, its ClusterIP Service, the console’s ServiceAccount and impersonation ClusterRole, and the four human roles. You own the proxy, the token Secret, the edge, the TLS certificate, and the decision about who gets which role.

The Helm values:

# ---------------------------------------------------------------------------
# Helm values — `helm upgrade --install kopiur ... -f this-excerpt.yaml`
# ---------------------------------------------------------------------------
ui:
enabled: true
auth:
# Header mode. Your proxy strips these headers from the client and sets them
# itself from the authenticated session.
userHeader: X-Forwarded-User
groupsHeader: X-Forwarded-Groups
# oauth2-proxy sends groups as a plain comma-joined list: `admins,dev`.
groupsFormat: delimited
groupsSeparator: ","
# Display only — never impersonated, never used for authorization.
emailHeader: X-Forwarded-Email
# What makes the headers above trustworthy. The proxy sends this token as
# `X-Kopiur-Proxy-Token`; the console compares it in constant time and
# answers 401 when it does not match.
#
# Without it the chart refuses to render, because any pod in the cluster —
# or anyone who can reach the Service through the apiserver's
# `services/proxy` subresource — could otherwise send `X-Forwarded-User` and
# be authorized as that person.
proxySecret:
existingSecret: kopiur-ui-proxy-token
key: token
rbac:
# The read-only and action roles, aggregated into `kopiur-ui-user`.
userRoles: true
# The in-snapshot file browser. OFF by default, and left off here unless you
# have read what it grants — see the RoleBinding at the end of this file.
browseRole: false
# Only your proxy may reach the app port; only your monitoring namespace may
# reach the ops port (/metrics, /healthz, /readyz). Defence in depth beside
# the shared token, not instead of it.
networkPolicy:
enabled: true
proxySelector:
namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: sso
podSelector:
matchLabels:
app.kubernetes.io/name: oauth2-proxy
monitoringSelector:
namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: monitoring

Create the shared token in both namespaces, as shown in The proxy shared secret. Then the proxy, which logs the user in and sets the headers:

---
# oauth2-proxy's alpha config: it authenticates the user with your IdP, then
# proxies to the console with the two identity headers and the shared token set.
#
# `injectRequestHeaders` REPLACES whatever the client sent under those names, so
# a browser that forges `X-Forwarded-User` cannot get past this.
#
# `secretSource.fromEnv` rather than `secretSource.value`: `value` expects
# base64 and has historically been injected without decoding. An env var has no
# such ambiguity.
apiVersion: v1
kind: ConfigMap
metadata:
name: oauth2-proxy-alpha
namespace: sso
data:
alpha-config.yaml: |
upstreamConfig:
upstreams:
- id: kopiur-ui
path: /
# The console's ClusterIP Service, in the operator's namespace.
uri: http://kopiur-ui.kopiur-system.svc.cluster.local:8090
injectRequestHeaders:
- name: X-Forwarded-User
values:
- claimSource:
claim: user
- name: X-Forwarded-Email
values:
- claimSource:
claim: email
- name: X-Forwarded-Groups
values:
- claimSource:
claim: groups
- name: X-Kopiur-Proxy-Token
values:
- secretSource:
fromEnv: KOPIUR_PROXY_TOKEN
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: oauth2-proxy
namespace: sso
labels:
app.kubernetes.io/name: oauth2-proxy
spec:
replicas: 2
selector:
matchLabels:
app.kubernetes.io/name: oauth2-proxy
template:
metadata:
labels:
app.kubernetes.io/name: oauth2-proxy
spec:
containers:
- name: oauth2-proxy
image: quay.io/oauth2-proxy/oauth2-proxy:v7.14.0
args:
- --http-address=0.0.0.0:4180
- --alpha-config=/etc/oauth2-proxy/alpha-config.yaml
- --provider=oidc
- --oidc-issuer-url=https://sso.example.com
- --email-domain=*
- --cookie-secure=true
# Your IdP must return a `groups` claim for the group bindings below
# to match anything.
- --scope=openid email profile groups
env:
- name: OAUTH2_PROXY_CLIENT_ID
value: REPLACE_ME
- name: OAUTH2_PROXY_CLIENT_SECRET
valueFrom:
secretKeyRef: { name: oauth2-proxy, key: client-secret }
- name: OAUTH2_PROXY_COOKIE_SECRET
valueFrom:
secretKeyRef: { name: oauth2-proxy, key: cookie-secret }
# Injected into X-Kopiur-Proxy-Token by the alpha config above.
- name: KOPIUR_PROXY_TOKEN
valueFrom:
secretKeyRef: { name: kopiur-ui-proxy-token, key: token }
ports:
- name: http
containerPort: 4180
volumeMounts:
- name: alpha-config
mountPath: /etc/oauth2-proxy
readOnly: true
volumes:
- name: alpha-config
configMap:
name: oauth2-proxy-alpha
---
apiVersion: v1
kind: Service
metadata:
name: oauth2-proxy
namespace: sso
spec:
selector:
app.kubernetes.io/name: oauth2-proxy
ports:
- name: http
port: 80
targetPort: http

Point your edge at the proxy, never at the console Service:

---
# Your edge, pointed at the PROXY — never at the console Service. An Ingress
# that targets `kopiur-ui` directly publishes a console that believes whatever
# header the caller sends.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: kopiur-ui
namespace: sso
spec:
ingressClassName: nginx
tls:
- hosts: [backups.example.com]
secretName: kopiur-ui-tls
rules:
- host: backups.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: oauth2-proxy
port:
name: http

or, with Gateway API:

---
# The Gateway API variant of the same thing. Same rule: the backend is the
# proxy, not the console.
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: kopiur-ui
namespace: sso
spec:
parentRefs:
- name: external
namespace: gateway
sectionName: https
hostnames:
- backups.example.com
rules:
- backendRefs:
- name: oauth2-proxy
port: 80

Then bind roles — Step 3.

Envoy Gateway can be the authenticating proxy itself: a SecurityPolicy on the console’s HTTPRoute logs the user in with OIDC (Authelia here; any OIDC provider works) and copies ID-token claims into headers with claimToHeaders. There is no proxy Deployment, and the route points straight at the console.

Two things differ from oauth2-proxy, and both are handled below:

  1. The groups header is base64 JSON, because Envoy encodes every list claim that way. Hence groupsFormat: base64Json.
  2. Envoy injects the shared token itself, from a Kubernetes Secret, with an HTTPRouteFilter (credentialInjection) that the HTTPRoute references. The token stays in the Secret and never appears in the route manifest. This needs Envoy Gateway v1.4 or later; for older versions see the fallback below.

The Helm values, including the bindings that grant Admins and Domain Users their access:

ui:
enabled: true
auth:
# The header names are whatever you choose in claimToHeaders below; these
# must match them. Case does not matter.
userHeader: x-user-username
groupsHeader: x-user-groups
# Envoy sends list claims base64-encoded. See the note above.
groupsFormat: base64Json
emailHeader: x-user-email
# The HTTPRouteFilter below injects X-Kopiur-Proxy-Token on every request
# the route forwards, from this same Secret. Envoy Gateway only reads the
# key `credential`, so the console reads that key too.
proxySecret:
existingSecret: kopiur-ui-proxy-token
key: credential
# OPTIONAL. Only these groups are forwarded to Kubernetes; the rest are
# dropped (the user menu has a collapsed "N groups not forwarded" line
# naming them). This RESTRICTS — it grants nothing. Leave it empty to
# forward every group.
allowedGroups: [Admins, Domain Users]
rbac:
userRoles: true
# WHO MAY DO WHAT. This is the step that grants access: each entry becomes
# one ClusterRoleBinding. Group names exactly as your IdP sends them.
bindings:
# Admins operate backups: view everything, take snapshots, restore,
# suspend, request maintenance.
- role: user
groups: [Admins]
# Everyone else in the directory may look, not touch.
- role: viewer
groups: [Domain Users]
# Only the Envoy proxy pods may reach the console's app port. This selector
# matches Envoy Gateway's default: proxy pods in `envoy-gateway-system`. If
# you run Envoy Gateway in gateway-namespace mode, the proxies run in the
# Gateway's own namespace instead — select that, or every request times out.
# (No monitoringSelector here, so the ops port — /metrics, /healthz,
# /readyz — stays reachable cluster-wide; it serves no user data.)
networkPolicy:
enabled: true
proxySelector:
namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: envoy-gateway-system
podSelector:
matchLabels:
app.kubernetes.io/component: proxy

The shared token. One Secret serves both sides — the console reads it through ui.auth.proxySecret, and Envoy injects it — so its key must be credential, the only key Envoy Gateway reads, and ui.auth.proxySecret.key says so:

---
# The OIDC client credentials Envoy uses to log users in — the client you
# register in your IdP (see the Authelia note in docs/src/content/docs/ui.mdx). Envoy Gateway
# reads `client-id` and `client-secret` from this Secret.
apiVersion: v1
kind: Secret
metadata:
name: kopiur-ui-oidc
namespace: kopiur-system
type: Opaque
stringData:
client-id: kopiur-ui
client-secret: REPLACE_ME
---
# The shared token, in the console's namespace — the same namespace as the
# HTTPRoute and HTTPRouteFilter below. Generate it with `openssl rand -hex 32`.
# ONE Secret serves both sides: the console reads it (ui.auth.proxySecret) and
# Envoy injects it into the header (the HTTPRouteFilter). Envoy Gateway reads
# ONLY the key `credential`, and sends its value as the whole header value —
# so store the bare token, with no "Bearer " in front.
apiVersion: v1
kind: Secret
metadata:
name: kopiur-ui-proxy-token
namespace: kopiur-system
type: Opaque
stringData:
credential: REPLACE_ME

The route, the filter that injects the token, and the login. Each claimToHeaders header must match the corresponding ui.auth.*Header value above:

---
# The route: your Gateway → the console Service directly (Envoy is the proxy).
# Its one filter hands every request to the HTTPRouteFilter below, which sets
# X-Kopiur-Proxy-Token from the Secret. The token itself never appears in this
# manifest.
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: kopiur-ui
namespace: kopiur-system
spec:
parentRefs:
- name: external
namespace: gateway
sectionName: https
hostnames:
- backups.example.com
rules:
- backendRefs:
- name: kopiur-ui
port: 8090
filters:
- type: ExtensionRef
extensionRef:
group: gateway.envoyproxy.io
kind: HTTPRouteFilter
name: kopiur-ui # the HTTPRouteFilter below
---
# Credential injection (Envoy Gateway v1.4+): copy the token from the Secret
# into the header on every request this route forwards. If the Secret or its
# `credential` key is missing, Envoy Gateway answers the route with a 500 —
# it fails closed, never forwarding without the token.
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: HTTPRouteFilter
metadata:
name: kopiur-ui
namespace: kopiur-system # the route's namespace, and the Secret's
spec:
credentialInjection:
# Defaults to Authorization when omitted — set it.
header: X-Kopiur-Proxy-Token
# Defaults to false, which would keep a token the BROWSER sent. true
# replaces it, so only requests that went through this route carry the
# right one.
overwrite: true
credential:
valueRef:
name: kopiur-ui-proxy-token # the Secret above; key `credential`
---
# The login and the identity headers.
#
# `oidc` sends anyone without a session to your IdP and stores the ID token in a
# cookie. `jwt` then validates that cookie on every request and copies claims
# into headers with `claimToHeaders` — each header the console reads must come
# from here.
#
# Envoy writes a header only for a claim the token actually contains — and if
# a claim is missing, a header of that name sent by the BROWSER is not
# replaced. So make sure the ID token always carries `preferred_username`,
# `groups` and `email` (for Authelia: a claims_policy, see docs/src/content/docs/ui.mdx).
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: SecurityPolicy
metadata:
name: kopiur-ui-oidc
namespace: kopiur-system
spec:
targetRefs:
- group: gateway.networking.k8s.io
kind: HTTPRoute
name: kopiur-ui
oidc:
provider:
issuer: https://auth.example.com
clientIDRef:
name: kopiur-ui-oidc # the Secret above, key client-id
clientSecret:
name: kopiur-ui-oidc # the Secret above, key client-secret
redirectURL: https://backups.example.com/oauth2/callback
cookieNames:
idToken: IdToken-kopiur-ui
scopes: [openid, profile, email, groups]
jwt:
providers:
- name: authelia
issuer: https://auth.example.com
remoteJWKS:
uri: https://auth.example.com/jwks.json
# Validate the ID token the oidc block stored, from its cookie.
extractFrom:
cookies: [IdToken-kopiur-ui]
claimToHeaders:
- claim: preferred_username # → ui.auth.userHeader
header: x-user-username
- claim: groups # a list → base64-encoded → groupsFormat: base64Json
header: x-user-groups
- claim: email # → ui.auth.emailHeader (display only)
header: x-user-email

Before v1.4, Envoy Gateway cannot inject a credential from a Secret. Use this route instead of the HTTPRoute and HTTPRouteFilter above; the SecurityPolicy is unchanged. Its RequestHeaderModifier set replaces any X-Kopiur-Proxy-Token the browser sent — but a route filter cannot read a Secret, so the token is written into the HTTPRoute itself. Treat that manifest as a secret (SOPS, or templated from your secret store), and keep the value identical to the Secret’s credential:

---
# FALLBACK for Envoy Gateway older than v1.4, which has no credential
# injection. Use this route INSTEAD OF the HTTPRoute and HTTPRouteFilter above
# (the SecurityPolicy stays as it is). The RequestHeaderModifier `set` REPLACES
# any X-Kopiur-Proxy-Token a browser sent. The cost: a route filter cannot read
# a Secret, so the token is written into this manifest — keep it encrypted
# (SOPS) or templated from your secret store, like any other credential in Git.
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: kopiur-ui
namespace: kopiur-system
spec:
parentRefs:
- name: external
namespace: gateway
sectionName: https
hostnames:
- backups.example.com
rules:
- backendRefs:
- name: kopiur-ui
port: 8090
filters:
- type: RequestHeaderModifier
requestHeaderModifier:
set:
- name: X-Kopiur-Proxy-Token
value: REPLACE_ME # the same value as `credential` in the Secret above

Most of ui.* you can leave alone. These are the ones that matter:

Value Why you would change it
ui.enabled Turn the console on. Requires installScope: cluster.
ui.auth.userHeader / groupsHeader Match what your proxy emits. oauth2-proxy emits X-Forwarded-User / X-Forwarded-Groups.
ui.auth.groupsFormat How the groups header is encoded: delimited (default, oauth2-proxy), base64Json (Envoy Gateway claimToHeaders), or json. See Step 1.
ui.auth.groupsSeparator Only for delimited: , for oauth2-proxy; some proxies use ; or a pipe.
ui.auth.proxySecret.existingSecret The Secret holding your shared token. Required in header mode.
ui.auth.allowedGroups Optional. Restricts which groups are forwarded to Kubernetes; never grants. Enforced twice — the console filters, and the same list is pinned as resourceNames on the impersonate rule.
ui.rbac.bindings Grants access: one entry per role (user, viewer, editor) and the groups/users that get it. Empty by default. See Step 3.
ui.rbac.browseRole The file browser. Read the warning above first.
ui.networkPolicy.enabled + proxySelector Make “only the proxy can reach it” true at the network layer.
ui.session.ttl How long an idle browse session pod lives.
ui.download.maxBytes Raise it if people legitimately download large files through the console.
ui.cache.enabled Leave on. Off means every read is an impersonated LIST, and removes the console’s read grants from its ClusterRole entirely.

Every refusal the console produces is an RFC 7807 document with a stable type URN, and that URN is the thing a user can actually read off a failed request — the browser shows it, and the SPA surfaces it. Find yours here.

type Status What it means, and what to do
urn:kopiur:problem:no-identity 401 No identity header reached the console. The proxy is not setting ui.auth.userHeader, or something is reaching the console without going through the proxy.
urn:kopiur:problem:proxy-secret 401 The identity headers arrived but the shared token was missing or wrong. Check that the proxy sends X-Kopiur-Proxy-Token, and that its value matches the Secret named by ui.auth.proxySecret.
urn:kopiur:problem:forbidden-principal 403 The header named a system: principal (or system:masters). The console refuses these unconditionally. Your proxy is asserting an identity it should not.
urn:kopiur:problem:too-many-groups 400 More than 64 groups in the groups header. Narrow what the proxy forwards, or set ui.auth.allowedGroups.
urn:kopiur:problem:invalid-header-value 400 An identity header could not be used: it carried control or non-ASCII bytes, a value exceeded 512 bytes, or the groups header was not in the configured ui.auth.groupsFormat (the message names the format — usually Envoy Gateway’s base64 groups with groupsFormat left at delimited, or the reverse). See Step 1.
urn:kopiur:problem:forbidden 403 The apiserver refused the impersonated request. This is normal RBAC: bind kopiur-ui-user (or -viewer/-editor) to the user or group — with ui.rbac.bindings or your own binding. If you did, open the user menu and follow Check it worked: the group may not be arriving, or allowedGroups may be dropping it. The why field carries the apiserver’s own words, naming the verb and resource.
urn:kopiur:problem:csrf 403 A mutating request arrived without the X-Kopiur-Request: 1 marker, without a JSON Content-Type, or from another site. If you are scripting against the API, send the header — or use kubectl kopiur, which writes the same CRs.
urn:kopiur:problem:session-required 409 You asked to read inside a snapshot with no browse session running. Start one — reads never create pods.
urn:kopiur:problem:too-many-requests 429 A concurrency cap; the message names which. Retry, or raise ui.session.maxExecPerIdentity / maxExecGlobal.
urn:kopiur:problem:list-too-large 422 The filter matched more rows than ui.limits.snapshotListCap. Narrow by repository or policy, or raise the cap. Nothing was truncated — that is the point.
urn:kopiur:problem:invalid-query 400 A query parameter this endpoint does not accept, usually a typo. Named in the message. A silently-ignored filter would hand you the unnarrowed answer as if it were narrowed.
urn:kopiur:problem:directory-too-large / catalog-too-large 422 The directory listing or snapshot catalog exceeded ui.limits.maxManifestBytes. Raise it, or browse a narrower path.
urn:kopiur:problem:download-too-large 413 The file is larger than ui.download.maxBytes. Raise it, or restore with a Restore CR instead.
urn:kopiur:problem:download-size-unknown 422 kopia reported no size for the entry, so the cap cannot be checked. Not the same as a zero-byte file.
urn:kopiur:problem:kind-not-installed 501 The Kopiur CRDs are missing or outdated on this cluster.
urn:kopiur:problem:timeout 504 A bounded wait expired — most often a browse session pod that was not ready within ui.session.readyTimeout. The work may still be running; retry.
urn:kopiur:problem:upstream 502 The apiserver, the session pod, or kopia failed or was unreachable. Usually retryable. If the why says cannot get resource "pods/exec", it is not retryable and not a missing binding: the bound role grants create on pods/exec but not get, and an exec over WebSocket is a GET upgrade. Use the chart’s kopiur-ui-browse, or add get to your hand-written role.
urn:kopiur:problem:not-wired 500 A kopiur-ui bug: a handler ran without the state it needs. Please report it.

/readyz on the ops port (ui.opsPort, default 8091) names the subsystem holding it back rather than just failing:

The body is ok when it is ready, or not ready: <reason>[, <reason>] when it is not — plus any non-blocking notes in parentheses ((web: placeholder) means the pod is serving a placeholder SPA bundle, which is worth knowing and never worth refusing traffic over).

Reason Meaning
impersonation-unavailable The console’s ServiceAccount may not impersonate on users/groups, so every request would 403 with something the caller cannot fix. Check that ui.rbac rendered the console’s ClusterRole and that nothing trimmed the impersonate rule.
cache-not-ready The reflector stores are still filling. Normal for a few seconds after start, longer on a large cluster.
cache-sync-timed-out The stores did not fill within the startup budget. The pod recovers without a restart if the sync lands later; if it never does, the ServiceAccount probably cannot list/watch the Kopiur kinds.
cache-watch-ended A watch stopped and nothing is refreshing the stores. Restart the pod.