Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -106,11 +106,11 @@ jobs:
# for the whole job.
- name: Load CI image
run: docker load -i "$RUNNER_TEMP/ci-image.tar" && rm -f "$RUNNER_TEMP/ci-image.tar"
# go mod tidy, go vet, golangci-lint, actionlint and hadolint.
- name: task tidy-check lint
# Module and browser-bundle integrity, then the linters, as in task verify.
- name: task tidy-check vendor-check lint
run: |
docker run --rm -v "$GITHUB_WORKSPACE:$CI_WORKDIR" -w "$CI_WORKDIR" \
"$CI_IMAGE" bash -lc 'git config --global --add safe.directory "$PWD" && task tidy-check lint'
"$CI_IMAGE" bash -lc 'git config --global --add safe.directory "$PWD" && task tidy-check vendor-check lint'

test:
name: Unit tests
Expand Down
6 changes: 3 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@
## Prerequisites

Open the repository in its devcontainer. It provides Go, Task, golangci-lint, actionlint,
hadolint, `kubectl` and `k3d`, at the versions CI uses.
hadolint, Node, Helm, `kubectl` and `k3d`, at the versions CI uses.

```bash
task # list every task
task test # go test -race ./...
task test # Go race tests, short fuzz runs, and Node tests for the browser helper
task lint # go vet, golangci-lint, actionlint, hadolint
task test-e2e # a real API server trusting a real Dex; see docs/testing.md
task verify # the whole gate, in CI's order; if this passes, CI passes
task verify # the local gate: the same checks CI runs in separate jobs
```

