diff --git a/CHANGELOG.d/2.12.7-mcp-api-key-boundary.md b/CHANGELOG.d/2.12.7-mcp-api-key-boundary.md new file mode 100644 index 000000000..3844e200d --- /dev/null +++ b/CHANGELOG.d/2.12.7-mcp-api-key-boundary.md @@ -0,0 +1,10 @@ +# 2.12.7 — Harden MCP API-key metadata + +## Fixed + +- Store and display only the constant `lw_mcp_` family prefix; random secret + characters are no longer persisted as key metadata. +- Interpret a buyer-selected expiry date at the end of the buyer's local day. +- Wire the MCP API-key migration into both fresh-volume bootstrap and the + existing-volume replay service, so the Settings API is usable after any + Compose deployment path. diff --git a/CHANGELOG.md b/CHANGELOG.md index c8ed1a099..3b13581d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ All notable changes to this project are documented here. Format follows ### Fixed +- Authenticated buyers can now manage LineageWeave-owned MCP API keys from + the Settings destination. Keyverse remains the OIDC identity boundary; + LineageWeave stores only a digest and reveals a new secret once. - `make smoke` and `make seed` now run through the locked project `uv` environment, so local OIDC and synthetic-data workflows resolve the same pinned dependencies as CI. diff --git a/backend/app/main.py b/backend/app/main.py index 374b97ea2..5e41e14f4 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -166,6 +166,12 @@ visible_team_mention_post_ids, ) from backend.app.lineage_ingestion import rebuild_lineage, visible_lineage_graph +from backend.app.mcp_api_keys import ( + CreateMcpApiKeyRequest, + create_mcp_api_key, + list_mcp_api_keys, + revoke_mcp_api_key, +) from backend.app.post_chat_ingestion import ( fetch_persisted_chat, fetch_persisted_chats, @@ -241,6 +247,49 @@ async def lifespan(app: FastAPI): ) +@app.get("/api/mcp/api-keys") +async def read_mcp_api_keys( + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), +) -> dict[str, Any]: + """List MCP key metadata for the Keyverse-authenticated account.""" + async with pool.acquire() as conn: + keys = await list_mcp_api_keys(conn, account.user_account_id) + return {"api_keys": keys} + + +@app.post("/api/mcp/api-keys", status_code=status.HTTP_201_CREATED) +async def create_account_mcp_api_key( + request: CreateMcpApiKeyRequest, + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), +) -> dict[str, Any]: + """Create one account-owned MCP key and reveal its secret once.""" + try: + async with pool.acquire() as conn: + return await create_mcp_api_key(conn, account.user_account_id, request) + except ValueError as exc: + raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, str(exc)) from exc + + +@app.post("/api/mcp/api-keys/{mcp_api_key_id}/revoke") +async def revoke_account_mcp_api_key( + mcp_api_key_id: str, + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), +) -> dict[str, Any]: + """Revoke only a key owned by the Keyverse-authenticated account.""" + try: + UUID(mcp_api_key_id) + except ValueError as exc: + raise HTTPException(status.HTTP_404_NOT_FOUND, "MCP API key was not found") from exc + async with pool.acquire() as conn: + key = await revoke_mcp_api_key(conn, account.user_account_id, mcp_api_key_id) + if key is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "MCP API key was not found") + return key + + def _require_post_read(account: CurrentAccount) -> None: """Raise 403 when the account has no ``post_read`` permission at all.""" if not account.has_permission(_POST_READ): diff --git a/backend/app/mcp_api_keys.py b/backend/app/mcp_api_keys.py new file mode 100644 index 000000000..6501bd049 --- /dev/null +++ b/backend/app/mcp_api_keys.py @@ -0,0 +1,140 @@ +"""Application-owned MCP API-key lifecycle behind Keyverse OIDC identity. + +Keyverse authenticates the account. LineageWeave stores only a digest of the +random application key and returns the raw value once at creation time. +""" + +from __future__ import annotations + +import hashlib +import secrets +from datetime import UTC, datetime +from typing import Any + +import asyncpg +from pydantic import BaseModel + +KEY_PREFIX = "lw_mcp_" + + +class CreateMcpApiKeyRequest(BaseModel): + """Buyer-supplied label and optional expiry for one MCP key.""" + + display_name: str + expires_at: datetime | None = None + + +def _hash_api_key(raw_key: str) -> str: + """Return the non-reversible digest persisted for ``raw_key``.""" + return hashlib.sha256(raw_key.encode("ascii")).hexdigest() + + +def _serialize_key(row: asyncpg.Record) -> dict[str, Any]: + """Serialize metadata without exposing a stored digest.""" + return { + "mcp_api_key_id": str(row["mcp_api_key_id"]), + "display_name": row["display_name"], + "key_prefix": row["key_prefix"], + "created_at": row["created_at"].isoformat(), + "expires_at": row["expires_at"].isoformat() if row["expires_at"] is not None else None, + "revoked_at": row["revoked_at"].isoformat() if row["revoked_at"] is not None else None, + } + + +def _validated_request(request: CreateMcpApiKeyRequest) -> tuple[str, datetime | None]: + """Normalize the label and reject expired or timezone-less input.""" + display_name = request.display_name.strip() + if not display_name or len(display_name) > 120: + raise ValueError("display_name must contain 1 to 120 non-space characters") + expires_at = request.expires_at + if expires_at is not None: + if expires_at.tzinfo is None or expires_at.utcoffset() is None: + raise ValueError("expires_at must include a timezone") + if expires_at <= datetime.now(UTC): + raise ValueError("expires_at must be in the future") + return display_name, expires_at + + +async def create_mcp_api_key( + conn: asyncpg.Connection, + user_account_id: str, + request: CreateMcpApiKeyRequest, +) -> dict[str, Any]: + """Create one key and return its raw secret exactly once.""" + display_name, expires_at = _validated_request(request) + raw_key = f"{KEY_PREFIX}{secrets.token_urlsafe(32)}" + row = await conn.fetchrow( + """ + insert into mcp_api_key (user_account_id, display_name, key_prefix, key_hash, expires_at) + values ($1, $2, $3, $4, $5) + returning mcp_api_key_id, display_name, key_prefix, created_at, expires_at, revoked_at + """, + user_account_id, + display_name, + KEY_PREFIX, + _hash_api_key(raw_key), + expires_at, + ) + if row is None: + raise RuntimeError("MCP API key insert returned no row") + return {**_serialize_key(row), "api_key": raw_key} + + +async def list_mcp_api_keys(conn: asyncpg.Connection, user_account_id: str) -> list[dict[str, Any]]: + """List one account's metadata without returning any secret material.""" + rows = await conn.fetch( + """ + select mcp_api_key_id, display_name, key_prefix, created_at, expires_at, revoked_at + from mcp_api_key + where user_account_id = $1 + order by created_at desc, mcp_api_key_id + """, + user_account_id, + ) + return [_serialize_key(row) for row in rows] + + +async def revoke_mcp_api_key( + conn: asyncpg.Connection, + user_account_id: str, + mcp_api_key_id: str, +) -> dict[str, Any] | None: + """Revoke an owned key; return ``None`` for an unknown or foreign key.""" + row = await conn.fetchrow( + """ + update mcp_api_key + set revoked_at = coalesce(revoked_at, now()) + where mcp_api_key_id = $1 and user_account_id = $2 + returning mcp_api_key_id, display_name, key_prefix, created_at, expires_at, revoked_at + """, + mcp_api_key_id, + user_account_id, + ) + return _serialize_key(row) if row is not None else None + + +async def resolve_mcp_api_key( + conn: asyncpg.Connection, + raw_key: str, +) -> dict[str, str] | None: + """Resolve a live raw key to its owning account for an MCP adapter.""" + if not raw_key.startswith(KEY_PREFIX): + return None + row = await conn.fetchrow( + """ + select key.mcp_api_key_id, key.user_account_id, account.display_name + from mcp_api_key key + join user_account account on account.user_account_id = key.user_account_id + where key.key_hash = $1 + and key.revoked_at is null + and (key.expires_at is null or key.expires_at > now()) + """, + _hash_api_key(raw_key), + ) + if row is None: + return None + return { + "mcp_api_key_id": str(row["mcp_api_key_id"]), + "user_account_id": str(row["user_account_id"]), + "display_name": row["display_name"], + } diff --git a/docker/postgres-init/Dockerfile b/docker/postgres-init/Dockerfile index 7088497da..28e531e98 100644 --- a/docker/postgres-init/Dockerfile +++ b/docker/postgres-init/Dockerfile @@ -47,6 +47,7 @@ COPY migrations/0037_source_record_identity.sql /docker-entrypoint-initdb.d/39-s COPY migrations/0038_source_named_hints.sql /docker-entrypoint-initdb.d/40-source-named-hints.sql COPY migrations/0039_source_org_named_hints.sql /docker-entrypoint-initdb.d/41-source-org-named-hints.sql COPY migrations/0040_post_summary_contract.sql /docker-entrypoint-initdb.d/42-post-summary-contract.sql +COPY migrations/0051_mcp_api_keys.sql /docker-entrypoint-initdb.d/43-mcp-api-keys.sql COPY migrations/ /opt/lineageweave/migrations/ COPY docker/postgres-init/migrate.sh /usr/local/bin/lineageweave-migrate diff --git a/docker/postgres-init/migrate.sh b/docker/postgres-init/migrate.sh index 7401534bd..f3d23d0ab 100644 --- a/docker/postgres-init/migrate.sh +++ b/docker/postgres-init/migrate.sh @@ -17,7 +17,7 @@ done for migration in /opt/lineageweave/migrations/*.sql; do migration_name=${migration##*/} case "$migration_name" in - 0012_*|0013_*|0014_*|0015_*|0016_*|0017_*|0018_*|0019_*|0020_*|0021_*|0022_*|0023_*|0024_*|0025_*|0026_*|0027_*|0028_*|0029_*|0030_*|0031_*|0032_*|0033_*|0034_*|0035_*|0036_*|0037_*|0038_*|0039_*|0040_*|0041_*|0042_*|0043_*|0044_*|0045_*|0046_*|0047_*|0048_*|0049_*|0050_*) ;; + 0012_*|0013_*|0014_*|0015_*|0016_*|0017_*|0018_*|0019_*|0020_*|0021_*|0022_*|0023_*|0024_*|0025_*|0026_*|0027_*|0028_*|0029_*|0030_*|0031_*|0032_*|0033_*|0034_*|0035_*|0036_*|0037_*|0038_*|0039_*|0040_*|0041_*|0042_*|0043_*|0044_*|0045_*|0046_*|0047_*|0048_*|0049_*|0050_*|0051_*) ;; 0060_*|0100_*) ;; *) continue ;; esac diff --git a/docs/adr/0103-keyverse-authenticated-mcp-api-keys.md b/docs/adr/0103-keyverse-authenticated-mcp-api-keys.md new file mode 100644 index 000000000..4d5e69c96 --- /dev/null +++ b/docs/adr/0103-keyverse-authenticated-mcp-api-keys.md @@ -0,0 +1,44 @@ +# ADR 0103: Keyverse-authenticated MCP API keys + +- Status: Accepted +- Date: 2026-08-21 +- Scope: LineageWeave buyer UI and MCP adapter boundary + +## Decision + +LineageWeave begins with its own login screen. Keyverse is the OIDC issuer and +account authority after the buyer chooses **Log in**; LineageWeave resolves the +verified subject to its normalized `user_account` row. + +LineageWeave owns application-scoped MCP API-key lifecycle because the current +Keyverse contract provides identity, OIDC claims, and operator-controlled +identity administration, not a user-scoped application-key resource. The +LineageWeave API therefore stores only a SHA-256 digest, the constant +non-secret `lw_mcp_` family prefix, +label, timestamps, and the owning `user_account_id`. The raw random key is +returned once on creation and is never returned by list, revoke, logs, or +runtime configuration. + +Every list/create/revoke operation is scoped to the authenticated account. A +foreign key is indistinguishable from a missing key. Keyverse operator tokens +and Keycloak admin credentials never cross into the browser or MCP client. + +## Consequences + +- The buyer can create, copy once, inspect metadata, and revoke keys from the + authenticated LineageWeave Settings destination. +- A future MCP transport resolves a presented key through + `resolve_mcp_api_key()` and then applies the same account/resource ABAC rules; + the key is not an authorization bypass. +- Central cross-application key lifecycle is intentionally not invented until + Keyverse publishes a user-scoped API-key contract. That future contract can + replace the resource owner without changing the buyer-facing boundary. + +## Rejected alternatives + +- Passing the Keyverse operator bearer token to the browser would grant a + coarse identity-administration capability and violate ADR 0008. +- Storing raw API keys would turn a database read into an immediate credential + compromise. +- A `user_account + post_id` session or key table would violate the existing + normalized identity/resource boundary; MCP keys are independent entities. diff --git a/docs/adr/README.md b/docs/adr/README.md index 762f1c051..2a368fa4a 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -14,6 +14,7 @@ decision from them. | [`PROV_O_IMPLEMENTATION.md`](../PROV_O_IMPLEMENTATION.md) | [0065](0065-prov-o-provenance-boundary.md) | | [`PROV_O_IMPLEMENTATION_MATRIX.md`](../PROV_O_IMPLEMENTATION_MATRIX.md) | [0065](0065-prov-o-provenance-boundary.md) | | [`image-content-schema.md`](../image-content-schema.md) | [0066](0066-position-preserving-image-content.md) | +| MCP API-key lifecycle | [0103](0103-keyverse-authenticated-mcp-api-keys.md) | Runtime evidence under `docs/doctoring/` is not converted into an ADR: it records observed results for already-decided behavior. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 2f8dd408b..785f4d32c 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -3,8 +3,8 @@ **Snapshot:** 2026-08-21 (Asia/Seoul) **Protected-main baseline:** `origin/main`; this document does not claim the active PR is shipped. **Audited PR code head:** #258 customer-hierarchy commit `21074cf80cbfab3001bf18b6e1a618f75f4bed24`; this active branch is not protected-main truth. -**Active PR update:** Customer Master now has an ORG-grounded, cycle-safe hierarchy projection with -explicit WAI-ARIA ownership; final-head hosted Checks and independent approval remain required. +**Audited PR exact branch head:** #258 `99244658bc7edb7cf0c71cce2e3dcc59ff891b2b`; this active branch is not protected-main truth. +**Active PR update:** Customer Master now has an ORG-grounded, cycle-safe hierarchy projection with explicit WAI-ARIA ownership; final-head hosted Checks and independent approval remain required. **Purpose:** connect the normative ADRs and research evidence to product requirements, technical contracts, implementation evidence, and active PRs. An active PR is proposed work, not shipped behavior. @@ -199,8 +199,7 @@ PR head, valid unresolved threads, qualifying independent review, and terminal h | #262 | Customer post to Event Lineage | `#261` → `v2.15.0` | Ready / BLOCKED / review required | | #261 | Calendar commitment to Event Lineage | `#260` → `v2.14.0` | Ready / BLOCKED / review required | | #260 | Weekly VOC to Event Lineage | `#258` → `v2.13.0` | Ready / DIRTY / review required | -| #258 | buyer evidence board, standards-composed ontology, and cycle-safe Customer Master tree | `main` → `21074cf80cbfab3001bf18b6e1a618f75f4bed24` | Ready / mergeable / final-head Checks and independent approval pending | -| #258 | buyer evidence board, standards-composed ontology, and cycle-safe Customer Master tree | `main` → `21074cf80cbfab3001bf18b6e1a618f75f4bed24` | Ready / mergeable / final-head Checks and independent approval pending | +| #258 | buyer evidence board, standards-composed ontology, and cycle-safe Customer Master tree | `main` → `99244658bc7edb7cf0c71cce2e3dcc59ff891b2b` | Ready / mergeable / final-head Checks and independent approval pending | | #192 | plural affiliation next action | `main` → `v0.77.0` | Ready / DIRTY / review required | | #190 | duplicate-numbered entity-resolution ADR | `main` → docs | Ready / BLOCKED | diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 82479e24d..aa5ce9867 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -83,6 +83,7 @@ import { CutoffKnownBody } from "./components/CutoffKnownBody"; import { LineageEntityPicker } from "./components/LineageEntityPicker"; import { PopupCloseButton } from "./components/PopupCloseButton"; import { BuyerNav, type BuyerDestination } from "./components/BuyerNav"; +import { McpApiKeysPanel } from "./components/McpApiKeysPanel"; import { CustomerMasterTree, CustomerRelatedPostCard } from "./components/CustomerMasterTree"; import { LineageDag } from "./LineageDag"; import { PostBody } from "./PostBody"; @@ -4456,6 +4457,7 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean }} /> ) : null} + {destination === "settings" ? : null} ); } diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 5dc05b330..f841e2280 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -521,6 +521,40 @@ export function fetchMe(accessToken: string): Promise { return backendFetch("/api/me", accessToken); } +export interface McpApiKey { + mcp_api_key_id: string; + display_name: string; + key_prefix: string; + created_at: string; + expires_at: string | null; + revoked_at: string | null; +} + +export interface CreatedMcpApiKey extends McpApiKey { + api_key: string; +} + +export function fetchMcpApiKeys(accessToken: string): Promise<{ api_keys: McpApiKey[] }> { + return backendFetch<{ api_keys: McpApiKey[] }>("/api/mcp/api-keys", accessToken); +} + +export function createMcpApiKey( + accessToken: string, + displayName: string, + expiresAt: string | null, +): Promise { + return backendFetch("/api/mcp/api-keys", accessToken, { + method: "POST", + body: JSON.stringify({ display_name: displayName, expires_at: expiresAt }), + }); +} + +export function revokeMcpApiKey(accessToken: string, keyId: string): Promise { + return backendFetch(`/api/mcp/api-keys/${encodeURIComponent(keyId)}/revoke`, accessToken, { + method: "POST", + }); +} + export function setPreferredLocale( accessToken: string, preferredLocale: string, diff --git a/frontend/src/components/BuyerNav.tsx b/frontend/src/components/BuyerNav.tsx index 4b5bddc92..1b5714463 100644 --- a/frontend/src/components/BuyerNav.tsx +++ b/frontend/src/components/BuyerNav.tsx @@ -1,7 +1,7 @@ import { t } from "../i18n"; import type { ReactNode } from "react"; -export type BuyerDestination = "board" | "customers" | "calendar" | "ask"; +export type BuyerDestination = "board" | "customers" | "calendar" | "ask" | "settings"; export type BuyerNavProps = { destination: BuyerDestination; @@ -9,13 +9,14 @@ export type BuyerNavProps = { tools?: ReactNode; }; -const ITEMS: BuyerDestination[] = ["board", "customers", "calendar", "ask"]; +const ITEMS: BuyerDestination[] = ["board", "customers", "calendar", "ask", "settings"]; const LABELS: Record = { board: "Board", customers: "Customer master", calendar: "Calendar", ask: "Ask Agent", + settings: "Settings", }; export function BuyerNav({ destination, onChange, tools }: BuyerNavProps) { diff --git a/frontend/src/components/McpApiKeysPanel.test.tsx b/frontend/src/components/McpApiKeysPanel.test.tsx new file mode 100644 index 000000000..bc3ac3d14 --- /dev/null +++ b/frontend/src/components/McpApiKeysPanel.test.tsx @@ -0,0 +1,74 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { McpApiKeysPanel } from "./McpApiKeysPanel"; +import { createMcpApiKey, fetchMcpApiKeys, revokeMcpApiKey } from "../api"; + +vi.mock("../api", () => ({ + createMcpApiKey: vi.fn(), + fetchMcpApiKeys: vi.fn(), + revokeMcpApiKey: vi.fn(), +})); + +const fetchKeys = vi.mocked(fetchMcpApiKeys); +const createKey = vi.mocked(createMcpApiKey); +const revokeKey = vi.mocked(revokeMcpApiKey); + +const activeKey = { + mcp_api_key_id: "key-1", + display_name: "local client", + key_prefix: "lw_mcp_", + created_at: "2026-08-21T00:00:00Z", + expires_at: null, + revoked_at: null, +}; + +describe("McpApiKeysPanel", () => { + beforeEach(() => { + vi.clearAllMocks(); + fetchKeys.mockResolvedValue({ api_keys: [] }); + }); + + it("requires a label and reveals a created secret only in the creation result", async () => { + const user = userEvent.setup(); + createKey.mockResolvedValue({ ...activeKey, api_key: "lw_mcp_secret" }); + render(); + + await user.click(screen.getByRole("button", { name: "Create key" })); + expect(screen.getByRole("alert")).toHaveTextContent("Key label is required."); + + await user.type(screen.getByLabelText("Key label"), "local client"); + await user.click(screen.getByRole("button", { name: "Create key" })); + expect(await screen.findByText("lw_mcp_secret")).toBeInTheDocument(); + expect(screen.getByText("A new key is shown once. Store it before leaving this page.")).toBeInTheDocument(); + expect(createKey).toHaveBeenCalledWith("oidc-token", "local client", null); + }); + + it("revokes an active key and removes the revoke action", async () => { + const user = userEvent.setup(); + fetchKeys.mockResolvedValue({ api_keys: [activeKey] }); + revokeKey.mockResolvedValue({ ...activeKey, revoked_at: "2026-08-21T01:00:00Z" }); + render(); + + await user.click(await screen.findByRole("button", { name: "Revoke" })); + await waitFor(() => expect(screen.getByRole("status")).toHaveTextContent("MCP key revoked.")); + expect(revokeKey).toHaveBeenCalledWith("oidc-token", "key-1"); + expect(screen.queryByRole("button", { name: "Revoke" })).not.toBeInTheDocument(); + }); + + it("sends the selected local calendar date's end as the expiry", async () => { + const user = userEvent.setup(); + createKey.mockResolvedValue({ ...activeKey, api_key: "lw_mcp_secret" }); + render(); + + await user.type(screen.getByLabelText("Key label"), "local client"); + fireEvent.change(screen.getByLabelText("Expires"), { target: { value: "2026-08-21" } }); + await user.click(screen.getByRole("button", { name: "Create key" })); + + expect(createKey).toHaveBeenCalledWith( + "oidc-token", + "local client", + new Date(2026, 7, 21, 23, 59, 59, 999).toISOString(), + ); + }); +}); diff --git a/frontend/src/components/McpApiKeysPanel.tsx b/frontend/src/components/McpApiKeysPanel.tsx new file mode 100644 index 000000000..1306450d4 --- /dev/null +++ b/frontend/src/components/McpApiKeysPanel.tsx @@ -0,0 +1,124 @@ +import { useCallback, useEffect, useState, type FormEvent } from "react"; +import { + createMcpApiKey, + fetchMcpApiKeys, + revokeMcpApiKey, + type McpApiKey, +} from "../api"; +import { t } from "../i18n"; + +function formatDate(value: string | null): string { + return value ? new Date(value).toLocaleString() : "-"; +} + +function localDateEndOfDayIso(value: string): string { + const [year, month, day] = value.split("-").map(Number); + return new Date(year, month - 1, day, 23, 59, 59, 999).toISOString(); +} + +export function McpApiKeysPanel({ accessToken }: { accessToken: string }) { + const [keys, setKeys] = useState([]); + const [displayName, setDisplayName] = useState(""); + const [expiresAt, setExpiresAt] = useState(""); + const [newKey, setNewKey] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + + const loadKeys = useCallback(async () => { + try { + setKeys((await fetchMcpApiKeys(accessToken)).api_keys); + } catch { + setError(t("The key could not be loaded.")); + } + }, [accessToken]); + + useEffect(() => { + void loadKeys(); + }, [loadKeys]); + + async function handleCreate(event: FormEvent) { + event.preventDefault(); + if (!displayName.trim()) { + setError(t("Key label is required.")); + return; + } + setBusy(true); + setError(null); + setNotice(null); + try { + const created = await createMcpApiKey( + accessToken, + displayName, + expiresAt ? localDateEndOfDayIso(expiresAt) : null, + ); + setKeys((current) => [created, ...current]); + setNewKey(created.api_key); + setDisplayName(""); + setExpiresAt(""); + setNotice(t("MCP key created.")); + } catch { + setError(t("The key could not be created.")); + } finally { + setBusy(false); + } + } + + async function handleRevoke(keyId: string) { + setBusy(true); + setError(null); + setNotice(null); + try { + const revoked = await revokeMcpApiKey(accessToken, keyId); + setKeys((current) => current.map((key) => (key.mcp_api_key_id === keyId ? revoked : key))); + setNotice(t("MCP key revoked.")); + } catch { + setError(t("The key could not be revoked.")); + } finally { + setBusy(false); + } + } + + return ( +
+

