feat: expose auth admin summaries

This commit is contained in:
lda
2026-06-06 12:16:45 +07:00 Verified
parent 58771e4423
commit c25c4cfee5
18 changed files with 478 additions and 8 deletions
+4
View File
@@ -234,6 +234,10 @@ implementation state.
Second implementation slice complete: missing explicit auth refs now surface Second implementation slice complete: missing explicit auth refs now surface
as `auth_not_found` diagnostics in live source checks and source registry as `auth_not_found` diagnostics in live source checks and source registry
apply summaries. apply summaries.
Third implementation slice complete: read-only auth admin summaries are
available through MCP-backed server admin, JSON-RPC, and CLI. Summaries show
ids, schemes, metadata, and payload keys only; secret payload values remain
hidden.
- Completed: `wf run watch` starts run progress UX with polling over existing - Completed: `wf run watch` starts run progress UX with polling over existing
`inspect_run` and optional bounded `read_run_trace`. SSE/WebSocket/MCP `inspect_run` and optional bounded `read_run_trace`. SSE/WebSocket/MCP
progress remains deferred until polling UX proves insufficient. progress remains deferred until polling UX proves insufficient.
@@ -15,7 +15,8 @@ the architecture.
Slice 1 implements the neutral auth record/store protocol and MCP compatibility Slice 1 implements the neutral auth record/store protocol and MCP compatibility
bridge. Slice 2 surfaces missing explicit auth refs through live source bridge. Slice 2 surfaces missing explicit auth refs through live source
diagnostics and source registry apply summaries. Auth admin surfaces and diagnostics and source registry apply summaries. Slice 3 exposes read-only auth
admin summaries without secret payload values. Auth mutation surfaces and
provider-specific auth unions are future slices. provider-specific auth unions are future slices.
## Current State ## Current State
+2
View File
@@ -4,6 +4,7 @@ from .auth import AUTH_ID_PATTERN, AuthRecord, AuthStore, validate_auth_id
from .listing import matches_query, paged_list_payload from .listing import matches_query, paged_list_payload
from .admin import ( from .admin import (
WorkflowAdminApi, WorkflowAdminApi,
WorkflowAdminAuthProvider,
WorkflowAdminConnectionProvider, WorkflowAdminConnectionProvider,
WorkflowAdminEventProvider, WorkflowAdminEventProvider,
) )
@@ -91,6 +92,7 @@ __all__ = [
"RuntimeDependencies", "RuntimeDependencies",
"TraceRange", "TraceRange",
"WorkflowAdminApi", "WorkflowAdminApi",
"WorkflowAdminAuthProvider",
"WorkflowAdminConnectionProvider", "WorkflowAdminConnectionProvider",
"WorkflowAdminEventProvider", "WorkflowAdminEventProvider",
"WorkflowAdminSurface", "WorkflowAdminSurface",
+24
View File
@@ -19,6 +19,14 @@ class WorkflowAdminEventProvider(Protocol):
def list_events(self) -> Sequence[Mapping[str, Any] | object]: ... def list_events(self) -> Sequence[Mapping[str, Any] | object]: ...
class WorkflowAdminAuthProvider(Protocol):
"""Provides read-only auth inventory without secret payload values."""
def list_auth_records(self) -> Sequence[Mapping[str, Any] | object]: ...
def inspect_auth_record(self, auth_ref: str) -> Mapping[str, Any] | object: ...
class WorkflowAdminApi: class WorkflowAdminApi:
"""Protocol-neutral read-only broker/server admin operations. """Protocol-neutral read-only broker/server admin operations.
@@ -31,9 +39,11 @@ class WorkflowAdminApi:
*, *,
connections: WorkflowAdminConnectionProvider, connections: WorkflowAdminConnectionProvider,
events: WorkflowAdminEventProvider, events: WorkflowAdminEventProvider,
auth: WorkflowAdminAuthProvider | None = None,
) -> None: ) -> None:
self.connections = connections self.connections = connections
self.events = events self.events = events
self.auth = auth
async def list_connections(self) -> dict[str, Any]: async def list_connections(self) -> dict[str, Any]:
connections = sorted( connections = sorted(
@@ -55,6 +65,20 @@ class WorkflowAdminApi:
events = [_payload(event) for event in self.events.list_events()] events = [_payload(event) for event in self.events.list_events()]
return {"events": events, "total": len(events)} return {"events": events, "total": len(events)}
async def list_auth_records(self) -> dict[str, Any]:
if self.auth is None:
raise RuntimeError("auth admin is not available for this target")
records = sorted(
(_payload(item) for item in self.auth.list_auth_records()),
key=lambda item: str(item.get("id", "")),
)
return {"auth_records": records, "total": len(records)}
async def inspect_auth_record(self, auth_ref: str) -> dict[str, Any]:
if self.auth is None:
raise RuntimeError("auth admin is not available for this target")
return _payload(self.auth.inspect_auth_record(auth_ref))
def _payload(value: Mapping[str, Any] | object) -> dict[str, Any]: def _payload(value: Mapping[str, Any] | object) -> dict[str, Any]:
"""Normalize provider objects without depending on MCP event/config types.""" """Normalize provider objects without depending on MCP event/config types."""
+4
View File
@@ -225,6 +225,10 @@ class WorkflowAdminSurface(Protocol):
async def list_events(self) -> dict[str, Any]: ... async def list_events(self) -> dict[str, Any]: ...
async def list_auth_records(self) -> dict[str, Any]: ...
async def inspect_auth_record(self, auth_ref: str) -> dict[str, Any]: ...
class WorkflowSourceRegistrySurface(Protocol): class WorkflowSourceRegistrySurface(Protocol):
"""Desired source registry methods exposed by platform frontends.""" """Desired source registry methods exposed by platform frontends."""
+2 -1
View File
@@ -7,7 +7,7 @@ from wf_cli.context import load_cli_context_from_typer
from wf_cli.formats import ListOutputFormat, emit_list_payload from wf_cli.formats import ListOutputFormat, emit_list_payload
from wf_cli.remote_errors import run_cli_operation from wf_cli.remote_errors import run_cli_operation
from . import source_registry from . import auth_admin, source_registry
app = typer.Typer( app = typer.Typer(
name="admin", name="admin",
@@ -16,6 +16,7 @@ app = typer.Typer(
) )
app.add_typer(source_registry.app, name="registry") app.add_typer(source_registry.app, name="registry")
app.add_typer(auth_admin.app, name="auth")
@app.command("connections") @app.command("connections")
+49
View File
@@ -0,0 +1,49 @@
from __future__ import annotations
from typing import Annotated
import typer
from wf_cli.context import load_cli_context_from_typer
from wf_cli.formats import ListOutputFormat, emit_list_payload
from wf_cli.io import emit_json
from wf_cli.remote_errors import run_cli_operation
app = typer.Typer(
name="auth",
help="Read auth record status without exposing secret payload values.",
no_args_is_help=True,
)
@app.command("list")
def list_auth_records(
ctx: typer.Context,
output_format: Annotated[
ListOutputFormat, typer.Option("--format", help="Output format.")
] = ListOutputFormat.JSON,
) -> None:
"""List auth records known to the target."""
context = load_cli_context_from_typer(ctx)
payload = run_cli_operation(context, context.admin.list_auth_records())
emit_list_payload(
payload,
collection_key="auth_records",
output_format=output_format,
id_field="id",
summary_fields=("scheme", "payload_keys"),
)
@app.command("inspect")
def inspect_auth_record(
ctx: typer.Context,
auth_ref: Annotated[str, typer.Argument(help="Auth record id/ref.")],
) -> None:
"""Inspect one auth record summary without secret payload values."""
context = load_cli_context_from_typer(ctx)
payload = run_cli_operation(
context,
context.admin.inspect_auth_record(auth_ref),
)
emit_json(payload)
+2
View File
@@ -18,6 +18,7 @@ from .config import broker_config_from_workflow_config, build_service_from_confi
from .prompts import register_broker_prompts from .prompts import register_broker_prompts
from .resources import register_broker_resources from .resources import register_broker_resources
from .service import WfMcpService from .service import WfMcpService
from .service.auth_admin import McpAuthAdminProvider
from .service.source_registry_admin import SourceRegistryAdminProvider from .service.source_registry_admin import SourceRegistryAdminProvider
from .service.workflow_operation_context import context_from_service from .service.workflow_operation_context import context_from_service
from .tools import register_broker_tools from .tools import register_broker_tools
@@ -67,6 +68,7 @@ def workflow_server_from_service(
admin = WorkflowAdminApi( admin = WorkflowAdminApi(
connections=service.connection_service, connections=service.connection_service,
events=service.events, events=service.events,
auth=McpAuthAdminProvider(store=service.store),
) )
registry_provider = SourceRegistryAdminProvider( registry_provider = SourceRegistryAdminProvider(
source_registry_store=source_registry_store, source_registry_store=source_registry_store,
+39
View File
@@ -0,0 +1,39 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from wf_api import WorkflowAdminAuthProvider
from ...storage import Store
@dataclass(frozen=True, slots=True)
class McpAuthAdminProvider(WorkflowAdminAuthProvider):
"""Read-only auth inventory for MCP-backed workflow servers.
Summaries intentionally expose payload keys, not payload values. Concrete
auth variants can provide richer safe display later.
"""
store: Store
def list_auth_records(self) -> list[dict[str, Any]]:
return [
self.inspect_auth_record(auth_ref)
for auth_ref in sorted(self.store.list_auth_refs())
]
def inspect_auth_record(self, auth_ref: str) -> dict[str, Any]:
record = self.store.load_auth(auth_ref)
if record is None:
raise KeyError(f"unknown auth record {auth_ref!r}")
return {
"id": record.connection_id,
"scheme": record.scheme,
"metadata": {},
"payload_keys": sorted(str(key) for key in record.payload),
}
__all__ = ["McpAuthAdminProvider"]
+8
View File
@@ -24,6 +24,9 @@ class Store:
def load_auth(self, connection_id: str) -> AuthRecord | None: def load_auth(self, connection_id: str) -> AuthRecord | None:
raise NotImplementedError raise NotImplementedError
def list_auth_refs(self) -> list[str]:
raise NotImplementedError
def save_auth_record(self, record: NeutralAuthRecord) -> None: def save_auth_record(self, record: NeutralAuthRecord) -> None:
raise NotImplementedError raise NotImplementedError
@@ -90,6 +93,11 @@ class FileStore(Store):
data = json.loads(path.read_text(encoding="utf-8")) data = json.loads(path.read_text(encoding="utf-8"))
return AuthRecord(**data) return AuthRecord(**data)
def list_auth_refs(self) -> list[str]:
"""Return auth refs present in the local file auth store."""
return sorted(path.stem for path in self.auth_dir.glob("*.json"))
def save_auth_record(self, record: NeutralAuthRecord) -> None: def save_auth_record(self, record: NeutralAuthRecord) -> None:
"""Save neutral auth through the legacy MCP file shape.""" """Save neutral auth through the legacy MCP file shape."""
@@ -16,3 +16,12 @@ class RpcAdminClientMixin:
async def list_events(self) -> dict[str, Any]: async def list_events(self) -> dict[str, Any]:
return await self._call("workflow.admin.events.list", {}) return await self._call("workflow.admin.events.list", {})
async def list_auth_records(self) -> dict[str, Any]:
return await self._call("workflow.admin.auth.list", {})
async def inspect_auth_record(self, auth_ref: str) -> dict[str, Any]:
return await self._call(
"workflow.admin.auth.inspect",
{"auth_ref": auth_ref},
)
+45 -6
View File
@@ -2,13 +2,13 @@ from __future__ import annotations
from typing import Any from typing import Any
from fastapi import Body
import fastapi_jsonrpc as jsonrpc import fastapi_jsonrpc as jsonrpc
from wf_server import WorkflowServer from wf_server import WorkflowServer
from .errors import WorkflowRpcError, raise_workflow_rpc_error from .errors import WorkflowRpcError, raise_workflow_rpc_error
from .models import AdminEmptyParams from .models import AdminEmptyParams, InspectAuthParams
from .params import RpcParams
def register_methods( def register_methods(
@@ -22,7 +22,7 @@ def register_methods(
errors=[WorkflowRpcError], errors=[WorkflowRpcError],
) )
async def workflow_admin_connections_list( async def workflow_admin_connections_list(
params: AdminEmptyParams = Body(default_factory=AdminEmptyParams), params: AdminEmptyParams = RpcParams(),
) -> dict[str, Any]: ) -> dict[str, Any]:
try: try:
return await server.admin.list_connections() return await server.admin.list_connections()
@@ -34,18 +34,57 @@ def register_methods(
errors=[WorkflowRpcError], errors=[WorkflowRpcError],
) )
async def workflow_admin_connection_statuses_list( async def workflow_admin_connection_statuses_list(
params: AdminEmptyParams = Body(default_factory=AdminEmptyParams), params: AdminEmptyParams = RpcParams(),
) -> dict[str, Any]: ) -> dict[str, Any]:
try: try:
return await server.admin.get_connection_statuses() return await server.admin.get_connection_statuses()
except (ValueError, KeyError, LookupError, FileNotFoundError) as exc: except (ValueError, KeyError, LookupError, FileNotFoundError) as exc:
raise_workflow_rpc_error(exc) raise_workflow_rpc_error(exc)
@entrypoint.method(name="workflow.admin.events.list", errors=[WorkflowRpcError]) @entrypoint.method(
name="workflow.admin.events.list",
errors=[WorkflowRpcError],
)
async def workflow_admin_events_list( async def workflow_admin_events_list(
params: AdminEmptyParams = Body(default_factory=AdminEmptyParams), params: AdminEmptyParams = RpcParams(),
) -> dict[str, Any]: ) -> dict[str, Any]:
try: try:
return await server.admin.list_events() return await server.admin.list_events()
except (ValueError, KeyError, LookupError, FileNotFoundError) as exc: except (ValueError, KeyError, LookupError, FileNotFoundError) as exc:
raise_workflow_rpc_error(exc) raise_workflow_rpc_error(exc)
@entrypoint.method(
name="workflow.admin.auth.list",
errors=[WorkflowRpcError],
)
async def workflow_admin_auth_list(
params: AdminEmptyParams = RpcParams(),
) -> dict[str, Any]:
try:
return await server.admin.list_auth_records()
except (
ValueError,
KeyError,
LookupError,
FileNotFoundError,
RuntimeError,
) as exc:
raise_workflow_rpc_error(exc)
@entrypoint.method(
name="workflow.admin.auth.inspect",
errors=[WorkflowRpcError],
)
async def workflow_admin_auth_inspect(
params: InspectAuthParams = RpcParams(),
) -> dict[str, Any]:
try:
return await server.admin.inspect_auth_record(params.auth_ref)
except (
ValueError,
KeyError,
LookupError,
FileNotFoundError,
RuntimeError,
) as exc:
raise_workflow_rpc_error(exc)
+4
View File
@@ -205,3 +205,7 @@ class RegistryEntryIdParams(RpcParamsModel):
class ApplyRegistryChangesParams(RpcParamsModel): class ApplyRegistryChangesParams(RpcParamsModel):
pass pass
class InspectAuthParams(RpcParamsModel):
auth_ref: str = Field(min_length=1)
+65
View File
@@ -4,6 +4,8 @@ import asyncio
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import Any
import pytest
from wf_api import WorkflowAdminApi, WorkflowAdminSurface from wf_api import WorkflowAdminApi, WorkflowAdminSurface
@@ -94,3 +96,66 @@ def test_admin_api_satisfies_surface_protocol() -> None:
api: WorkflowAdminSurface = WorkflowAdminApi(connections=provider, events=provider) api: WorkflowAdminSurface = WorkflowAdminApi(connections=provider, events=provider)
assert api is not None assert api is not None
class AuthProvider:
def list_auth_records(self) -> list[dict[str, Any]]:
return [
{
"id": "github.work",
"scheme": "bearer",
"metadata": {"owner": "platform"},
"payload_keys": ["token"],
},
{
"id": "api.work",
"scheme": "headers",
"metadata": {},
"payload_keys": ["headers"],
},
]
def inspect_auth_record(self, auth_ref: str) -> dict[str, Any]:
for record in self.list_auth_records():
if record["id"] == auth_ref:
return record
raise KeyError(auth_ref)
def _api(auth=None) -> WorkflowAdminApi:
return WorkflowAdminApi(
connections=FakeAdminProvider(),
events=FakeAdminProvider(),
auth=auth,
)
def test_admin_lists_auth_records_sorted_without_payload_values() -> None:
payload = asyncio.run(_api(AuthProvider()).list_auth_records())
assert payload["total"] == 2
assert [record["id"] for record in payload["auth_records"]] == [
"api.work",
"github.work",
]
assert payload["auth_records"][0]["payload_keys"] == ["headers"]
assert "payload" not in payload["auth_records"][0]
def test_admin_inspects_auth_record_without_payload_values() -> None:
payload = asyncio.run(_api(AuthProvider()).inspect_auth_record("github.work"))
assert payload == {
"id": "github.work",
"scheme": "bearer",
"metadata": {"owner": "platform"},
"payload_keys": ["token"],
}
def test_admin_auth_methods_report_unavailable_without_provider() -> None:
with pytest.raises(RuntimeError, match="auth admin is not available"):
asyncio.run(_api().list_auth_records())
with pytest.raises(RuntimeError, match="auth admin is not available"):
asyncio.run(_api().inspect_auth_record("github.work"))
+60
View File
@@ -0,0 +1,60 @@
from __future__ import annotations
from typer.testing import CliRunner
from wf_cli.app import app
class FakeAdmin:
async def list_auth_records(self):
return {
"auth_records": [
{
"id": "github.work",
"scheme": "bearer",
"metadata": {},
"payload_keys": ["token"],
}
],
"total": 1,
}
async def inspect_auth_record(self, auth_ref: str):
return {
"id": auth_ref,
"scheme": "bearer",
"metadata": {},
"payload_keys": ["token"],
}
class FakeContext:
admin = FakeAdmin()
verbose = False
def test_wf_admin_auth_list(monkeypatch) -> None:
monkeypatch.setattr(
"wf_cli.commands.auth_admin.load_cli_context_from_typer",
lambda ctx: FakeContext(),
)
result = CliRunner().invoke(app, ["admin", "auth", "list"])
assert result.exit_code == 0
assert "github.work" in result.stdout
assert "secret" not in result.stdout
def test_wf_admin_auth_inspect(monkeypatch) -> None:
monkeypatch.setattr(
"wf_cli.commands.auth_admin.load_cli_context_from_typer",
lambda ctx: FakeContext(),
)
result = CliRunner().invoke(app, ["admin", "auth", "inspect", "github.work"])
assert result.exit_code == 0
assert "github.work" in result.stdout
assert "payload_keys" in result.stdout
assert "secret" not in result.stdout
+75
View File
@@ -0,0 +1,75 @@
from __future__ import annotations
from pathlib import Path
import pytest
from wf_mcp.broker.service.auth_admin import McpAuthAdminProvider
from wf_mcp.models import AuthRecord
from wf_mcp.storage import FileStore
def _store(tmp_path: Path) -> FileStore:
return FileStore(tmp_path)
def test_auth_admin_lists_safe_summaries_sorted(tmp_path: Path) -> None:
store = _store(tmp_path)
store.save_auth(
AuthRecord(
connection_id="github.work",
scheme="bearer",
payload={"token": "secret", "headers": {"Authorization": "Bearer secret"}},
)
)
store.save_auth(
AuthRecord(
connection_id="api.work",
scheme="headers",
payload={"headers": {"X-API-Key": "secret"}},
)
)
provider = McpAuthAdminProvider(store=store)
records = provider.list_auth_records()
assert records == [
{
"id": "api.work",
"scheme": "headers",
"metadata": {},
"payload_keys": ["headers"],
},
{
"id": "github.work",
"scheme": "bearer",
"metadata": {},
"payload_keys": ["headers", "token"],
},
]
def test_auth_admin_inspects_safe_summary(tmp_path: Path) -> None:
store = _store(tmp_path)
store.save_auth(
AuthRecord(
connection_id="github.work",
scheme="bearer",
payload={"token": "secret"},
)
)
provider = McpAuthAdminProvider(store=store)
assert provider.inspect_auth_record("github.work") == {
"id": "github.work",
"scheme": "bearer",
"metadata": {},
"payload_keys": ["token"],
}
def test_auth_admin_inspect_unknown_raises_key_error(tmp_path: Path) -> None:
provider = McpAuthAdminProvider(store=_store(tmp_path))
with pytest.raises(KeyError, match="unknown auth record"):
provider.inspect_auth_record("missing.auth")
+35
View File
@@ -96,3 +96,38 @@ def test_workflow_server_from_service_rejects_missing_stores(tmp_path) -> None:
config=config, config=config,
source_registry_store=FileSourceRegistryStore(config.store_root), source_registry_store=FileSourceRegistryStore(config.store_root),
) )
async def test_workflow_server_from_service_exposes_auth_admin(tmp_path) -> None:
from wf_mcp.models import AuthRecord
config = BrokerConfig(
store_root=tmp_path / "store",
connections=[
ConnectionConfig(id="demo.default", server="demo", account="default")
],
)
service = build_service_from_config(config)
service.save_auth(
AuthRecord(
connection_id="github.work",
scheme="bearer",
payload={"token": "secret"},
)
)
server = workflow_server_from_service(
service,
config=config,
source_registry_store=FileSourceRegistryStore(config.store_root),
)
payload = await server.admin.list_auth_records()
assert payload["auth_records"] == [
{
"id": "github.work",
"scheme": "bearer",
"metadata": {},
"payload_keys": ["token"],
}
]
@@ -0,0 +1,49 @@
from __future__ import annotations
import httpx
from wf_mcp.broker.server import build_workflow_server_from_config
from wf_mcp.models import AuthRecord, BrokerConfig
from wf_mcp.storage import FileStore
from wf_transport_rpc_http import RpcWorkflowApiClient, create_rpc_app
async def test_rpc_lists_auth_records(tmp_path) -> None:
config = BrokerConfig(store_root=tmp_path / "store", connections=[])
server = build_workflow_server_from_config(config)
FileStore(tmp_path / "store").save_auth(
AuthRecord(connection_id="github.work", scheme="bearer", payload={"token": "secret"})
)
app = create_rpc_app(server)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as http_client:
client = RpcWorkflowApiClient(url="http://test/rpc", http_client=http_client)
payload = await client.list_auth_records()
assert payload["auth_records"] == [
{
"id": "github.work",
"scheme": "bearer",
"metadata": {},
"payload_keys": ["token"],
}
]
async def test_rpc_inspects_auth_record(tmp_path) -> None:
config = BrokerConfig(store_root=tmp_path / "store", connections=[])
server = build_workflow_server_from_config(config)
FileStore(tmp_path / "store").save_auth(
AuthRecord(connection_id="github.work", scheme="bearer", payload={"token": "secret"})
)
app = create_rpc_app(server)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as http_client:
client = RpcWorkflowApiClient(url="http://test/rpc", http_client=http_client)
payload = await client.inspect_auth_record("github.work")
assert payload["id"] == "github.work"
assert payload["payload_keys"] == ["token"]
assert "payload" not in payload