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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- **API authentication with roles** (`kview.auth.*`, off by default): `token` mode (static bearer tokens) and `oidc` mode (JWTs from any issuer, configurable role claim), each with an **admin** and a **read-only** role — reads for everyone, mutations (produce, topic/group management, offset resets, connection management) admin-only. The web UI prompts for the token (🔑 in the top bar), the CLI takes `--token`/`KVIEW_TOKEN`, the MCP server takes `KVIEW_TOKEN`. In authenticated modes CORS is closed unless `KVIEW_AUTH_ALLOWED_ORIGINS` lists origins; the static UI and `/actuator/health` stay public. See the new Authentication section in the README.

### Fixed
- **MCP server**: `mcp/kview-mcp.js` crashed on startup with `SyntaxError: Cannot use import statement outside a module` on Node ≤ 22.6 (ESM in a `.js` file with no `package.json`) — fixed by shipping `mcp/package.json` with `"type": "module"`.
- **CLI**: `kview lag <group>` read a non-existent `byTopic` field and silently printed no per-partition lag rows — now reads `partitionsByTopic`, as the API serializes it.
Expand Down
56 changes: 51 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,8 @@ consumer group, so it is safe to point at production topics.

## Three ways to use it

> [!WARNING]
> **Network Security:** Kview has no built-in authentication on its REST API and binds to `127.0.0.1` by default. If deploying to a shared server or network, place it behind an authenticating reverse proxy (e.g., OAuth2-Proxy, NGINX, Caddy, Cloudflare Access). See [SECURITY.md](SECURITY.md) for details.
> [!NOTE]
> **Network Security:** Kview binds to `127.0.0.1` by default and the API is unauthenticated in that mode — fine for a single-user machine. For shared servers, enable the built-in authentication (`KVIEW_AUTH_MODE=token` or `oidc`, see [Authentication](#authentication)) or place it behind an authenticating reverse proxy. See [SECURITY.md](SECURITY.md) for details.

### 1. Web UI

Expand Down Expand Up @@ -111,7 +111,7 @@ node cli/kview.mjs reset-offsets my-group --topic orders --mode latest

Point it at another server with `--server http://host:port` (or env `KVIEW_URL`) and
another cluster with `--cluster ID` (env `KVIEW_CLUSTER`). `--json` gives raw output for
scripting.
scripting. If the server runs with authentication enabled, add `--token <T>` (env `KVIEW_TOKEN`).

### 3. MCP server (AI assistants)

Expand Down Expand Up @@ -147,7 +147,7 @@ claude mcp add kview -- node /absolute/path/to/kview/mcp/kview-mcp.js

Any other stdio MCP client works the same way: command `node`, args
`["/absolute/path/to/kview/mcp/kview-mcp.js"]`, env `KVIEW_URL` (and optional
`KVIEW_CLUSTER`, default `default`). Check your setup without a client:
`KVIEW_CLUSTER`, default `default`; `KVIEW_TOKEN` when the server runs with authentication). Check your setup without a client:

```bash
node scripts/mcp-smoke-test.mjs # 25 checks incl. produce -> browse round-trip
Expand Down Expand Up @@ -180,13 +180,59 @@ browser. The API returns masked values, and saving a masked value keeps the stor
| `KAFKA_BOOTSTRAP_SERVERS` | `localhost:9092` | bootstrap servers of the default cluster |
| `KVIEW_BIND` | `127.0.0.1` | address to bind to (`0.0.0.0` in Docker container) |
| `KVIEW_DATA_DIR` | `./data` | where connections.json is stored |
| `KVIEW_AUTH_MODE` | `none` | API authentication: `none`, `token` (static bearer tokens) or `oidc` (JWT from any OIDC issuer) — see [Authentication](#authentication) |
| `KVIEW_AUTH_TOKENS` | — | token mode: comma-separated `TOKEN` (= admin) or `TOKEN:readonly` entries |
| `KVIEW_AUTH_ALLOWED_ORIGINS` | — | authenticated modes: browser origins allowed to call the API cross-origin (default: none — CORS is closed) |
| `server.port` (application.yml) | `8090` | http port |

## Authentication

The API is open by default — safe on the loopback binding for a single user. For shared
deployments, `KVIEW_AUTH_MODE` enables bearer-token authentication with two roles:
**admin** (everything) and **read-only** (dashboard, browse, tail; produce, topic
create/delete/edit, offset resets, group deletion and connection management are refused).

**Token mode** — static tokens, no external dependency:

```bash
KVIEW_AUTH_MODE=token KVIEW_AUTH_TOKENS="team-admin-secret,ci-viewer-secret:readonly" java -jar kview.jar
```

Every request must then send `Authorization: Bearer <token>`:

```bash
curl -H "Authorization: Bearer team-admin-secret" http://localhost:8090/api/clusters
```

**OIDC mode** — JWTs from any issuer (Keycloak, Entra ID, Okta, …). The role claim decides
admin vs read-only; every other valid token is read-only:

```yaml
kview:
auth:
mode: oidc
audience: kview-api # optional: require this aud claim
admin-role-claim: roles # claim inspected for admin-role-value
admin-role-value: kview-admin
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://id.example.com/realms/kview
```

(env equivalents: `KVIEW_AUTH_MODE=oidc`, `SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUERURI`, `KVIEW_AUTH_AUDIENCE`)

Clients: the web UI prompts for the token (🔑 button in the top bar), the CLI takes
`--token <T>` / `KVIEW_TOKEN`, and the MCP server takes `KVIEW_TOKEN`. In authenticated
modes CORS is closed unless you list browser origins in `KVIEW_AUTH_ALLOWED_ORIGINS`.

## Roadmap

- Message replay: copy a filtered set of messages to another topic or cluster
- Prometheus metrics for lag and produce rate
- Authentication for the API itself (Schema Registry support for Avro and JSON Schema shipped in v1.0.0)
- Protobuf decoding for Schema Registry topics (Avro and JSON Schema shipped)

## Contributing

Expand Down
26 changes: 18 additions & 8 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,25 +13,35 @@
Kview is designed for **local development and trusted internal networks**.

### Authentication & Access Control
The Kview REST API and web UI currently do **not** have a built-in user authentication or authorization layer. Anyone with network access to the Kview server port (`8090` by default) can:
- Browse, tail, and search messages across all connected Kafka clusters.
- Produce messages to any topic.
- Create, modify, and delete topics.
- Reset consumer group offsets.
The REST API ships built-in bearer-token authentication (`kview.auth.*`), **disabled by default**:

| `KVIEW_AUTH_MODE` | Behavior |
|---|---|
| `none` (default) | API is open — acceptable on the default loopback binding for a single-user machine. |
| `token` | Static tokens from `KVIEW_AUTH_TOKENS` (`TOKEN` = admin, `TOKEN:readonly` = read-only). |
| `oidc` | JWTs validated against any OIDC issuer (`spring.security.oauth2.resourceserver.jwt.issuer-uri`); a configurable claim separates admin from read-only. |

In authenticated modes: every request needs `Authorization: Bearer <token>`; **read-only** covers
dashboard, browsing, tailing and schema decoding, while produce, topic create/delete/edit, offset
resets, group deletion and connection management require the **admin** role. CORS is closed unless
`KVIEW_AUTH_ALLOWED_ORIGINS` lists browser origins. The web UI prompts for the token (🔑 in the top
bar), the CLI takes `--token` / `KVIEW_TOKEN`, and the MCP server takes `KVIEW_TOKEN`. Cluster
secrets stay server-side and masked in responses regardless of mode.

### Default Loopback Binding
To prevent inadvertent exposure on shared servers or cloud VMs, Kview binds to **`127.0.0.1`** by default (`server.address: ${KVIEW_BIND:127.0.0.1}`).
- When running in Docker containers, `KVIEW_BIND` is set to `0.0.0.0` so that ports can be forwarded explicitly.
- **Do not** expose port `8090` directly to the public Internet without protection.
- **Do not** expose port `8090` directly to the public Internet without enabling the built-in authentication or placing Kview behind a proxy.

### Recommended Production / Team Setup
If deploying Kview in a team environment or shared private cloud:
1. Deploy behind an authenticating reverse proxy such as:
1. Enable built-in authentication (`KVIEW_AUTH_MODE=token` or `oidc`) — for OIDC you can still front Kview with your identity provider of choice.
2. Alternatively/ additionally, deploy behind an authenticating reverse proxy such as:
- [OAuth2-Proxy](https://oauth2-proxy.github.io/oauth2-proxy/)
- [NGINX](https://nginx.org/) with `http_auth_basic_module` or OIDC
- [Caddy](https://caddyserver.com/) with forward auth
- [Cloudflare Access](https://www.cloudflare.com/products/zero-trust/access/) or AWS ALB / Azure App Gateway with authentication.
2. Restrict file permissions on the storage directory (`./data` by default), which contains `connections.json` and saved cluster credentials.
3. Restrict file permissions on the storage directory (`./data` by default), which contains `connections.json` and saved cluster credentials.

---

Expand Down
11 changes: 9 additions & 2 deletions cli/kview.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,16 @@ const num = (name) => flags[name] === undefined ? undefined : Number(flags[name]

const clusterPath = (suffix = '') => `/api/clusters/${encodeURIComponent(CLUSTER)}${suffix}`;

// API bearer token: --token flag or KVIEW_TOKEN (needed when the server runs with kview.auth.mode != none)
const TOKEN = typeof flags.token === 'string' && flags.token ? flags.token : (process.env.KVIEW_TOKEN || '');
const authHeaders = () => (TOKEN ? { Authorization: `Bearer ${TOKEN}` } : {});

async function api(method, path, body) {
let res;
try {
res = await fetch(SERVER + path, {
method,
headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
headers: { ...authHeaders(), ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}) },
body: body !== undefined ? JSON.stringify(body) : undefined,
});
} catch (e) {
Expand Down Expand Up @@ -110,6 +114,7 @@ Groups: groups | lag <group> | reset-offsets <group> --topic T --mode earlies

Options: --server URL (-s) Kview server [KVIEW_URL, default http://localhost:8090]
--cluster ID (-c) cluster id [KVIEW_CLUSTER, default "default"]
--token T API bearer token (auth-enabled servers) [KVIEW_TOKEN]
--json (-j) raw JSON output

Examples: kview overview
Expand Down Expand Up @@ -223,7 +228,9 @@ Examples: kview overview
const topic = positional[1] ?? fail('usage: kview tail <topic> [--from latest|earliest] [--partition N]');
const qs = new URLSearchParams({ from: flags.from ?? 'latest' });
if (flags.partition !== undefined) qs.set('partition', String(flags.partition));
const res = await fetch(`${SERVER}${clusterPath(`/topics/${encodeURIComponent(topic)}/tail`)}?${qs}`);
const res = await fetch(`${SERVER}${clusterPath(`/topics/${encodeURIComponent(topic)}/tail`)}?${qs}`, {
headers: authHeaders(),
});
if (!res.ok || !res.body) fail(`HTTP ${res.status} while opening tail`);
console.error(`tailing ${topic} (${flags.from ?? 'latest'}) — Ctrl+C to stop`);
const dec = new TextDecoder();
Expand Down
8 changes: 7 additions & 1 deletion mcp/kview-mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@
// Environment:
// KVIEW_URL base URL of the kview server (default http://localhost:8090)
// KVIEW_CLUSTER default cluster/connection id (default "default")
// KVIEW_TOKEN API bearer token (needed when the server runs with kview.auth.mode != none)

import { createInterface } from 'node:readline';

const KVIEW_URL = (process.env.KVIEW_URL || 'http://localhost:8090').replace(/\/+$/, '');
const DEFAULT_CLUSTER = process.env.KVIEW_CLUSTER || 'default';
const TOKEN = process.env.KVIEW_TOKEN || '';
const VERSION = '1.0.0';
const MAX_VALUE_CHARS = 4000; // per-string truncation inside tool output

Expand All @@ -30,8 +32,12 @@ async function api(path, options = {}) {
let res;
try {
res = await fetch(KVIEW_URL + path, {
headers: { 'Content-Type': 'application/json' },
...options,
headers: {
'Content-Type': 'application/json',
...(TOKEN ? { Authorization: `Bearer ${TOKEN}` } : {}),
...(options.headers || {}),
},
});
} catch (e) {
throw new Error(`Kview is not reachable at ${KVIEW_URL} (${e.message}). Is the kview process running?`);
Expand Down
8 changes: 8 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package com.ritikbansod.kafkawrapper.auth;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequestWrapper;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.util.Collections;
import java.util.Enumeration;
import java.util.List;

/**
* Lets SSE clients (the browser's EventSource cannot send headers) present a
* bearer token via {@code ?access_token=...} — the parameter is turned into the
* Authorization header the rest of the chain expects. Real-header requests are
* left untouched.
*/
final class AccessTokenParamFilter extends OncePerRequestFilter {

@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
return !request.getRequestURI().startsWith("/api/");
}

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
String token = request.getParameter("access_token");
if (token == null || token.isBlank() || request.getHeader("Authorization") != null) {
chain.doFilter(request, response);
return;
}
chain.doFilter(new BearerHeaderRequest(request, token), response);
}

private static final class BearerHeaderRequest extends HttpServletRequestWrapper {

private BearerHeaderRequest(HttpServletRequest delegate, String token) {
super(wrap(delegate, token));
}

private static HttpServletRequest wrap(HttpServletRequest delegate, String token) {
String value = "Bearer " + token;
return new HttpServletRequestWrapper(delegate) {
@Override
public String getHeader(String name) {
if ("Authorization".equalsIgnoreCase(name)) return value;
return super.getHeader(name);
}

@Override
public Enumeration<String> getHeaders(String name) {
if ("Authorization".equalsIgnoreCase(name)) return Collections.enumeration(List.of(value));
return super.getHeaders(name);
}

@Override
public Enumeration<String> getHeaderNames() {
List<String> names = new java.util.ArrayList<>(Collections.list(super.getHeaderNames()));
if (!names.contains("Authorization")) names.add("Authorization");
return Collections.enumeration(names);
}
};
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.ritikbansod.kafkawrapper.auth;

import org.springframework.boot.context.properties.ConfigurationProperties;

import java.util.List;

/**
* API authentication settings ({@code kview.auth.*}).
*
* <ul>
* <li>{@code mode=none} (default) — no authentication; safe for the default
* loopback binding on a single-user machine.</li>
* <li>{@code mode=token} — static bearer tokens from {@code tokens}
* ("token" = admin, "token:readonly" = read-only).</li>
* <li>{@code mode=oidc} — JWT bearer tokens validated against the OAuth2/OIDC
* issuer configured via {@code spring.security.oauth2.resourceserver.jwt.*};
* the {@code adminRoleClaim} decides admin vs read-only.</li>
* </ul>
*/
@ConfigurationProperties(prefix = "kview.auth")
public record AuthProperties(
String mode,
List<String> tokens,
List<String> allowedOrigins,
String adminRoleClaim,
String adminRoleValue,
String readonlyRoleValue,
String audience) {

public static final String MODE_NONE = "none";
public static final String MODE_TOKEN = "token";
public static final String MODE_OIDC = "oidc";

public AuthProperties {
mode = mode == null || mode.isBlank() ? MODE_NONE : mode.toLowerCase();
tokens = tokens == null ? List.of() : tokens;
allowedOrigins = allowedOrigins == null ? List.of() : allowedOrigins;
adminRoleClaim = adminRoleClaim == null || adminRoleClaim.isBlank() ? "roles" : adminRoleClaim;
adminRoleValue = adminRoleValue == null || adminRoleValue.isBlank() ? "kview-admin" : adminRoleValue;
readonlyRoleValue = readonlyRoleValue == null || readonlyRoleValue.isBlank() ? "kview-readonly" : readonlyRoleValue;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.ritikbansod.kafkawrapper.auth;

import org.springframework.core.convert.converter.Converter;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;

import java.util.List;

/**
* Maps a validated JWT to the Kview roles: a token carrying
* {@code kview.auth.admin-role-value} inside {@code kview.auth.admin-role-claim}
* is an admin; every other valid token is read-only (least privilege).
*/
final class JwtRolesConverter implements Converter<Jwt, AbstractAuthenticationToken> {

private final AuthProperties props;

JwtRolesConverter(AuthProperties props) {
this.props = props;
}

@Override
public AbstractAuthenticationToken convert(Jwt jwt) {
boolean admin = claimValues(jwt).contains(props.adminRoleValue());
var authorities = List.of(new SimpleGrantedAuthority(admin ? "ROLE_ADMIN" : "ROLE_READONLY"));
return new JwtAuthenticationToken(jwt, authorities);
}

/** Reads the configured claim as a list, tolerating a single string or a space/comma list (scope style). */
private List<String> claimValues(Jwt jwt) {
Object value = jwt.getClaims().get(props.adminRoleClaim());
if (value instanceof List<?> list) return list.stream().map(String::valueOf).toList();
if (value instanceof String s && !s.isBlank()) return List.of(s.split("[\\s,]+"));
return List.of();
}
}
Loading