From e2d74e765d72a87c18fbab95e5a465460b11fe27 Mon Sep 17 00:00:00 2001 From: ritikbansod2912 Date: Sun, 27 Sep 2026 02:04:40 +0530 Subject: [PATCH] feat(auth): API authentication with admin and read-only roles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements F1 from the deep analysis — the high-severity finding that the entire API was unauthenticated with permissive CORS (WebConfig.java). - kview.auth.mode=none (default): zero-config localhost behavior, exactly as before - kview.auth.mode=token: static bearer tokens via KVIEW_AUTH_TOKENS (TOKEN = admin, TOKEN:readonly = read-only) - kview.auth.mode=oidc: JWTs from any OIDC issuer (issuer-uri/jwk-set-uri via spring.security.oauth2.resourceserver.jwt.*), optional audience check, role decided by a configurable claim (default roles: kview-admin) - roles: reads under /api/** need any role; mutations (POST/PUT/DELETE except the read-shaped posts browse/decode/encode/test) need admin; static UI and /actuator/health stay public - CORS stays permissive only in none-mode; authenticated modes close it unless KVIEW_AUTH_ALLOWED_ORIGINS lists origins - SSE clients (EventSource cannot send headers) can pass ?access_token= - web UI prompts for the token on 401 (top-bar button to set/clear) - CLI: --token flag / KVIEW_TOKEN env on every call incl. tail - MCP server: KVIEW_TOKEN env on all tool calls - tests: 13 new (token-mode 401/403/200 matrix, read-shaped POSTs, public static/health, default-mode regression, JWT role converter) — full suite 43/43 - docs: README Authentication section + config rows, SECURITY.md rewrite --- CHANGELOG.md | 3 + README.md | 56 +++++- SECURITY.md | 26 ++- cli/kview.mjs | 11 +- mcp/kview-mcp.js | 8 +- pom.xml | 8 + .../auth/AccessTokenParamFilter.java | 69 +++++++ .../kafkawrapper/auth/AuthProperties.java | 42 ++++ .../kafkawrapper/auth/JwtRolesConverter.java | 38 ++++ .../kafkawrapper/auth/SecurityConfig.java | 190 ++++++++++++++++++ .../kafkawrapper/auth/TokenAuthFilter.java | 70 +++++++ .../kafkawrapper/web/WebConfig.java | 17 +- src/main/resources/static/index.html | 1 + src/main/resources/static/js/api.js | 29 ++- src/main/resources/static/js/main.js | 20 +- .../resources/static/js/views/explorer.js | 3 +- .../auth/AuthIntegrationTest.java | 91 +++++++++ .../auth/JwtRolesConverterTest.java | 56 ++++++ .../auth/NoAuthByDefaultTest.java | 37 ++++ 19 files changed, 742 insertions(+), 33 deletions(-) create mode 100644 src/main/java/com/ritikbansod/kafkawrapper/auth/AccessTokenParamFilter.java create mode 100644 src/main/java/com/ritikbansod/kafkawrapper/auth/AuthProperties.java create mode 100644 src/main/java/com/ritikbansod/kafkawrapper/auth/JwtRolesConverter.java create mode 100644 src/main/java/com/ritikbansod/kafkawrapper/auth/SecurityConfig.java create mode 100644 src/main/java/com/ritikbansod/kafkawrapper/auth/TokenAuthFilter.java create mode 100644 src/test/java/com/ritikbansod/kafkawrapper/auth/AuthIntegrationTest.java create mode 100644 src/test/java/com/ritikbansod/kafkawrapper/auth/JwtRolesConverterTest.java create mode 100644 src/test/java/com/ritikbansod/kafkawrapper/auth/NoAuthByDefaultTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b0756b..fc1b215 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` read a non-existent `byTopic` field and silently printed no per-partition lag rows — now reads `partitionsByTopic`, as the API serializes it. diff --git a/README.md b/README.md index 1174e96..a9f876e 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 ` (env `KVIEW_TOKEN`). ### 3. MCP server (AI assistants) @@ -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 @@ -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 `: + +```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 ` / `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 diff --git a/SECURITY.md b/SECURITY.md index 8916392..e3e52a8 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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 `; **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. --- diff --git a/cli/kview.mjs b/cli/kview.mjs index 6b27a02..c533600 100644 --- a/cli/kview.mjs +++ b/cli/kview.mjs @@ -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) { @@ -110,6 +114,7 @@ Groups: groups | lag | reset-offsets --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 @@ -223,7 +228,9 @@ Examples: kview overview const topic = positional[1] ?? fail('usage: kview tail [--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(); diff --git a/mcp/kview-mcp.js b/mcp/kview-mcp.js index 698f429..d91d4ec 100644 --- a/mcp/kview-mcp.js +++ b/mcp/kview-mcp.js @@ -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 @@ -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?`); diff --git a/pom.xml b/pom.xml index 7e3a31f..17a9085 100644 --- a/pom.xml +++ b/pom.xml @@ -36,6 +36,14 @@ org.springframework.boot spring-boot-starter-web + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + org.springframework.kafka spring-kafka diff --git a/src/main/java/com/ritikbansod/kafkawrapper/auth/AccessTokenParamFilter.java b/src/main/java/com/ritikbansod/kafkawrapper/auth/AccessTokenParamFilter.java new file mode 100644 index 0000000..1c431de --- /dev/null +++ b/src/main/java/com/ritikbansod/kafkawrapper/auth/AccessTokenParamFilter.java @@ -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 getHeaders(String name) { + if ("Authorization".equalsIgnoreCase(name)) return Collections.enumeration(List.of(value)); + return super.getHeaders(name); + } + + @Override + public Enumeration getHeaderNames() { + List names = new java.util.ArrayList<>(Collections.list(super.getHeaderNames())); + if (!names.contains("Authorization")) names.add("Authorization"); + return Collections.enumeration(names); + } + }; + } + } +} diff --git a/src/main/java/com/ritikbansod/kafkawrapper/auth/AuthProperties.java b/src/main/java/com/ritikbansod/kafkawrapper/auth/AuthProperties.java new file mode 100644 index 0000000..bbacbdf --- /dev/null +++ b/src/main/java/com/ritikbansod/kafkawrapper/auth/AuthProperties.java @@ -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.*}). + * + *
    + *
  • {@code mode=none} (default) — no authentication; safe for the default + * loopback binding on a single-user machine.
  • + *
  • {@code mode=token} — static bearer tokens from {@code tokens} + * ("token" = admin, "token:readonly" = read-only).
  • + *
  • {@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.
  • + *
+ */ +@ConfigurationProperties(prefix = "kview.auth") +public record AuthProperties( + String mode, + List tokens, + List 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; + } +} diff --git a/src/main/java/com/ritikbansod/kafkawrapper/auth/JwtRolesConverter.java b/src/main/java/com/ritikbansod/kafkawrapper/auth/JwtRolesConverter.java new file mode 100644 index 0000000..653a6d2 --- /dev/null +++ b/src/main/java/com/ritikbansod/kafkawrapper/auth/JwtRolesConverter.java @@ -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 { + + 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 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(); + } +} diff --git a/src/main/java/com/ritikbansod/kafkawrapper/auth/SecurityConfig.java b/src/main/java/com/ritikbansod/kafkawrapper/auth/SecurityConfig.java new file mode 100644 index 0000000..72f7fab --- /dev/null +++ b/src/main/java/com/ritikbansod/kafkawrapper/auth/SecurityConfig.java @@ -0,0 +1,190 @@ +package com.ritikbansod.kafkawrapper.auth; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtDecoders; +import org.springframework.security.oauth2.jwt.JwtValidators; +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; + +/** + * API authentication ({@code kview.auth.mode}, default {@code none}): + *
    + *
  • none — everything open (safe on the default loopback binding, zero config);
  • + *
  • token — static bearer tokens with admin / read-only roles;
  • + *
  • oidc — JWTs from any OIDC issuer, role from a configurable claim.
  • + *
+ * When authentication is on, reads under {@code /api/**} need any role, mutations + * need admin, CORS is closed unless {@code kview.auth.allowed-origins} says otherwise, + * and {@code /}, the static UI and {@code /actuator/health} stay public. + */ +@Configuration +@EnableWebSecurity +@EnableConfigurationProperties(AuthProperties.class) +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) { + this.auth = auth; + if (auth.mode().equals(AuthProperties.MODE_TOKEN) && auth.tokens().isEmpty()) { + log.warn("kview.auth.mode=token but no kview.auth.tokens configured — every request will be rejected"); + } + if (!auth.mode().equals(AuthProperties.MODE_NONE)) { + log.info("Kview API authentication is ON (mode {}, mutations need the admin role)", auth.mode()); + } + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http.csrf(AbstractHttpConfigurer::disable); + http.cors(Customizer.withDefaults()); + http.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); + + if (auth.mode().equals(AuthProperties.MODE_NONE)) { + http.authorizeHttpRequests(a -> a.anyRequest().permitAll()); + return http.build(); + } + + http.authorizeHttpRequests(a -> a + .requestMatchers("/", "/index.html", "/css/**", "/js/**", "/favicon.ico", "/error", + "/actuator/health").permitAll() + .requestMatchers("/actuator/**").hasAnyRole("ADMIN", "READONLY") + .requestMatchers(MutationMatcher.INSTANCE).hasRole("ADMIN") + .requestMatchers("/api/**").hasAnyRole("ADMIN", "READONLY") + .anyRequest().permitAll()) + .exceptionHandling(e -> e + .authenticationEntryPoint(jsonEntryPoint()) + .accessDeniedHandler(jsonDeniedHandler())); + + http.addFilterBefore(new AccessTokenParamFilter(), UsernamePasswordAuthenticationFilter.class); + switch (auth.mode()) { + case AuthProperties.MODE_TOKEN -> + http.addFilterBefore(new TokenAuthFilter(auth.tokens()), UsernamePasswordAuthenticationFilter.class); + case AuthProperties.MODE_OIDC -> http.oauth2ResourceServer(rs -> + rs.jwt(jwt -> jwt.jwtAuthenticationConverter(new JwtRolesConverter(auth)))); + default -> throw new IllegalStateException( + "kview.auth.mode must be none | token | oidc — got '" + auth.mode() + "'"); + } + return http.build(); + } + + /** Issuer/JWK configuration comes from spring.security.oauth2.resourceserver.jwt.*; audience check is optional. */ + @Bean + @org.springframework.boot.autoconfigure.condition.ConditionalOnProperty(name = "kview.auth.mode", havingValue = "oidc") + public JwtDecoder jwtDecoder( + @Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri:}") String issuerUri, + @Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri:}") String jwkSetUri) { + NimbusJwtDecoder decoder; + if (!jwkSetUri.isBlank()) { + decoder = NimbusJwtDecoder.withJwkSetUri(jwkSetUri).build(); + } else if (!issuerUri.isBlank()) { + decoder = (NimbusJwtDecoder) JwtDecoders.fromIssuerLocation(issuerUri); + } else { + throw new IllegalStateException( + "kview.auth.mode=oidc needs spring.security.oauth2.resourceserver.jwt.issuer-uri (or jwk-set-uri)"); + } + if (auth.audience() != null && !auth.audience().isBlank()) { + String expected = auth.audience(); + OAuth2TokenValidator audience = jwt -> { + Object aud = jwt.getClaims().get("aud"); + boolean ok = expected.equals(aud) || (aud instanceof List list && list.contains(expected)); + return ok ? OAuth2TokenValidatorResult.success() + : OAuth2TokenValidatorResult.failure(new OAuth2Error( + "invalid_token", "Token audience does not include '" + expected + "'", null)); + }; + decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(JwtValidators.createDefault(), audience)); + } + return decoder; + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); + if (auth.mode().equals(AuthProperties.MODE_NONE)) { + // local development (e.g. a Vite dev server talking to the API directly) + config.setAllowedOriginPatterns(List.of("*")); + config.setAllowedHeaders(List.of("*")); + } else { + // authenticated: no wildcard — opt in per origin + config.setAllowedOrigins(auth.allowedOrigins()); + config.setAllowedHeaders(List.of("Authorization", "Content-Type")); + } + 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)); + } + } + + private static org.springframework.security.web.AuthenticationEntryPoint jsonEntryPoint() { + return (request, response, authException) -> writeJson(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, + "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/auth/TokenAuthFilter.java b/src/main/java/com/ritikbansod/kafkawrapper/auth/TokenAuthFilter.java new file mode 100644 index 0000000..1ecfeb5 --- /dev/null +++ b/src/main/java/com/ritikbansod/kafkawrapper/auth/TokenAuthFilter.java @@ -0,0 +1,70 @@ +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.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Static-token authentication ({@code kview.auth.mode=token}): each configured + * entry is a raw token (admin) or {@code token:readonly}. Requests with a valid + * bearer token get their role; everything else stays unauthenticated so the + * entry point answers 401. + */ +final class TokenAuthFilter extends OncePerRequestFilter { + + private static final String ADMIN = "ADMIN"; + private static final String READONLY = "READONLY"; + + private final Map> tokensByRole = new HashMap<>(); + + TokenAuthFilter(List configured) { + for (String entry : configured) { + String trimmed = entry == null ? "" : entry.trim(); + if (trimmed.isEmpty()) continue; + String role = ADMIN; + int colon = trimmed.lastIndexOf(':'); + if (colon > 0) { + String suffix = trimmed.substring(colon + 1); + if (ADMIN.equalsIgnoreCase(suffix)) { + role = ADMIN; + trimmed = trimmed.substring(0, colon); + } else if (READONLY.equalsIgnoreCase(suffix)) { + role = READONLY; + trimmed = trimmed.substring(0, colon); + } // anything else is part of the token itself + } + tokensByRole.computeIfAbsent(trimmed, t -> new java.util.ArrayList<>()).add("ROLE_" + role); + } + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + String header = request.getHeader("Authorization"); + if (header != null && header.regionMatches(true, 0, "Bearer ", 0, 7)) { + List authorities = tokensByRole.get(header.substring(7).trim()); + if (authorities != null) { + var authoritiesList = authorities.stream().map(SimpleAuthorities::new).toList(); + SecurityContextHolder.getContext().setAuthentication( + new PreAuthenticatedAuthenticationToken("kview-token", null, authoritiesList)); + } + } + chain.doFilter(request, response); + } + + private record SimpleAuthorities(String role) implements org.springframework.security.core.GrantedAuthority { + @Override + public String getAuthority() { + return role; + } + } +} diff --git a/src/main/java/com/ritikbansod/kafkawrapper/web/WebConfig.java b/src/main/java/com/ritikbansod/kafkawrapper/web/WebConfig.java index 53b1ee5..a28f2fc 100644 --- a/src/main/java/com/ritikbansod/kafkawrapper/web/WebConfig.java +++ b/src/main/java/com/ritikbansod/kafkawrapper/web/WebConfig.java @@ -1,21 +1,12 @@ package com.ritikbansod.kafkawrapper.web; import org.springframework.context.annotation.Configuration; -import org.springframework.web.servlet.config.annotation.CorsRegistry; -import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; /** - * Permissive CORS for local development setups (e.g. a Vite dev server on 5173 - * proxying or talking to the API directly). The production UI is served by this - * same application, so CORS is irrelevant there. + * CORS lives in {@link com.ritikbansod.kafkawrapper.auth.SecurityConfig}, which + * keeps the permissive local-development setting only while the API is + * unauthenticated (kview.auth.mode=none) and closes it otherwise. */ @Configuration -public class WebConfig implements WebMvcConfigurer { - - @Override - public void addCorsMappings(CorsRegistry registry) { - registry.addMapping("/api/**") - .allowedOriginPatterns("*") - .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS"); - } +public class WebConfig { } diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html index 2a6d524..37b0b20 100644 --- a/src/main/resources/static/index.html +++ b/src/main/resources/static/index.html @@ -65,6 +65,7 @@
+
diff --git a/src/main/resources/static/js/api.js b/src/main/resources/static/js/api.js index bf3cc97..2367d06 100644 --- a/src/main/resources/static/js/api.js +++ b/src/main/resources/static/js/api.js @@ -1,16 +1,41 @@ // ===== API client + global store ===== +const TOKEN_KEY = 'kview.token'; + export const store = { clusterId: localStorage.getItem('kw.cluster') || 'default', clusters: [], }; -export async function api(method, path, body) { +export const getToken = () => localStorage.getItem(TOKEN_KEY) || ''; + +export function setToken(token) { + if (token && token.trim()) localStorage.setItem(TOKEN_KEY, token.trim()); + else localStorage.removeItem(TOKEN_KEY); +} + +function headers(body) { + const h = {}; + const token = getToken(); + if (token) h.Authorization = `Bearer ${token}`; + if (body !== undefined) h['Content-Type'] = 'application/json'; + return h; +} + +export async function api(method, path, body, retry = true) { const res = await fetch('/api' + path, { method, - headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined, + headers: headers(body), body: body !== undefined ? JSON.stringify(body) : undefined, }); + if (res.status === 401 && retry) { + const entered = prompt('This Kview server requires an API token.\n' + + 'Enter a token configured in KVIEW_AUTH_TOKENS on the server:'); + if (entered !== null && entered.trim()) { + setToken(entered); + return api(method, path, body, false); + } + } if (!res.ok) { let detail = `${res.status} ${res.statusText}`; try { diff --git a/src/main/resources/static/js/main.js b/src/main/resources/static/js/main.js index 7af6b44..801773f 100644 --- a/src/main/resources/static/js/main.js +++ b/src/main/resources/static/js/main.js @@ -1,5 +1,5 @@ // ===== App bootstrap: cluster selector + hash router ===== -import { get, store, setActiveCluster } from './api.js'; +import { get, store, setActiveCluster, getToken, setToken } from './api.js'; import { esc, protocolBadge } from './ui.js'; import { renderDashboard } from './views/dashboard.js'; import { renderTopics, renderTopicDetail } from './views/topics.js'; @@ -108,6 +108,24 @@ themeBtn.addEventListener('click', () => { }); paintThemeBtn(); +// ---- API token (only relevant when the server runs with kview.auth.mode != none) ---- +const authBtn = document.getElementById('auth-btn'); +function paintAuthBtn() { + const has = !!getToken(); + authBtn.textContent = has ? '🔑 Token ✓' : '🔑 Token'; + authBtn.title = has + ? 'API token is set — click to change or clear it' + : 'Set the API token (needed when the server runs with authentication enabled)'; +} +authBtn.addEventListener('click', () => { + const entered = prompt('API token for this Kview server (leave empty to clear):', getToken()); + if (entered === null) return; // cancelled + setToken(entered); + paintAuthBtn(); + render(); // re-fetch the current view with the new credentials +}); +paintAuthBtn(); + // ---- keyboard shortcuts: 1-6 jump between views ---- const SHORTCUTS = { 1: '#/', 2: '#/topics', 3: '#/explorer', 4: '#/groups', 5: '#/architecture', 6: '#/connections' }; document.addEventListener('keydown', (e) => { diff --git a/src/main/resources/static/js/views/explorer.js b/src/main/resources/static/js/views/explorer.js index 1cf8422..45ad28f 100644 --- a/src/main/resources/static/js/views/explorer.js +++ b/src/main/resources/static/js/views/explorer.js @@ -1,5 +1,5 @@ // ===== Data Explorer: browse (seek-based), live tail (SSE), produce ===== -import { get, post, clusterPath, loadTopics } from '../api.js'; +import { get, post, clusterPath, loadTopics, getToken } from '../api.js'; import { esc, fmtNum, fmtTs, fmtRel, badge, spinner, toast, modal, jsonBlock, previewValue, copyText, focusable, debounce, } from '../ui.js'; @@ -330,6 +330,7 @@ function renderTail(panel, topicSelect, getPartitions) { const group = groupInput.value.trim(); if (group) params.set('groupId', group); if (group && commitBox.checked) params.set('autoCommit', 'true'); + if (getToken()) params.set('access_token', getToken()); // EventSource cannot send headers rows.innerHTML = ''; total = 0; diff --git a/src/test/java/com/ritikbansod/kafkawrapper/auth/AuthIntegrationTest.java b/src/test/java/com/ritikbansod/kafkawrapper/auth/AuthIntegrationTest.java new file mode 100644 index 0000000..f89ae72 --- /dev/null +++ b/src/test/java/com/ritikbansod/kafkawrapper/auth/AuthIntegrationTest.java @@ -0,0 +1,91 @@ +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.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Token mode ({@code kview.auth.mode=token}): reads need any valid token, mutations + * need the admin role, the static UI and /actuator/health stay public. Assertions on + * mutation endpoints use an unknown cluster id, so authorization is what's exercised — + * the call fails fast with 404 before any broker is contacted. + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = { + "kview.data-dir=target/auth-test-data", + "kview.auth.mode=token", + "kview.auth.tokens=admin-secret,viewer-secret:readonly" +}) +class AuthIntegrationTest { + + @Autowired + TestRestTemplate rest; + + @Test + void apiRequiresAToken() { + var response = rest.getForEntity("/api/clusters", String.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + assertThat(response.getBody()).contains("unauthorized"); + } + + @Test + void unknownTokenIsUnauthorized() { + assertThat(rest.exchange("/api/clusters", HttpMethod.GET, entity("not-a-token"), String.class) + .getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @Test + void readonlyTokenCanRead() { + assertThat(rest.exchange("/api/clusters", HttpMethod.GET, entity("viewer-secret"), String.class) + .getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + void readonlyTokenCannotMutate() { + var response = rest.exchange("/api/clusters/does-not-exist/topics/some-topic", + HttpMethod.DELETE, entity("viewer-secret"), String.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + assertThat(response.getBody()).contains("admin role"); + } + + @Test + void readonlyTokenCanUseReadShapedPosts() { + // browse is POST-shaped but a read: authorization passes, the unknown cluster 404s afterwards + var response = rest.exchange("/api/clusters/does-not-exist/topics/some-topic/browse", + HttpMethod.POST, entity("viewer-secret", "{\"start\":\"latest\",\"limit\":1}"), String.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + void adminTokenCanReadAndMutate() { + assertThat(rest.exchange("/api/clusters", HttpMethod.GET, entity("admin-secret"), String.class) + .getStatusCode()).isEqualTo(HttpStatus.OK); + var mutation = rest.exchange("/api/clusters/does-not-exist/topics/some-topic", + HttpMethod.DELETE, entity("admin-secret"), String.class); + assertThat(mutation.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); // past authorization, unknown cluster + } + + @Test + void staticUiAndHealthStayPublic() { + assertThat(rest.getForEntity("/actuator/health", String.class).getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(rest.getForEntity("/", String.class).getStatusCode()).isEqualTo(HttpStatus.OK); + } + + private HttpEntity entity(String token) { + return entity(token, null); + } + + private HttpEntity entity(String token, String jsonBody) { + var headers = new HttpHeaders(); + headers.setBearerAuth(token); + if (jsonBody != null) headers.setContentType(MediaType.APPLICATION_JSON); + return new HttpEntity<>(jsonBody, headers); + } +} diff --git a/src/test/java/com/ritikbansod/kafkawrapper/auth/JwtRolesConverterTest.java b/src/test/java/com/ritikbansod/kafkawrapper/auth/JwtRolesConverterTest.java new file mode 100644 index 0000000..44a3551 --- /dev/null +++ b/src/test/java/com/ritikbansod/kafkawrapper/auth/JwtRolesConverterTest.java @@ -0,0 +1,56 @@ +package com.ritikbansod.kafkawrapper.auth; + +import org.junit.jupiter.api.Test; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class JwtRolesConverterTest { + + private final JwtRolesConverter converter = new JwtRolesConverter( + new AuthProperties("oidc", List.of(), List.of(), null, null, null, null)); + + @Test + void adminClaimGrantsTheAdminRole() { + var authentication = converter.convert(jwt(Map.of("roles", List.of("kview-admin")))); + assertThat(authentication.getAuthorities()) + .extracting(GrantedAuthority::getAuthority) + .containsExactly("ROLE_ADMIN"); + } + + @Test + void anyOtherValidTokenIsReadOnly() { + var authentication = converter.convert(jwt(Map.of("roles", List.of("viewer")))); + assertThat(authentication.getAuthorities()) + .extracting(GrantedAuthority::getAuthority) + .containsExactly("ROLE_READONLY"); + } + + @Test + void missingClaimIsReadOnly() { + var authentication = converter.convert(jwt(Map.of("sub", "someone"))); + assertThat(authentication.getAuthorities()) + .extracting(GrantedAuthority::getAuthority) + .containsExactly("ROLE_READONLY"); + } + + @Test + void scopeStyleStringClaimsAreParsed() { + var converter = new JwtRolesConverter(new AuthProperties( + "oidc", List.of(), List.of(), "scope", "kview-admin", "kview-readonly", null)); + var authentication = converter.convert(jwt(Map.of("scope", "openid kview-admin"))); + assertThat(authentication.getAuthorities()) + .extracting(GrantedAuthority::getAuthority) + .containsExactly("ROLE_ADMIN"); + } + + private Jwt jwt(Map claims) { + return new Jwt("token-value", Instant.now(), Instant.now().plusSeconds(60), + Map.of("alg", "RS256"), claims); + } +} diff --git a/src/test/java/com/ritikbansod/kafkawrapper/auth/NoAuthByDefaultTest.java b/src/test/java/com/ritikbansod/kafkawrapper/auth/NoAuthByDefaultTest.java new file mode 100644 index 0000000..827f571 --- /dev/null +++ b/src/test/java/com/ritikbansod/kafkawrapper/auth/NoAuthByDefaultTest.java @@ -0,0 +1,37 @@ +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; + +/** + * Zero-config contract: with no kview.auth.* settings (the default), the API + * behaves exactly as before — open, so a localhost single-user setup keeps + * working and every existing integration (CLI, MCP, scripts) is unaffected. + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "kview.data-dir=target/auth-none-test-data") +class NoAuthByDefaultTest { + + @Autowired + TestRestTemplate rest; + + @Test + void apiStaysOpenWithoutConfiguration() { + assertThat(rest.getForEntity("/api/clusters", String.class).getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + void mutationsAreNotBlockedByAuth() { + // unknown cluster id fails fast with 404 — the point is that auth did not intercept + var response = rest.exchange("/api/clusters/does-not-exist/topics/some-topic", + HttpMethod.DELETE, HttpEntity.EMPTY, String.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } +}