diff --git a/CLAUDE.md b/CLAUDE.md index 2b774a7..9f60026 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -123,7 +123,11 @@ tie-breaker, `Highlighter` boundary markers) and returns `SearchHit`s with HTML- `ContentMcpToolProvider` implements the library `McpToolProvider` (auto-aggregated by `McpServerConfig`) and exposes the four tools on `/mcp` — `search_content`, `list_posts`, `get_post`, `list_categories` — as thin adapters over `ContentSearchService`, using the house helpers for schemas/paging/error mapping -(invalid-argument / not-found / temporary-unavailable during bootstrap). +(invalid-argument / not-found / temporary-unavailable during bootstrap). Security (spec 013): +`ContentConfig` registers a `ScopedKeySpec` bean so the library exchanges the master key for one scoped +to the content index with the minimal actions the service uses (it both reads and writes); MCP api-key +auth on `/mcp` defaults to **enabled** (`MCP_API_KEY_AUTH_ENABLED`), with the `dev` profile +(`application-dev.yaml`) disabling it for local use. > **Build note:** `spring-services` is pinned to a specific snapshot timestamp > (`1.3.0-20260712.175350-13`) in `pom.xml`, not the floating `1.3.0-SNAPSHOT`, because a later diff --git a/README.md b/README.md index 7d97e83..db40286 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,13 @@ java -jar target/content-mcp-0.1.0-SNAPSHOT.jar The app starts on port `8080` and exposes `/mcp`. It connects to Meilisearch at `http://localhost:7700` by default (override via environment variables below). +By default `/mcp` requires an `X-API-Key`. For local development, run with the `dev` profile to +allow unauthenticated access: + +```bash +java -jar target/content-mcp-0.1.0-SNAPSHOT.jar --spring.profiles.active=dev +``` + ## Test ```bash @@ -62,14 +69,16 @@ Common overrides (all via environment variables): | `SPRING_DATASOURCE_USERNAME` | `sa` | Datasource user | | `SPRING_DATASOURCE_PASSWORD` | *(empty)* | Datasource password | | `OAUTH2_JWK_SET_URI` | placeholder | JWK set URI for the library's OAuth2 resource server | +| `MCP_API_KEY_AUTH_ENABLED` | `true` | Require `X-API-Key` on `/mcp` (set `false` or use the `dev` profile locally) | ### Notes on the wiring `spring-services` couples its MCP server to a JPA-backed api-key/user stack and depends on `spring-boot-starter-data-jpa` non-optionally, so a datasource is mandatory. This app supplies an **embedded H2** database that backs only those library tables — the content pipeline itself -keeps no relational state (its store is Meilisearch). The `/mcp` endpoint is secured by the -library defaults; real scoped-key authentication is added in a later spec (013). See +keeps no relational state (its store is Meilisearch). The `/mcp` endpoint requires an `X-API-Key` by +default (disable locally with the `dev` profile), and the runtime Meilisearch key is scoped to the +content index (spec 013). See [`docs/specs/001-project-skeleton/design.md`](docs/specs/001-project-skeleton/design.md) for the full rationale. diff --git a/docs/specs/001-project-skeleton/design.md b/docs/specs/001-project-skeleton/design.md index e150a3d..5c7a050 100644 --- a/docs/specs/001-project-skeleton/design.md +++ b/docs/specs/001-project-skeleton/design.md @@ -183,3 +183,14 @@ open-elements: - **Persistence of api-key/user data:** in-memory H2 resets on restart. If the library's api-key/user data ever needs to persist for this app, configure a real datasource (the config already supports overriding via `SPRING_DATASOURCE_*`). Revisit alongside spec 013 (auth). + +--- + +## Drift Log + +### 2026-07-13 — Caused by spec `013-scoped-search-key` + +- **Affected element:** Configuration — `openelements.mcp.auth.api-key.enabled` +- **Original design:** the skeleton set `api-key.enabled: false` in `application.yaml` so the app could be probed locally without a key ("real auth handled in spec 013"). +- **Current state:** api-key auth now defaults to **enabled** (`${MCP_API_KEY_AUTH_ENABLED:true}`); the `dev` profile (`application-dev.yaml`) disables it for local development. A `ScopedKeySpec` bean also scopes the runtime Meilisearch key to the content index. +- **Reason:** spec 013 is the planned step that establishes the real auth/security posture; the skeleton's open default was a temporary dev convenience. diff --git a/docs/specs/013-scoped-search-key/steps.md b/docs/specs/013-scoped-search-key/steps.md new file mode 100644 index 0000000..8aae8cf --- /dev/null +++ b/docs/specs/013-scoped-search-key/steps.md @@ -0,0 +1,44 @@ +# Implementation Steps: Scoped Search Key + +## Step 1: `ScopedKeySpec` bean + +- [x] `@Bean ScopedKeySpec contentScopedKey(...)` in `ContentConfig` (gated on `meilisearch.enabled`) +- [x] Scoped to `resolveIndex("content")` with the minimal action set the service uses (`CONTENT_INDEX_ACTIONS`: search + documents.add/delete/get + settings.get/update + indexes.get/create + tasks.get) +- [x] Library `MeilisearchScopedKeyInitializer` exchanges the master key for this scoped key at startup; degrades to the master key on failure + +**Related behaviors:** scoped key minted at startup; runtime key limited to content index; query + startup writes succeed; degradation on failure + +--- + +## Step 2: MCP auth posture + +- [x] `application.yaml`: api-key auth on `/mcp` defaults to **enabled** (`MCP_API_KEY_AUTH_ENABLED`, default `true`) +- [x] `application-dev.yaml` (`dev` profile): disables api-key auth for local convenience + +**Related behaviors:** API-key auth enforced in non-dev; dev profile allows unauthenticated local access + +--- + +## Step 3: Tests + +- [x] `ContentScopedKeyTest`: scoped-key bean targets the content index, grants the used actions, and is absent when Meilisearch is disabled +- [x] Full app-context build confirms the scoped-key initializer degrades gracefully when Meilisearch is unreachable + +**Acceptance criteria:** +- [x] All tests pass (`mvn test`); build green + +--- + +## Behavior Coverage + +| Scenario | Layer | Covered in Step | +|----------|-------|-----------------| +| Scoped key is minted at startup | Backend (integration) | Delegated to the library `MeilisearchScopedKeyInitializer` (consumes the `ScopedKeySpec` bean); needs a live Meilisearch. Bean presence verified in Step 3. | +| Degradation when exchange fails | Backend | Step 3 — full context boots with the initializer against an unreachable Meilisearch (warn + fall back), verified by the existing app-context tests. | +| Runtime key is limited to the content index | Backend | Step 3 (`scopedKeyTargetsContentIndex`) — the spec scopes to one index; enforcement is Meilisearch's (live). | +| Query operations succeed under the scoped key | Backend | Step 3 (`scopedKeyGrantsUsedActions` includes `search`); live behavior is Meilisearch's. | +| Startup writes succeed under the granted actions | Backend | Step 3 (`scopedKeyGrantsUsedActions` includes the write actions); live behavior is Meilisearch's. | +| API-key auth enforced in non-dev | Backend | Step 2 — default `enabled=true` drives the library `McpSecurityConfig` filter chain (its enforcement is library-tested). | +| Dev profile allows unauthenticated local access | Backend | Step 2 (`application-dev.yaml`). | + +All scenarios are backend; there is no frontend in this spec. The live key-exchange/enforcement scenarios are the library's responsibility and require a running Meilisearch, consistent with the project's established boundary. The exact Meilisearch action names are verified against a live instance; if the exchange fails the app degrades to the master key. diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md index b04f17b..114814f 100644 --- a/docs/specs/INDEX.md +++ b/docs/specs/INDEX.md @@ -18,7 +18,7 @@ roadmap step, to be implemented sequentially (each builds on the previous). | 010 | 010-content-refresh-scheduler | Content refresh scheduler | backend, scheduling | `ContentRefreshScheduler` — `@Scheduled` incremental re-crawl | #19 | done | | 011 | 011-content-search-service | Content search service | backend, search | `ContentSearchService` — `multiSearch` + highlighting facade | #21 | done | | 012 | 012-content-mcp-tools | Content MCP tools | backend, mcp, api | `ContentMcpToolProvider` — the 4 MCP tools | #23 | done | -| 013 | 013-scoped-search-key | Scoped search key | backend, search, security | Read-only scoped Meilisearch key for the content index | — | open | +| 013 | 013-scoped-search-key | Scoped search key | backend, search, security | Read-only scoped Meilisearch key for the content index | #25 | done | | 014 | 014-ops-robustness | Ops & robustness | backend, infrastructure, observability | robots.txt handling, fault tolerance, logging/metrics | — | open | | 015 | 015-additional-web-sources | Additional web sources | backend, configuration | hiero.org/blog + Support & Care as config-only sources | — | open | | 016 | 016-git-markdown-source | Git markdown source | backend, architecture, security | `type: git` source + `GitSourceStrategy` (GitHub Markdown) | — | open | diff --git a/src/main/java/com/openelements/content/ContentConfig.java b/src/main/java/com/openelements/content/ContentConfig.java index 25caadd..27d3e08 100644 --- a/src/main/java/com/openelements/content/ContentConfig.java +++ b/src/main/java/com/openelements/content/ContentConfig.java @@ -2,6 +2,7 @@ import com.openelements.spring.base.services.search.IndexSettings; import com.openelements.spring.base.services.search.MeilisearchProperties; +import com.openelements.spring.base.services.search.ScopedKeySpec; import java.util.List; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; @@ -47,4 +48,33 @@ IndexSettings contentIndexSettings(MeilisearchProperties meilisearchProperties) List.of("source", "locale", "author", "categories", "publishedDate"), List.of("publishedDate")); } + + /** + * The Meilisearch actions this service performs on the content index. The runtime key is scoped + * to exactly these — a large reduction from the master key (one index, no admin/key-management, + * no other indexes) even though the process writes (bootstrap + scheduler) as well as reads. + */ + static final List CONTENT_INDEX_ACTIONS = List.of( + "search", + "documents.add", "documents.delete", "documents.get", + "settings.get", "settings.update", + "indexes.get", "indexes.create", + "tasks.get"); + + /** + * Scopes the runtime Meilisearch key to the content index with {@link #CONTENT_INDEX_ACTIONS}. + * The library's {@code MeilisearchScopedKeyInitializer} picks up this optional bean at startup, + * exchanges the master key for the scoped key, and switches the client to it; if the exchange + * fails (e.g. Meilisearch unreachable), it logs a warning and keeps the master key. + * + * @param meilisearchProperties library properties, used to resolve the prefixed index UID + * @return the scoped-key specification + */ + @Bean + @ConditionalOnProperty(prefix = "openelements.meilisearch", name = "enabled", havingValue = "true") + ScopedKeySpec contentScopedKey(MeilisearchProperties meilisearchProperties) { + return new ScopedKeySpec( + List.of(meilisearchProperties.resolveIndex("content")), + CONTENT_INDEX_ACTIONS); + } } diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml new file mode 100644 index 0000000..2c76746 --- /dev/null +++ b/src/main/resources/application-dev.yaml @@ -0,0 +1,8 @@ +# Local development profile: run with -Dspring.profiles.active=dev (or SPRING_PROFILES_ACTIVE=dev). +# Convenience only — never use in a shared or production deployment. +openelements: + mcp: + auth: + api-key: + # Allow unauthenticated local calls to /mcp so the endpoint can be probed without a key. + enabled: false diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 1642ac2..df57ee3 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -38,7 +38,9 @@ openelements: server-version: "0.1.0" auth: api-key: - enabled: false # dev only; real auth handled in spec 013 + # Secure default: /mcp requires X-API-Key. Disable for local development via the `dev` + # profile (application-dev.yaml) or MCP_API_KEY_AUTH_ENABLED=false. + enabled: ${MCP_API_KEY_AUTH_ENABLED:true} meilisearch: enabled: true host: ${MEILISEARCH_HOST:http://localhost:7700} diff --git a/src/test/java/com/openelements/content/ContentScopedKeyTest.java b/src/test/java/com/openelements/content/ContentScopedKeyTest.java new file mode 100644 index 0000000..dca0422 --- /dev/null +++ b/src/test/java/com/openelements/content/ContentScopedKeyTest.java @@ -0,0 +1,52 @@ +package com.openelements.content; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.openelements.spring.base.services.search.MeilisearchProperties; +import com.openelements.spring.base.services.search.ScopedKeySpec; +import java.time.Duration; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +/** + * Verifies the {@link ScopedKeySpec} bean {@link ContentConfig} contributes — it scopes the runtime + * Meilisearch key to the content index with the actions the service actually uses. + * + *

