feat: add auth admin mutations

This commit is contained in:
lda
2026-06-06 16:49:34 +07:00 Verified
parent 56104c7849
commit f137b8fcb2
17 changed files with 627 additions and 15 deletions
+6 -2
View File
@@ -238,8 +238,12 @@ implementation state.
available through MCP-backed server admin, JSON-RPC, and CLI. Summaries show available through MCP-backed server admin, JSON-RPC, and CLI. Summaries show
ids, schemes, metadata, and payload keys only; secret payload values remain ids, schemes, metadata, and payload keys only; secret payload values remain
hidden. hidden.
Not done: auth is still compatibility-grade. There is no auth mutation UI/CLI, Fourth implementation slice complete: local/dev auth records can be saved and
OAuth flow, production secret manager, provider-specific display model, or deleted through neutral admin, JSON-RPC, and `wf admin auth`. This is still not
a production secret manager or OAuth flow; payload values are accepted only as
write inputs and never returned.
Not done: auth is still compatibility-grade. There is no OAuth flow,
production secret manager, provider-specific display model, or
full removal of the legacy MCP auth record shape yet. full removal of the legacy MCP auth record shape yet.
- 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
@@ -16,8 +16,11 @@ 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. Slice 3 exposes read-only auth diagnostics and source registry apply summaries. Slice 3 exposes read-only auth
admin summaries without secret payload values. Auth mutation surfaces and admin summaries without secret payload values. Slice 4 adds local/dev file-backed
provider-specific auth unions are future slices. auth save/delete through neutral admin, JSON-RPC, and CLI. Responses still
expose only ids, schemes, metadata, and payload keys; secret payload values
remain write-only. OAuth, production secret managers, and provider-specific auth
variants remain future work.
This is not a complete auth product yet. The implemented runtime path only wires This is not a complete auth product yet. The implemented runtime path only wires
existing MCP-compatible auth records into source calls, diagnostics, and existing MCP-compatible auth records into source calls, diagnostics, and
+15
View File
@@ -286,6 +286,21 @@ generate prose.
## Common Diagnostics ## Common Diagnostics
### Local/dev auth records
Auth payload values are write-only. `list`, `inspect`, `save`, and `delete`
responses show ids, schemes, metadata, and payload keys only.
```powershell
wf admin auth save drive.work --scheme bearer --payload-file drive-auth.json
wf admin auth list
wf admin auth inspect drive.work
wf admin auth delete drive.work --confirm
```
Use source `auth_ref` values to point sources at these records. Do not commit
payload files containing real secrets.
### `source_missing` ### `source_missing`
A required logical source is not available or not bound. A required logical source is not available or not bound.
+30 -1
View File
@@ -4,6 +4,8 @@ from collections.abc import Mapping, Sequence
from dataclasses import asdict, is_dataclass from dataclasses import asdict, is_dataclass
from typing import Any, Protocol from typing import Any, Protocol
from wf_api.auth import AuthRecord
class WorkflowAdminConnectionProvider(Protocol): class WorkflowAdminConnectionProvider(Protocol):
"""Provides read-only connection inventory for admin frontends.""" """Provides read-only connection inventory for admin frontends."""
@@ -20,12 +22,16 @@ class WorkflowAdminEventProvider(Protocol):
class WorkflowAdminAuthProvider(Protocol): class WorkflowAdminAuthProvider(Protocol):
"""Provides read-only auth inventory without secret payload values.""" """Provides auth inventory and local/dev auth mutation."""
def list_auth_records(self) -> Sequence[Mapping[str, Any] | object]: ... def list_auth_records(self) -> Sequence[Mapping[str, Any] | object]: ...
def inspect_auth_record(self, auth_ref: str) -> Mapping[str, Any] | object: ... def inspect_auth_record(self, auth_ref: str) -> Mapping[str, Any] | object: ...
def save_auth_record(self, record: AuthRecord) -> Mapping[str, Any] | object: ...
def delete_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.
@@ -79,6 +85,29 @@ class WorkflowAdminApi:
raise RuntimeError("auth admin is not available for this target") raise RuntimeError("auth admin is not available for this target")
return _payload(self.auth.inspect_auth_record(auth_ref)) return _payload(self.auth.inspect_auth_record(auth_ref))
async def save_auth_record(
self,
*,
auth_ref: str,
scheme: str,
payload: Mapping[str, object],
metadata: Mapping[str, object] | None = None,
) -> dict[str, Any]:
if self.auth is None:
raise RuntimeError("auth admin is not available for this target")
record = AuthRecord(
id=auth_ref,
scheme=scheme,
payload=dict(payload),
metadata=dict(metadata or {}),
)
return _payload(self.auth.save_auth_record(record))
async def delete_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.delete_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."""
+12 -1
View File
@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Sequence from collections.abc import Mapping, Sequence
from typing import Any, Protocol from typing import Any, Protocol
from wf_artifacts import ArtifactKind from wf_artifacts import ArtifactKind
@@ -229,6 +229,17 @@ class WorkflowAdminSurface(Protocol):
async def inspect_auth_record(self, auth_ref: str) -> dict[str, Any]: ... async def inspect_auth_record(self, auth_ref: str) -> dict[str, Any]: ...
async def save_auth_record(
self,
*,
auth_ref: str,
scheme: str,
payload: Mapping[str, object],
metadata: Mapping[str, object] | None = None,
) -> dict[str, Any]: ...
async def delete_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."""
+90 -1
View File
@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
import json
from pathlib import Path
from typing import Annotated from typing import Annotated
import typer import typer
@@ -11,11 +13,39 @@ from wf_cli.remote_errors import run_cli_operation
app = typer.Typer( app = typer.Typer(
name="auth", name="auth",
help="Read auth record status without exposing secret payload values.", help="Manage local/dev auth records without exposing secret payload values.",
no_args_is_help=True, no_args_is_help=True,
) )
def _read_json_object(
inline: str | None,
file_path: str | None,
flag_names: str,
) -> dict[str, object]:
if inline and file_path:
raise typer.BadParameter(f"provide exactly one of {flag_names}")
if inline:
try:
value = json.loads(inline)
except json.JSONDecodeError as exc:
raise typer.BadParameter(f"invalid JSON: {exc}") from exc
if not isinstance(value, dict):
raise typer.BadParameter(f"{flag_names} must be a JSON object")
return dict(value)
if file_path:
try:
value = json.loads(Path(file_path).read_text(encoding="utf-8"))
except FileNotFoundError as exc:
raise typer.BadParameter(f"file not found: {file_path}") from exc
except json.JSONDecodeError as exc:
raise typer.BadParameter(f"invalid JSON in file: {exc}") from exc
if not isinstance(value, dict):
raise typer.BadParameter(f"{flag_names} must be a JSON object")
return dict(value)
raise typer.BadParameter(f"{flag_names} is required")
@app.command("list") @app.command("list")
def list_auth_records( def list_auth_records(
ctx: typer.Context, ctx: typer.Context,
@@ -47,3 +77,62 @@ def inspect_auth_record(
context.admin.inspect_auth_record(auth_ref), context.admin.inspect_auth_record(auth_ref),
) )
emit_json(payload) emit_json(payload)
@app.command("save")
def save_auth_record(
ctx: typer.Context,
auth_ref: Annotated[str, typer.Argument(help="Auth record id/ref.")],
scheme: Annotated[str, typer.Option("--scheme", help="Auth scheme/kind.")],
payload_json: Annotated[
str | None,
typer.Option("--payload", help="Secret payload JSON object."),
] = None,
payload_file: Annotated[
str | None,
typer.Option("--payload-file", help="File containing secret payload JSON object."),
] = None,
metadata_json: Annotated[
str | None,
typer.Option("--metadata", help="Non-secret metadata JSON object."),
] = None,
metadata_file: Annotated[
str | None,
typer.Option("--metadata-file", help="File containing non-secret metadata JSON object."),
] = None,
) -> None:
"""Save or replace a local/dev auth record; response never includes payload values."""
payload = _read_json_object(payload_json, payload_file, "--payload/--payload-file")
metadata = (
_read_json_object(metadata_json, metadata_file, "--metadata/--metadata-file")
if metadata_json or metadata_file
else None
)
context = load_cli_context_from_typer(ctx)
result = run_cli_operation(
context,
context.admin.save_auth_record(
auth_ref=auth_ref,
scheme=scheme,
payload=payload,
metadata=metadata,
),
)
emit_json(result)
@app.command("delete")
def delete_auth_record(
ctx: typer.Context,
auth_ref: Annotated[str, typer.Argument(help="Auth record id/ref.")],
confirm: Annotated[
bool,
typer.Option("--confirm", help="Required to delete an auth record."),
] = False,
) -> None:
"""Delete a local/dev auth record."""
if not confirm:
raise typer.BadParameter("--confirm is required to delete an auth record")
context = load_cli_context_from_typer(ctx)
result = run_cli_operation(context, context.admin.delete_auth_record(auth_ref))
emit_json(result)
+12 -1
View File
@@ -4,13 +4,14 @@ from dataclasses import dataclass
from typing import Any from typing import Any
from wf_api import WorkflowAdminAuthProvider from wf_api import WorkflowAdminAuthProvider
from wf_api.auth import AuthRecord as NeutralAuthRecord
from ...storage import Store from ...storage import Store
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class McpAuthAdminProvider(WorkflowAdminAuthProvider): class McpAuthAdminProvider(WorkflowAdminAuthProvider):
"""Read-only auth inventory for MCP-backed workflow servers. """Auth inventory and local/dev mutation for MCP-backed workflow servers.
Summaries intentionally expose payload keys, not payload values. Concrete Summaries intentionally expose payload keys, not payload values. Concrete
auth variants can provide richer safe display later. auth variants can provide richer safe display later.
@@ -35,5 +36,15 @@ class McpAuthAdminProvider(WorkflowAdminAuthProvider):
"payload_keys": sorted(str(key) for key in record.payload), "payload_keys": sorted(str(key) for key in record.payload),
} }
def save_auth_record(self, record: NeutralAuthRecord) -> dict[str, Any]:
self.store.save_auth_record(record)
return self.inspect_auth_record(record.id)
def delete_auth_record(self, auth_ref: str) -> dict[str, Any]:
deleted = self.store.delete_auth_record(auth_ref)
if not deleted:
raise KeyError(f"unknown auth record {auth_ref!r}")
return {"deleted": True, "id": auth_ref}
__all__ = ["McpAuthAdminProvider"] __all__ = ["McpAuthAdminProvider"]
+32 -3
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import json import json
from pathlib import Path from pathlib import Path
from wf_api.auth import AuthRecord as NeutralAuthRecord from wf_api.auth import AuthRecord as NeutralAuthRecord, validate_auth_id
from wf_mcp.capabilities import ( from wf_mcp.capabilities import (
CatalogNodeEntry, CatalogNodeEntry,
CatalogPromptEntry, CatalogPromptEntry,
@@ -34,6 +34,12 @@ class Store:
def load_auth_record(self, auth_ref: str) -> NeutralAuthRecord | None: def load_auth_record(self, auth_ref: str) -> NeutralAuthRecord | None:
raise NotImplementedError raise NotImplementedError
def delete_auth(self, connection_id: str) -> bool:
raise NotImplementedError
def delete_auth_record(self, auth_ref: str) -> bool:
raise NotImplementedError
def save_catalog(self, snapshot: CatalogSnapshot) -> None: def save_catalog(self, snapshot: CatalogSnapshot) -> None:
raise NotImplementedError raise NotImplementedError
@@ -56,8 +62,20 @@ class FileStore(Store):
def catalog_dir(self) -> Path: def catalog_dir(self) -> Path:
return self.root / "catalog" return self.root / "catalog"
def _auth_path(self, connection_id: str) -> Path: def _auth_path(self, auth_ref: str) -> Path:
return self._connection_path(self.auth_dir, connection_id) """Map one auth ref to one file.
Auth refs used to be connection ids, but neutral auth refs now carry no
provider/account semantics. Keep catalog paths on connection-id
validation while auth storage accepts the wider auth-id contract.
"""
validate_auth_id(auth_ref)
root = self.auth_dir.resolve()
path = (self.auth_dir / f"{auth_ref}.json").resolve()
if path.parent != root:
raise ValueError(f"auth ref escapes store directory: {auth_ref!r}")
return path
def _catalog_path(self, connection_id: str) -> Path: def _catalog_path(self, connection_id: str) -> Path:
return self._connection_path(self.catalog_dir, connection_id) return self._connection_path(self.catalog_dir, connection_id)
@@ -112,6 +130,17 @@ class FileStore(Store):
return None return None
return neutral_auth_from_mcp(record) return neutral_auth_from_mcp(record)
def delete_auth(self, connection_id: str) -> bool:
path = self._auth_path(connection_id)
if not path.exists():
return False
path.unlink()
return True
def delete_auth_record(self, auth_ref: str) -> bool:
"""Delete neutral auth through the legacy MCP file shape."""
return self.delete_auth(auth_ref)
def save_catalog(self, snapshot: CatalogSnapshot) -> None: def save_catalog(self, snapshot: CatalogSnapshot) -> None:
self._catalog_path(snapshot.connection_id).write_text( self._catalog_path(snapshot.connection_id).write_text(
json.dumps(dump_catalog_snapshot(snapshot), indent=2), json.dumps(dump_catalog_snapshot(snapshot), indent=2),
+25
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping
from typing import Any from typing import Any
@@ -25,3 +26,27 @@ class RpcAdminClientMixin:
"workflow.admin.auth.inspect", "workflow.admin.auth.inspect",
{"auth_ref": auth_ref}, {"auth_ref": auth_ref},
) )
async def save_auth_record(
self,
*,
auth_ref: str,
scheme: str,
payload: Mapping[str, object],
metadata: Mapping[str, object] | None = None,
) -> dict[str, Any]:
return await self._call(
"workflow.admin.auth.save",
{
"auth_ref": auth_ref,
"scheme": scheme,
"payload": dict(payload),
"metadata": dict(metadata or {}),
},
)
async def delete_auth_record(self, auth_ref: str) -> dict[str, Any]:
return await self._call(
"workflow.admin.auth.delete",
{"auth_ref": auth_ref},
)
+42 -1
View File
@@ -7,7 +7,7 @@ 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, InspectAuthParams from .models import AdminEmptyParams, DeleteAuthParams, InspectAuthParams, SaveAuthParams
from .params import RpcParams from .params import RpcParams
@@ -88,3 +88,44 @@ def register_methods(
RuntimeError, RuntimeError,
) as exc: ) as exc:
raise_workflow_rpc_error(exc) raise_workflow_rpc_error(exc)
@entrypoint.method(
name="workflow.admin.auth.save",
errors=[WorkflowRpcError],
)
async def workflow_admin_auth_save(
params: SaveAuthParams = RpcParams(),
) -> dict[str, Any]:
try:
return await server.admin.save_auth_record(
auth_ref=params.auth_ref,
scheme=params.scheme,
payload=params.payload,
metadata=params.metadata,
)
except (
ValueError,
KeyError,
LookupError,
FileNotFoundError,
RuntimeError,
) as exc:
raise_workflow_rpc_error(exc)
@entrypoint.method(
name="workflow.admin.auth.delete",
errors=[WorkflowRpcError],
)
async def workflow_admin_auth_delete(
params: DeleteAuthParams = RpcParams(),
) -> dict[str, Any]:
try:
return await server.admin.delete_auth_record(params.auth_ref)
except (
ValueError,
KeyError,
LookupError,
FileNotFoundError,
RuntimeError,
) as exc:
raise_workflow_rpc_error(exc)
+11
View File
@@ -209,3 +209,14 @@ class ApplyRegistryChangesParams(RpcParamsModel):
class InspectAuthParams(RpcParamsModel): class InspectAuthParams(RpcParamsModel):
auth_ref: str = Field(min_length=1) auth_ref: str = Field(min_length=1)
class SaveAuthParams(RpcParamsModel):
auth_ref: str = Field(min_length=1)
scheme: str = Field(min_length=1)
payload: dict[str, Any] = Field(default_factory=dict)
metadata: dict[str, Any] = Field(default_factory=dict)
class DeleteAuthParams(RpcParamsModel):
auth_ref: str = Field(min_length=1)
+84
View File
@@ -7,6 +7,7 @@ from typing import Any
import pytest import pytest
from wf_api import WorkflowAdminApi, WorkflowAdminSurface from wf_api import WorkflowAdminApi, WorkflowAdminSurface
from wf_api.auth import AuthRecord
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -159,3 +160,86 @@ def test_admin_auth_methods_report_unavailable_without_provider() -> None:
with pytest.raises(RuntimeError, match="auth admin is not available"): with pytest.raises(RuntimeError, match="auth admin is not available"):
asyncio.run(_api().inspect_auth_record("github.work")) asyncio.run(_api().inspect_auth_record("github.work"))
class MutableAuthProvider(AuthProvider):
def __init__(self) -> None:
self.records: dict[str, dict[str, Any]] = {}
def list_auth_records(self):
return list(self.records.values())
def inspect_auth_record(self, auth_ref: str):
try:
return self.records[auth_ref]
except KeyError as exc:
raise KeyError(auth_ref) from exc
def save_auth_record(self, record: AuthRecord):
self.records[record.id] = {
"id": record.id,
"scheme": record.scheme,
"metadata": dict(record.metadata),
"payload_keys": sorted(str(key) for key in record.payload),
}
return self.records[record.id]
def delete_auth_record(self, auth_ref: str):
if auth_ref not in self.records:
raise KeyError(auth_ref)
del self.records[auth_ref]
return {"deleted": True, "id": auth_ref}
def test_admin_saves_auth_record_without_payload_values() -> None:
provider = MutableAuthProvider()
api = _api(provider)
payload = asyncio.run(
api.save_auth_record(
auth_ref="drive.work",
scheme="bearer",
payload={"token": "secret"},
metadata={"owner": "test"},
)
)
assert payload == {
"id": "drive.work",
"scheme": "bearer",
"metadata": {"owner": "test"},
"payload_keys": ["token"],
}
assert "secret" not in str(payload)
def test_admin_deletes_auth_record() -> None:
provider = MutableAuthProvider()
api = _api(provider)
asyncio.run(
api.save_auth_record(
auth_ref="drive.work",
scheme="bearer",
payload={"token": "secret"},
)
)
payload = asyncio.run(api.delete_auth_record("drive.work"))
assert payload == {"deleted": True, "id": "drive.work"}
with pytest.raises(KeyError):
provider.inspect_auth_record("drive.work")
def test_admin_auth_mutations_report_unavailable_without_provider() -> None:
with pytest.raises(RuntimeError, match="auth admin is not available"):
asyncio.run(
_api().save_auth_record(
auth_ref="drive.work",
scheme="bearer",
payload={"token": "secret"},
)
)
with pytest.raises(RuntimeError, match="auth admin is not available"):
asyncio.run(_api().delete_auth_record("drive.work"))
+118 -1
View File
@@ -1,11 +1,17 @@
from __future__ import annotations from __future__ import annotations
import json
from typer.testing import CliRunner from typer.testing import CliRunner
from wf_cli.app import app from wf_cli.app import app
class FakeAdmin: class FakeAdmin:
def __init__(self) -> None:
self._saved: dict[str, dict] = {}
self._deleted: list[str] = []
async def list_auth_records(self): async def list_auth_records(self):
return { return {
"auth_records": [ "auth_records": [
@@ -27,9 +33,28 @@ class FakeAdmin:
"payload_keys": ["token"], "payload_keys": ["token"],
} }
async def save_auth_record(self, *, auth_ref, scheme, payload, metadata=None):
self._saved[auth_ref] = {
"auth_ref": auth_ref,
"scheme": scheme,
"payload": payload,
"metadata": metadata,
}
return {
"id": auth_ref,
"scheme": scheme,
"metadata": metadata or {},
"payload_keys": sorted(str(key) for key in payload),
}
async def delete_auth_record(self, auth_ref):
self._deleted.append(auth_ref)
return {"deleted": True, "id": auth_ref}
class FakeContext: class FakeContext:
admin = FakeAdmin() def __init__(self) -> None:
self.admin = FakeAdmin()
verbose = False verbose = False
@@ -58,3 +83,95 @@ def test_wf_admin_auth_inspect(monkeypatch) -> None:
assert "github.work" in result.stdout assert "github.work" in result.stdout
assert "payload_keys" in result.stdout assert "payload_keys" in result.stdout
assert "secret" not in result.stdout assert "secret" not in result.stdout
def test_wf_admin_auth_save(monkeypatch) -> None:
fake_ctx = FakeContext()
monkeypatch.setattr(
"wf_cli.commands.auth_admin.load_cli_context_from_typer",
lambda ctx: fake_ctx,
)
result = CliRunner().invoke(
app,
[
"admin",
"auth",
"save",
"drive.work",
"--scheme",
"bearer",
"--payload",
'{"token":"secret"}',
],
)
assert result.exit_code == 0
payload = json.loads(result.stdout)
assert payload["id"] == "drive.work"
assert payload["payload_keys"] == ["token"]
assert "secret" not in result.stdout
assert fake_ctx.admin._saved["drive.work"] == {
"auth_ref": "drive.work",
"scheme": "bearer",
"payload": {"token": "secret"},
"metadata": None,
}
def test_wf_admin_auth_save_reads_payload_file(tmp_path, monkeypatch) -> None:
fake_ctx = FakeContext()
payload_file = tmp_path / "auth.json"
payload_file.write_text('{"token":"secret"}', encoding="utf-8")
monkeypatch.setattr(
"wf_cli.commands.auth_admin.load_cli_context_from_typer",
lambda ctx: fake_ctx,
)
result = CliRunner().invoke(
app,
[
"admin",
"auth",
"save",
"drive.work",
"--scheme",
"bearer",
"--payload-file",
str(payload_file),
],
)
assert result.exit_code == 0
assert fake_ctx.admin._saved["drive.work"]["payload"] == {"token": "secret"}
def test_wf_admin_auth_delete_requires_confirm(monkeypatch) -> None:
fake_ctx = FakeContext()
monkeypatch.setattr(
"wf_cli.commands.auth_admin.load_cli_context_from_typer",
lambda ctx: fake_ctx,
)
result = CliRunner().invoke(app, ["admin", "auth", "delete", "drive.work"])
assert result.exit_code != 0
assert "confirm" in (result.stdout + result.output).lower()
assert fake_ctx.admin._deleted == []
def test_wf_admin_auth_delete(monkeypatch) -> None:
fake_ctx = FakeContext()
monkeypatch.setattr(
"wf_cli.commands.auth_admin.load_cli_context_from_typer",
lambda ctx: fake_ctx,
)
result = CliRunner().invoke(
app,
["admin", "auth", "delete", "drive.work", "--confirm"],
)
assert result.exit_code == 0
assert json.loads(result.stdout) == {"deleted": True, "id": "drive.work"}
assert fake_ctx.admin._deleted == ["drive.work"]
+46
View File
@@ -4,6 +4,7 @@ from pathlib import Path
import pytest import pytest
from wf_api.auth import AuthRecord as NeutralAuthRecord
from wf_mcp.broker.service.auth_admin import McpAuthAdminProvider from wf_mcp.broker.service.auth_admin import McpAuthAdminProvider
from wf_mcp.models import AuthRecord from wf_mcp.models import AuthRecord
from wf_mcp.storage import FileStore from wf_mcp.storage import FileStore
@@ -73,3 +74,48 @@ def test_auth_admin_inspect_unknown_raises_key_error(tmp_path: Path) -> None:
with pytest.raises(KeyError, match="unknown auth record"): with pytest.raises(KeyError, match="unknown auth record"):
provider.inspect_auth_record("missing.auth") provider.inspect_auth_record("missing.auth")
def test_auth_admin_provider_saves_auth_without_returning_payload(tmp_path) -> None:
store = FileStore(tmp_path / "store")
provider = McpAuthAdminProvider(store)
payload = provider.save_auth_record(
NeutralAuthRecord(
id="drive.work",
scheme="bearer",
payload={"token": "secret"},
metadata={"owner": "test"},
)
)
assert payload == {
"id": "drive.work",
"scheme": "bearer",
"metadata": {},
"payload_keys": ["token"],
}
assert "secret" not in str(payload)
assert store.load_auth("drive.work") == AuthRecord(
connection_id="drive.work",
scheme="bearer",
payload={"token": "secret"},
)
def test_auth_admin_provider_deletes_auth(tmp_path) -> None:
store = FileStore(tmp_path / "store")
provider = McpAuthAdminProvider(store)
store.save_auth(AuthRecord(connection_id="drive.work", scheme="bearer"))
payload = provider.delete_auth_record("drive.work")
assert payload == {"deleted": True, "id": "drive.work"}
assert store.load_auth("drive.work") is None
def test_auth_admin_provider_delete_unknown_auth_raises_key_error(tmp_path) -> None:
provider = McpAuthAdminProvider(FileStore(tmp_path / "store"))
with pytest.raises(KeyError, match="unknown auth record 'missing.auth'"):
provider.delete_auth_record("missing.auth")
+52 -2
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import pytest import pytest
from wf_api.auth import AuthRecord as NeutralAuthRecord
from wf_mcp.connections import parse_connection_id from wf_mcp.connections import parse_connection_id
from wf_mcp.models import AuthRecord, CatalogSnapshot from wf_mcp.models import AuthRecord, CatalogSnapshot
from wf_mcp.storage import FileStore from wf_mcp.storage import FileStore
@@ -29,7 +30,7 @@ def test_parse_connection_id_rejects_path_traversal() -> None:
parse_connection_id(connection_id) parse_connection_id(connection_id)
def test_file_store_rejects_auth_connection_id_path_traversal(tmp_path) -> None: def test_file_store_rejects_auth_ref_path_traversal(tmp_path) -> None:
store = FileStore(tmp_path / "store") store = FileStore(tmp_path / "store")
record = AuthRecord( record = AuthRecord(
connection_id="../outside", connection_id="../outside",
@@ -37,7 +38,7 @@ def test_file_store_rejects_auth_connection_id_path_traversal(tmp_path) -> None:
payload={"token": "secret"}, payload={"token": "secret"},
) )
with pytest.raises(ValueError, match="connection id"): with pytest.raises(ValueError, match="auth id"):
store.save_auth(record) store.save_auth(record)
assert not (tmp_path / "outside.json").exists() assert not (tmp_path / "outside.json").exists()
@@ -58,3 +59,52 @@ def test_file_store_rejects_catalog_connection_id_path_traversal(tmp_path) -> No
store.save_catalog(snapshot) store.save_catalog(snapshot)
assert not (tmp_path / "outside.json").exists() assert not (tmp_path / "outside.json").exists()
def test_file_store_deletes_auth_record(tmp_path) -> None:
store = FileStore(tmp_path / "store")
record = AuthRecord(
connection_id="drive.work",
scheme="bearer",
payload={"token": "secret"},
)
store.save_auth(record)
assert store.load_auth("drive.work") == record
assert store.delete_auth("drive.work") is True
assert store.load_auth("drive.work") is None
assert store.delete_auth("drive.work") is False
def test_file_store_deletes_neutral_auth_record(tmp_path) -> None:
store = FileStore(tmp_path / "store")
store.save_auth_record(
NeutralAuthRecord(
id="drive.work",
scheme="bearer",
payload={"token": "secret"},
)
)
assert store.delete_auth_record("drive.work") is True
assert store.load_auth_record("drive.work") is None
def test_file_store_accepts_neutral_auth_ref_without_connection_shape(
tmp_path,
) -> None:
store = FileStore(tmp_path / "store")
record = NeutralAuthRecord(
id="api_ci-1",
scheme="bearer",
payload={"token": "secret"},
)
store.save_auth_record(record)
loaded = store.load_auth_record("api_ci-1")
assert loaded is not None
assert loaded.id == "api_ci-1"
assert loaded.scheme == "bearer"
assert loaded.payload == {"token": "secret"}
assert store.delete_auth_record("api_ci-1") is True
@@ -55,3 +55,50 @@ async def test_rpc_inspects_auth_record(tmp_path) -> None:
assert payload["id"] == "github.work" assert payload["id"] == "github.work"
assert payload["payload_keys"] == ["token"] assert payload["payload_keys"] == ["token"]
assert "payload" not in payload assert "payload" not in payload
async def test_rpc_saves_auth_record_without_returning_payload(tmp_path) -> None:
store = FileStore(tmp_path / "store")
config = BrokerConfig(store_root=store.root, connections=[])
server = build_workflow_server_from_config(config)
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.save_auth_record(
auth_ref="drive.work",
scheme="bearer",
payload={"token": "secret"},
metadata={"owner": "test"},
)
assert payload["id"] == "drive.work"
assert payload["scheme"] == "bearer"
assert payload["payload_keys"] == ["token"]
assert "secret" not in str(payload)
assert FileStore(store.root).load_auth("drive.work") == AuthRecord(
connection_id="drive.work",
scheme="bearer",
payload={"token": "secret"},
)
async def test_rpc_deletes_auth_record(tmp_path) -> None:
store = FileStore(tmp_path / "store")
store.save_auth(AuthRecord(connection_id="drive.work", scheme="bearer"))
config = BrokerConfig(store_root=store.root, connections=[])
server = build_workflow_server_from_config(config)
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.delete_auth_record("drive.work")
assert payload == {"deleted": True, "id": "drive.work"}
assert FileStore(store.root).load_auth("drive.work") is None