{t("API keys")}

+

{t("API keys")}

+

{t("Manage MCP access keys for this account.")}

+ {error ?

{error}

: null} + {notice ?

{notice}

: null} + {newKey ? ( +
+

{t("A new key is shown once. Store it before leaving this page.")}

+ {newKey} + +
+ ) : null} +
+ + + +
+ {keys.length === 0 ?

{t("No MCP keys have been created.")}

: null} +
    + {keys.map((key) => ( +
  • + {key.display_name} {key.key_prefix}... + {t("Created")}: {formatDate(key.created_at)} + {t("Expires")}: {formatDate(key.expires_at)} + {key.revoked_at ? {t("Revoked")}: {formatDate(key.revoked_at)} : ( + + )} +
  • + ))} +
+
+ ); +} diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index ef5c2047f..a0b7e97f9 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -1353,6 +1353,98 @@ function detectLocale(): Locale { return isLocale(prefix) ? prefix : "en"; } +const MCP_API_KEY_TRANSLATIONS: Partial>> = { + ko: { + Settings: "설정", + "API keys": "API 키", + "Manage MCP access keys for this account.": "이 계정의 MCP 접근 키를 관리하세요.", + "Key label": "키 이름", + "Create key": "키 생성", + "Creating...": "생성 중...", + "No MCP keys have been created.": "생성된 MCP 키가 없습니다.", + "Copy key": "키 복사", + Revoke: "폐기", + "Revoking...": "폐기 중...", + Created: "생성일", + Expires: "만료일", + Revoked: "폐기됨", + "A new key is shown once. Store it before leaving this page.": + "새 키는 한 번만 표시됩니다. 이 페이지를 떠나기 전에 보관하세요.", + "Key label is required.": "키 이름을 입력하세요.", + "The key could not be loaded.": "키를 불러오지 못했습니다.", + "The key could not be created.": "키를 생성하지 못했습니다.", + "The key could not be revoked.": "키를 폐기하지 못했습니다.", + "MCP key created.": "MCP 키가 생성되었습니다.", + "MCP key revoked.": "MCP 키가 폐기되었습니다.", + }, + zh: { + Settings: "设置", + "API keys": "API 密钥", + "Manage MCP access keys for this account.": "管理此账户的 MCP 访问密钥。", + "Key label": "密钥名称", + "Create key": "创建密钥", + "Creating...": "创建中...", + "No MCP keys have been created.": "尚未创建 MCP 密钥。", + "Copy key": "复制密钥", + Revoke: "撤销", + "Revoking...": "撤销中...", + Created: "创建时间", + Expires: "到期时间", + Revoked: "已撤销", + "A new key is shown once. Store it before leaving this page.": "新密钥只显示一次。离开此页前请保存。", + "Key label is required.": "请输入密钥名称。", + "The key could not be loaded.": "无法加载密钥。", + "The key could not be created.": "无法创建密钥。", + "The key could not be revoked.": "无法撤销密钥。", + "MCP key created.": "MCP 密钥已创建。", + "MCP key revoked.": "MCP 密钥已撤销。", + }, + ja: { + Settings: "設定", + "API keys": "API キー", + "Manage MCP access keys for this account.": "このアカウントの MCP アクセスキーを管理します。", + "Key label": "キー名", + "Create key": "キーを作成", + "Creating...": "作成中...", + "No MCP keys have been created.": "MCP キーはまだ作成されていません。", + "Copy key": "キーをコピー", + Revoke: "失効", + "Revoking...": "失効中...", + Created: "作成日", + Expires: "有効期限", + Revoked: "失効済み", + "A new key is shown once. Store it before leaving this page.": "新しいキーは一度だけ表示されます。移動前に保存してください。", + "Key label is required.": "キー名を入力してください。", + "The key could not be loaded.": "キーを読み込めませんでした。", + "The key could not be created.": "キーを作成できませんでした。", + "The key could not be revoked.": "キーを失効できませんでした。", + "MCP key created.": "MCP キーを作成しました。", + "MCP key revoked.": "MCP キーを失効しました。", + }, + vi: { + Settings: "Cài đặt", + "API keys": "Khóa API", + "Manage MCP access keys for this account.": "Quản lý khóa truy cập MCP của tài khoản này.", + "Key label": "Tên khóa", + "Create key": "Tạo khóa", + "Creating...": "Đang tạo...", + "No MCP keys have been created.": "Chưa có khóa MCP nào được tạo.", + "Copy key": "Sao chép khóa", + Revoke: "Thu hồi", + "Revoking...": "Đang thu hồi...", + Created: "Đã tạo", + Expires: "Hết hạn", + Revoked: "Đã thu hồi", + "A new key is shown once. Store it before leaving this page.": "Khóa mới chỉ hiển thị một lần. Hãy lưu trước khi rời trang.", + "Key label is required.": "Cần nhập tên khóa.", + "The key could not be loaded.": "Không thể tải khóa.", + "The key could not be created.": "Không thể tạo khóa.", + "The key could not be revoked.": "Không thể thu hồi khóa.", + "MCP key created.": "Đã tạo khóa MCP.", + "MCP key revoked.": "Đã thu hồi khóa MCP.", + }, +}; + let currentLocale: Locale = detectLocale(); const listeners = new Set<() => void>(); @@ -1390,7 +1482,7 @@ export function useLocale(): Locale { } export function t(key: string): string { - return TRANSLATIONS[currentLocale]?.[key] ?? key; + return TRANSLATIONS[currentLocale]?.[key] ?? MCP_API_KEY_TRANSLATIONS[currentLocale]?.[key] ?? key; } export function tf(key: string, values: Record): string { diff --git a/migrations/0051_mcp_api_keys.sql b/migrations/0051_mcp_api_keys.sql new file mode 100644 index 000000000..5d7e6375f --- /dev/null +++ b/migrations/0051_mcp_api_keys.sql @@ -0,0 +1,17 @@ +-- ADR 0103: LineageWeave owns application-scoped MCP key material while +-- Keyverse remains the OIDC identity issuer and account authority. +create table if not exists mcp_api_key ( + mcp_api_key_id uuid primary key default gen_random_uuid(), + user_account_id uuid not null references user_account(user_account_id) on delete cascade, + display_name text not null check (char_length(btrim(display_name)) between 1 and 120), + key_prefix text not null check (char_length(key_prefix) between 7 and 32), + key_hash text not null unique check (char_length(key_hash) = 64), + created_at timestamptz not null default now(), + expires_at timestamptz, + revoked_at timestamptz, + check (expires_at is null or expires_at > created_at), + check (revoked_at is null or revoked_at >= created_at) +); + +create index if not exists mcp_api_key_user_account_idx + on mcp_api_key (user_account_id, created_at desc); diff --git a/migrations/rollback/0051_mcp_api_keys.sql b/migrations/rollback/0051_mcp_api_keys.sql new file mode 100644 index 000000000..cbde5fbd4 --- /dev/null +++ b/migrations/rollback/0051_mcp_api_keys.sql @@ -0,0 +1 @@ +drop table if exists mcp_api_key; diff --git a/tests/test_mcp_api_keys.py b/tests/test_mcp_api_keys.py new file mode 100644 index 000000000..ceb56a443 --- /dev/null +++ b/tests/test_mcp_api_keys.py @@ -0,0 +1,104 @@ +"""Security-focused tests for the Keyverse-authenticated MCP key boundary.""" + +from __future__ import annotations + +import asyncio +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import pytest +from fastapi import HTTPException + +from backend.app.main import revoke_account_mcp_api_key +from backend.app.mcp_api_keys import ( + KEY_PREFIX, + CreateMcpApiKeyRequest, + create_mcp_api_key, + list_mcp_api_keys, + resolve_mcp_api_key, + revoke_mcp_api_key, +) + + +class _Connection: + def __init__(self, row=None, rows=None): + self.row = row + self.rows = rows or [] + self.fetchrow_args = None + + async def fetchrow(self, query: str, *args): + self.fetchrow_args = (query, args) + return self.row + + async def fetch(self, _query: str, *_args): + return self.rows + + +def _row(*, revoked_at=None): + now = datetime.now(UTC) + return { + "mcp_api_key_id": "key-id", + "user_account_id": "account-id", + "display_name": "automation", + "key_prefix": KEY_PREFIX, + "created_at": now, + "expires_at": now + timedelta(days=30), + "revoked_at": revoked_at, + } + + +def test_create_returns_raw_secret_once_and_persists_only_digest() -> None: + conn = _Connection(_row()) + created = asyncio.run( + create_mcp_api_key( + conn, + "account-id", + CreateMcpApiKeyRequest(display_name=" automation ", expires_at=datetime.now(UTC) + timedelta(days=1)), + ) + ) + + assert created["api_key"].startswith(KEY_PREFIX) + assert "key_hash" not in created + assert conn.fetchrow_args is not None + query, args = conn.fetchrow_args + assert "insert into mcp_api_key" in query + assert args[1] == "automation" + assert args[2] == KEY_PREFIX + assert len(args[3]) == 64 + assert args[3] != created["api_key"] + + +def test_list_never_returns_secret_or_digest() -> None: + listed = asyncio.run(list_mcp_api_keys(_Connection(rows=[_row()]), "account-id")) + assert listed[0]["display_name"] == "automation" + assert "api_key" not in listed[0] + assert "key_hash" not in listed[0] + + +def test_revoke_is_scoped_to_current_account() -> None: + revoked = asyncio.run(revoke_mcp_api_key(_Connection(_row()), "account-id", "key-id")) + assert revoked is not None + foreign = asyncio.run(revoke_mcp_api_key(_Connection(None), "other-account", "key-id")) + assert foreign is None + + +def test_resolve_rejects_non_mcp_key_and_returns_account_for_live_key() -> None: + conn = _Connection(_row()) + assert asyncio.run(resolve_mcp_api_key(conn, "not-a-lineageweave-key")) is None + resolved = asyncio.run(resolve_mcp_api_key(conn, f"{KEY_PREFIX}secret")) + assert resolved == {"mcp_api_key_id": "key-id", "user_account_id": "account-id", "display_name": "automation"} + + +def test_revoke_route_rejects_malformed_id_before_database_access() -> None: + """Malformed path values fail closed without touching the connection pool.""" + with pytest.raises(HTTPException) as raised: + asyncio.run(revoke_account_mcp_api_key("not-a-uuid", object(), object())) + + assert raised.value.status_code == 404 + + +def test_schema_has_owner_foreign_key_and_no_secret_column() -> None: + sql = (Path(__file__).parents[1] / "migrations" / "0051_mcp_api_keys.sql").read_text() + assert "references user_account(user_account_id)" in sql + assert "key_hash text" in sql + assert "api_key text" not in sql diff --git a/tests/test_migration_replay.py b/tests/test_migration_replay.py index 29fe1c176..8a751dfb3 100644 --- a/tests/test_migration_replay.py +++ b/tests/test_migration_replay.py @@ -33,3 +33,22 @@ def test_migrate_sh_replays_leftover_pair_migration_on_existing_volumes() -> Non ).read_text(encoding="utf-8") assert "0012_*" in script + + +def test_mcp_api_key_migration_is_wired_for_existing_and_fresh_volumes() -> None: + """MCP key storage must exist on both Compose database paths.""" + migrate = ( + Path(__file__).resolve().parents[1] + / "docker" + / "postgres-init" + / "migrate.sh" + ).read_text(encoding="utf-8") + dockerfile = ( + Path(__file__).resolve().parents[1] + / "docker" + / "postgres-init" + / "Dockerfile" + ).read_text(encoding="utf-8") + + assert "0051_*)" in migrate + assert "migrations/0051_mcp_api_keys.sql" in dockerfile diff --git a/tests/test_post_content_queue.py b/tests/test_post_content_queue.py index 7071f7d80..b6b977f7c 100644 --- a/tests/test_post_content_queue.py +++ b/tests/test_post_content_queue.py @@ -326,4 +326,4 @@ def test_migration_contains_normalized_job_and_status_event_tables() -> None: def test_migration_replay_window_includes_post_content_queue() -> None: migrate = (_ROOT / "docker/postgres-init/migrate.sh").read_text() - assert "0050_*)" in migrate + assert "0050_*" in migrate