Skip to content
11 changes: 11 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

```
Expand Down

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/main/java/com/indexer/Application.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
6 changes: 4 additions & 2 deletions src/main/java/com/indexer/auth/ApiKeyAuthenticator.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ public class ApiKeyAuthenticator {

private final List<ApiKeyConfig> 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<String> repos) {}

public ApiKeyAuthenticator(List<ApiKeyConfig> apiKeys) {
this.apiKeys = apiKeys != null ? apiKeys : List.of();
Expand Down Expand Up @@ -50,7 +51,8 @@ public Optional<CallerIdentity> 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();
Expand Down
22 changes: 14 additions & 8 deletions src/main/java/com/indexer/auth/CallerIdentity.java
Original file line number Diff line number Diff line change
Expand Up @@ -12,40 +12,46 @@ public record CallerIdentity(
String clientVersion,
List<String> groups,
boolean auditReader,
boolean scipUpload
boolean scipUpload,
List<String> 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<String> 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<String> 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());
}
}
10 changes: 9 additions & 1 deletion src/main/java/com/indexer/config/ConfigLoader.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> 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));
}
}
}
Expand Down
7 changes: 6 additions & 1 deletion src/main/java/com/indexer/config/IndexerConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,12 @@ public record McpAuthConfig(List<ApiKeyEntry> 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<String> 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";
Expand Down
27 changes: 27 additions & 0 deletions src/main/java/com/indexer/mcp/QueryExecutor.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> 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;
Expand Down
20 changes: 14 additions & 6 deletions src/test/java/com/indexer/auth/ApiKeyAuthenticatorTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -19,25 +19,33 @@ 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();
}

@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");
Expand All @@ -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();
}

Expand Down
13 changes: 13 additions & 0 deletions src/test/java/com/indexer/auth/CallerIdentityTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
34 changes: 34 additions & 0 deletions src/test/java/com/indexer/config/ConfigLoaderTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Expand Down
89 changes: 89 additions & 0 deletions src/test/java/com/indexer/mcp/ApiKeyScopeGateTest.java
Original file line number Diff line number Diff line change
@@ -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 ["*"]
}
}
Loading
Loading