From 7af04bc2b58103014d0717882f4b7d636d8d612c Mon Sep 17 00:00:00 2001 From: ritikbansod2912 Date: Sun, 27 Sep 2026 02:28:04 +0530 Subject: [PATCH] feat(safety): global read-only mode, mutation audit trail, MCP tool allowlist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements F7 from the deep analysis — the AI/MCP safety pack: - KVIEW_READONLY=true rejects every mutation with a clear 403, for all clients and all roles (filter ahead of the security chain); the web UI shows a banner and GET /api/meta reports the mode for scripts - KVIEW_AUDIT=true appends one JSONL line per mutation to /audit.log: timestamp, client (X-Kview-Client), method, full path (cluster + topic + action) and response status; payloads never logged - all three clients identify themselves with X-Kview-Client (kview-ui / kview-cli/ / kview-mcp/) so an agent's writes are distinguishable from a human's in the audit log - MCP server: KVIEW_ALLOWED_TOOLS restricts the served tools to a comma-separated allowlist (e.g. read-only tools for an AI agent); disallowed calls get a clear error listing the enabled tools - tests: read-only matrix (mutations refused, reads + read-shaped POSTs fine, meta public) and audit trail (mutation lines, reads not audited); MCP allowlist verified over stdio; full suite 49/49 --- CHANGELOG.md | 1 + README.md | 22 +++++- SECURITY.md | 4 + cli/kview.mjs | 5 +- mcp/kview-mcp.js | 21 ++++- .../kafkawrapper/auth/AuditFilter.java | 77 +++++++++++++++++++ .../kafkawrapper/auth/JsonError.java | 19 +++++ .../kafkawrapper/auth/MutationRequests.java | 37 +++++++++ .../kafkawrapper/auth/ReadOnlyModeFilter.java | 29 +++++++ .../kafkawrapper/auth/SecurityConfig.java | 66 +++++++--------- .../kafkawrapper/web/MetaController.java | 34 ++++++++ src/main/resources/static/css/app.css | 9 +++ src/main/resources/static/index.html | 1 + src/main/resources/static/js/api.js | 2 +- src/main/resources/static/js/main.js | 8 ++ .../kafkawrapper/auth/AuditLogTest.java | 52 +++++++++++++ .../kafkawrapper/auth/ReadOnlyModeTest.java | 55 +++++++++++++ 17 files changed, 395 insertions(+), 47 deletions(-) create mode 100644 src/main/java/com/ritikbansod/kafkawrapper/auth/AuditFilter.java create mode 100644 src/main/java/com/ritikbansod/kafkawrapper/auth/JsonError.java create mode 100644 src/main/java/com/ritikbansod/kafkawrapper/auth/MutationRequests.java create mode 100644 src/main/java/com/ritikbansod/kafkawrapper/auth/ReadOnlyModeFilter.java create mode 100644 src/main/java/com/ritikbansod/kafkawrapper/web/MetaController.java create mode 100644 src/test/java/com/ritikbansod/kafkawrapper/auth/AuditLogTest.java create mode 100644 src/test/java/com/ritikbansod/kafkawrapper/auth/ReadOnlyModeTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index fc1b215..6993dad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` → `/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"`. diff --git a/README.md b/README.md index a9f876e..5f2afa1 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 `/audit.log` JSONL line per mutation: who (client), what (method + path), outcome (status) | | `server.port` (application.yml) | `8090` | http port | ## Authentication @@ -228,6 +232,22 @@ Clients: the web UI prompts for the token (🔑 button in the top bar), the CLI `--token ` / `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 `/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 diff --git a/SECURITY.md b/SECURITY.md index e3e52a8..6548866 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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 `/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. diff --git a/cli/kview.mjs b/cli/kview.mjs index c533600..868a8bf 100644 --- a/cli/kview.mjs +++ b/cli/kview.mjs @@ -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; diff --git a/mcp/kview-mcp.js b/mcp/kview-mcp.js index d91d4ec..7661bd8 100644 --- a/mcp/kview-mcp.js +++ b/mcp/kview-mcp.js @@ -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 @@ -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 || {}), }, @@ -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) { @@ -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)); @@ -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(', ')})` : ''}`); diff --git a/src/main/java/com/ritikbansod/kafkawrapper/auth/AuditFilter.java b/src/main/java/com/ritikbansod/kafkawrapper/auth/AuditFilter.java new file mode 100644 index 0000000..90d2d87 --- /dev/null +++ b/src/main/java/com/ritikbansod/kafkawrapper/auth/AuditFilter.java @@ -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 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; + } +} diff --git a/src/main/java/com/ritikbansod/kafkawrapper/auth/JsonError.java b/src/main/java/com/ritikbansod/kafkawrapper/auth/JsonError.java new file mode 100644 index 0000000..f098630 --- /dev/null +++ b/src/main/java/com/ritikbansod/kafkawrapper/auth/JsonError.java @@ -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 + "\"}"); + } +} diff --git a/src/main/java/com/ritikbansod/kafkawrapper/auth/MutationRequests.java b/src/main/java/com/ritikbansod/kafkawrapper/auth/MutationRequests.java new file mode 100644 index 0000000..a54385b --- /dev/null +++ b/src/main/java/com/ritikbansod/kafkawrapper/auth/MutationRequests.java @@ -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 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)); + } +} diff --git a/src/main/java/com/ritikbansod/kafkawrapper/auth/ReadOnlyModeFilter.java b/src/main/java/com/ritikbansod/kafkawrapper/auth/ReadOnlyModeFilter.java new file mode 100644 index 0000000..2ad87eb --- /dev/null +++ b/src/main/java/com/ritikbansod/kafkawrapper/auth/ReadOnlyModeFilter.java @@ -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); + } +} diff --git a/src/main/java/com/ritikbansod/kafkawrapper/auth/SecurityConfig.java b/src/main/java/com/ritikbansod/kafkawrapper/auth/SecurityConfig.java index 72f7fab..3c2c467 100644 --- a/src/main/java/com/ritikbansod/kafkawrapper/auth/SecurityConfig.java +++ b/src/main/java/com/ritikbansod/kafkawrapper/auth/SecurityConfig.java @@ -1,11 +1,13 @@ package com.ritikbansod.kafkawrapper.auth; -import jakarta.servlet.http.HttpServletRequest; +import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.servlet.http.HttpServletResponse; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.Customizer; @@ -24,14 +26,10 @@ import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; -import org.springframework.security.web.util.matcher.RequestMatcher; -import org.springframework.util.AntPathMatcher; -import org.springframework.util.PathMatcher; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; -import java.io.IOException; import java.util.List; /** @@ -52,9 +50,6 @@ public class SecurityConfig { private static final Logger log = LoggerFactory.getLogger(SecurityConfig.class); - /** POST endpoints that read, not mutate — a read-only role may call these. */ - private static final List READ_SHAPED_POSTS = List.of("/**/browse", "/**/decode", "/**/encode", "/**/test"); - private final AuthProperties auth; public SecurityConfig(AuthProperties auth) { @@ -80,9 +75,10 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests(a -> a .requestMatchers("/", "/index.html", "/css/**", "/js/**", "/favicon.ico", "/error", - "/actuator/health").permitAll() + "/api/meta", "/actuator/health").permitAll() .requestMatchers("/actuator/**").hasAnyRole("ADMIN", "READONLY") - .requestMatchers(MutationMatcher.INSTANCE).hasRole("ADMIN") + .requestMatchers(MutationRequests.INSTANCE) + .hasRole("ADMIN") .requestMatchers("/api/**").hasAnyRole("ADMIN", "READONLY") .anyRequest().permitAll()) .exceptionHandling(e -> e @@ -141,50 +137,40 @@ public CorsConfigurationSource corsConfigurationSource() { } else { // authenticated: no wildcard — opt in per origin config.setAllowedOrigins(auth.allowedOrigins()); - config.setAllowedHeaders(List.of("Authorization", "Content-Type")); + config.setAllowedHeaders(List.of("Authorization", "Content-Type", "X-Kview-Client")); } UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/api/**", config); return source; } - /** Mutations under /api: every non-GET except the read-shaped POSTs (browse, decode, encode, test). */ - static final class MutationMatcher implements RequestMatcher { - - static final MutationMatcher INSTANCE = new MutationMatcher(); - private static final PathMatcher ANT = new AntPathMatcher(); - - @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)); - } + /** Global read-only mode (kview.readonly=true): mutations rejected before anything else runs. */ + @Bean + @ConditionalOnProperty(name = "kview.readonly", havingValue = "true") + public FilterRegistrationBean readOnlyModeFilter() { + FilterRegistrationBean bean = new FilterRegistrationBean<>(new ReadOnlyModeFilter()); + bean.setOrder(-110); // ahead of the security chain (-100): the reason is "read-only", not "unauthorized" + return bean; + } + + /** Append-only JSONL audit trail (kview.audit=true) — one line per mutation request. */ + @Bean + @ConditionalOnProperty(name = "kview.audit", havingValue = "true") + public FilterRegistrationBean auditFilter( + @Value("${kview.data-dir:./data}") String dataDir, ObjectMapper mapper) { + FilterRegistrationBean bean = new FilterRegistrationBean<>(new AuditFilter(dataDir, mapper)); + bean.setOrder(10); // outside the security chain, so the final response status is recorded + return bean; } private static org.springframework.security.web.AuthenticationEntryPoint jsonEntryPoint() { - return (request, response, authException) -> writeJson(response, HttpServletResponse.SC_UNAUTHORIZED, + return (request, response, authException) -> JsonError.write(response, HttpServletResponse.SC_UNAUTHORIZED, "unauthorized", "Authentication required — send 'Authorization: Bearer ' " + "(see the Authentication section in the README)."); } private static org.springframework.security.web.access.AccessDeniedHandler jsonDeniedHandler() { - return (request, response, accessDeniedException) -> writeJson(response, HttpServletResponse.SC_FORBIDDEN, + return (request, response, accessDeniedException) -> JsonError.write(response, HttpServletResponse.SC_FORBIDDEN, "forbidden", "This operation requires the admin role."); } - - private static void writeJson(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 + "\"}"); - } } diff --git a/src/main/java/com/ritikbansod/kafkawrapper/web/MetaController.java b/src/main/java/com/ritikbansod/kafkawrapper/web/MetaController.java new file mode 100644 index 0000000..11c5f2f --- /dev/null +++ b/src/main/java/com/ritikbansod/kafkawrapper/web/MetaController.java @@ -0,0 +1,34 @@ +package com.ritikbansod.kafkawrapper.web; + +import com.ritikbansod.kafkawrapper.auth.AuthProperties; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Public runtime info for clients: the UI uses it to show the read-only banner, + * scripts can detect the auth mode before hitting protected endpoints. + */ +@RestController +public class MetaController { + + private final AuthProperties auth; + private final boolean readonly; + + public MetaController(AuthProperties auth, + @Value("${kview.readonly:false}") boolean readonly) { + this.auth = auth; + this.readonly = readonly; + } + + @GetMapping("/api/meta") + public Map meta() { + Map meta = new LinkedHashMap<>(); + meta.put("readonly", readonly); + meta.put("authMode", auth.mode()); + return meta; + } +} diff --git a/src/main/resources/static/css/app.css b/src/main/resources/static/css/app.css index 97e1a7c..aea6ba7 100644 --- a/src/main/resources/static/css/app.css +++ b/src/main/resources/static/css/app.css @@ -665,3 +665,12 @@ tr[tabindex]:focus-visible, .pcard:focus-visible { button.arch-broker-chip { font: inherit; color: var(--text); background: var(--panel); } button.arch-broker-chip:hover { border-color: var(--accent); } .arch-broker-chip.is-active { border-color: var(--accent); background: var(--accent-soft); box-shadow: 0 0 0 1px var(--accent-soft); } + +/* read-only mode banner (KVIEW_READONLY=true) */ +.readonly-banner { + background: #b45309; + color: #ffffff; + padding: 6px 16px; + font-size: 12.5px; + font-weight: 500; +} diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html index 37b0b20..895326b 100644 --- a/src/main/resources/static/index.html +++ b/src/main/resources/static/index.html @@ -58,6 +58,7 @@
+
diff --git a/src/main/resources/static/js/api.js b/src/main/resources/static/js/api.js index 2367d06..17156ce 100644 --- a/src/main/resources/static/js/api.js +++ b/src/main/resources/static/js/api.js @@ -15,7 +15,7 @@ export function setToken(token) { } function headers(body) { - const h = {}; + const h = { 'X-Kview-Client': 'kview-ui' }; const token = getToken(); if (token) h.Authorization = `Bearer ${token}`; if (body !== undefined) h['Content-Type'] = 'application/json'; diff --git a/src/main/resources/static/js/main.js b/src/main/resources/static/js/main.js index 801773f..39f72d1 100644 --- a/src/main/resources/static/js/main.js +++ b/src/main/resources/static/js/main.js @@ -136,6 +136,14 @@ document.addEventListener('keydown', (e) => { if (SHORTCUTS[e.key]) location.hash = SHORTCUTS[e.key]; }); +// ---- read-only awareness (server can run with KVIEW_READONLY=true) ---- +fetch('/api/meta').then((r) => r.json()).then((meta) => { + if (meta.readonly) { + const banner = document.getElementById('readonly-banner'); + if (banner) banner.hidden = false; + } +}).catch(() => { /* meta is best-effort */ }); + loadClusters().then(render).catch((err) => { document.getElementById('view').innerHTML = `

Backend unreachable

${esc(err.message || err)}
diff --git a/src/test/java/com/ritikbansod/kafkawrapper/auth/AuditLogTest.java b/src/test/java/com/ritikbansod/kafkawrapper/auth/AuditLogTest.java new file mode 100644 index 0000000..08f32cb --- /dev/null +++ b/src/test/java/com/ritikbansod/kafkawrapper/auth/AuditLogTest.java @@ -0,0 +1,52 @@ +package com.ritikbansod.kafkawrapper.auth; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The audit trail ({@code KVIEW_AUDIT=true}): one JSONL line per mutation with + * method, path (cluster + topic), status and client; reads are not audited. + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = { + "kview.data-dir=target/audit-test-data", + "kview.audit=true" +}) +class AuditLogTest { + + @Autowired + TestRestTemplate rest; + + private static final Path AUDIT = Path.of("target/audit-test-data/audit.log"); + + @Test + void mutationsAreAuditedWithMethodPathStatusAndClient() throws Exception { + var headers = new org.springframework.http.HttpHeaders(); + headers.set("X-Kview-Client", "kview-cli/test"); + rest.exchange("/api/clusters/does-not-exist/topics/some-topic", + HttpMethod.DELETE, new HttpEntity(headers), String.class); + + assertThat(AUDIT).exists(); + String line = Files.readString(AUDIT).trim(); + assertThat(line).contains("\"method\":\"DELETE\""); + assertThat(line).contains("/api/clusters/does-not-exist/topics/some-topic"); + assertThat(line).contains("\"status\":404"); + assertThat(line).contains("\"client\":\"kview-cli/test\""); + } + + @Test + void readsAreNotAudited() throws Exception { + int linesBefore = Files.exists(AUDIT) ? Files.readAllLines(AUDIT).size() : 0; + rest.getForEntity("/api/clusters", String.class); + int linesAfter = Files.exists(AUDIT) ? Files.readAllLines(AUDIT).size() : 0; + assertThat(linesAfter).isEqualTo(linesBefore); + } +} diff --git a/src/test/java/com/ritikbansod/kafkawrapper/auth/ReadOnlyModeTest.java b/src/test/java/com/ritikbansod/kafkawrapper/auth/ReadOnlyModeTest.java new file mode 100644 index 0000000..5d8ec5f --- /dev/null +++ b/src/test/java/com/ritikbansod/kafkawrapper/auth/ReadOnlyModeTest.java @@ -0,0 +1,55 @@ +package com.ritikbansod.kafkawrapper.auth; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Global read-only mode ({@code KVIEW_READONLY=true}): every mutation is + * rejected — in every auth mode, for every role — while reads (including the + * read-shaped POSTs) keep working. + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = { + "kview.data-dir=target/readonly-test-data", + "kview.readonly=true" +}) +class ReadOnlyModeTest { + + @Autowired + TestRestTemplate rest; + + @Test + void mutationsAreRejectedWithTheReadOnlyReason() { + var response = rest.exchange("/api/clusters/does-not-exist/topics/some-topic", + HttpMethod.DELETE, HttpEntity.EMPTY, String.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + assertThat(response.getBody()).contains("read-only mode"); + } + + @Test + void readsStillWork() { + assertThat(rest.getForEntity("/api/clusters", String.class).getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + void readShapedPostsStillWork() { + var headers = new org.springframework.http.HttpHeaders(); + headers.setContentType(org.springframework.http.MediaType.APPLICATION_JSON); + var response = rest.exchange("/api/clusters/does-not-exist/topics/some-topic/browse", + HttpMethod.POST, new HttpEntity<>("{\"start\":\"latest\",\"limit\":1}", headers), String.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); // past the read-only check + } + + @Test + void metaEndpointIsPublicAndReportsReadonly() { + var response = rest.getForEntity("/api/meta", String.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).contains("\"readonly\":true"); + } +}