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.
What it is, and what it is not
Section titled “What it is, and what it is not”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.
The trust model, in plain words
Section titled “The trust model, in plain words”Three sentences, and every other rule on this page follows from them.
- Your proxy is the trust boundary. It authenticates the person and tells the console who they are, in a header.
- The console impersonates that person. Every apiserver call it makes on
your behalf carries
Impersonate-UserandImpersonate-Groupfor you — it never reads the cluster “as itself” on your behalf. - 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
Identity and access
Section titled “Identity and access”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.
Three settings, three different jobs
Section titled “Three settings, three different jobs”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.
Step 3: grant access
Section titled “Step 3: grant access”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 userHeaderEach 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.
Check it worked
Section titled “Check it worked”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):
$ kubectl auth can-i create snapshots.kopiur.home-operations.com -A \ --as=jane --as-group=Admins --as-group=system:authenticatedyes$ kubectl get clusterrolebindings -l app.kubernetes.io/component=uiallowedGroups never blocks system:authenticated: the console always adds it,
and the chart always allows it.
Enabling it grants nobody anything
Section titled “Enabling it grants nobody anything”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 four roles
Section titled “The four roles”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/v1kind: ClusterRoleBindingmetadata: name: kopiur-ui-userroleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole name: kopiur-ui-usersubjects: # 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/v1kind: RoleBindingmetadata: name: kopiur-ui-browse # ONE namespace. Never a ClusterRoleBinding: that would be every namespace's # repository credentials at once. namespace: mediaroleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole name: kopiur-ui-browsesubjects: - apiGroup: rbac.authorization.k8s.io kind: Group name: media-adminsGranting 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.
The one mitigation RBAC cannot express
Section titled “The one mitigation RBAC cannot express”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.
The proxy shared secret
Section titled “The proxy shared secret”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: v1kind: Secretmetadata: name: kopiur-ui-proxy-token namespace: kopiur-systemtype: OpaquestringData: 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: v1kind: Secretmetadata: name: kopiur-ui-proxy-token namespace: ssotype: OpaquestringData: token: REPLACE_METhe five configurations the chart refuses
Section titled “The five configurations the chart refuses”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:
ui.auth.userHeaderset,ui.auth.proxySecret.existingSecretempty — header mode with nothing proving the headers came from your proxy. Override withui.auth.acknowledgeNoProxySecret: trueonly when something else (a mesh with mTLS, aNetworkPolicy) genuinely closes that hole.- 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.
ui.auth.anonymous.enabledwith noui.auth.anonymous.user— anonymous mode with nobody to be.ui.auth.anonymous.fallbackwith no anonymous identity configured — a header-less request would have nobody to fall back to.- A published Service plus the acknowledgement —
ui.service.typeis notClusterIPwhileui.auth.acknowledgeNoProxySecretis 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).
Anonymous mode
Section titled “Anonymous mode”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”- You open a snapshot’s file browser and press start session. That is a
POST, because it writes to your cluster. - The console creates a
Jobnamedkopiur-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. - Listing a directory or downloading a file
pods/execs kopia inside that pod — as you, not as the console. - 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.
- It expires after
ui.session.ttl(default15m) 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
ClusterRepositorysetsspec.credentialProjection.allowed: true; - the run that took the snapshot opted in, through its
SnapshotPolicy’sspec.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.
What the console never does
Section titled “What the console never does”- It never reads Secrets. Its ClusterRole has no
secretsrule 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/execis not in the console’s own ClusterRole; the exec is performed as you, underkopiur-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:mastersis refused unconditionally, and every othersystem:user or group is rejected exceptsystem:authenticated. - It never logs credentials. Identity middleware strips
Cookie,AuthorizationandX-Forwarded-Access-Tokenbefore any handler sees them, request tracing records no headers, and pod log tails quoted in error responses are redacted ofAWS_/KEY-like tokens.
Deploying it
Section titled “Deploying it”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.
With oauth2-proxy
Section titled “With oauth2-proxy”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: monitoringCreate 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: v1kind: ConfigMapmetadata: name: oauth2-proxy-alpha namespace: ssodata: 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/v1kind: Deploymentmetadata: name: oauth2-proxy namespace: sso labels: app.kubernetes.io/name: oauth2-proxyspec: 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: v1kind: Servicemetadata: name: oauth2-proxy namespace: ssospec: selector: app.kubernetes.io/name: oauth2-proxy ports: - name: http port: 80 targetPort: httpPoint 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/v1kind: Ingressmetadata: name: kopiur-ui namespace: ssospec: 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: httpor, 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/v1kind: HTTPRoutemetadata: name: kopiur-ui namespace: ssospec: parentRefs: - name: external namespace: gateway sectionName: https hostnames: - backups.example.com rules: - backendRefs: - name: oauth2-proxy port: 80Then bind roles — Step 3.
With Envoy Gateway
Section titled “With Envoy Gateway”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:
- The groups header is base64 JSON, because Envoy encodes every list claim
that way. Hence
groupsFormat: base64Json. - Envoy injects the shared token itself, from a Kubernetes Secret, with an
HTTPRouteFilter(credentialInjection) that theHTTPRoutereferences. 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: proxyThe 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: v1kind: Secretmetadata: name: kopiur-ui-oidc namespace: kopiur-systemtype: OpaquestringData: 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: v1kind: Secretmetadata: name: kopiur-ui-proxy-token namespace: kopiur-systemtype: OpaquestringData: credential: REPLACE_METhe 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/v1kind: HTTPRoutemetadata: name: kopiur-ui namespace: kopiur-systemspec: 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/v1alpha1kind: HTTPRouteFiltermetadata: name: kopiur-ui namespace: kopiur-system # the route's namespace, and the Secret'sspec: 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/v1alpha1kind: SecurityPolicymetadata: name: kopiur-ui-oidc namespace: kopiur-systemspec: 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-emailOlder Envoy Gateway: token in the route
Section titled “Older Envoy Gateway: token in the route”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/v1kind: HTTPRoutemetadata: name: kopiur-ui namespace: kopiur-systemspec: 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 aboveWhich values you will actually change
Section titled “Which values you will actually change”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. |
Troubleshooting, by problem type
Section titled “Troubleshooting, by problem type”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. |
The console pod will not become Ready
Section titled “The console pod will not become Ready”/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. |
See also
Section titled “See also”- RBAC reference — every rule the console’s ServiceAccount and the four human roles carry.
- Helm chart values — the full
ui.*surface. - Inspecting & browsing snapshots — the same session-pod
mechanism from the CLI, including
--local. - Web UI (kopia server) — kopia’s own UI, a different thing.