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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,12 +74,13 @@ change in one appear in the other, and a conflict when both edit the same note.
| --- | --- |
| [docs/vision.md](docs/vision.md) | Why krm-foyer exists, who it helps, what it expects from your domain, and what it will not become |
| [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/application-scope.md](docs/application-scope.md) | Why a browser application might be limited beyond RBAC, what that would block, and how to give the browser its own narrow identity in the cluster |
| [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/ingress.md](docs/ingress.md) | Terminating TLS itself or behind an ingress, sharing one domain with other services, `/auth/check` for a login gate and a domain backend, and recipes for Gateway API, Traefik, nginx and Vite |
| [docs/room-pass.md](docs/room-pass.md) | Signing an audience in with Room Pass's QR join through krm-foyer |
| [docs/frontend.md](docs/frontend.md) | Which pages krm-foyer serves itself, and why it ships no single-page application |
| [docs/testing.md](docs/testing.md) | How the tests prove krm-foyer invents neither authentication nor authorization, and how to run the e2e fixture |
| [docs/alternatives.md](docs/alternatives.md) | Nearby tools and the goals each meets: other ways to reach the Kubernetes API, and JavaScript clients in place of the browser helper |
Expand Down
4 changes: 3 additions & 1 deletion Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,11 +76,12 @@ tasks:
- test/e2e/cluster/install-traefik.sh

e2e-deploy:
desc: "Build the image, deploy krm-foyer into the e2e fixture (as cluster-admin bait), and put the hello example in front of it."
desc: "Build the image, deploy krm-foyer into the e2e fixture (as cluster-admin bait), put the hello example in front of it, and Room Pass's QR login beside it."
deps: [image]
cmds:
- IMAGE={{.IMAGE}}:{{.VERSION}} test/e2e/cluster/deploy-foyer.sh
- test/e2e/cluster/front-door.sh
- test/e2e/cluster/room-pass.sh

demo:
desc: "Start the hello example in a disposable cluster, for a browser on this machine. task e2e-down removes it."
Expand Down Expand Up @@ -127,6 +128,7 @@ tasks:
# The schema and the rendered arguments are tested in cmd/krm-foyer/chart_test.go.
- helm lint --strict charts/krm-foyer -f test/e2e/cluster/foyer-values.yaml
- helm lint --strict charts/krm-foyer -f test/e2e/cluster/foyer-values.yaml -f test/e2e/cluster/foyer-brief-values.yaml
- helm lint --strict charts/krm-foyer -f test/e2e/cluster/foyer-room-values.yaml

