diff --git a/CLAUDE.md b/CLAUDE.md index 4caac98..93595ad 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -400,8 +400,19 @@ auth: id: ci-pipeline name: CI Pipeline scipUpload: true + repos: ["*"] # query scope — see "API key authorization" below ``` +### API key authorization (per-repo scoping) + +API keys are **scoped per key** for read/query access via the `repos:` field, mirroring the OAuth per-repo entitlement model (groups → allowed repos). This ensures the shared indexer never exposes a repo's code (source, symbols, file tree, search) to a remote caller who isn't entitled to it — regardless of auth method. + +- `repos: ["*"]` — full read access to all indexed repos (explicit, auditable). +- `repos: [backend-api, data-pipeline]` — restricts the key to those repos. +- **`repos:` omitted → the key is denied all queries (fail-closed)** — every key must declare its scope. (Migration note: existing keys must add `repos:` or they will be denied.) + +Authorization is enforced centrally in `QueryExecutor.executeQuery`, the single choke point for all repo-scoped MCP tools. Repo-less/system tools (`get_index_health`, `query_audit_log`, `verify_audit_chain`) require a `["*"]` key — same as OAuth callers, which cannot call them. The local **stdio** transport (a subprocess run as the OS user) is trusted and unscoped. **SCIP upload** (`POST /api/scip/{repoName}`) is a separate write path gated by the `scipUpload` flag and the repo name in the URL, not by `repos:`. + ### Upload Endpoint ``` diff --git a/docs/superpowers/plans/2026-05-30-apikey-repo-scoping-and-boundary-tests.md b/docs/superpowers/plans/2026-05-30-apikey-repo-scoping-and-boundary-tests.md new file mode 100644 index 0000000..e720ca3 --- /dev/null +++ b/docs/superpowers/plans/2026-05-30-apikey-repo-scoping-and-boundary-tests.md @@ -0,0 +1,675 @@ +# API-Key Repo Scoping + Authorization Boundary & Stack-Trace Diagnosis Tests — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Bring API-key callers under the same per-repo least-privilege authorization the OAuth path already enforces (fail-closed, with a `["*"]` escape hatch for full-access keys), then add two test suites: a security **authorization-boundary** suite proving a remote caller without entitlement can do nothing meaningful, and a **stack-trace diagnosis** poster-child test exercising the headline "paste a stack trace → diagnose" flow. + +**Architecture:** The single authorization choke point is `QueryExecutor.executeQuery()` — all 13 repo-scoped MCP tools funnel through it. Today it gates only `authMethod == "oauth"` callers (via `PermissionCache`); API-key and stdio callers bypass per-repo authorization, so any valid API key can read *every* indexed repo. We add a per-key repo allow-list (`repos:`) carried on `CallerIdentity.allowedRepos`, enforced in the same gate: a key scoped to `["*"]` reads everything (explicit, auditable), a key scoped to a concrete list reads only those repos, and a key with **no** scope is denied everything (fail-closed). stdio (local subprocess, trusted OS user) and `anonymous` (no-auth dev mode) keep their current bypass and are documented as such. + +**Tech Stack:** Java 21, JDBI 3, PostgreSQL 16 + Flyway, Jackson (config), JUnit 5 + AssertJ + Mockito, Testcontainers (`@Tag("integration")`), MCP Java SDK (`McpSchema.CallToolResult`). + +**Branch/timing note:** Phase 3 PR #11 is open and touches `QueryExecutor` only in the SCIP query methods (`getScipStatus`, `getIndexHealth`, `getTypeHierarchy`, `getSymbolReferences`, `resolveScipSymbol`, `traverseHierarchy`) — far from `executeQuery` (lines ~74-168). Conflict risk is low, but prefer branching from `main` **after #11 merges**; if branching before, expect a trivial rebase. Use a new branch e.g. `feat/apikey-repo-scoping`. + +**Out of scope (note, don't build):** SCIP *upload* authorization (`POST /api/scip/{repoName}`, gated by the `scipUpload` flag, not repo scope) is a write path on a different endpoint and is not changed here. `get_index_health` / `query_audit_log` / `verify_audit_chain` are repo-less; under this change a non-wildcard key (like an OAuth caller today) cannot call them — repo-less/system tools require full (`["*"]`) access. This is intentional and consistent with the existing OAuth behavior. + +--- + +## File Structure + +- **Modify** `src/main/java/com/indexer/config/IndexerConfig.java` — add `List repos` to `McpAuthConfig.ApiKeyEntry`. +- **Modify** `src/main/java/com/indexer/config/ConfigLoader.java` — parse `repos:` per API key in `parseMcpAuth`. +- **Modify** `src/main/java/com/indexer/auth/CallerIdentity.java` — add `List allowedRepos` field + factory updates. +- **Modify** `src/main/java/com/indexer/auth/ApiKeyAuthenticator.java` — `ApiKeyConfig` carries `repos`; `authenticate` passes it to `fromApiKey`. +- **Modify** `src/main/java/com/indexer/Application.java` — map `ApiKeyEntry.repos()` into `ApiKeyConfig`. +- **Modify** `src/main/java/com/indexer/mcp/QueryExecutor.java` — add an API-key repo-scope branch to `executeQuery` (fail-closed, `["*"]` = all). +- **Modify** `CLAUDE.md` — document per-key `repos:` scoping and the security model. +- **Create** `src/test/java/com/indexer/mcp/PermissionBoundaryTest.java` — boundary suite (OAuth + API-key denial/allow across tools). +- **Create** `src/test/java/com/indexer/mcp/StackTraceDiagnosisTest.java` — poster-child diagnosis flow. +- **Modify** `src/test/java/com/indexer/config/ConfigLoaderTest.java`, `src/test/java/com/indexer/auth/CallerIdentityTest.java`, `src/test/java/com/indexer/auth/ApiKeyAuthenticatorTest.java` — extend for the new field. + +--- + +### Task 1: Config — per-key `repos` allow-list + +**Files:** +- Modify: `src/main/java/com/indexer/config/IndexerConfig.java` (the `ApiKeyEntry` record ~line 85) +- Modify: `src/main/java/com/indexer/config/ConfigLoader.java` (`parseMcpAuth`, ~lines 188-201) +- Test: `src/test/java/com/indexer/config/ConfigLoaderTest.java` + +> Context: `ApiKeyEntry` is currently `record ApiKeyEntry(String key, String id, String name, boolean auditReader, boolean scipUpload) {}`. `parseMcpAuth` builds each entry from a `JsonNode`. We add a `repos` allow-list (list of repo names; `"*"` means all). Absent → empty list (fail-closed at the gate in Task 3). + +- [ ] **Step 1: Write the failing config-parse test** + +In `ConfigLoaderTest.java`, read the existing tests first to match the harness (how it loads YAML — likely a temp file or classpath resource string). Add a test that loads an `auth.apiKeys` entry with a `repos:` list and one without, asserting the parsed `ApiKeyEntry.repos()`: + +```java +@Test +void parsesApiKeyReposAllowList() { + String yaml = """ + server: + cloneBaseDir: /tmp/repos + database: + host: localhost + name: idx + auth: + apiKeys: + - key: scoped-key + id: ci-a + name: CI A + repos: [repo-a, repo-b] + - key: full-key + id: admin + name: Admin + repos: ["*"] + - key: bare-key + id: legacy + name: Legacy + """; + IndexerConfig config = loadFromYaml(yaml); // adapt to ConfigLoaderTest's existing load helper + var keys = config.mcpAuth().apiKeys(); + assertThat(keys).hasSize(3); + assertThat(keys.get(0).repos()).containsExactly("repo-a", "repo-b"); + assertThat(keys.get(1).repos()).containsExactly("*"); + assertThat(keys.get(2).repos()).isEmpty(); // absent → empty (fail-closed) +} +``` + +> Adapt `loadFromYaml(...)` and the minimal required `server`/`database` fields to whatever `ConfigLoaderTest` already does (read it; it may parse from a `Path` or a String via `new ConfigLoader().load(...)`). Keep the three-entry shape and the three `repos()` assertions. + +- [ ] **Step 2: Run it to verify it fails** + +Run: `./gradlew test --tests "com.indexer.config.ConfigLoaderTest"` +Expected: FAIL — `ApiKeyEntry` has no `repos()` accessor (compile error) or the field doesn't exist. + +- [ ] **Step 3: Add `repos` to `ApiKeyEntry`** + +In `IndexerConfig.java`, change the record and default the list: + +```java + public record ApiKeyEntry(String key, String id, String name, boolean auditReader, + boolean scipUpload, java.util.List repos) { + public ApiKeyEntry { + repos = repos != null ? java.util.List.copyOf(repos) : java.util.List.of(); + } + } +``` + +- [ ] **Step 4: Parse `repos` in `ConfigLoader.parseMcpAuth`** + +In `ConfigLoader.java`, inside the `apiKeys` loop (~lines 191-200), parse the array and pass it. Replace the `keys.add(...)` line with: + +```java + java.util.List repos = new ArrayList<>(); + JsonNode reposNode = keyNode.get("repos"); + if (reposNode != null && reposNode.isArray()) { + for (JsonNode r : reposNode) { + if (r.isTextual() && !r.asText().isBlank()) repos.add(r.asText()); + } + } + keys.add(new IndexerConfig.McpAuthConfig.ApiKeyEntry( + key, id, name != null ? name : id, auditReader, scipUpload, repos)); +``` + +> Any OTHER construction of `ApiKeyEntry` will now fail to compile (it gained an arg). Search and fix: `grep -rn "new IndexerConfig.McpAuthConfig.ApiKeyEntry(\|new ApiKeyEntry(" src/`. For test/throwaway constructions that don't care about repos, pass `List.of()`. + +- [ ] **Step 5: Run it to verify it passes** + +Run: `./gradlew test --tests "com.indexer.config.ConfigLoaderTest"` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/main/java/com/indexer/config/IndexerConfig.java \ + src/main/java/com/indexer/config/ConfigLoader.java \ + src/test/java/com/indexer/config/ConfigLoaderTest.java +git commit -m "feat: per-API-key repos allow-list in config" +``` + +--- + +### Task 2: Thread the repo scope onto `CallerIdentity` via `ApiKeyAuthenticator` + +**Files:** +- Modify: `src/main/java/com/indexer/auth/CallerIdentity.java` +- Modify: `src/main/java/com/indexer/auth/ApiKeyAuthenticator.java` +- Modify: `src/main/java/com/indexer/Application.java` (~lines 118-121, the `ApiKeyEntry` → `ApiKeyConfig` mapping) +- Test: `src/test/java/com/indexer/auth/CallerIdentityTest.java`, `src/test/java/com/indexer/auth/ApiKeyAuthenticatorTest.java` + +> Context: `CallerIdentity` is a 10-component record with factories (`anonymous`, `fromStdio`, `fromApiKey` ×3 overloads, `fromOAuth`, `fromAdminToken`). We add `allowedRepos` as the 11th (last) component. Only API-key identities populate it; everyone else gets `List.of()`. `ApiKeyAuthenticator.authenticate` builds the identity from an `ApiKeyConfig`; we add `repos` to that config and pass it through. `Application` converts each `ApiKeyEntry` to an `ApiKeyConfig`. + +- [ ] **Step 1: Write the failing identity + authenticator tests** + +In `CallerIdentityTest.java`, add: + +```java +@Test +void fromApiKeyCarriesAllowedRepos() { + var id = CallerIdentity.fromApiKey("ci-a", "CI A", "10.0.0.1", false, false, List.of("repo-a", "repo-b")); + assertThat(id.authMethod()).isEqualTo("api-key"); + assertThat(id.allowedRepos()).containsExactly("repo-a", "repo-b"); +} + +@Test +void nonApiKeyIdentitiesHaveEmptyAllowedRepos() { + assertThat(CallerIdentity.fromStdio().allowedRepos()).isEmpty(); + assertThat(CallerIdentity.fromOAuth("u", "U", List.of("g"), "ip").allowedRepos()).isEmpty(); +} +``` + +In `ApiKeyAuthenticatorTest.java`, add (read the existing `authenticatesValidKey` test to match style): + +```java +@Test +void authenticatedIdentityCarriesConfiguredRepos() { + var auth = new ApiKeyAuthenticator(List.of( + new ApiKeyAuthenticator.ApiKeyConfig("k1", "ci-a", "CI A", false, false, List.of("repo-a")))); + var id = auth.authenticate("k1", "10.0.0.1").orElseThrow(); + assertThat(id.allowedRepos()).containsExactly("repo-a"); +} +``` + +- [ ] **Step 2: Run to verify they fail** + +Run: `./gradlew test --tests "com.indexer.auth.CallerIdentityTest" --tests "com.indexer.auth.ApiKeyAuthenticatorTest"` +Expected: FAIL (compile) — no `allowedRepos()` accessor, `fromApiKey` 6-arg overload and `ApiKeyConfig` 6-arg constructor don't exist. + +- [ ] **Step 3: Add `allowedRepos` to `CallerIdentity`** + +In `CallerIdentity.java`: +- Add the field as the last record component: + ```java + public record CallerIdentity( + String userId, String displayName, String authMethod, String transport, + String sourceIp, String clientName, String clientVersion, + List groups, boolean auditReader, boolean scipUpload, + List allowedRepos + ) { + public CallerIdentity { + groups = groups != null ? List.copyOf(groups) : List.of(); + allowedRepos = allowedRepos != null ? List.copyOf(allowedRepos) : List.of(); + } + ``` +- Update every factory to pass an `allowedRepos` argument (append `List.of()` to all existing ones; add a new `fromApiKey` overload that accepts the scope): + ```java + public static CallerIdentity anonymous(String transport) { + return new CallerIdentity(null, "anonymous", "none", transport, null, null, null, List.of(), false, false, List.of()); + } + public static CallerIdentity fromStdio() { + String osUser = System.getProperty("user.name"); + return new CallerIdentity(osUser, osUser, "stdio-os-user", "stdio", null, null, null, List.of(), true, false, List.of()); + } + public static CallerIdentity fromApiKey(String id, String name, String sourceIp) { + return new CallerIdentity(id, name, "api-key", "streamable-http", sourceIp, null, null, List.of(), false, false, List.of()); + } + public static CallerIdentity fromApiKey(String id, String name, String sourceIp, boolean auditReader) { + return new CallerIdentity(id, name, "api-key", "streamable-http", sourceIp, null, null, List.of(), auditReader, false, List.of()); + } + public static CallerIdentity fromApiKey(String id, String name, String sourceIp, boolean auditReader, boolean scipUpload) { + return new CallerIdentity(id, name, "api-key", "streamable-http", sourceIp, null, null, List.of(), auditReader, scipUpload, List.of()); + } + public static CallerIdentity fromApiKey(String id, String name, String sourceIp, boolean auditReader, boolean scipUpload, List allowedRepos) { + return new CallerIdentity(id, name, "api-key", "streamable-http", sourceIp, null, null, List.of(), auditReader, scipUpload, allowedRepos); + } + public static CallerIdentity fromOAuth(String sub, String name, List groups, String sourceIp) { + return new CallerIdentity(sub, name, "oauth", "streamable-http", sourceIp, null, null, groups, false, false, List.of()); + } + public static CallerIdentity fromAdminToken(String sourceIp) { + return new CallerIdentity("admin", "Admin", "admin-token", "streamable-http", sourceIp, null, null, List.of(), false, false, List.of()); + } + ``` + +> Search for any DIRECT `new CallerIdentity(...)` outside this file (`grep -rn "new CallerIdentity(" src/`) and append `List.of()` (or the intended scope) to each. Factories cover most call sites. + +- [ ] **Step 4: Add `repos` to `ApiKeyConfig` and pass it through `authenticate`** + +In `ApiKeyAuthenticator.java`: +- Change the record: + ```java + public record ApiKeyConfig(String key, String id, String name, boolean auditReader, + boolean scipUpload, java.util.List repos) {} + ``` +- In `authenticate`, pass `keyConfig.repos()`: + ```java + if (constantTimeEquals(keyConfig.key(), bearerToken)) { + return Optional.of(CallerIdentity.fromApiKey( + keyConfig.id(), keyConfig.name(), sourceIp, + keyConfig.auditReader(), keyConfig.scipUpload(), keyConfig.repos())); + } + ``` + +- [ ] **Step 5: Map `ApiKeyEntry.repos()` in `Application`** + +In `Application.java` (the conversion of config `ApiKeyEntry` → `ApiKeyAuthenticator.ApiKeyConfig`, ~lines 118-121), add `e.repos()` to the constructed `ApiKeyConfig`. Read the exact stream/loop and update it, e.g.: + +```java + var apiKeyConfigs = config.mcpAuth().apiKeys().stream() + .map(e -> new ApiKeyAuthenticator.ApiKeyConfig( + e.key(), e.id(), e.name(), e.auditReader(), e.scipUpload(), e.repos())) + .toList(); +``` + +> Match the actual variable names/shape in Application (it may use a for-loop). Just ensure `repos` is threaded. + +- [ ] **Step 6: Run to verify pass + full compile** + +Run: `./gradlew test --tests "com.indexer.auth.*"` then `./gradlew compileJava compileTestJava` +Expected: PASS and clean compile (all `CallerIdentity`/`ApiKeyConfig`/`ApiKeyEntry` construction sites updated). + +- [ ] **Step 7: Commit** + +```bash +git add src/main/java/com/indexer/auth/CallerIdentity.java \ + src/main/java/com/indexer/auth/ApiKeyAuthenticator.java \ + src/main/java/com/indexer/Application.java \ + src/test/java/com/indexer/auth/CallerIdentityTest.java \ + src/test/java/com/indexer/auth/ApiKeyAuthenticatorTest.java +git commit -m "feat: carry API-key repo scope onto CallerIdentity.allowedRepos" +``` + +--- + +### Task 3: Enforce API-key repo scope in `executeQuery` (fail-closed + `["*"]`) + +**Files:** +- Modify: `src/main/java/com/indexer/mcp/QueryExecutor.java` (`executeQuery`, ~lines 74-107) +- Test: `src/test/java/com/indexer/mcp/ApiKeyScopeGateTest.java` (new, pure unit — no DB) + +> Context: `executeQuery` currently has one authorization block — `if (permissionCache != null && "oauth".equals(caller.authMethod())) { ... }`. We add a SECOND, independent block for `"api-key"` callers that consults `caller.allowedRepos()` directly (no `PermissionCache` needed). The denied path returns `CallToolResult.isError(true)` and calls `auditBestEffort(...)`; the query lambda never runs. Because the denied path doesn't touch `jdbi`, this is testable with a `QueryExecutor` built from nulls + a capturing `AuditSink`. + +- [ ] **Step 1: Write the failing gate unit test** + +Create `src/test/java/com/indexer/mcp/ApiKeyScopeGateTest.java`: + +```java +package com.indexer.mcp; + +import com.indexer.audit.AuditEvent; +import com.indexer.audit.AuditSink; +import com.indexer.auth.CallerIdentity; +import io.modelcontextprotocol.spec.McpSchema; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.assertj.core.api.Assertions.assertThat; + +class ApiKeyScopeGateTest { + + /** Captures audit events so we can assert denials are recorded. */ + static class CapturingAuditSink implements AuditSink { + final List events = new ArrayList<>(); + @Override public void record(AuditEvent e) { events.add(e); } + } + + private QueryExecutor newExecutor(CapturingAuditSink sink) { + // Only the auth gate is exercised; all DB collaborators are null and never touched. + return new QueryExecutor(null, null, null, null, null, null, sink); + } + + private static String textOf(McpSchema.CallToolResult r) { + // Adapt to the MCP SDK Content API used elsewhere (see StreamableHttpTransportIntegrationTest + // for how tool-result text is extracted). It concatenates the text content blocks. + var sb = new StringBuilder(); + for (var c : r.content()) { + if (c instanceof McpSchema.TextContent tc) sb.append(tc.text()); + } + return sb.toString(); + } + + @Test + void scopedKeyAllowedRepoRunsQuery() { + var sink = new CapturingAuditSink(); + var qe = newExecutor(sink); + var caller = CallerIdentity.fromApiKey("ci-a", "CI A", "ip", false, false, List.of("repo-a")); + var ran = new AtomicBoolean(false); + + var result = qe.executeQuery(caller, "repo-a", "search_symbols", java.util.Map.of(), + () -> { ran.set(true); return java.util.Map.of("results", List.of()); }); + + assertThat(ran).isTrue(); + assertThat(result.isError()).isFalse(); + } + + @Test + void scopedKeyForbiddenRepoIsDeniedAndLambdaNeverRuns() { + var sink = new CapturingAuditSink(); + var qe = newExecutor(sink); + var caller = CallerIdentity.fromApiKey("ci-a", "CI A", "ip", false, false, List.of("repo-a")); + var ran = new AtomicBoolean(false); + + var result = qe.executeQuery(caller, "secret-repo", "search_code", java.util.Map.of(), + () -> { ran.set(true); return java.util.Map.of("leak", "TOP SECRET"); }); + + assertThat(ran).isFalse(); // query never executed + assertThat(result.isError()).isTrue(); + assertThat(textOf(result)).contains("Access denied to repository: secret-repo"); + assertThat(textOf(result)).doesNotContain("TOP SECRET"); + assertThat(sink.events).anyMatch(e -> !e.authorized()); // denial audited + } + + @Test + void wildcardKeyReadsAnyRepo() { + var sink = new CapturingAuditSink(); + var qe = newExecutor(sink); + var caller = CallerIdentity.fromApiKey("admin", "Admin", "ip", false, false, List.of("*")); + + var result = qe.executeQuery(caller, "any-repo", "search_symbols", java.util.Map.of(), + () -> java.util.Map.of("results", List.of())); + + assertThat(result.isError()).isFalse(); + } + + @Test + void unscopedKeyIsDeniedEverything() { + var sink = new CapturingAuditSink(); + var qe = newExecutor(sink); + var caller = CallerIdentity.fromApiKey("legacy", "Legacy", "ip"); // no repos → List.of() + + var result = qe.executeQuery(caller, "repo-a", "search_symbols", java.util.Map.of(), + () -> java.util.Map.of("results", List.of())); + + assertThat(result.isError()).isTrue(); + assertThat(textOf(result)).contains("Access denied to repository: repo-a"); + } + + @Test + void scopedKeyRepoLessToolIsDenied() { + var sink = new CapturingAuditSink(); + var qe = newExecutor(sink); + var caller = CallerIdentity.fromApiKey("ci-a", "CI A", "ip", false, false, List.of("repo-a")); + + var result = qe.executeQuery(caller, null, "get_index_health", java.util.Map.of(), + () -> java.util.Map.of("ok", true)); + + assertThat(result.isError()).isTrue(); // repo-less/system tools require ["*"] + } +} +``` + +> If the MCP SDK's `TextContent`/`content()` accessors differ, fix `textOf` by modeling on an existing test that reads a `CallToolResult` (e.g. `StreamableHttpTransportIntegrationTest`). The behavioral assertions stay the same. + +- [ ] **Step 2: Run to verify it fails** + +Run: `./gradlew test --tests "com.indexer.mcp.ApiKeyScopeGateTest"` +Expected: FAIL — no API-key gate exists, so forbidden/unscoped/repo-less calls run the lambda and return success. + +- [ ] **Step 3: Add the API-key authorization block to `executeQuery`** + +In `QueryExecutor.executeQuery`, immediately AFTER the existing OAuth block (after line ~107, before `// Execute query`), insert: + +```java + // Authorization check — API-key callers are scoped to their configured repos. + // ["*"] = full access (explicit, auditable); a concrete list restricts to those repos; + // an empty scope denies everything (fail-closed). stdio/anonymous are intentionally unscoped. + if ("api-key".equals(caller.authMethod())) { + List scope = caller.allowedRepos(); + boolean wildcard = scope.contains("*"); + if (!wildcard) { + if (repo == null) { + log.warn("Access denied: api-key {} called {} without repo (requires full access)", + caller.displayName(), action); + auditBestEffort(caller, action, null, false, "denied", "Repository parameter required"); + return McpSchema.CallToolResult.builder() + .addTextContent("Repository parameter is required for scoped API keys") + .isError(true) + .build(); + } + if (!scope.contains(repo)) { + log.warn("Access denied: api-key {} not scoped for repo {}", caller.displayName(), repo); + auditBestEffort(caller, action, repo, false, "denied", "Access denied to repository: " + repo); + return McpSchema.CallToolResult.builder() + .addTextContent("Access denied to repository: " + repo) + .isError(true) + .build(); + } + } + } +``` + +> Confirm `java.util.List` and `java.util.Set` are already imported in QueryExecutor (they are). No new collaborators required — this block reads only `caller`. + +- [ ] **Step 4: Run to verify pass** + +Run: `./gradlew test --tests "com.indexer.mcp.ApiKeyScopeGateTest"` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/main/java/com/indexer/mcp/QueryExecutor.java \ + src/test/java/com/indexer/mcp/ApiKeyScopeGateTest.java +git commit -m "feat: enforce per-API-key repo scope in executeQuery (fail-closed, [*] = all)" +``` + +--- + +### Task 4: `PermissionBoundaryTest` — end-to-end "nothing meaningful leaks" suite + +**Files:** +- Test: `src/test/java/com/indexer/mcp/PermissionBoundaryTest.java` (new, `@Tag("integration")`) + +> Context: This is the security boundary proof. It seeds two repos with real data (so allowed queries return content and denied queries can be shown to leak nothing), wires a `PermissionCache` over a **mock** `PermissionResolver` for the OAuth path, and a capturing `AuditSink`. It calls `executeQuery` exactly as the MCP handlers do — wrapping the real `QueryExecutor` tool methods in the lambda — and asserts denials across MULTIPLE meaningful tools. Model the Testcontainers + Flyway + seeding harness on `src/test/java/com/indexer/mcp/SemanticQueryTest.java` (or `BranchQueryTest`) — read one for the exact `Jdbi.create` + `Flyway.configure().dataSource(...).cleanDisabled(false).load()` + seed pattern. Read `PermissionCache`'s constructor and `PermissionResolver`'s interface to mock them (Mockito is on the classpath). + +- [ ] **Step 1: Write the boundary tests** + +Create `src/test/java/com/indexer/mcp/PermissionBoundaryTest.java`. Structure: + +- `@Container static PostgreSQLContainer pg = new PostgreSQLContainer<>("postgres:16")...` +- `@BeforeEach`: `jdbi = Jdbi.create(...)`; Flyway clean+migrate; seed TWO repos `repo-pub` and `repo-secret`, each with a file + a symbol + file_contents. Example seed (adapt column lists to the real schema — see SemanticQueryTest/BranchQueryTest): + ```java + jdbi.useHandle(h -> { + h.execute("INSERT INTO repositories (name, url, branch, clone_path, auth_type, last_indexed_sha) VALUES ('repo-pub','u','main','/tmp/pub','ssh-key','sha1')"); + h.execute("INSERT INTO repositories (name, url, branch, clone_path, auth_type, last_indexed_sha) VALUES ('repo-secret','u','main','/tmp/sec','ssh-key','sha2')"); + int pub = h.createQuery("SELECT id FROM repositories WHERE name='repo-pub'").mapTo(Integer.class).one(); + int sec = h.createQuery("SELECT id FROM repositories WHERE name='repo-secret'").mapTo(Integer.class).one(); + h.execute("INSERT INTO files (repo_id, path, language) VALUES (?,'src/Pub.java','java')", pub); + h.execute("INSERT INTO files (repo_id, path, language) VALUES (?,'src/Secret.java','java')", sec); + int secFile = h.createQuery("SELECT id FROM files WHERE repo_id=? AND path='src/Secret.java'").bind(0, sec).mapTo(Integer.class).one(); + h.execute("INSERT INTO symbols (file_id, name, kind, signature, start_line, end_line, visibility, is_static) VALUES (?,'TopSecretKey','class','class TopSecretKey',1,5,'public',false)", secFile); + h.createUpdate("INSERT INTO file_contents (file_id, content) VALUES (:f, :c) ON CONFLICT (file_id) DO UPDATE SET content=EXCLUDED.content") + .bind("f", secFile).bind("c", "class TopSecretKey { String apiSecret = \"sk-LEAK\"; }").execute(); + }); + ``` +- Build the executor with a `PermissionCache` (mock resolver) + capturing `AuditSink`: + ```java + resolver = org.mockito.Mockito.mock(PermissionResolver.class); + org.mockito.Mockito.when(resolver.resolveAllowedRepos(java.util.List.of("team-pub"))) + .thenReturn(java.util.Set.of("repo-pub")); + var cache = new PermissionCache(resolver, java.util.Set.of(), java.time.Duration.ofMinutes(30)); // adapt to real ctor + sink = new CapturingAuditSink(); // reuse the same fake as Task 3 (copy it in, or extract to a test util) + qe = new QueryExecutor(jdbi, null, null, null, null, cache, sink); + ``` + > Read `PermissionCache`'s real constructor signature (param order/types for resolver, openRepos, TTL) and `PermissionResolver`'s method name; adapt the two lines above. Use a small private `textOf(CallToolResult)` helper as in Task 3. + +Tests to include: + +```java +// --- OAuth path --- +@Test +void oauthUserCannotReadRepoOutsideEntitlement_acrossTools() { + var caller = CallerIdentity.fromOAuth("alice", "Alice", java.util.List.of("team-pub"), "ip"); // entitled to repo-pub only + for (String tool : java.util.List.of("search_symbols", "get_symbol_detail", "search_code", "get_file_summary", "get_directory_tree")) { + var result = qe.executeQuery(caller, "repo-secret", tool, java.util.Map.of(), + () -> { throw new AssertionError("query lambda must not run when denied"); }); + assertThat(result.isError()).as(tool).isTrue(); + assertThat(textOf(result)).as(tool).contains("Access denied to repository: repo-secret"); + assertThat(textOf(result)).as(tool).doesNotContain("TopSecretKey").doesNotContain("sk-LEAK"); + } + assertThat(sink.events).allMatch(e -> !e.authorized()); // every attempt audited as denied +} + +@Test +void oauthUserCanReadEntitledRepo() { + var caller = CallerIdentity.fromOAuth("alice", "Alice", java.util.List.of("team-pub"), "ip"); + var result = qe.executeQuery(caller, "repo-pub", "search_symbols", java.util.Map.of(), + () -> qe.searchSymbols(null, null, null, "repo-pub", null, 20)); + assertThat(result.isError()).isFalse(); +} + +@Test +void oauthFailClosedWhenResolverThrows() { + org.mockito.Mockito.when(resolver.resolveAllowedRepos(java.util.List.of("team-broken"))) + .thenThrow(new RuntimeException("github down")); + var caller = CallerIdentity.fromOAuth("bob", "Bob", java.util.List.of("team-broken"), "ip"); + var result = qe.executeQuery(caller, "repo-pub", "search_symbols", java.util.Map.of(), + () -> { throw new AssertionError("must not run"); }); + assertThat(result.isError()).isTrue(); // fail-closed +} + +// --- API-key path (end-to-end, real tools) --- +@Test +void scopedApiKeyCannotReadUnscopedRepo_acrossTools() { + var caller = CallerIdentity.fromApiKey("ci", "CI", "ip", false, false, java.util.List.of("repo-pub")); + for (String tool : java.util.List.of("search_symbols", "get_symbol_detail", "search_code", "get_file_summary")) { + var result = qe.executeQuery(caller, "repo-secret", tool, java.util.Map.of(), + () -> { throw new AssertionError("must not run"); }); + assertThat(result.isError()).as(tool).isTrue(); + assertThat(textOf(result)).as(tool).doesNotContain("TopSecretKey").doesNotContain("sk-LEAK"); + } +} + +@Test +void scopedApiKeyReadsItsRepo_andWildcardReadsAll() { + var scoped = CallerIdentity.fromApiKey("ci", "CI", "ip", false, false, java.util.List.of("repo-pub")); + assertThat(qe.executeQuery(scoped, "repo-pub", "search_symbols", java.util.Map.of(), + () -> qe.searchSymbols(null, null, null, "repo-pub", null, 20)).isError()).isFalse(); + + var admin = CallerIdentity.fromApiKey("admin", "Admin", "ip", false, false, java.util.List.of("*")); + assertThat(qe.executeQuery(admin, "repo-secret", "search_symbols", java.util.Map.of(), + () -> qe.searchSymbols(null, null, null, "repo-secret", null, 20)).isError()).isFalse(); +} + +@Test +void unscopedApiKeyReadsNothing() { + var caller = CallerIdentity.fromApiKey("legacy", "Legacy", "ip"); // no repos + var result = qe.executeQuery(caller, "repo-pub", "search_symbols", java.util.Map.of(), + () -> { throw new AssertionError("must not run"); }); + assertThat(result.isError()).isTrue(); +} +``` + +> The `CapturingAuditSink` and `textOf` helper are duplicated from Task 3 — either copy them in, or (cleaner) extract them to `src/test/java/com/indexer/mcp/TestAuthSupport.java` in this task and have both test classes use it. Prefer extraction. + +- [ ] **Step 2: Run to verify pass** + +Run: `./gradlew integrationTest --tests "com.indexer.mcp.PermissionBoundaryTest"` +Expected: PASS (requires Docker/Testcontainers). If a control test (allowed query) fails, the seed column lists likely need adjusting to the real schema — read `SemanticQueryTest`/`BranchQueryTest` seeding. + +- [ ] **Step 3: Commit** + +```bash +git add src/test/java/com/indexer/mcp/PermissionBoundaryTest.java \ + src/test/java/com/indexer/mcp/TestAuthSupport.java +git commit -m "test: authorization boundary — remote caller without entitlement leaks nothing (OAuth + API key)" +``` + +--- + +### Task 5: `StackTraceDiagnosisTest` — poster-child diagnosis flow + +**Files:** +- Test: `src/test/java/com/indexer/mcp/StackTraceDiagnosisTest.java` (new, `@Tag("integration")`) + +> Context: There is no stack-trace tool; diagnosis is a composition of existing tools. This test takes a representative Java frame — `at com.example.payment.PaymentProcessor.charge(PaymentProcessor.java:42)` — and walks the realistic diagnosis path against seeded structural data (no SCIP needed; `findImplementations` uses `type_relationships`). It proves the headline workflow end-to-end at the `QueryExecutor` level. Model the harness on `BranchQueryTest` (DatabaseManager/Jdbi + `RepositoryDao`/`FileDao`/`SymbolDao` + an `insertContent` helper) — read it for exact DAO method names (`fileDao.upsert(SourceFile)`, `symbolDao.insertSymbol(Symbol)`, `symbolDao.insertTypeRelationship(...)` or the real equivalents) and record shapes. + +- [ ] **Step 1: Write the diagnosis-flow test** + +Create `src/test/java/com/indexer/mcp/StackTraceDiagnosisTest.java`: + +- `@Container` Postgres; `@BeforeEach`: init DB, seed repo `payment-app` with two files: + - `src/main/java/com/example/payment/PaymentProcessor.java` — class `PaymentProcessor` (implements `PaymentGateway` via `type_relationships`), method `charge` at lines 40-50; `file_contents` includes a line near 42 like `throw new IllegalStateException("charge failed: invalid state");`. + - `src/main/java/com/example/PaymentService.java` — class `PaymentService`, method `processTransaction`; `file_contents` `import com.example.payment.PaymentProcessor;` and a call `new PaymentProcessor().charge(amount);`. + - Also seed a second implementer `StripeGateway` (implements `PaymentGateway`) so `findImplementations` returns >1. +- Build `queryExecutor = new QueryExecutor(jdbi);` (single-arg ctor — no auth gate, no fault-in; matches BranchQueryTest). + +```java +@Test +void diagnoseFromStackFrame_PaymentProcessor_charge_line42() { + // FRAME: at com.example.payment.PaymentProcessor.charge(PaymentProcessor.java:42) + String repo = "payment-app"; + String file = "src/main/java/com/example/payment/PaymentProcessor.java"; + + // 1) Jump to the throwing method's source (file + method + line disambiguates overloads) + var detail = queryExecutor.getSymbolDetail(repo, file, "charge", 42, null); + assertThat(detail.get("name")).isEqualTo("charge"); + assertThat(detail.get("source_code")).asString().contains("IllegalStateException"); + + // 2) Who references the throwing class? (import-based callers) + var refs = queryExecutor.findReferences("PaymentProcessor", repo, null, 20); + assertThat(refs.toString()).contains("PaymentService.java"); + + // 3) Polymorphism: which concrete types implement the same interface? + var impls = queryExecutor.findImplementations("PaymentGateway", repo, null); + assertThat(impls.toString()).contains("PaymentProcessor").contains("StripeGateway"); + + // 4) Widen to file context: sibling symbols + imports + var summary = queryExecutor.getFileSummary(repo, file, null); + assertThat(summary.get("symbols").toString()).contains("charge"); + + // 5) Hunt the error string across the codebase + var hits = queryExecutor.searchCode("IllegalStateException", null, repo, null, 10); + assertThat(hits.toString()).contains(file); +} +``` + +> Adapt the return-shape assertions to the actual structures (read each method's return map keys in QueryExecutor: `getSymbolDetail` → `name`/`source_code`; `findReferences`/`findImplementations` → `{results: [...]}` wrappers when repo is set; `getFileSummary` → `{symbols, imports}`; `searchCode` → `List` with `file_path`). Use `.toString().contains(...)` only as a robust fallback; prefer extracting the specific field where the shape is clear (model on `BranchQueryTest` assertions). The five steps must each assert a real value, not just non-null. + +- [ ] **Step 2: Run to verify pass** + +Run: `./gradlew integrationTest --tests "com.indexer.mcp.StackTraceDiagnosisTest"` +Expected: PASS. If `getSymbolDetail` returns `source_code: null`, the `file_contents` seed didn't take (check the `ON CONFLICT` insert and that the file_id matches). If `findImplementations` is empty, the `type_relationships` seed (symbol_id + related_name + kind='implements') is missing or mis-shaped — read the real schema/DAO. + +- [ ] **Step 3: Commit** + +```bash +git add src/test/java/com/indexer/mcp/StackTraceDiagnosisTest.java +git commit -m "test: poster-child stack-trace diagnosis flow (frame -> source -> callers -> impls -> context)" +``` + +--- + +### Task 6: Document the model in CLAUDE.md + +**Files:** +- Modify: `CLAUDE.md` (SCIP Upload API / Supported Auth Types / a new note under the auth `apiKeys` config block) + +- [ ] **Step 1: Document per-key `repos` scoping** + +Find the `auth.apiKeys` config example in CLAUDE.md (the SCIP Upload API section shows `apiKeys: - key: ... scipUpload: true`). Add a `repos:` field to the example and a short subsection explaining the authorization model: +- API keys are **scoped per key** via `repos:`. `repos: ["*"]` grants full read access to all indexed repos (explicit, auditable); `repos: [repo-a, repo-b]` restricts the key to those repos; **omitting `repos` denies all queries** (fail-closed) — a key must declare its scope. +- This mirrors the OAuth per-repo entitlement (groups → allowed repos), so the indexer never exposes a repo's code to a remote caller who isn't entitled to it, regardless of auth method. +- Repo-less/system tools (`get_index_health`, `query_audit_log`, `verify_audit_chain`) require a `["*"]` key (same as OAuth, which cannot call them). +- stdio (local subprocess, OS user) is trusted and unscoped. + +Add `repos: ["*"]` to the SCIP-upload example key so the documented example still works after this change. Note the migration: existing keys need `repos:` added or they will be denied. + +- [ ] **Step 2: Commit** + +```bash +git add CLAUDE.md +git commit -m "docs: document per-API-key repo scoping (fail-closed, [*] for full access)" +``` + +--- + +## Self-Review + +**Spec coverage:** +- "API keys get per-repo scope, fail-closed, `["*"]` escape hatch" → Task 1 (config), Task 2 (identity threading), Task 3 (enforcement). The fail-closed default (absent `repos` → `List.of()` → denied) is set in Task 1's compact constructor and enforced in Task 3. +- "Prove a remote caller without access can't do anything meaningful" → Task 4 (OAuth + API-key denial across 5/4 tools, asserting no `TopSecretKey`/`sk-LEAK` leak), plus controls, fail-closed-on-resolver-error, and audit-of-denials. +- "Stack-trace diagnose poster-child" → Task 5 (frame → source → callers → implementations → file context → error-string search). +- Documentation → Task 6. + +**Placeholder scan:** All code blocks are concrete. Three spots explicitly say "adapt to the real signature/shape" — `ConfigLoaderTest`'s load helper (Task 1), `PermissionCache` ctor + `PermissionResolver` method (Task 4), the `textOf(CallToolResult)` content accessor (Tasks 3-4), and DAO method/record shapes (Task 5) — because those are existing APIs the implementer must read rather than ones this plan defines. Each names the exact file to model on. The behavioral assertions are fully specified. + +**Type/name consistency:** `ApiKeyEntry(... , List repos)` (Task 1) → `ApiKeyConfig(... , List repos)` (Task 2) → `CallerIdentity.fromApiKey(..., List allowedRepos)` → `caller.allowedRepos()` read in `executeQuery` (Task 3). `CapturingAuditSink` + `textOf` defined in Task 3 and reused (extracted to `TestAuthSupport`) in Task 4. The `["*"]` wildcard and "absent → empty → deny" semantics are consistent across Tasks 1/3/6. + +**Atomicity:** Task 1 (config) and Task 2 (identity) each compile + pass independently. Task 3 adds the gate (unit-tested, no DB). Tasks 4-5 add integration tests over the now-complete feature. Each task commits green. + +## Out of scope (later) +- SCIP upload (`POST /api/scip/{repoName}`) write-path authorization by repo scope (currently `scipUpload` flag only). +- Per-ref `get_index_health` (repo-global today). +- Rotating the `dev-*` API tokens (operational follow-up; they'll be denied until given a `repos:` scope). diff --git a/src/main/java/com/indexer/Application.java b/src/main/java/com/indexer/Application.java index 50d2845..bf508a2 100644 --- a/src/main/java/com/indexer/Application.java +++ b/src/main/java/com/indexer/Application.java @@ -116,7 +116,7 @@ public void start(Path configPath) { // 5b. Set up API key authenticator var apiKeyConfigs = config.mcpAuth().apiKeys().stream() - .map(e -> new ApiKeyAuthenticator.ApiKeyConfig(e.key(), e.id(), e.name(), e.auditReader(), e.scipUpload())) + .map(e -> new ApiKeyAuthenticator.ApiKeyConfig(e.key(), e.id(), e.name(), e.auditReader(), e.scipUpload(), e.repos())) .toList(); var authenticator = new ApiKeyAuthenticator(apiKeyConfigs); diff --git a/src/main/java/com/indexer/auth/ApiKeyAuthenticator.java b/src/main/java/com/indexer/auth/ApiKeyAuthenticator.java index a93b1a6..875d7d9 100644 --- a/src/main/java/com/indexer/auth/ApiKeyAuthenticator.java +++ b/src/main/java/com/indexer/auth/ApiKeyAuthenticator.java @@ -16,7 +16,8 @@ public class ApiKeyAuthenticator { private final List apiKeys; - public record ApiKeyConfig(String key, String id, String name, boolean auditReader, boolean scipUpload) {} + public record ApiKeyConfig(String key, String id, String name, boolean auditReader, + boolean scipUpload, List repos) {} public ApiKeyAuthenticator(List apiKeys) { this.apiKeys = apiKeys != null ? apiKeys : List.of(); @@ -50,7 +51,8 @@ public Optional authenticate(String bearerToken, String sourceIp } for (var keyConfig : apiKeys) { if (constantTimeEquals(keyConfig.key(), bearerToken)) { - return Optional.of(CallerIdentity.fromApiKey(keyConfig.id(), keyConfig.name(), sourceIp, keyConfig.auditReader(), keyConfig.scipUpload())); + return Optional.of(CallerIdentity.fromApiKey(keyConfig.id(), keyConfig.name(), sourceIp, + keyConfig.auditReader(), keyConfig.scipUpload(), keyConfig.repos())); } } return Optional.empty(); diff --git a/src/main/java/com/indexer/auth/CallerIdentity.java b/src/main/java/com/indexer/auth/CallerIdentity.java index 1348ae0..e2ca04e 100644 --- a/src/main/java/com/indexer/auth/CallerIdentity.java +++ b/src/main/java/com/indexer/auth/CallerIdentity.java @@ -12,40 +12,46 @@ public record CallerIdentity( String clientVersion, List groups, boolean auditReader, - boolean scipUpload + boolean scipUpload, + List allowedRepos ) { public CallerIdentity { groups = groups != null ? List.copyOf(groups) : List.of(); + allowedRepos = allowedRepos != null ? List.copyOf(allowedRepos) : List.of(); } public static final String CONTEXT_KEY = "callerIdentity"; public static CallerIdentity anonymous(String transport) { - return new CallerIdentity(null, "anonymous", "none", transport, null, null, null, List.of(), false, false); + return new CallerIdentity(null, "anonymous", "none", transport, null, null, null, List.of(), false, false, List.of()); } public static CallerIdentity fromStdio() { String osUser = System.getProperty("user.name"); - return new CallerIdentity(osUser, osUser, "stdio-os-user", "stdio", null, null, null, List.of(), true, false); + return new CallerIdentity(osUser, osUser, "stdio-os-user", "stdio", null, null, null, List.of(), true, false, List.of()); } public static CallerIdentity fromApiKey(String id, String name, String sourceIp) { - return new CallerIdentity(id, name, "api-key", "streamable-http", sourceIp, null, null, List.of(), false, false); + return new CallerIdentity(id, name, "api-key", "streamable-http", sourceIp, null, null, List.of(), false, false, List.of()); } public static CallerIdentity fromApiKey(String id, String name, String sourceIp, boolean auditReader) { - return new CallerIdentity(id, name, "api-key", "streamable-http", sourceIp, null, null, List.of(), auditReader, false); + return new CallerIdentity(id, name, "api-key", "streamable-http", sourceIp, null, null, List.of(), auditReader, false, List.of()); } public static CallerIdentity fromApiKey(String id, String name, String sourceIp, boolean auditReader, boolean scipUpload) { - return new CallerIdentity(id, name, "api-key", "streamable-http", sourceIp, null, null, List.of(), auditReader, scipUpload); + return new CallerIdentity(id, name, "api-key", "streamable-http", sourceIp, null, null, List.of(), auditReader, scipUpload, List.of()); + } + + public static CallerIdentity fromApiKey(String id, String name, String sourceIp, boolean auditReader, boolean scipUpload, List allowedRepos) { + return new CallerIdentity(id, name, "api-key", "streamable-http", sourceIp, null, null, List.of(), auditReader, scipUpload, allowedRepos); } public static CallerIdentity fromOAuth(String sub, String name, List groups, String sourceIp) { - return new CallerIdentity(sub, name, "oauth", "streamable-http", sourceIp, null, null, groups, false, false); + return new CallerIdentity(sub, name, "oauth", "streamable-http", sourceIp, null, null, groups, false, false, List.of()); } public static CallerIdentity fromAdminToken(String sourceIp) { - return new CallerIdentity("admin", "Admin", "admin-token", "streamable-http", sourceIp, null, null, List.of(), false, false); + return new CallerIdentity("admin", "Admin", "admin-token", "streamable-http", sourceIp, null, null, List.of(), false, false, List.of()); } } diff --git a/src/main/java/com/indexer/config/ConfigLoader.java b/src/main/java/com/indexer/config/ConfigLoader.java index 6ffcae9..83e0029 100644 --- a/src/main/java/com/indexer/config/ConfigLoader.java +++ b/src/main/java/com/indexer/config/ConfigLoader.java @@ -195,7 +195,15 @@ private IndexerConfig.McpAuthConfig parseMcpAuth(JsonNode node) { if (key != null && id != null) { boolean auditReader = keyNode.has("auditReader") && keyNode.get("auditReader").asBoolean(false); boolean scipUpload = keyNode.has("scipUpload") && keyNode.get("scipUpload").asBoolean(false); - keys.add(new IndexerConfig.McpAuthConfig.ApiKeyEntry(key, id, name != null ? name : id, auditReader, scipUpload)); + List repos = new ArrayList<>(); + JsonNode reposNode = keyNode.get("repos"); + if (reposNode != null && reposNode.isArray()) { + for (JsonNode r : reposNode) { + if (r.isTextual() && !r.asText().isBlank()) repos.add(r.asText()); + } + } + keys.add(new IndexerConfig.McpAuthConfig.ApiKeyEntry( + key, id, name != null ? name : id, auditReader, scipUpload, repos)); } } } diff --git a/src/main/java/com/indexer/config/IndexerConfig.java b/src/main/java/com/indexer/config/IndexerConfig.java index 222a218..f5a6e33 100644 --- a/src/main/java/com/indexer/config/IndexerConfig.java +++ b/src/main/java/com/indexer/config/IndexerConfig.java @@ -89,7 +89,12 @@ public record McpAuthConfig(List apiKeys, OAuthConfig oauth, Permis public McpAuthConfig { if (apiKeys == null) apiKeys = List.of(); } - public record ApiKeyEntry(String key, String id, String name, boolean auditReader, boolean scipUpload) {} + public record ApiKeyEntry(String key, String id, String name, boolean auditReader, + boolean scipUpload, List repos) { + public ApiKeyEntry { + repos = repos != null ? List.copyOf(repos) : List.of(); + } + } public record OAuthConfig(String jwksUrl, String issuer, String audience, String groupsClaim) { public OAuthConfig { if (groupsClaim == null) groupsClaim = "groups"; diff --git a/src/main/java/com/indexer/mcp/QueryExecutor.java b/src/main/java/com/indexer/mcp/QueryExecutor.java index 6c56723..4f1cd09 100644 --- a/src/main/java/com/indexer/mcp/QueryExecutor.java +++ b/src/main/java/com/indexer/mcp/QueryExecutor.java @@ -106,6 +106,33 @@ public McpSchema.CallToolResult executeQuery( } } + // Authorization check — API-key callers are scoped to their configured repos. + // ["*"] = full access (explicit, auditable); a concrete list restricts to those repos; + // an empty scope denies everything (fail-closed). stdio/anonymous are intentionally unscoped. + if ("api-key".equals(caller.authMethod())) { + List scope = caller.allowedRepos(); + boolean wildcard = scope.contains("*"); + if (!wildcard) { + if (repo == null) { + log.warn("Access denied: api-key {} called {} without repo (requires full access)", + caller.displayName(), action); + auditBestEffort(caller, action, null, false, "denied", "Repository parameter required"); + return McpSchema.CallToolResult.builder() + .addTextContent("Repository parameter is required for scoped API keys") + .isError(true) + .build(); + } + if (!scope.contains(repo)) { + log.warn("Access denied: api-key {} not scoped for repo {}", caller.displayName(), repo); + auditBestEffort(caller, action, repo, false, "denied", "Access denied to repository: " + repo); + return McpSchema.CallToolResult.builder() + .addTextContent("Access denied to repository: " + repo) + .isError(true) + .build(); + } + } + } + // Execute query Object result; String resultStatus; diff --git a/src/test/java/com/indexer/auth/ApiKeyAuthenticatorTest.java b/src/test/java/com/indexer/auth/ApiKeyAuthenticatorTest.java index 3c65b51..c991c9b 100644 --- a/src/test/java/com/indexer/auth/ApiKeyAuthenticatorTest.java +++ b/src/test/java/com/indexer/auth/ApiKeyAuthenticatorTest.java @@ -9,7 +9,7 @@ class ApiKeyAuthenticatorTest { @Test void authenticatesValidKey() { - var keys = List.of(new ApiKeyAuthenticator.ApiKeyConfig("secret-key-123", "team-payments", "Payments Team", false, false)); + var keys = List.of(new ApiKeyAuthenticator.ApiKeyConfig("secret-key-123", "team-payments", "Payments Team", false, false, List.of())); var authenticator = new ApiKeyAuthenticator(keys); var result = authenticator.authenticate("secret-key-123", "10.0.0.1"); assertThat(result).isPresent(); @@ -19,16 +19,24 @@ void authenticatesValidKey() { assertThat(result.get().sourceIp()).isEqualTo("10.0.0.1"); } + @Test + void authenticatedIdentityCarriesConfiguredRepos() { + var auth = new ApiKeyAuthenticator(List.of( + new ApiKeyAuthenticator.ApiKeyConfig("k1", "ci-a", "CI A", false, false, List.of("repo-a")))); + var id = auth.authenticate("k1", "10.0.0.1").orElseThrow(); + assertThat(id.allowedRepos()).containsExactly("repo-a"); + } + @Test void rejectsInvalidKey() { - var keys = List.of(new ApiKeyAuthenticator.ApiKeyConfig("secret-key-123", "team-payments", "Payments Team", false, false)); + var keys = List.of(new ApiKeyAuthenticator.ApiKeyConfig("secret-key-123", "team-payments", "Payments Team", false, false, List.of())); var authenticator = new ApiKeyAuthenticator(keys); assertThat(authenticator.authenticate("wrong-key", "10.0.0.1")).isEmpty(); } @Test void rejectsNullKey() { - var keys = List.of(new ApiKeyAuthenticator.ApiKeyConfig("secret-key-123", "team-payments", "Payments Team", false, false)); + var keys = List.of(new ApiKeyAuthenticator.ApiKeyConfig("secret-key-123", "team-payments", "Payments Team", false, false, List.of())); var authenticator = new ApiKeyAuthenticator(keys); assertThat(authenticator.authenticate(null, "10.0.0.1")).isEmpty(); } @@ -36,8 +44,8 @@ void rejectsNullKey() { @Test void supportsMultipleKeys() { var keys = List.of( - new ApiKeyAuthenticator.ApiKeyConfig("key-alpha", "alice", "Alice Chen", false, false), - new ApiKeyAuthenticator.ApiKeyConfig("key-beta", "bob", "Bob Smith", false, false) + new ApiKeyAuthenticator.ApiKeyConfig("key-alpha", "alice", "Alice Chen", false, false, List.of()), + new ApiKeyAuthenticator.ApiKeyConfig("key-beta", "bob", "Bob Smith", false, false, List.of()) ); var authenticator = new ApiKeyAuthenticator(keys); assertThat(authenticator.authenticate("key-alpha", "1.1.1.1").get().userId()).isEqualTo("alice"); @@ -46,7 +54,7 @@ void supportsMultipleKeys() { @Test void isEnabledWithKeys() { - var authenticator = new ApiKeyAuthenticator(List.of(new ApiKeyAuthenticator.ApiKeyConfig("key", "id", "name", false, false))); + var authenticator = new ApiKeyAuthenticator(List.of(new ApiKeyAuthenticator.ApiKeyConfig("key", "id", "name", false, false, List.of()))); assertThat(authenticator.isEnabled()).isTrue(); } diff --git a/src/test/java/com/indexer/auth/CallerIdentityTest.java b/src/test/java/com/indexer/auth/CallerIdentityTest.java index 5e4522b..3b05616 100644 --- a/src/test/java/com/indexer/auth/CallerIdentityTest.java +++ b/src/test/java/com/indexer/auth/CallerIdentityTest.java @@ -15,6 +15,19 @@ void fromStdioUsesOsUsername() { assertThat(identity.sourceIp()).isNull(); } + @Test + void fromApiKeyCarriesAllowedRepos() { + var id = CallerIdentity.fromApiKey("ci-a", "CI A", "10.0.0.1", false, false, List.of("repo-a", "repo-b")); + assertThat(id.authMethod()).isEqualTo("api-key"); + assertThat(id.allowedRepos()).containsExactly("repo-a", "repo-b"); + } + + @Test + void nonApiKeyIdentitiesHaveEmptyAllowedRepos() { + assertThat(CallerIdentity.fromStdio().allowedRepos()).isEmpty(); + assertThat(CallerIdentity.fromOAuth("u", "U", List.of("g"), "ip").allowedRepos()).isEmpty(); + } + @Test void anonymousHasNoAuth() { var identity = CallerIdentity.anonymous("streamable-http"); diff --git a/src/test/java/com/indexer/config/ConfigLoaderTest.java b/src/test/java/com/indexer/config/ConfigLoaderTest.java index 4764358..4aff7bf 100644 --- a/src/test/java/com/indexer/config/ConfigLoaderTest.java +++ b/src/test/java/com/indexer/config/ConfigLoaderTest.java @@ -188,6 +188,40 @@ void webhookSecretIsNullWhenOmitted() throws IOException { assertThat(config.repositories().get(0).webhookSecret()).isNull(); } + @Test + void parsesApiKeyReposAllowList() throws IOException { + String yaml = """ + server: + cloneBaseDir: /tmp/repos + + database: + host: localhost + name: indexer_db + + auth: + apiKeys: + - key: scoped-key + id: ci-a + name: CI A + repos: [repo-a, repo-b] + - key: full-key + id: admin + name: Admin + repos: ["*"] + - key: bare-key + id: legacy + name: Legacy + """; + ConfigLoader loader = new ConfigLoader(); + IndexerConfig config = loader.load(toStream(yaml)); + + var keys = config.mcpAuth().apiKeys(); + assertThat(keys).hasSize(3); + assertThat(keys.get(0).repos()).containsExactly("repo-a", "repo-b"); + assertThat(keys.get(1).repos()).containsExactly("*"); + assertThat(keys.get(2).repos()).isEmpty(); // absent → empty (fail-closed) + } + private InputStream toStream(String yaml) { return new ByteArrayInputStream(yaml.getBytes(StandardCharsets.UTF_8)); } diff --git a/src/test/java/com/indexer/mcp/ApiKeyScopeGateTest.java b/src/test/java/com/indexer/mcp/ApiKeyScopeGateTest.java new file mode 100644 index 0000000..5c6fee6 --- /dev/null +++ b/src/test/java/com/indexer/mcp/ApiKeyScopeGateTest.java @@ -0,0 +1,89 @@ +package com.indexer.mcp; + +import com.indexer.auth.CallerIdentity; +import com.indexer.mcp.TestAuthSupport.CapturingAuditSink; +import io.modelcontextprotocol.spec.McpSchema; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; + +import static com.indexer.mcp.TestAuthSupport.textOf; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for the API-key repo-scope authorization branch in {@link QueryExecutor#executeQuery}. + * The denied path never touches the DB (the query lambda is not run), so all collaborators are null. + */ +class ApiKeyScopeGateTest { + + private QueryExecutor newExecutor(CapturingAuditSink sink) { + // Only the auth gate is exercised; DB collaborators are null and never touched on the denied path. + return new QueryExecutor(null, null, null, null, null, null, sink); + } + + @Test + void scopedKeyAllowedRepoRunsQuery() { + var qe = newExecutor(new CapturingAuditSink()); + var caller = CallerIdentity.fromApiKey("ci-a", "CI A", "ip", false, false, List.of("repo-a")); + var ran = new AtomicBoolean(false); + + var result = qe.executeQuery(caller, "repo-a", "search_symbols", Map.of(), + () -> { ran.set(true); return Map.of("results", List.of()); }); + + assertThat(ran).isTrue(); + assertThat(result.isError()).isFalse(); + } + + @Test + void scopedKeyForbiddenRepoIsDeniedAndLambdaNeverRuns() { + var sink = new CapturingAuditSink(); + var qe = newExecutor(sink); + var caller = CallerIdentity.fromApiKey("ci-a", "CI A", "ip", false, false, List.of("repo-a")); + var ran = new AtomicBoolean(false); + + var result = qe.executeQuery(caller, "secret-repo", "search_code", Map.of(), + () -> { ran.set(true); return Map.of("leak", "TOP SECRET"); }); + + assertThat(ran).isFalse(); // query never executed + assertThat(result.isError()).isTrue(); + assertThat(textOf(result)).contains("Access denied to repository: secret-repo"); + assertThat(textOf(result)).doesNotContain("TOP SECRET"); + assertThat(sink.events).anyMatch(e -> !e.authorized()); // denial audited + } + + @Test + void wildcardKeyReadsAnyRepo() { + var qe = newExecutor(new CapturingAuditSink()); + var caller = CallerIdentity.fromApiKey("admin", "Admin", "ip", false, false, List.of("*")); + + var result = qe.executeQuery(caller, "any-repo", "search_symbols", Map.of(), + () -> Map.of("results", List.of())); + + assertThat(result.isError()).isFalse(); + } + + @Test + void unscopedKeyIsDeniedEverything() { + var qe = newExecutor(new CapturingAuditSink()); + var caller = CallerIdentity.fromApiKey("legacy", "Legacy", "ip"); // no repos → List.of() + + var result = qe.executeQuery(caller, "repo-a", "search_symbols", Map.of(), + () -> Map.of("results", List.of())); + + assertThat(result.isError()).isTrue(); + assertThat(textOf(result)).contains("Access denied to repository: repo-a"); + } + + @Test + void scopedKeyRepoLessToolIsDenied() { + var qe = newExecutor(new CapturingAuditSink()); + var caller = CallerIdentity.fromApiKey("ci-a", "CI A", "ip", false, false, List.of("repo-a")); + + var result = qe.executeQuery(caller, null, "get_index_health", Map.of(), + () -> Map.of("ok", true)); + + assertThat(result.isError()).isTrue(); // repo-less/system tools require ["*"] + } +} diff --git a/src/test/java/com/indexer/mcp/PermissionBoundaryTest.java b/src/test/java/com/indexer/mcp/PermissionBoundaryTest.java new file mode 100644 index 0000000..27587fc --- /dev/null +++ b/src/test/java/com/indexer/mcp/PermissionBoundaryTest.java @@ -0,0 +1,154 @@ +package com.indexer.mcp; + +import com.indexer.auth.CallerIdentity; +import com.indexer.auth.PermissionCache; +import com.indexer.auth.PermissionResolver; +import com.indexer.mcp.TestAuthSupport.CapturingAuditSink; +import org.jdbi.v3.core.Jdbi; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static com.indexer.mcp.TestAuthSupport.textOf; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Security boundary: a remote caller who is not entitled to a repo must not be able to do + * anything meaningful with it — no source, symbols, paths, or search results may leak. Proven + * for both the OAuth path (group → allowed repos) and the API-key path (per-key repo scope), + * across multiple tools, at the central {@link QueryExecutor#executeQuery} choke point. + */ +@Testcontainers +@Tag("integration") +class PermissionBoundaryTest { + + @Container + static PostgreSQLContainer pg = new PostgreSQLContainer<>("postgres:16") + .withDatabaseName("test_index").withUsername("test").withPassword("test"); + + private Jdbi jdbi; + private QueryExecutor qe; + private PermissionResolver resolver; + private CapturingAuditSink sink; + + /** Tools that read meaningful repo content; each must deny an unentitled caller. */ + private static final List READ_TOOLS = + List.of("search_symbols", "get_symbol_detail", "search_code", "get_file_summary", "get_directory_tree"); + + @BeforeEach + void setUp() { + jdbi = Jdbi.create(pg.getJdbcUrl(), pg.getUsername(), pg.getPassword()); + var flyway = org.flywaydb.core.Flyway.configure() + .dataSource(pg.getJdbcUrl(), pg.getUsername(), pg.getPassword()) + .cleanDisabled(false) + .load(); + flyway.clean(); + flyway.migrate(); + + jdbi.useHandle(h -> { + h.execute("INSERT INTO repositories (name, url, branch, clone_path, auth_type, last_indexed_sha) VALUES ('repo-pub','u','main','/tmp/pub','ssh-key','sha1')"); + h.execute("INSERT INTO repositories (name, url, branch, clone_path, auth_type, last_indexed_sha) VALUES ('repo-secret','u','main','/tmp/sec','ssh-key','sha2')"); + int pub = h.createQuery("SELECT id FROM repositories WHERE name='repo-pub'").mapTo(Integer.class).one(); + int sec = h.createQuery("SELECT id FROM repositories WHERE name='repo-secret'").mapTo(Integer.class).one(); + + h.execute("INSERT INTO files (repo_id, path, language) VALUES (?,'src/Pub.java','java')", pub); + h.execute("INSERT INTO files (repo_id, path, language) VALUES (?,'src/Secret.java','java')", sec); + int pubFile = h.createQuery("SELECT id FROM files WHERE repo_id=:r AND path='src/Pub.java'").bind("r", pub).mapTo(Integer.class).one(); + int secFile = h.createQuery("SELECT id FROM files WHERE repo_id=:r AND path='src/Secret.java'").bind("r", sec).mapTo(Integer.class).one(); + + h.execute("INSERT INTO symbols (file_id, name, kind, signature, start_line, end_line, visibility, is_static) VALUES (?,'PublicThing','class','class PublicThing',1,5,'public',false)", pubFile); + h.execute("INSERT INTO symbols (file_id, name, kind, signature, start_line, end_line, visibility, is_static) VALUES (?,'TopSecretKey','class','class TopSecretKey',1,5,'public',false)", secFile); + + h.createUpdate("INSERT INTO file_contents (file_id, content) VALUES (:f,:c) ON CONFLICT (file_id) DO UPDATE SET content=EXCLUDED.content") + .bind("f", secFile).bind("c", "class TopSecretKey { String apiSecret = \"sk-LEAK\"; }").execute(); + h.createUpdate("INSERT INTO file_contents (file_id, content) VALUES (:f,:c) ON CONFLICT (file_id) DO UPDATE SET content=EXCLUDED.content") + .bind("f", pubFile).bind("c", "class PublicThing {}").execute(); + }); + + resolver = mock(PermissionResolver.class); + when(resolver.resolveAllowedRepos(List.of("team-pub"))).thenReturn(Set.of("repo-pub")); + var cache = new PermissionCache(resolver, Set.of(), Duration.ofMinutes(30)); + sink = new CapturingAuditSink(); + qe = new QueryExecutor(jdbi, null, null, null, null, cache, sink); + } + + // ---- OAuth path ---- + + @Test + void oauthUserCannotReadRepoOutsideEntitlement_acrossTools() { + var caller = CallerIdentity.fromOAuth("alice", "Alice", List.of("team-pub"), "ip"); // entitled to repo-pub only + for (String tool : READ_TOOLS) { + var result = qe.executeQuery(caller, "repo-secret", tool, Map.of(), + () -> { throw new AssertionError("query lambda must not run when denied: " + tool); }); + assertThat(result.isError()).as(tool).isTrue(); + assertThat(textOf(result)).as(tool).contains("Access denied to repository: repo-secret"); + assertThat(textOf(result)).as(tool).doesNotContain("TopSecretKey").doesNotContain("sk-LEAK"); + } + assertThat(sink.events).isNotEmpty().allMatch(e -> !e.authorized()); + } + + @Test + void oauthUserCanReadEntitledRepo() { + var caller = CallerIdentity.fromOAuth("alice", "Alice", List.of("team-pub"), "ip"); + var result = qe.executeQuery(caller, "repo-pub", "search_symbols", Map.of(), + () -> qe.searchSymbols(null, null, null, "repo-pub", null, 20)); + assertThat(result.isError()).isFalse(); + assertThat(textOf(result)).contains("PublicThing"); + } + + @Test + void oauthFailClosedWhenResolverThrows() { + when(resolver.resolveAllowedRepos(List.of("team-broken"))) + .thenThrow(new RuntimeException("github down")); + var caller = CallerIdentity.fromOAuth("bob", "Bob", List.of("team-broken"), "ip"); + var result = qe.executeQuery(caller, "repo-pub", "search_symbols", Map.of(), + () -> { throw new AssertionError("must not run"); }); + assertThat(result.isError()).isTrue(); + } + + // ---- API-key path (end-to-end, real tools) ---- + + @Test + void scopedApiKeyCannotReadUnscopedRepo_acrossTools() { + var caller = CallerIdentity.fromApiKey("ci", "CI", "ip", false, false, List.of("repo-pub")); + for (String tool : READ_TOOLS) { + var result = qe.executeQuery(caller, "repo-secret", tool, Map.of(), + () -> { throw new AssertionError("query lambda must not run when denied: " + tool); }); + assertThat(result.isError()).as(tool).isTrue(); + assertThat(textOf(result)).as(tool).doesNotContain("TopSecretKey").doesNotContain("sk-LEAK"); + } + } + + @Test + void scopedApiKeyReadsItsRepo_andWildcardReadsAll() { + var scoped = CallerIdentity.fromApiKey("ci", "CI", "ip", false, false, List.of("repo-pub")); + var scopedResult = qe.executeQuery(scoped, "repo-pub", "search_symbols", Map.of(), + () -> qe.searchSymbols(null, null, null, "repo-pub", null, 20)); + assertThat(scopedResult.isError()).isFalse(); + assertThat(textOf(scopedResult)).contains("PublicThing"); + + var admin = CallerIdentity.fromApiKey("admin", "Admin", "ip", false, false, List.of("*")); + var adminResult = qe.executeQuery(admin, "repo-secret", "search_symbols", Map.of(), + () -> qe.searchSymbols(null, null, null, "repo-secret", null, 20)); + assertThat(adminResult.isError()).isFalse(); + assertThat(textOf(adminResult)).contains("TopSecretKey"); + } + + @Test + void unscopedApiKeyReadsNothing() { + var caller = CallerIdentity.fromApiKey("legacy", "Legacy", "ip"); // no repos → fail-closed + var result = qe.executeQuery(caller, "repo-pub", "search_symbols", Map.of(), + () -> { throw new AssertionError("must not run"); }); + assertThat(result.isError()).isTrue(); + } +} diff --git a/src/test/java/com/indexer/mcp/StackTraceDiagnosisTest.java b/src/test/java/com/indexer/mcp/StackTraceDiagnosisTest.java new file mode 100644 index 0000000..fc8c929 --- /dev/null +++ b/src/test/java/com/indexer/mcp/StackTraceDiagnosisTest.java @@ -0,0 +1,157 @@ +package com.indexer.mcp; + +import org.jdbi.v3.core.Jdbi; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Poster-child workflow: a developer has a stack trace and wants to diagnose it fast. + * + * There is no dedicated "diagnose" tool — diagnosis is a composition of existing query tools. + * This test walks a representative Java frame end-to-end at the QueryExecutor level: + * at com.example.payment.PaymentProcessor.charge(PaymentProcessor.java:42) + * frame → source (get_symbol_detail) → callers (find_references) → polymorphism + * (find_implementations) → file context (get_file_summary) → error string (search_code). + */ +@Testcontainers +@Tag("integration") +class StackTraceDiagnosisTest { + + @Container + static PostgreSQLContainer pg = new PostgreSQLContainer<>("postgres:16") + .withDatabaseName("test_index").withUsername("test").withPassword("test"); + + private QueryExecutor queryExecutor; + + private static final String REPO = "payment-app"; + private static final String PROCESSOR_FILE = "src/main/java/com/example/payment/PaymentProcessor.java"; + + @BeforeEach + void setUp() { + Jdbi jdbi = Jdbi.create(pg.getJdbcUrl(), pg.getUsername(), pg.getPassword()); + var flyway = org.flywaydb.core.Flyway.configure() + .dataSource(pg.getJdbcUrl(), pg.getUsername(), pg.getPassword()) + .cleanDisabled(false) + .load(); + flyway.clean(); + flyway.migrate(); + + jdbi.useHandle(h -> { + h.execute("INSERT INTO repositories (name, url, branch, clone_path, auth_type, last_indexed_sha) VALUES (?, 'u','main','/tmp/pay','ssh-key','sha1')", REPO); + int repoId = h.createQuery("SELECT id FROM repositories WHERE name=:n").bind("n", REPO).mapTo(Integer.class).one(); + + // --- PaymentProcessor.java: the throwing class, implements PaymentGateway --- + h.execute("INSERT INTO files (repo_id, path, language) VALUES (?, ?, 'java')", repoId, PROCESSOR_FILE); + int procFile = fileId(h, repoId, PROCESSOR_FILE); + // class PaymentProcessor declared at line 2; method charge declared at line 42. + // get_symbol_detail matches a symbol's declaration (start) line and slices source by + // [start_line, end_line], so the seeded content must actually span those line numbers. + h.execute("INSERT INTO symbols (file_id, name, kind, signature, start_line, end_line, visibility, is_static) VALUES (?,'PaymentProcessor','class','public class PaymentProcessor',2,47,'public',false)", procFile); + int procClass = symbolId(h, procFile, "PaymentProcessor"); + h.execute("INSERT INTO symbols (file_id, name, kind, signature, start_line, end_line, parent_id, visibility, is_static) VALUES (?,'charge','method','public void charge(BigDecimal amount)',42,46,?,'public',false)", procFile, procClass); + // PaymentProcessor implements PaymentGateway + h.execute("INSERT INTO type_relationships (symbol_id, related_name, kind) VALUES (?, 'PaymentGateway', 'implements')", procClass); + insertContent(h, procFile, processorSource()); + + // --- PaymentService.java: imports + calls PaymentProcessor (an inbound reference) --- + String serviceFile = "src/main/java/com/example/PaymentService.java"; + h.execute("INSERT INTO files (repo_id, path, language) VALUES (?, ?, 'java')", repoId, serviceFile); + int svcFile = fileId(h, repoId, serviceFile); + h.execute("INSERT INTO symbols (file_id, name, kind, signature, start_line, end_line, visibility, is_static) VALUES (?,'PaymentService','class','public class PaymentService',1,30,'public',false)", svcFile); + h.execute("INSERT INTO imports (file_id, import_path) VALUES (?, 'com.example.payment.PaymentProcessor')", svcFile); + insertContent(h, svcFile, """ + package com.example; + import com.example.payment.PaymentProcessor; + public class PaymentService { + void run() { new PaymentProcessor().charge(amount); } + } + """); + + // --- StripeGateway.java: a second implementer of PaymentGateway --- + String stripeFile = "src/main/java/com/example/payment/StripeGateway.java"; + h.execute("INSERT INTO files (repo_id, path, language) VALUES (?, ?, 'java')", repoId, stripeFile); + int stripeFileId = fileId(h, repoId, stripeFile); + h.execute("INSERT INTO symbols (file_id, name, kind, signature, start_line, end_line, visibility, is_static) VALUES (?,'StripeGateway','class','public class StripeGateway',1,40,'public',false)", stripeFileId); + int stripeClass = symbolId(h, stripeFileId, "StripeGateway"); + h.execute("INSERT INTO type_relationships (symbol_id, related_name, kind) VALUES (?, 'PaymentGateway', 'implements')", stripeClass); + insertContent(h, stripeFileId, "package com.example.payment;\npublic class StripeGateway implements PaymentGateway {}"); + }); + + queryExecutor = new QueryExecutor(jdbi); + } + + @Test + @SuppressWarnings("unchecked") + void diagnoseFromStackFrame_PaymentProcessor_charge_line42() { + // FRAME: at com.example.payment.PaymentProcessor.charge(PaymentProcessor.java:42) + + // 1) Jump to the throwing method's source. get_symbol_detail matches the symbol's + // declaration (start) line, so the frame's line maps to charge's declaration at 42. + var detail = queryExecutor.getSymbolDetail(REPO, PROCESSOR_FILE, "charge", 42, null); + assertThat(detail).isNotEmpty(); + assertThat(detail.get("name")).isEqualTo("charge"); + assertThat(detail.get("signature")).isEqualTo("public void charge(BigDecimal amount)"); + assertThat((String) detail.get("source_code")).contains("IllegalStateException"); + + // 2) Who references the throwing class? (import-based inbound callers) + var refs = (Map) queryExecutor.findReferences("PaymentProcessor", REPO, null, 20); + var refList = (List>) refs.get("results"); + assertThat(refList).extracting(m -> m.get("file_path")) + .anyMatch(p -> p.toString().contains("PaymentService.java")); + + // 3) Polymorphism: which concrete types implement the same interface? (could be the real thrower) + var impls = (Map) queryExecutor.findImplementations("PaymentGateway", REPO, null); + var implList = (List>) impls.get("results"); + assertThat(implList).extracting(m -> m.get("class_name")) + .contains("PaymentProcessor", "StripeGateway"); + + // 4) Widen to file context: sibling symbols + imports in the throwing file. + var summary = queryExecutor.getFileSummary(REPO, PROCESSOR_FILE, null); + var symbols = (List>) summary.get("symbols"); + assertThat(symbols).extracting(m -> m.get("name")).contains("PaymentProcessor", "charge"); + + // 5) Hunt the error string across the codebase. + var hits = queryExecutor.searchCode("IllegalStateException", null, REPO, null, 10); + assertThat(hits).extracting(m -> m.get("file_path")) + .anyMatch(p -> p.toString().equals(PROCESSOR_FILE)); + } + + /** Build PaymentProcessor.java where charge() is declared at line 42 and throws around line 44. */ + private static String processorSource() { + var sb = new StringBuilder(); + sb.append("package com.example.payment;\n"); // line 1 + sb.append("public class PaymentProcessor implements PaymentGateway {\n"); // line 2 + for (int i = 3; i <= 41; i++) sb.append(" // ... line ").append(i).append("\n"); // lines 3-41 + sb.append(" public void charge(BigDecimal amount) {\n"); // line 42 + sb.append(" if (amount == null) {\n"); // line 43 + sb.append(" throw new IllegalStateException(\"charge failed: amount is null\");\n"); // line 44 + sb.append(" }\n"); // line 45 + sb.append(" }\n"); // line 46 + sb.append("}\n"); // line 47 + return sb.toString(); + } + + private static int fileId(org.jdbi.v3.core.Handle h, int repoId, String path) { + return h.createQuery("SELECT id FROM files WHERE repo_id=:r AND path=:p") + .bind("r", repoId).bind("p", path).mapTo(Integer.class).one(); + } + + private static int symbolId(org.jdbi.v3.core.Handle h, int fileId, String name) { + return h.createQuery("SELECT id FROM symbols WHERE file_id=:f AND name=:n") + .bind("f", fileId).bind("n", name).mapTo(Integer.class).one(); + } + + private static void insertContent(org.jdbi.v3.core.Handle h, int fileId, String content) { + h.createUpdate("INSERT INTO file_contents (file_id, content) VALUES (:f,:c) ON CONFLICT (file_id) DO UPDATE SET content=EXCLUDED.content") + .bind("f", fileId).bind("c", content).execute(); + } +} diff --git a/src/test/java/com/indexer/mcp/TestAuthSupport.java b/src/test/java/com/indexer/mcp/TestAuthSupport.java new file mode 100644 index 0000000..29fd58b --- /dev/null +++ b/src/test/java/com/indexer/mcp/TestAuthSupport.java @@ -0,0 +1,29 @@ +package com.indexer.mcp; + +import com.indexer.audit.AuditEvent; +import com.indexer.audit.AuditSink; +import io.modelcontextprotocol.spec.McpSchema; + +import java.util.ArrayList; +import java.util.List; + +/** Shared test helpers for authorization tests. */ +final class TestAuthSupport { + + private TestAuthSupport() {} + + /** Captures audit events so tests can assert denials/successes are recorded. */ + static final class CapturingAuditSink implements AuditSink { + final List events = new ArrayList<>(); + @Override public void record(AuditEvent event) { events.add(event); } + } + + /** Concatenate the text content blocks of a tool result. */ + static String textOf(McpSchema.CallToolResult result) { + var sb = new StringBuilder(); + for (var c : result.content()) { + if (c instanceof McpSchema.TextContent tc) sb.append(tc.text()); + } + return sb.toString(); + } +} diff --git a/src/test/java/com/indexer/mcp/transport/StreamableHttpTransportIntegrationTest.java b/src/test/java/com/indexer/mcp/transport/StreamableHttpTransportIntegrationTest.java index c55bc69..91f3155 100644 --- a/src/test/java/com/indexer/mcp/transport/StreamableHttpTransportIntegrationTest.java +++ b/src/test/java/com/indexer/mcp/transport/StreamableHttpTransportIntegrationTest.java @@ -261,7 +261,7 @@ void authenticatedRequestSucceeds() throws Exception { var queryExecutor = new QueryExecutor(jdbi); var authenticator = new ApiKeyAuthenticator(List.of( - new ApiKeyAuthenticator.ApiKeyConfig("test-secret-key", "test-user", "Test User", false, false) + new ApiKeyAuthenticator.ApiKeyConfig("test-secret-key", "test-user", "Test User", false, false, java.util.List.of("*")) )); var authTransport = HttpServletStreamableServerTransportProvider.builder() @@ -347,7 +347,7 @@ void unauthenticatedRequestRejectedWhenAuthConfigured() throws Exception { var queryExecutor = new QueryExecutor(jdbi); var authenticator = new ApiKeyAuthenticator(List.of( - new ApiKeyAuthenticator.ApiKeyConfig("test-secret-key", "test-user", "Test User", false, false) + new ApiKeyAuthenticator.ApiKeyConfig("test-secret-key", "test-user", "Test User", false, false, java.util.List.of("*")) )); var authTransport = HttpServletStreamableServerTransportProvider.builder()