feat: expose auth admin summaries
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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},
|
||||||
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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")
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user