fmt:
desc: "Format the Go code."
Expand Down
12 changes: 12 additions & 0 deletions cmd/krm-foyer/chart_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,18 @@ func TestChartRendersArgsTheBinaryAccepts(t *testing.T) {
t.Fatalf("shared %+v", cfg.login.shared)
}
})
t.Run("e2e Room Pass fixture", func(t *testing.T) {
cfg := parseRendered(t, render(t, []string{e2e + "foyer-room-values.yaml"}))
l := cfg.login.auth
if !slices.Equal(l.Scopes, []string{"openid", "email", "profile", "groups", "federated:id"}) || cfg.login.shared != nil {
t.Fatalf("scopes %q, shared %+v", l.Scopes, cfg.login.shared)
}
p := l.Login.AuthorizationParameters
if c := p["connector_id"]; !c.AllowFromRequest || !slices.Equal(c.AllowedValues, []string{"room-pass"}) || len(p) != 1 ||
l.Login.SessionClaims.Connector != "/federated_claims/connector_id" {
t.Fatalf("login %+v", l.Login)
}
})
t.Run("every value set", func(t *testing.T) {
values := filepath.Join(t.TempDir(), "values.yaml")
// Large numbers, which Helm reads from a file as floats, and a fraction.
Expand Down
3 changes: 2 additions & 1 deletion cmd/krm-foyer/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,8 @@ func handler(cfg config, logger *slog.Logger, m *metrics.Metrics) (http.Handler,
return nil, nil, err
}
return server.New(server.Config{
Version: version, Kubernetes: api, Stream: streams, WhoAmI: streams.WhoAmI(), Auth: login.Handler(), Ready: login.Ready,
Version: version, Kubernetes: api, Stream: streams, WhoAmI: streams.WhoAmI(),
Check: login.Check(streams.Identify), Auth: login.Handler(), Ready: login.Ready,
}), login.Run, nil
}

Expand Down
121 changes: 106 additions & 15 deletions docs/application-scope.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,22 +94,113 @@ which someone has to keep in step with the application.

### A browser identity in Kubernetes

The API server's `AuthenticationConfiguration` can map tokens for the `krm-foyer`
audience to their own identity: a username such as `browser:alice@example.com`, or a
group. RBAC then grants that identity only what the application needs. Kubernetes
enforces the scope itself, and the audit log records it.
**Documented and proved (2026-10-05; client rules tightened 2026-10-06).** This is the first step the decision names, and
it needs no krm-foyer code. Voter's operator asked for it
([implementer feedback](implementer-feedback.md), entry 4): they sign in as a
cluster-admin, and through krm-foyer every script on Voter's origin would hold that.

The API server's `AuthenticationConfiguration` can give one person two usernames, told
apart by the OIDC client the issuer gave the token to: krm-foyer's tokens one name, the
command line's another. RBAC then grants the browser's name only what the application
needs, and keeps the broad grants on the other. Kubernetes enforces the scope itself,
and the audit log records which name acted.

The client is the token's **authorized party**: `azp` when the token has one, which
Dex sets when a client asks for another audience with the cross-client scope
`audience:server:client_id:<id>`, and the audience otherwise. The rules are strict,
because a mistake hands out the command line's name:

- **With `azp`,** it must be a string and one of the token's audiences.
- **Without `azp`,** the audience must be a string or a list of exactly one. A list of
two or more names no client: OpenID Connect requires `azp` with several audiences, and
no rule can say which of them asked.
- **Every client is listed, each with its prefix.** Nothing falls back to a prefix. A
client the list does not name, or a token whose client is in doubt, has no key in
the map; the lookup fails and the API server refuses the token. A validation rule
says so first, with a message.

So one configuration works for a cluster that accepts krm-foyer's own audience, as the
e2e fixture does, and for one where krm-foyer asks for the cluster's audience, as
Voter's does:

This is the purest form of "Kubernetes decides". Its costs:
```yaml
apiVersion: apiserver.config.k8s.io/v1
kind: AuthenticationConfiguration
jwt:
- issuer:
url: https://dex.example.com
# Both clients' tokens are accepted. With the cross-client scope krm-foyer's
# carry the audience kubernetes and azp: krm-foyer; the command line's own
# client is kubernetes, and its tokens carry that audience and no azp.
audiences: [kubernetes]
claimMappings:
# The client, as described above, looked up in a map of the clients there are:
# foyer:<email> for the browser, github:<email> for the command line, which
# keeps the cluster-admin binding. CEL has no variables here, so the client is
# spelled out each time it is used.
username:
expression: >-
{'krm-foyer': 'foyer:', 'kubernetes': 'github:'}[(has(claims.azp) ? (type(claims.azp) == string && claims.azp in (type(claims.aud) == string ? [claims.aud] : claims.aud) ? claims.azp : '') : (type(claims.aud) == string ? claims.aud : size(claims.aud) == 1 ? claims.aud[0] : ''))]
+ claims.email
# The same split for groups, so that no group binding reaches both.
groups:
expression: >-
has(claims.groups) ? dyn(claims.groups).map(g,
{'krm-foyer': 'foyer:', 'kubernetes': 'github:'}[(has(claims.azp) ? (type(claims.azp) == string && claims.azp in (type(claims.aud) == string ? [claims.aud] : claims.aud) ? claims.azp : '') : (type(claims.aud) == string ? claims.aud : size(claims.aud) == 1 ? claims.aud[0] : ''))]
+ g) : []
claimValidationRules:
# The same client, which must be one in the map. The map lookup above refuses
# any other on its own; this says why.
- expression: >-
(has(claims.azp) ? (type(claims.azp) == string && claims.azp in (type(claims.aud) == string ? [claims.aud] : claims.aud) ? claims.azp : '') : (type(claims.aud) == string ? claims.aud : size(claims.aud) == 1 ? claims.aud[0] : ''))
in ['krm-foyer', 'kubernetes']
message: "the token's client (azp, or its only audience) must be krm-foyer or kubernetes"
# A username from email is only an identity if the issuer vouched for it.
- expression: "has(claims.email_verified) && type(claims.email_verified) == bool && claims.email_verified"
message: "email_verified must be the boolean true"
```

- **RBAC twice.** Each grant is written for `alice` and for `browser:alice`, or through a
group for each.
- **Domain logic that matches on identity breaks.** Admission rules and owner fields that
expect `alice` see `browser:alice`.
Then bind the browser's name narrowly: `foyer:simonkoudijs@gmail.com` gets a Role over
the application's resources, and `github:simonkoudijs@gmail.com` keeps `cluster-admin`.

Which side gets the new name is a choice. Give it to whichever has fewer grants to
rewrite: in Voter's cluster that is the browser, since the existing bindings are for the
command line. The e2e fixture does the reverse, so that its many specs keep their
names: krm-foyer's tokens are `oidc:<email>` and the command line's are
`kubectl:<email>` ([authentication-config.yaml](../test/e2e/cluster/authentication-config.yaml),
with a `kubectl` client in [dex.yaml](../test/e2e/cluster/dex.yaml)). Its spec
([foyer_scope_test.go](../test/e2e/foyer_scope_test.go)) signs alice in both ways,
checks the API server's two names for her, grants the command-line name `list` on
Secrets, and shows krm-foyer refused with the API server's 403 for `oidc:alice`. With
tokens the suite signs itself, it checks every client form above against the real API
server: the browser's and the command line's audiences as strings, as lists of one and
with `azp`, each named and grouped as its client; and an `azp` that is another client,
not an audience, a list, a number, null or empty, two audiences without `azp` in either
order, and another client's audience, each refused with 401, so neither the
command-line name nor a command-line group grant is reachable through them.

What to know before using it:

- **A list of audiences needs `azp`.** A token with two audiences and no `azp` is
refused, whatever the order of its audiences, as is an `azp` that is not a string, is
not one of the audiences, or is a client the map does not list. An earlier version
of this recipe compared the client with `krm-foyer` and gave everything else the
command line's prefix: a list audience, or another client's `azp`, got the
command-line name and its groups. Replace any copy of that version.
- **Another client of the same issuer** that has the cluster's audience, as a Dex
`trustedPeers` client can get it, is refused until it is added to the map with a
prefix of its own.
- **RBAC twice.** Each grant the person needs in both places is written for both names,
or through a group for each.
- **Domain logic that matches on identity sees the browser's name.** Admission rules and
owner fields that expect `github:alice` see `foyer:alice`. Attribution through the
[extras](design.md#attribution) is unaffected, since those come from the same claims.
- **It needs control of the API server's authentication configuration.** Managed
clusters often do not allow that, so it cannot be the only answer.

The e2e fixture already configures that authenticator, so it can show this working
without any krm-foyer code.
clusters often do not allow that, so it cannot be the only answer. That is what the
scope list above is for.
- **It scopes by client, not by application.** Two applications behind one krm-foyer
client share the browser's name. Give each its own krm-foyer and client if their
grants should differ.

## How the rest of the design would change

Expand All @@ -130,5 +221,5 @@ With a scope layer, three things would come back:
not fully trusted.
- A security review asks what a cross-site-scripting bug in a frontend could reach.

When that happens, document the browser identity first, since it needs no krm-foyer
code, and build the scope list for clusters where that is not possible.
The browser identity is now documented and proved, above. Build the scope list when an
adopter's cluster cannot change its authentication configuration.
11 changes: 6 additions & 5 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,11 @@ verifies krm-foyer's certificate. See [both TLS models](ingress.md#decision-2026
Its public URL is configuration: the OIDC redirect URI, same-origin and CSRF checks and return
paths use it, never `Host` or `X-Forwarded-*`. An ingress in front must be transparent: no
buffering of streams, and no authentication or header rewriting of its own on krm-foyer's
routes. The planned `/auth/check` lets an ingress's external-authentication feature
(`auth_request`, ForwardAuth) act only as a login gate for the application's pages.
Until it exists, the helper's `requireSession()` handles that navigation. A login gate
never decides on `/k8s` or `/stream` traffic. The [ingress decision](ingress.md) explains why, and what would make
routes. `/auth/check` lets an ingress's external-authentication feature
(`auth_request`, ForwardAuth) act as a login gate for the application's pages, and tell a
domain backend on the same origin who the user is, as the API server names them, never
with the token. The helper's `requireSession()` handles the same navigation where no
ingress gate is configured. The check never decides on `/k8s` or `/stream` traffic. The [ingress decision](ingress.md) explains why, and what would make
more worth revisiting.

krm-foyer has two halves. The **login half** (`/auth/...`) obtains the user's OIDC token
Expand Down Expand Up @@ -90,7 +91,7 @@ come from krm-stream, not from a reimplementation here.
| `/auth/callback` | Validate the callback and establish a session, then `303` to the return path |
| `/auth/session` | Return minimal identity/session state and CSRF information, never bearer tokens: `200` with `authenticated`, `issuer`, `subject`, `email`, `displayName`, `groups`, `connector` when configured, `expiresAt`, `csrfToken` and `csrfHeader`, or `401` with `{"authenticated":false}`. See [session claims](#session-claims) |
| `/auth/logout` | CSRF-protected POST that clears the session cookie, ends the session's open responses in this process and answers `204`; it revokes no copy of the cookie ([sessions](#sessions)). The caller then goes where it likes, `/auth/logged-out` by default |
| `/auth/check` **(planned)** | 204 or 401 (or 302 to login on request) for an ingress gating the application's pages; never a token or identity. See the [login gate](ingress.md#decision-2026-10-01-a-login-gate-for-the-applications-pages) |
| `/auth/check` | For an ingress's forward-auth: `204` when the browser is signed in and may make the request the ingress forwards (`X-Forwarded-Method`, with the CSRF rules of `/k8s` for a write), the `401` or `403` `/k8s` would give otherwise, or with `?redirect=true` a `302` to the login for a page load. With `?identity=true` the `204` carries `Krm-Foyer-Identity`, the API server's `userInfo` with the session's `displayName` and `connector`, for a domain backend. Never a token. See [the check](ingress.md#the-check) |
| `/k8s/api/...` | Proxy core Kubernetes APIs after stripping `/k8s` |
| `/k8s/apis/...` | Proxy grouped APIs, including CRDs and aggregated APIs |
| `/k8s/api`, `/k8s/apis`, `/k8s/version`, `/k8s/openapi/...` | Proxy discovery and schema endpoints |
Expand Down
Loading
Loading