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

### 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.
- **AI/MCP safety pack**: global read-only mode (`KVIEW_READONLY=true` — every mutation refused for all clients and roles, UI shows a banner), an append-only mutation audit trail (`KVIEW_AUDIT=true` → `<data-dir>/audit.log` JSONL with client, method, path and status; all three clients send an `X-Kview-Client` identifier), a public `GET /api/meta` endpoint, and a per-tool allowlist for the MCP server (`KVIEW_ALLOWED_TOOLS`, e.g. serve only read-only tools to an AI agent).

### 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"`.
Expand Down
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,9 @@ 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`; `KVIEW_TOKEN` when the server runs with authentication). Check your setup without a client:
`KVIEW_CLUSTER`, default `default`; `KVIEW_TOKEN` when the server runs with authentication;
`KVIEW_ALLOWED_TOOLS` restricts the served tools to a comma-separated allowlist, e.g. only
read-only tools for an AI agent). Check your setup without a client:

```bash
node scripts/mcp-smoke-test.mjs # 25 checks incl. produce -> browse round-trip
Expand Down Expand Up @@ -183,6 +185,8 @@ browser. The API returns masked values, and saving a masked value keeps the stor
| `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) |
| `KVIEW_READONLY` | `false` | global read-only mode: every mutation is refused, for all clients and roles (UI shows a banner) |
| `KVIEW_AUDIT` | `false` | append `<data-dir>/audit.log` JSONL line per mutation: who (client), what (method + path), outcome (status) |
| `server.port` (application.yml) | `8090` | http port |

## Authentication
Expand Down Expand Up @@ -228,6 +232,22 @@ Clients: the web UI prompts for the token (🔑 button in the top bar), the CLI
`--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`.

### Read-only mode & audit trail

Two more switches pair with authentication for a production- or AI-safe setup:

- **`KVIEW_READONLY=true`** — global read-only: every mutation (produce, topic
create/delete/edit, offset resets, group deletion, connection management) is refused with
a clear 403, for all clients and all roles, while browsing/tailing/dashboards keep
working. The web UI shows a banner; `GET /api/meta` reports `{"readonly":true,...}` for scripts.
- **`KVIEW_AUDIT=true`** — append-only audit trail: every mutation is logged as one JSONL
line in `<data-dir>/audit.log` with the sending client (`X-Kview-Client`, e.g.
`kview-mcp/1.0.0` for AI agents, `kview-cli/…`, `kview-ui`), the full path (cluster +
topic + action) and the response status. Message payloads are never logged.

All three official clients identify themselves with an `X-Kview-Client` header, so an
agent's writes are distinguishable from a human's in the audit log.

## Roadmap

- Message replay: copy a filtered set of messages to another topic or cluster
Expand Down
4 changes: 4 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ resets, group deletion and connection management require the **admin** role. COR
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.

### Read-only mode & audit trail
- `KVIEW_READONLY=true` disables every mutation server-side — for all clients (UI, CLI, MCP agents) and all roles. Pair it with `KVIEW_AUTH_MODE` for an explore-only deployment, or run it alone to turn a shared Kview into a pure viewer.
- `KVIEW_AUDIT=true` appends one JSONL line per mutation to `<data-dir>/audit.log`: timestamp, client identification (`X-Kview-Client` — `kview-mcp/x.y.z` for AI agents, `kview-cli`, `kview-ui`), method, full path (cluster, topic, action) and response status. Message payloads are never logged. Restrict the MCP server further with `KVIEW_ALLOWED_TOOLS` (comma-separated tool allowlist).

### 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.
Expand Down
5 changes: 4 additions & 1 deletion cli/kview.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,10 @@ const clusterPath = (suffix = '') => `/api/clusters/${encodeURIComponent(CLUSTER

// 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}` } : {});
const authHeaders = () => ({
'X-Kview-Client': `kview-cli/${VERSION}`,
...(TOKEN ? { Authorization: `Bearer ${TOKEN}` } : {}),
});

async function api(method, path, body) {
let res;
Expand Down
21 changes: 17 additions & 4 deletions mcp/kview-mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ 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 || '';
// optional per-tool allowlist: KVIEW_ALLOWED_TOOLS="kview_overview,kview_browse" — only these tools are served
const ALLOWED_TOOLS = (process.env.KVIEW_ALLOWED_TOOLS || '').split(',').map((s) => s.trim()).filter(Boolean);
const VERSION = '1.0.0';
const MAX_VALUE_CHARS = 4000; // per-string truncation inside tool output

Expand All @@ -35,6 +37,7 @@ async function api(path, options = {}) {
...options,
headers: {
'Content-Type': 'application/json',
'X-Kview-Client': `kview-mcp/${VERSION}`,
...(TOKEN ? { Authorization: `Bearer ${TOKEN}` } : {}),
...(options.headers || {}),
},
Expand Down Expand Up @@ -324,6 +327,11 @@ const TOOLS = [
},
];

// when an allowlist is set, only those tools exist for the client
const SERVED_TOOLS = ALLOWED_TOOLS.length
? TOOLS.filter((t) => ALLOWED_TOOLS.includes(t.name))
: TOOLS;

// ---- MCP protocol plumbing (JSON-RPC 2.0 over stdio, NDJSON framing) ---

function send(obj) {
Expand All @@ -343,10 +351,14 @@ async function dispatch(method, params = {}) {
serverInfo: { name: 'kview', title: 'Kview — Apache Kafka MCP', version: VERSION },
};
case 'tools/list':
return { tools: TOOLS.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })) };
return { tools: SERVED_TOOLS.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })) };
case 'tools/call': {
const tool = TOOLS.find((t) => t.name === params.name);
if (!tool) return toolResult(`Unknown tool: ${params.name}`, true);
const tool = SERVED_TOOLS.find((t) => t.name === params.name);
if (!tool) {
return toolResult(ALLOWED_TOOLS.length
? `Tool not allowed. Enabled tools: ${SERVED_TOOLS.map((t) => t.name).join(', ')}`
: `Unknown tool: ${params.name}`, true);
}
try {
const data = await tool.run(params.arguments || {});
return toolResult(JSON.stringify(slim(data), null, 2));
Expand Down Expand Up @@ -394,4 +406,5 @@ rl.on('line', (line) => {
});
rl.on('close', () => process.exit(0));

log(`kview MCP server ready — REST base ${KVIEW_URL}, default cluster "${DEFAULT_CLUSTER}", ${TOOLS.length} tools`);
log(`kview MCP server ready — REST base ${KVIEW_URL}, default cluster "${DEFAULT_CLUSTER}", ` +
`${SERVED_TOOLS.length} tools${ALLOWED_TOOLS.length ? ` (allowlist: ${ALLOWED_TOOLS.join(', ')})` : ''}`);
77 changes: 77 additions & 0 deletions src/main/java/com/ritikbansod/kafkawrapper/auth/AuditFilter.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package com.ritikbansod.kafkawrapper.auth;

import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;

/**
* Append-only JSONL audit trail ({@code kview.audit=true}): one line per
* mutation request — who sent it (X-Kview-Client / User-Agent), what (method +
* full path, which includes the cluster and topic) and how it ended (status).
* Message payloads are never logged.
*/
final class AuditFilter extends OncePerRequestFilter {

private static final Logger log = LoggerFactory.getLogger(AuditFilter.class);
private static final String CLIENT_HEADER = "X-Kview-Client";

private final Path file;
private final ObjectMapper mapper;

AuditFilter(String dataDir, ObjectMapper mapper) {
this.file = Path.of(dataDir, "audit.log");
this.mapper = mapper;
log.info("Kview audit trail is ON — mutations are appended to {}", file);
}

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
try {
chain.doFilter(request, response);
} finally {
record(request, response);
}
}

private void record(HttpServletRequest request, HttpServletResponse response) {
if (!MutationRequests.INSTANCE.matches(request)) return;
Map<String, Object> entry = new LinkedHashMap<>();
entry.put("ts", Instant.now().toString());
entry.put("method", request.getMethod());
entry.put("path", request.getRequestURI());
if (request.getQueryString() != null) entry.put("query", request.getQueryString());
entry.put("status", response.getStatus());
entry.put("client", clientOf(request));
try {
synchronized (this) {
Files.createDirectories(file.getParent());
Files.writeString(file, mapper.writeValueAsString(entry) + System.lineSeparator(),
StandardCharsets.UTF_8, StandardOpenOption.CREATE, StandardOpenOption.APPEND);
}
} catch (IOException e) {
log.warn("Failed to append to the audit trail {}: {}", file, e.getMessage());
}
}

private static String clientOf(HttpServletRequest request) {
String client = request.getHeader(CLIENT_HEADER);
if (client != null && !client.isBlank()) return client;
String userAgent = request.getHeader("User-Agent");
return userAgent == null || userAgent.isBlank() ? "unknown" : userAgent;
}
}
19 changes: 19 additions & 0 deletions src/main/java/com/ritikbansod/kafkawrapper/auth/JsonError.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.ritikbansod.kafkawrapper.auth;

import jakarta.servlet.http.HttpServletResponse;

import java.io.IOException;

/** JSON error bodies in the API's {@code {error, detail}} shape, for filter-level rejections. */
final class JsonError {

private JsonError() { }

static void write(HttpServletResponse response, int status, String error, String detail) throws IOException {
response.setStatus(status);
response.setHeader("WWW-Authenticate", "Bearer");
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
response.getWriter().write("{\"error\":\"" + error + "\",\"detail\":\"" + detail + "\"}");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.ritikbansod.kafkawrapper.auth;

import jakarta.servlet.http.HttpServletRequest;
import org.springframework.security.web.util.matcher.RequestMatcher;
import org.springframework.util.AntPathMatcher;
import org.springframework.util.PathMatcher;

import java.util.List;

/**
* Matches mutation requests under {@code /api}: every non-GET except the
* read-shaped POSTs (browse, decode, encode, test). One definition shared by
* the security chain (admin-only rule), the read-only-mode filter and the
* audit trail.
*/
final class MutationRequests implements RequestMatcher {

static final MutationRequests INSTANCE = new MutationRequests();

private static final PathMatcher ANT = new AntPathMatcher();
private static final List<String> READ_SHAPED_POSTS = List.of("/**/browse", "/**/decode", "/**/encode", "/**/test");

private MutationRequests() { }

@Override
public boolean matches(HttpServletRequest request) {
String method = request.getMethod();
if ("GET".equalsIgnoreCase(method) || "HEAD".equalsIgnoreCase(method)
|| "OPTIONS".equalsIgnoreCase(method)) {
return false;
}
String path = request.getRequestURI();
if (!path.startsWith("/api/")) return false;
if (!"POST".equalsIgnoreCase(method)) return true;
return READ_SHAPED_POSTS.stream().noneMatch(pattern -> ANT.match(pattern, path));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.ritikbansod.kafkawrapper.auth;

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

import java.io.IOException;

/**
* Global read-only mode ({@code kview.readonly=true}): every mutation is
* rejected with 403 — for all clients (UI, CLI, MCP) and all roles. Registered
* ahead of the security chain so the reason is "read-only mode" even without
* credentials.
*/
final class ReadOnlyModeFilter extends OncePerRequestFilter {

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
if (MutationRequests.INSTANCE.matches(request)) {
JsonError.write(response, HttpServletResponse.SC_FORBIDDEN, "forbidden",
"Kview is running in read-only mode (KVIEW_READONLY=true) — mutations are disabled.");
return;
}
chain.doFilter(request, response);
}
}
Loading