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
30 changes: 16 additions & 14 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -222,20 +222,22 @@ jobs:
# until the release PR merges — and `go get` would fail here for a reason that is not a
# bug. Skip that case in ordinary CI; release CI requires the tags to exist.
core="$(sed -n 's|^[[:space:]]*github.com/ConfigButler/krm-stream/gateway v\([^ ]*\).*|\1|p' gateway/kube/go.mod | head -1)"
case "$core" in
0.0.0-*) ;; # a pseudo-version names a commit, and always resolves
"")
echo "::error::gateway/kube/go.mod does not require the core module at all" && exit 1 ;;
*)
if ! git ls-remote --exit-code --tags origin "refs/tags/gateway/v${core}" >/dev/null 2>&1; then
if [ -n "$RELEASE_VERSION" ]; then
echo "::error::released adapter requires missing core tag gateway/v${core}"
exit 1
fi
echo "::notice::core v${core} is not tagged yet; release CI verifies the public tag after tagging."
exit 0
fi ;;
esac
if [ -z "$core" ]; then
echo "::error::gateway/kube/go.mod does not require the core module at all" && exit 1
fi
# A pseudo-version names a commit, and always resolves. It ends in a 14-digit timestamp and a
# 12-character revision, whatever its base: v0.0.0-…, or v0.4.1-0.… once a tag precedes it,
# which is the form Go chooses when an adapter change needs unreleased core API.
if ! printf '%s' "$core" | grep -Eq '[-.][0-9]{14}-[0-9a-f]{12}$'; then
if ! git ls-remote --exit-code --tags origin "refs/tags/gateway/v${core}" >/dev/null 2>&1; then
if [ -n "$RELEASE_VERSION" ]; then
echo "::error::released adapter requires missing core tag gateway/v${core}"
exit 1
fi
echo "::notice::core v${core} is not tagged yet; release CI verifies the public tag after tagging."
exit 0
fi
fi

