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