## Design rules
Expand Down
34 changes: 22 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ krm-foyer is a backend for frontend (BFF) for browser applications built on Kube
APIs. It owns OIDC login and server-side sessions, proxies Kubernetes API requests with
the user's own credential, and hosts
[krm-stream](https://github.com/ConfigButler/krm-stream) resource streams on the same
origin as your frontend. The browser never holds a cluster credential.
origin as your frontend. krm-foyer keeps its login and upstream credentials on the
server; the browser receives an opaque session cookie.

KRM is the [Kubernetes Resource Model](https://github.com/kubernetes/design-proposals-archive/blob/main/architecture/resource-management.md):
the idea that everything is a declarative resource with a spec and a status. krm-foyer is
Expand All @@ -15,10 +16,12 @@ for applications whose domain is modelled that way.
> **Status: a working prototype, not yet for a cluster that matters.** Sign-in through
> OIDC, server-side sessions and the API proxy work, and e2e specs against a real API
> server and Dex try to get past each security boundary. `task demo` runs an example
> application against them in your browser. Still missing: the bounds on request rate and
> response size that make it safe in front of a real cluster, token refresh, more than
> one replica, and streams. The [roadmap](docs/roadmap.md) tracks which of the principles
> below have tests.
> application against them in your browser. Request and response bounds, live streams
> and opt-in shared watches are implemented and tested. Sessions still live in memory:
> run one replica, expect a new login after a restart or ID-token expiry, and do not
> treat the e2e manifests as a production deployment. Certificate reload, token refresh,
> and shared session storage remain to be built; a Helm chart is being developed separately.
> The [roadmap](docs/roadmap.md) gives the next priorities and their required evidence.

## Try it

Expand All @@ -45,13 +48,13 @@ change in one appear in the other, and a conflict when both edit the same note.

## Principles

- **The issuer decides who you are; Kubernetes decides what you may do.** Every request
reaches the API server with the user's own OIDC token, and RBAC and admission answer it.
- **The issuer decides who you are; Kubernetes decides what you may do.** Proxied API
requests and per-user watches use the user's own OIDC token; RBAC and admission answer them.
No impersonation, and no fallback to krm-foyer's service account. A
[shared watch](docs/watches.md), where configured, is opened once with an identity of
its own, and the API server is asked about every user who reads it.
- **Tokens stay on the server.** The browser holds only an opaque session ID in a Secure,
HttpOnly cookie. Frontend code never sees a token. The session ID is itself a bearer
- **Login tokens stay on the server.** The browser holds an opaque session ID in a Secure,
HttpOnly cookie. Frontend code never receives those tokens. The session ID is itself a bearer
credential, and is guarded like one.
- **One domain is one trust boundary.** The application, krm-foyer and any domain backend
share an origin, routed by path. Everything on that domain can act as the signed-in
Expand All @@ -62,7 +65,7 @@ change in one appear in the other, and a conflict when both edit the same note.
user's full Kubernetes access; give users grants that match the application.
[Why, and what a scope would add](docs/application-scope.md)
- **Kubernetes semantics, exactly.** Status codes, errors, patch types and conflicts pass
through unchanged, and nothing is retried on the user's behalf.
through unchanged. A mutation that reached Kubernetes is never automatically replayed.
[Why](docs/heritage.md#what-broke-on-stage)

## Documentation
Expand All @@ -73,6 +76,8 @@ change in one appear in the other, and a conflict when both edit the same note.
| [docs/design.md](docs/design.md) | The contract: routes, access, sessions, upstream responses, streams and release criteria |
| [docs/application-scope.md](docs/application-scope.md) | Why a browser application might be limited beyond RBAC, what that would block, and why krm-foyer starts without it |
| [docs/roadmap.md](docs/roadmap.md) | What exists, what is next, and in what order |
| [docs/bounds.md](docs/bounds.md) | Limits, defaults, metrics and the scope of the capacity measurements |
| [docs/watches.md](docs/watches.md) | Choosing per-user streams, shared streams or native watches, and configuring sharing |
| [docs/bff-choice.md](docs/bff-choice.md) | Whether your application should use a universal BFF like this one, a domain backend, or both |
| [docs/ingress.md](docs/ingress.md) | Terminating TLS itself or behind an ingress, sharing one domain with other services, and the login gate for an ingress |
| [docs/frontend.md](docs/frontend.md) | Which pages krm-foyer serves itself, and why it ships no single-page application |
Expand All @@ -90,7 +95,7 @@ task verify # everything CI checks, including e2e against k3d and Dex
```

Without flags, krm-foyer serves its start page and probes only. Sign-in and the API
proxy come together, and need:
proxy and streams come together, and need:

| Flag | |
| --- | --- |
Expand All @@ -104,7 +109,12 @@ Optional: `-oidc-ca-file` and `-kubernetes-ca-file` (CAs to trust), `-oidc-scope
[bounds](docs/bounds.md) each have a flag and a documented default: the request rate and
concurrent requests per session, concurrent requests per replica, how long a response
may stay open, its size, and how often an open response checks its session. Sessions
live in memory: run one replica.
live in memory: run one replica. Shared watches are opt-in; see their
[configuration and identity requirements](docs/watches.md#setting-up-shared-watches).
The OIDC redirect URI to register is `<public-url>/auth/callback`. The public URL,
issuer and Kubernetes server must use HTTPS. Mounted TLS certificates, CA bundles and
the OIDC client secret are read at startup; restart after changing them. The explicitly
configured shared-watch token file is reread by client-go for rotation.

Or as a container:

Expand Down
12 changes: 8 additions & 4 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,15 @@ receives security fixes.
krm-foyer holds users' sessions and forwards their requests to a Kubernetes API server, so
we treat the following as security bugs:

- **A token reaching the browser.** An ID, access or refresh token in a response body,
- **A login or shared-watch token reaching the browser.** A token krm-foyer holds in a response body,
header, readable cookie, log line or error message.
- **A session ID leaking.** The session ID is a bearer credential: one in a log line, a
URL, an error page, or anywhere outside the cookie is a vulnerability.
- **krm-foyer deciding access.** A request answered differently through krm-foyer than the
API server answers the same token directly, or a non-canonical path forwarded instead of
rejected.
API server answers the same token directly, except the documented
[interruptions](docs/design.md#interruptions), or a non-canonical path forwarded
instead of rejected. Shared streams must follow their documented review and
reauthorization contract.
- **A request made with the wrong credential.** Another user's, or the service's own
service account standing in for a user.
- **A shared watch read without the API server's say.** A stream served from a shared
Expand All @@ -48,7 +50,9 @@ we treat the following as security bugs:
## What does not

- Anything a user can do with permissions Kubernetes RBAC already grants them, including
through the browser. krm-foyer has no [application scope](docs/application-scope.md)
through the browser, including reading Secrets or creating tokens where those
permissions are granted. The server-held-token guarantee is not a filter on
authorized Kubernetes resource data. krm-foyer has no [application scope](docs/application-scope.md)
yet, so a session carries the user's full access by design. Enumerating a namespace you
are allowed to list is not a leak.
- Denial of service by an already authenticated user within the configured limits.
4 changes: 2 additions & 2 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ vars:
IMAGE: '{{.IMAGE | default "ghcr.io/configbutler/krm-foyer"}}'
# The krm-stream browser bundle the hello example vendors, and npm's integrity for
# its package. Bump both together, then `task vendor-krm-stream`.
KRM_STREAM_VERSION: 0.6.0
KRM_STREAM_INTEGRITY: sha512-8vGYVE3np6Ec0bs+Bd0/Y7+PqPp4sQ2YRwbR3Y/VrPPeiIxcHyKt8GHtHa9uUqCp59YXgPALMIJwRh/RXIxhFQ==
KRM_STREAM_VERSION: 0.7.0
KRM_STREAM_INTEGRITY: sha512-LFXyJIv4UmlBBEJEdx68wzwaqUIVai5HbDe70ACTWIpFZexlJbd/wyhzryoW5uBg55y/f+dZ4bapajybPJ7c0w==
VERSION:
sh: git describe --tags --always --dirty 2>/dev/null || echo dev

Expand Down
18 changes: 12 additions & 6 deletions docs/alternatives.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,14 @@ Use it when all of these hold:

- **The user is in a browser**, in an application you build, not in `kubectl` or a
dashboard someone else built.
- **Kubernetes should decide with the user's identity.** No service account acts for
the user, and nothing impersonates them.
- **No token reaches the browser.** A script injected into the page can act as the user
while the page is open. It cannot carry a credential away.
- **Kubernetes should decide with the user's identity.** API requests use the user's
token. Opt-in shared watches use a separate identity and API-server reviews of every
subscriber; no service account substitutes for a user's API request, and nothing
impersonates them.
- **Login tokens stay on the server.** A script injected into the page can still act as
the user and read anything their grants allow, including Secrets or token-issuing
APIs if granted. HttpOnly protects the session cookie; it does not narrow Kubernetes
access. See [application scope](application-scope.md).
- **Your API server trusts an OIDC issuer**, or you can make it trust one.

It is weaker when you want a ready-made UI, a CLI, or a cluster whose API server you
Expand Down Expand Up @@ -114,8 +118,10 @@ header per change, in a browser with no Node polyfills.
paths (no token, a proof on every change, a resend only of krm-foyer's own
[refusal](design.md#interruptions)) stay short enough to audit, and they are tested
under `node --test` ([foyer.test.js](../internal/pages/foyer.test.js)).
- **Watches need no library.** A `fetch` whose body is read line by line, resumed from
the last `resourceVersion` and re-listed on 410 Gone, is about 40 lines.
- **Live views use krm-stream.** The helper handles sessions and API calls; the
[hello example](../examples/hello) uses krm-stream for snapshots, recovery and drafts.
Native watches remain available for clients that already implement that protocol;
see [which watch to use](watches.md).
- **For types, use `kubernetes-models` with `import type`.** It costs nothing at run
time, is current, and covers CRDs. client-node's model classes do not fit, because they
turn timestamps into `Date` objects and no longer match the JSON.
Expand Down
41 changes: 27 additions & 14 deletions docs/bounds.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ A *bound* is something krm-foyer limits, such as how long a response may stay op

- **Native watches stay open, and are not a special case.** Every bound applies to every
request through `/k8s`, so krm-foyer never has to tell a watch from any other request.
Live views in applications belong to krm-stream ([roadmap step 5](roadmap.md#order-of-work));
Live views in applications belong to krm-stream ([watches](watches.md));
the native watch stays available underneath it. See [native watches](#native-watches).
- **Five bounds**, each configurable with a documented default: the request rate per
session, concurrent requests per session and per replica, how long a response may
Expand Down Expand Up @@ -55,7 +55,7 @@ They are not three other things:

**Every bound is per replica.** krm-foyer keeps no shared counters. With one replica, as
today, that is the whole deployment. Once there are several
([step 6](roadmap.md#order-of-work)), a session whose requests reach N replicas gets up
([shared storage on the roadmap](roadmap.md#order-of-work)), a session whose requests reach N replicas gets up
to N times its per-session limits, and the per-replica limits are what hold.

The per-replica bounds on concurrent requests are the ones that protect krm-foyer itself,
Expand All @@ -72,8 +72,8 @@ stream into events, reconnect when it ends, and resume from the last `resourceVe
without losing or repeating a change.

krm-stream does that work once, for every application. It is the path krm-foyer
recommends for live views ([step 5](roadmap.md#order-of-work)), and the hello example
moves to it. So the question was whether native watches through `/k8s` should stay at
recommends for live views ([watches](watches.md)), and the hello example
uses it. So the question was whether native watches through `/k8s` should stay at
all. **They stay**, for three reasons:

- **Refusing them would be an access rule.** RBAC already says who may watch what.
Expand All @@ -82,7 +82,7 @@ all. **They stay**, for three reasons:
- **They work today**, against the real API server, and a spec proves it.
- **They cost almost nothing extra.** What they need, an end when the session ends and
cancellation at the API server, every long response needs too: a followed log, or a
slow list open at logout. krm-stream's user-authenticated watches will need the same.
slow list open at logout. krm-stream's per-user streams use the same gate.

What is not offered: a watch client in the [browser helper](frontend.md), or an example
that reads native watches. That would be a second, lesser krm-stream.
Expand Down Expand Up @@ -230,8 +230,9 @@ within one interval of its session ending, plus at most one more for a check tha
5 seconds normally, 10 at worst, with the defaults.

Each open response reads the store once per interval: nothing for the memory store, and
some 400 reads a second at the per-replica limit of 2000 once the store is shared
([step 6](roadmap.md#order-of-work)).
up to about 800 reads a second when both the 2000-request and 2000-stream limits
are occupied, once the store is shared
([shared storage on the roadmap](roadmap.md#order-of-work)).

### Response bytes

Expand Down Expand Up @@ -311,8 +312,19 @@ apart again:
blocked write would also hold off the recheck that applies a revoked grant. A test
stops reading, fills the buffers, revokes the grant and sees the stream end.

What is not bounded yet: a native watch through `/k8s` whose tab stops reading holds
its slot until the response duration ends it.
What is not bounded yet:

- A native watch through `/k8s` whose tab stops reading has no separate write timeout;
its session ending or response duration still cuts it short.
- Snapshot size, per-stream object bookkeeping and shared-cache bytes have no foyer
limit. `-max-response-bytes` applies only to `/k8s`; it does not protect `/stream`.
A few large scopes can therefore exhaust memory below the stream-count limit.
- Active sessions have no count limit. The 10,000 pending-login cap bounds login
transaction storage, but is not a rate limit on anonymous login/callback traffic.
The API gate's per-session rate applies to `/k8s` and `/stream`, not `/auth`.

These are capacity/admission questions for the [next work](roadmap.md#order-of-work),
not guarantees established by the small-snapshot rehearsal.

## Measured: the rehearsal

Expand Down Expand Up @@ -432,8 +444,8 @@ What is left out on purpose:
- **No automatic HTTP instrumentation.** Middleware of that kind labels requests by
route, and under `/k8s` the path names namespaces and objects: unbounded label values,
and a record of what each user looks at, kept outside the audit log. krm-foyer's
metrics are labelled only by bound, cause and interruption reason, fixed sets known in
advance. Details of a single request belong in its log line.
metrics use fixed label sets: bound, cause, interruption reason, watch identity
class, access-check source and result. They never label a series with a user or scope. Details of a single request belong in its log line.
- **No tracing yet.** Tracing is where OpenTelemetry is the right choice, and it would
come with its tracing SDK only, not its metrics. But the API server accepts a W3C
`traceparent`, and forwarding the browser's would let a page write into the cluster's
Expand Down Expand Up @@ -534,6 +546,7 @@ Rules of thumb for tuning:
- **Lower a limit when the 99th percentile stays far below it** for weeks: the room
above it serves only runaway pages.
- **Expect `session_ended` at logout and at expiry**, one for each response the tab had
open. Idle expiry cannot cause it, since reconnects renew the session; the absolute
timeout and the token's expiry can. Far more of them than sessions ending means
something else: a session store that does not answer the check in time.
open. Ordinary reconnects renew idle time, but failed or refused reconnects need not;
idle, absolute and token expiry can all end a session. One session can own many
responses, so compare against open-response counts rather than expecting one cut
per logout. Store checks that fail or time out are another cause.
Loading
Loading