From 4155522149772fff8680a115a03a595dbf7d1627 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 08:04:37 +0900 Subject: [PATCH 1/7] feat: add Keyverse-authenticated MCP API keys --- CHANGELOG.md | 3 + backend/app/main.py | 45 ++++++ backend/app/mcp_api_keys.py | 140 ++++++++++++++++++ ...103-keyverse-authenticated-mcp-api-keys.md | 43 ++++++ docs/adr/README.md | 1 + frontend/src/App.tsx | 2 + frontend/src/api.ts | 34 +++++ frontend/src/components/BuyerNav.tsx | 5 +- .../src/components/McpApiKeysPanel.test.tsx | 58 ++++++++ frontend/src/components/McpApiKeysPanel.tsx | 115 ++++++++++++++ frontend/src/i18n.ts | 94 +++++++++++- migrations/0051_mcp_api_keys.sql | 17 +++ migrations/rollback/0051_mcp_api_keys.sql | 1 + tests/test_mcp_api_keys.py | 91 ++++++++++++ 14 files changed, 646 insertions(+), 3 deletions(-) create mode 100644 backend/app/mcp_api_keys.py create mode 100644 docs/adr/0103-keyverse-authenticated-mcp-api-keys.md create mode 100644 frontend/src/components/McpApiKeysPanel.test.tsx create mode 100644 frontend/src/components/McpApiKeysPanel.tsx create mode 100644 migrations/0051_mcp_api_keys.sql create mode 100644 migrations/rollback/0051_mcp_api_keys.sql create mode 100644 tests/test_mcp_api_keys.py 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..8f1bc1d41 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,45 @@ 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.""" + 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..aef8d6990 --- /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, + raw_key[:12], + _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/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..71744ff8b --- /dev/null +++ b/docs/adr/0103-keyverse-authenticated-mcp-api-keys.md @@ -0,0 +1,43 @@ +# 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, a non-secret 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/frontend/src/App.tsx b/frontend/src/App.tsx index 41ff08aab..314cc092c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -84,6 +84,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 { LineageDag } from "./LineageDag"; import { PostBody } from "./PostBody"; import { decodeHtmlEntities } from "./postBodyDisplay"; @@ -4626,6 +4627,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..202950409 --- /dev/null +++ b/frontend/src/components/McpApiKeysPanel.test.tsx @@ -0,0 +1,58 @@ +import { 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_abc", + 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(); + }); +}); diff --git a/frontend/src/components/McpApiKeysPanel.tsx b/frontend/src/components/McpApiKeysPanel.tsx new file mode 100644 index 000000000..61fa11110 --- /dev/null +++ b/frontend/src/components/McpApiKeysPanel.tsx @@ -0,0 +1,115 @@ +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() : "-"; +} + +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 ? `${expiresAt}T23:59:59Z` : 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..d04646536 --- /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 8 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..89ea35838 --- /dev/null +++ b/tests/test_mcp_api_keys.py @@ -0,0 +1,91 @@ +"""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 + +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": f"{KEY_PREFIX}abc", + "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 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_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 From 99c9f819e6da6fa124a83e75a2c665b6e523c48a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 08:07:59 +0900 Subject: [PATCH 2/7] fix: close malformed MCP key revoke ids --- backend/app/main.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/app/main.py b/backend/app/main.py index 8f1bc1d41..5e41e14f4 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -279,6 +279,10 @@ async def revoke_account_mcp_api_key( 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: From 236b83b5af9632f83457dcc822b792dc239bcc7a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 08:36:09 +0900 Subject: [PATCH 3/7] test: cover malformed MCP key revocation paths --- tests/test_mcp_api_keys.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/test_mcp_api_keys.py b/tests/test_mcp_api_keys.py index 89ea35838..816858762 100644 --- a/tests/test_mcp_api_keys.py +++ b/tests/test_mcp_api_keys.py @@ -6,6 +6,10 @@ 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, @@ -84,6 +88,14 @@ def test_resolve_rejects_non_mcp_key_and_returns_account_for_live_key() -> None: 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 From 065d14f5022f052d8b096f4388a62cb227d12d9e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 09:38:44 +0900 Subject: [PATCH 4/7] fix: keep MCP key metadata non-secret --- CHANGELOG.d/2.12.7-mcp-api-key-boundary.md | 7 +++++++ backend/app/mcp_api_keys.py | 2 +- ...103-keyverse-authenticated-mcp-api-keys.md | 3 ++- .../src/components/McpApiKeysPanel.test.tsx | 20 +++++++++++++++++-- frontend/src/components/McpApiKeysPanel.tsx | 11 +++++++++- migrations/0051_mcp_api_keys.sql | 2 +- tests/test_mcp_api_keys.py | 3 ++- 7 files changed, 41 insertions(+), 7 deletions(-) create mode 100644 CHANGELOG.d/2.12.7-mcp-api-key-boundary.md 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..325b430fa --- /dev/null +++ b/CHANGELOG.d/2.12.7-mcp-api-key-boundary.md @@ -0,0 +1,7 @@ +# 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. diff --git a/backend/app/mcp_api_keys.py b/backend/app/mcp_api_keys.py index aef8d6990..6501bd049 100644 --- a/backend/app/mcp_api_keys.py +++ b/backend/app/mcp_api_keys.py @@ -71,7 +71,7 @@ async def create_mcp_api_key( """, user_account_id, display_name, - raw_key[:12], + KEY_PREFIX, _hash_api_key(raw_key), expires_at, ) diff --git a/docs/adr/0103-keyverse-authenticated-mcp-api-keys.md b/docs/adr/0103-keyverse-authenticated-mcp-api-keys.md index 71744ff8b..4d5e69c96 100644 --- a/docs/adr/0103-keyverse-authenticated-mcp-api-keys.md +++ b/docs/adr/0103-keyverse-authenticated-mcp-api-keys.md @@ -13,7 +13,8 @@ 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, a non-secret prefix, +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. diff --git a/frontend/src/components/McpApiKeysPanel.test.tsx b/frontend/src/components/McpApiKeysPanel.test.tsx index 202950409..bc3ac3d14 100644 --- a/frontend/src/components/McpApiKeysPanel.test.tsx +++ b/frontend/src/components/McpApiKeysPanel.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor } from "@testing-library/react"; +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"; @@ -17,7 +17,7 @@ const revokeKey = vi.mocked(revokeMcpApiKey); const activeKey = { mcp_api_key_id: "key-1", display_name: "local client", - key_prefix: "lw_mcp_abc", + key_prefix: "lw_mcp_", created_at: "2026-08-21T00:00:00Z", expires_at: null, revoked_at: null, @@ -55,4 +55,20 @@ describe("McpApiKeysPanel", () => { 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 index 61fa11110..1306450d4 100644 --- a/frontend/src/components/McpApiKeysPanel.tsx +++ b/frontend/src/components/McpApiKeysPanel.tsx @@ -11,6 +11,11 @@ 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(""); @@ -42,7 +47,11 @@ export function McpApiKeysPanel({ accessToken }: { accessToken: string }) { setError(null); setNotice(null); try { - const created = await createMcpApiKey(accessToken, displayName, expiresAt ? `${expiresAt}T23:59:59Z` : null); + const created = await createMcpApiKey( + accessToken, + displayName, + expiresAt ? localDateEndOfDayIso(expiresAt) : null, + ); setKeys((current) => [created, ...current]); setNewKey(created.api_key); setDisplayName(""); diff --git a/migrations/0051_mcp_api_keys.sql b/migrations/0051_mcp_api_keys.sql index d04646536..5d7e6375f 100644 --- a/migrations/0051_mcp_api_keys.sql +++ b/migrations/0051_mcp_api_keys.sql @@ -4,7 +4,7 @@ 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 8 and 32), + 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, diff --git a/tests/test_mcp_api_keys.py b/tests/test_mcp_api_keys.py index 816858762..ceb56a443 100644 --- a/tests/test_mcp_api_keys.py +++ b/tests/test_mcp_api_keys.py @@ -40,7 +40,7 @@ def _row(*, revoked_at=None): "mcp_api_key_id": "key-id", "user_account_id": "account-id", "display_name": "automation", - "key_prefix": f"{KEY_PREFIX}abc", + "key_prefix": KEY_PREFIX, "created_at": now, "expires_at": now + timedelta(days=30), "revoked_at": revoked_at, @@ -63,6 +63,7 @@ def test_create_returns_raw_secret_once_and_persists_only_digest() -> 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"] From e9d13a61b32199b336f8f310e891efc900fe2dac Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 10:03:31 +0900 Subject: [PATCH 5/7] docs: pin current customer tree evidence --- docs/product-technical-gap-baseline.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 893981869..7ff7fe100 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -2,7 +2,7 @@ **Snapshot:** 2026-08-21 (Asia/Seoul) **Protected-main baseline:** `origin/main`; this document does not claim the active PR is shipped. -**Audited PR head:** #258 code commit `__TREE_CODE_SHA__` for customer-master hierarchy hardening. +**Audited PR head:** #258 code commit `4228c48fd8795586ed6859ad4df047508a12f86f` for synchronized ontology and customer-master hierarchy hardening. **Active PR update:** Customer Master now has a cycle-safe, accessible hierarchy projection; exact-head frontend and Storybook verification is required before merge. **Purpose:** connect the normative ADRs and research evidence to product @@ -66,7 +66,7 @@ claims that an unmerged PR or historical runtime observation is live behavior. | FR-10 | Standard provenance uses normalized PROV-O relations; qualified influence implies its unqualified relation and KG edges remain a navigation projection. | ADR 0011, 0065 | PROV-O implementation matrices, ontology, CI contract | | FR-11 | Post summaries expose evidence-bearing events and R&R. Requester/processor actions are nullable and may only name actors already bound to the same post summary. | ADR 0052, ADR 0100 | Commit `15e1a378` is on PR #258; one authorized target refresh stored three action rows, while corpus-wide buyer-data acceptance remains unproven | | FR-12 | A hierarchy-enrichment timeout leaves the source-grounded summary readable and the actor unbound; it never creates a guessed catalog identity. | ADR 0101, ADR 0010, ADR 0026 | Commit `1c260f20` contains the boundary, ADR, and focused test; independent review, protected-main merge, and fresh runtime evidence remain pending | -| FR-13 | Customer Master projects authorized corporate entities as a Group → Company → Plant tree. Missing-parent, self-parent, and cyclic edges remain visible as unresolved roots; the UI supports WAI-ARIA tree keyboard navigation and opens source-backed posts independently from hierarchy disclosure. | ADR 0124, ADR 0004, ADR 0010 | `customerMasterTree.ts`, `CustomerMasterTree.tsx`, pure/component tests, and Storybook on code commit `__TREE_CODE_SHA__` | +| FR-13 | Customer Master projects authorized corporate entities as a Group → Company → Plant tree. Missing-parent, self-parent, and cyclic edges remain visible as unresolved roots; the UI supports WAI-ARIA tree keyboard navigation and opens source-backed posts independently from hierarchy disclosure. | ADR 0124, ADR 0004, ADR 0010 | `customerMasterTree.ts`, `CustomerMasterTree.tsx`, pure/component tests, and Storybook on code commit `4228c48fd8795586ed6859ad4df047508a12f86f` | ## TRD @@ -173,7 +173,7 @@ evidence for one authorized post, not a corpus-wide acceptance claim. | Closed gap | Root cause | Closure evidence | Remaining boundary | |---|---|---|---| -| Customer entities could disappear from the buyer surface when `parent_entity_id` formed a self-parent or cycle, and the visual nesting lacked a real tree keyboard contract. | The UI recursively assembled only root-reachable nodes and reused `aria-expanded` for related-post evidence rather than hierarchy state. | Code commit `__TREE_CODE_SHA__` promotes malformed edges to visible unresolved roots, separates branch/evidence disclosure, and adds pure, component, and Storybook coverage. | The API still exposes one flat parent context; legal, operating, sales, billing, and time-valid hierarchies require a later normalized relation model. | +| Customer entities could disappear from the buyer surface when `parent_entity_id` formed a self-parent or cycle, and the visual nesting lacked a real tree keyboard contract. | The UI recursively assembled only root-reachable nodes and reused `aria-expanded` for related-post evidence rather than hierarchy state. | Code commit `4228c48fd8795586ed6859ad4df047508a12f86f` promotes malformed edges to visible unresolved roots, separates branch/evidence disclosure, and adds pure, component, and Storybook coverage. | The API still exposes one flat parent context; legal, operating, sales, billing, and time-valid hierarchies require a later normalized relation model. | ## Active PR audit From 08aa5bc01689e9dc3737741b93aa941ee09594bd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 10:16:55 +0900 Subject: [PATCH 6/7] chore: remove completed hierarchy finalizer --- ...verify-customer-hierarchy-finalization.yml | 88 ------------------ .../finalize_customer_hierarchy_evidence.py | 91 ------------------- 2 files changed, 179 deletions(-) delete mode 100644 .github/workflows/verify-customer-hierarchy-finalization.yml delete mode 100755 scripts/finalize_customer_hierarchy_evidence.py diff --git a/.github/workflows/verify-customer-hierarchy-finalization.yml b/.github/workflows/verify-customer-hierarchy-finalization.yml deleted file mode 100644 index 2f2b4f315..000000000 --- a/.github/workflows/verify-customer-hierarchy-finalization.yml +++ /dev/null @@ -1,88 +0,0 @@ -name: Verify customer hierarchy finalization - -on: - push: - branches: [feat/analysis-run-name-evidence-lineage] - paths: - - scripts/finalize_customer_hierarchy_evidence.py - - .github/workflows/verify-customer-hierarchy-finalization.yml - -permissions: - contents: write - -concurrency: - group: verify-customer-hierarchy-finalization - cancel-in-progress: true - -jobs: - verify-and-finalize: - runs-on: ubuntu-latest - steps: - - name: Checkout exact trigger head - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 - with: - ref: ${{ github.sha }} - fetch-depth: 0 - persist-credentials: true - - - name: Set up Python - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # actions/setup-python@v6 - with: - python-version: "3.12" - - - name: Set up uv - uses: astral-sh/setup-uv@c7710ff06683c1dc102d2f262c25cc36c819530a # v7 - with: - version: "0.9.27" - enable-cache: true - cache-dependency-glob: uv.lock - - - name: Verify ontology and documentation contracts - run: | - uv sync --frozen --extra dev --extra backend - uv run --frozen python -m pytest -q \ - tests/test_ontology.py \ - tests/test_ontology_interoperability.py \ - tests/test_documentation_hygiene.py - - - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # actions/setup-node@v5 - with: - node-version: "24" - - - name: Install locked frontend dependencies - working-directory: frontend - run: | - corepack enable - pnpm install --frozen-lockfile - - - name: Verify focused customer hierarchy - working-directory: frontend - run: pnpm exec vitest run src/customerMasterTree.test.ts src/components/CustomerMasterTree.test.tsx - - - name: Verify complete frontend and Storybook - working-directory: frontend - run: | - pnpm run lint - pnpm run test - pnpm run build - pnpm run build-storybook - - - name: Check source hygiene - run: git diff --check - - - name: Finalize baseline and remove one-shot machinery - run: | - python scripts/finalize_customer_hierarchy_evidence.py - rm scripts/finalize_customer_hierarchy_evidence.py - rm .github/workflows/verify-customer-hierarchy-finalization.yml - git diff --check - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add docs/product-technical-gap-baseline.md \ - scripts/finalize_customer_hierarchy_evidence.py \ - .github/workflows/verify-customer-hierarchy-finalization.yml - git commit -m "docs: finalize customer hierarchy evidence" - - - name: Push without overwriting concurrent work - run: git push origin HEAD:feat/analysis-run-name-evidence-lineage diff --git a/scripts/finalize_customer_hierarchy_evidence.py b/scripts/finalize_customer_hierarchy_evidence.py deleted file mode 100755 index 06a6f1638..000000000 --- a/scripts/finalize_customer_hierarchy_evidence.py +++ /dev/null @@ -1,91 +0,0 @@ -#!/usr/bin/env python3 -"""Finalize customer-hierarchy evidence after exact code verification.""" - -from __future__ import annotations - -from pathlib import Path - -CODE_SHA = "21074cf80cbf" - - -def replace_once(text: str, old: str, new: str, label: str) -> str: - """Replace one exact block and fail closed when the baseline has drifted.""" - count = text.count(old) - if count != 1: - raise RuntimeError(f"{label}: expected one match, found {count}") - return text.replace(old, new, 1) - - -def replace_row(lines: list[str], prefix: str, replacement: str) -> None: - """Replace exactly one Markdown table row selected by a stable prefix.""" - indexes = [index for index, line in enumerate(lines) if line.startswith(prefix)] - if len(indexes) != 1: - raise RuntimeError(f"{prefix}: expected one row, found {len(indexes)}") - lines[indexes[0]] = replacement - - -def main() -> None: - """Remove stale placeholders and synchronize the current PR/gap evidence.""" - path = Path("docs/product-technical-gap-baseline.md") - text = path.read_text(encoding="utf-8") - old_header = """**Audited PR head:** #258 code commit `__TREE_CODE_SHA__` for customer-master hierarchy hardening. -**Active PR update:** Customer Master now has a cycle-safe, accessible hierarchy projection; exact-head -frontend and Storybook verification is required before merge. -""" - new_header = f"""**Audited PR code head:** #258 customer-hierarchy commit `{CODE_SHA}`; 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. -""" - text = replace_once(text, old_header, new_header, "baseline header") - text = replace_once( - text, - "## Closed gap evidence\n", - "## Active-PR gap closure evidence\n", - "gap closure heading", - ) - old_audit = """GitHub reported 18 open PRs at the snapshot: all were marked Ready and 8 -required review; merge state was 8 `BLOCKED`, 8 `UNSTABLE`, and 2 `DIRTY`. -Queued checks and review gates mean none of these rows is protected-main truth. -""" - new_audit = f"""A focused 2026-08-21 refresh found PR #258 open and mergeable at customer-hierarchy -code commit `{CODE_SHA}`. The organization queue has changed since the 18-row inventory below, so the -table is retained only as historical stack topology. Current acceptance must be read from the final -PR head, valid unresolved threads, qualifying independent review, and terminal hosted Checks. -""" - text = replace_once(text, old_audit, new_audit, "active PR audit summary") - - lines = text.splitlines() - replace_row( - lines, - "| FR-13 |", - f"| FR-13 | Customer Master projects authorized corporate entities as a Group → Company → Plant tree. Real organization containment uses W3C ORG while Group/Company/Plant remain separate SKOS level concepts. Missing-parent, self-parent, and cyclic edges remain visible as unresolved roots; the UI owns nested `group` elements from their parent `treeitem`, supports Arrow/Home/End and Enter/Space operation, and opens source-backed evidence outside the tree. | ADR 0124, ADR 0004, ADR 0010 | Ontology/SHACL interoperability tests, `customerMasterTree.ts`, `CustomerMasterTree.tsx`, component tests, Storybook, and code commit `{CODE_SHA}` |", - ) - replace_row( - lines, - "| NFR-07 |", - "| NFR-07 | Buyer hierarchy controls meet WCAG 2.2 keyboard operation and the WAI-ARIA tree ownership contract without inventing ontology facts | Ontology tests, focused hierarchy tests, full frontend test/lint/build, Storybook build, and final-head hosted verification |", - ) - replace_row( - lines, - "| Customer entities could disappear", - f"| Customer entities could disappear from the buyer surface when `parent_entity_id` formed a self-parent or cycle; the first tree refactor also placed child `group` content beside rather than inside its parent `treeitem`. | The old projection assembled only root-reachable nodes, overloaded evidence state with hierarchy semantics, and did not satisfy the APG ownership rule. | Code commit `{CODE_SHA}` promotes malformed edges to visible unresolved roots, keeps ORG containment separate from SKOS classification, makes every parent `treeitem` own its child `group`, separates evidence into an external region, and adds navigation, failure, stale-response, ontology, and Storybook regressions. | The API still exposes one parent context; authoritative acyclicity, level-transition rules, legal/operating/sales/billing contexts, and effective-dated history remain future normalized-model work. |", - ) - replace_row( - lines, - "| #258 |", - f"| #258 | buyer evidence board, standards-composed ontology, and cycle-safe Customer Master tree | `main` → `{CODE_SHA}` | Ready / mergeable / final-head Checks and independent approval pending |", - ) - replace_row( - lines, - "| P0 | PR #258 is not review/CI complete", - f"| P0 | PR #258 still requires final-head review and hosted CI | Customer hierarchy code is at `{CODE_SHA}`; branch-local verification does not transfer to the following documentation-only head | Re-read review threads, obtain qualifying independent approval, require all final-head hosted Checks to reach terminal success, and merge only through normal protection |", - ) - - text = "\n".join(lines) + "\n" - if "__TREE_CODE_SHA__" in text: - raise RuntimeError("unresolved customer-tree SHA placeholder remains") - path.write_text(text, encoding="utf-8") - - -if __name__ == "__main__": - main() From 292aad3734390455cf8fcd4e61a6f93e54ae59b5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 10:43:36 +0900 Subject: [PATCH 7/7] fix: wire MCP API-key migration into Compose --- CHANGELOG.d/2.12.7-mcp-api-key-boundary.md | 3 +++ docker/postgres-init/Dockerfile | 1 + docker/postgres-init/migrate.sh | 2 +- tests/test_migration_replay.py | 19 +++++++++++++++++++ tests/test_post_content_queue.py | 2 +- 5 files changed, 25 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.d/2.12.7-mcp-api-key-boundary.md b/CHANGELOG.d/2.12.7-mcp-api-key-boundary.md index 325b430fa..3844e200d 100644 --- a/CHANGELOG.d/2.12.7-mcp-api-key-boundary.md +++ b/CHANGELOG.d/2.12.7-mcp-api-key-boundary.md @@ -5,3 +5,6 @@ - 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/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/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