Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.d/2.12.7-mcp-api-key-boundary.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
49 changes: 49 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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):
Expand Down
140 changes: 140 additions & 0 deletions backend/app/mcp_api_keys.py
Original file line number Diff line number Diff line change
@@ -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"],
}
1 change: 1 addition & 0 deletions docker/postgres-init/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docker/postgres-init/migrate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
44 changes: 44 additions & 0 deletions docs/adr/0103-keyverse-authenticated-mcp-api-keys.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
7 changes: 3 additions & 4 deletions docs/product-technical-gap-baseline.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 |

Expand Down
2 changes: 2 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -4456,6 +4457,7 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean
}}
/>
) : null}
{destination === "settings" ? <McpApiKeysPanel accessToken={accessToken} /> : null}
</main>
);
}
34 changes: 34 additions & 0 deletions frontend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,40 @@ export function fetchMe(accessToken: string): Promise<CurrentUser> {
return backendFetch<CurrentUser>("/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<CreatedMcpApiKey> {
return backendFetch<CreatedMcpApiKey>("/api/mcp/api-keys", accessToken, {
method: "POST",
body: JSON.stringify({ display_name: displayName, expires_at: expiresAt }),
});
}

export function revokeMcpApiKey(accessToken: string, keyId: string): Promise<McpApiKey> {
return backendFetch<McpApiKey>(`/api/mcp/api-keys/${encodeURIComponent(keyId)}/revoke`, accessToken, {
method: "POST",
});
}

export function setPreferredLocale(
accessToken: string,
preferredLocale: string,
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/components/BuyerNav.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,22 @@
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;
onChange: (destination: BuyerDestination) => void;
tools?: ReactNode;
};

const ITEMS: BuyerDestination[] = ["board", "customers", "calendar", "ask"];
const ITEMS: BuyerDestination[] = ["board", "customers", "calendar", "ask", "settings"];

const LABELS: Record<BuyerDestination, string> = {
board: "Board",
customers: "Customer master",
calendar: "Calendar",
ask: "Ask Agent",
settings: "Settings",
};

export function BuyerNav({ destination, onChange, tools }: BuyerNavProps) {
Expand Down
Loading
Loading