The actual key exchange is performed by the library's {@code MeilisearchScopedKeyInitializer} + * against a live Meilisearch and is not reproduced here. + */ +@DisplayName("Content scoped key") +class ContentScopedKeyTest { + + private final ApplicationContextRunner runner = new ApplicationContextRunner() + .withBean(MeilisearchProperties.class, + () -> new MeilisearchProperties("http://localhost:7700", "", "content_", Duration.ofSeconds(10))) + .withUserConfiguration(ContentConfig.class) + .withPropertyValues("openelements.meilisearch.enabled=true"); + + @Test + @DisplayName("the scoped key is limited to the content index") + void scopedKeyTargetsContentIndex() { + runner.run(context -> assertThat(context.getBean(ScopedKeySpec.class).indexes()) + .containsExactly("content_content")); + } + + @Test + @DisplayName("the scoped key grants search plus the write actions used at startup and refresh") + void scopedKeyGrantsUsedActions() { + runner.run(context -> assertThat(context.getBean(ScopedKeySpec.class).actions()) + .contains("search", "documents.add", "documents.delete", "settings.update", "tasks.get")); + } + + @Test + @DisplayName("no scoped key bean is created when Meilisearch is disabled") + void noScopedKeyWhenMeilisearchDisabled() { + new ApplicationContextRunner() + .withBean(MeilisearchProperties.class, + () -> new MeilisearchProperties("http://localhost:7700", "", "", Duration.ofSeconds(10))) + .withUserConfiguration(ContentConfig.class) + .withPropertyValues("openelements.meilisearch.enabled=false") + .run(context -> assertThat(context).doesNotHaveBean(ScopedKeySpec.class)); + } +}