revision="${{ inputs.ref || github.event.pull_request.head.sha || github.sha }}"
if [ -n "$RELEASE_VERSION" ]; then
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,13 @@ version: see [why a quiet stream can reject a save](docs/saving.md#why-a-quiet-s

## Start here

**Want it running in minutes?** [krm-foyer](https://github.com/ConfigButler/krm-foyer) is a
ready-made backend for browser apps on Kubernetes: OIDC sign-in, server-side sessions, a `/k8s` API
proxy, and krm-stream's live resources, with each watch opened as the signed-in user so Kubernetes
RBAC decides what they see. Its `task demo` starts a test cluster and an example app. Start there if
you don't already have a Go backend to embed the gateway in. To build the integration yourself, read
on.

There are two halves, and they are usually two different people.

### The browser half
Expand Down
41 changes: 40 additions & 1 deletion docs/adopting.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,28 @@ namespaced resource. `ScopePolicy` makes the host choose explicitly.
Keep all-namespaces access rare. It changes the size, disclosure risk, and operating cost of a stream.
Use an `Authorizer` to pin a user to a namespace or target before any watch opens.

### Delegating resource admission to Kubernetes

A host whose endpoint should expose whatever Kubernetes lets each user read, as a `/k8s` proxy does,
has no resource list to keep. `AnyResource` says so explicitly:

```go
Scopes: gateway.ScopePolicy{
Targets: []string{""},
AnyResource: true, // admission is left to the upstream's own authorization
},
```

Any resource the scope syntax accepts then passes, with or without a namespace. Without one, the API
server decides: the list of a cluster-scoped resource, or every namespace for a namespaced one, which
RBAC allows only with a cluster-wide grant. The target allowlist, `AllowLabelSelector`, the refusal of
API-server addresses and the `Authorizer` all still apply.

`AnyResource` is safe only when `Clients` returns a backend built from the caller's own credential.
The library cannot check that: a `kube.Backend` can carry a service account's client, and a wrapper
can hide a `SharedBackend`. With either, every resource that identity can read is open to everyone
your `Authorizer` admits. Prove the binding in the host's own tests.

### Errors from the exported surface are `error`

`ScopeFromQuery` and `ScopePolicy.Validate` return `error`, and the concrete value is a
Expand All @@ -31,11 +53,28 @@ if errors.As(err, &serr) {
}
```

### Errors on the wire, and the detail behind them

A `StreamError` you return keeps its `Message`; its `Cause` never reaches the wire. Any other error
reaches the browser as `INTERNAL` with the message `internal error`. Set `Options.Diagnostics` to see
the raw error, with the principal and scope it belongs to, and log what your policy allows:

```go
Diagnostics: func(d gateway.Diagnostic) {
slog.Warn("stream error", "code", d.Code, "resource", d.Scope.Resource, "err", redact(d.Err))
},
```

`Principal` may return a `*StreamError` too: `gateway.Unauthenticated` when signing in again will
help, or `gateway.UpstreamUnavailable` when your session store is down. Any other `Principal` error
is sent as `UNAUTHENTICATED` "not authenticated".

### Targets with a path prefix

A `rest.Config.Host` may include a path prefix, such as a kcp workspace URL
(`https://kcp.example/clusters/root:org:ws`). The dynamic client preserves it when constructing API
requests. Use `kube.NewBackendForConfig(cfg)` to include the endpoint in upstream error diagnostics;
requests. Use `kube.NewBackendForConfig(cfg)` to include the endpoint in upstream errors, which reach
`Options.Diagnostics` and never the browser;
`kube.NewBackend(dynamicClient)` also works when a dynamic client already exists.

## 2. Mount the same-origin cookie endpoint
Expand Down
12 changes: 9 additions & 3 deletions docs/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,14 @@ gateway.Handler(gateway.Options{
})
```

- `Principal` resolves the request to an opaque application identity.
- `Principal` resolves the request to an opaque application identity. Return a `*StreamError` to
choose the refusal (`UNAUTHENTICATED`, `UPSTREAM_UNAVAILABLE`); any other error is sent as
`UNAUTHENTICATED` without its text.
- `Authorizer` denies unauthorized scopes before a watch opens and on subsequent checks.
- `Clients` is a `ClientFor` callback supplying the backend for that identity and target.
- `Scopes` allowlists targets and resources. A browser cannot supply a raw API-server URL.
- `Scopes` allowlists targets and resources, or with `AnyResource` leaves resource admission to the
API server's RBAC for a backend that acts as the caller. A browser cannot supply a raw API-server
URL.

A per-user backend can use the user's bearer token or Kubernetes impersonation. Impersonation
requires explicit host credentials with impersonation rights. Scope and disclosure policy remain
Expand All @@ -76,7 +80,9 @@ options.ReauthorizationTimeout = 5 * time.Second
```

Timed checks run per subscriber and pause that subscriber's object delivery. Denial, timeout, policy
failure or a changed projection terminates only that stream; other subscribers continue. Zero
failure or a changed projection terminates only that stream; other subscribers continue. A
SubjectAccessReview that cannot reach the API server ends the stream with a non-terminal
`UPSTREAM_UNAVAILABLE`, so the client may reconnect once it is back. Zero
interval keeps cycle-only checks; zero timeout uses 10 seconds. The bound assumes callbacks honor
context cancellation and sinks do not block indefinitely.

Expand Down
4 changes: 3 additions & 1 deletion docs/client-state-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ Use `applyStreamEvent` or the transport helpers instead of translating protocol
- `deleted` removes the resource and its draft.
- `synced` prunes resources that were not seen during the completed snapshot.
- `error` is terminal only when the event says it is terminal. `RESYNC_REQUIRED` starts a new
snapshot on the same connection.
snapshot on the same connection. After any other non-terminal error, such as
`UPSTREAM_UNAVAILABLE`, the gateway closes the connection and the client reconnects, waiting at
least `retryAfterMs`.

An incomplete snapshot never prunes state. That prevents a transient disconnect from making a UI lose
objects it has not yet reloaded.
Expand Down
Loading
Loading