auth-bundle-2 Phase 9: 11 OIDC + session MCP tools (Phase-5 surface parity)

Closes Phase 9 of cowork/auth-bundle-2-prompt.md. Every Phase-5 HTTP
endpoint now has a matching MCP tool so operators driving certctl
from Claude / VS Code / any MCP client get the same OIDC-provider +
group-mapping + session management capability the GUI + CLI already
expose.

Coverage map (each tool → HTTP endpoint → permission)
=====================================================

  certctl_auth_list_oidc_providers      GET    /v1/auth/oidc/providers                   auth.oidc.list
  certctl_auth_get_oidc_provider        GET    /v1/auth/oidc/providers (filtered)        auth.oidc.list
  certctl_auth_create_oidc_provider     POST   /v1/auth/oidc/providers                   auth.oidc.create
  certctl_auth_update_oidc_provider     PUT    /v1/auth/oidc/providers/{id}              auth.oidc.edit
  certctl_auth_delete_oidc_provider     DELETE /v1/auth/oidc/providers/{id}              auth.oidc.delete
  certctl_auth_refresh_oidc_provider    POST   /v1/auth/oidc/providers/{id}/refresh      auth.oidc.edit
  certctl_auth_list_group_mappings      GET    /v1/auth/oidc/group-mappings?provider_id  auth.oidc.list
  certctl_auth_add_group_mapping        POST   /v1/auth/oidc/group-mappings              auth.oidc.edit
  certctl_auth_remove_group_mapping     DELETE /v1/auth/oidc/group-mappings/{id}         auth.oidc.edit
  certctl_auth_list_sessions            GET    /v1/auth/sessions[?actor_id=&actor_type=] auth.session.list (own) | auth.session.list.all (other)
  certctl_auth_revoke_session           DELETE /v1/auth/sessions/{id}                    auth.session.revoke (or own-bypass)

Implementation notes
====================

internal/mcp/tools_auth_bundle2.go (NEW): 11 tools wired through three
focused register functions (registerAuthOIDCProviderTools,
registerAuthGroupMappingTools, registerAuthSessionTools). Every tool
routes through the existing Client (Get/Post/Put/Delete) so permission
gates fire server-side via the Phase-5 rbacGate wrappers — a non-admin
caller's MCP tool invocation gets whatever 403 the underlying HTTP
handler emits, not an MCP-side bypass.

Empty-id guard
--------------

Every path-id tool short-circuits to errorResult(fmt.Errorf("id is required"))
BEFORE the HTTP call. Defense against url.PathEscape("") collapsing a
singular op into the list endpoint (which would silently succeed against
a permissive backend). Same pattern across all 6 path-id tools (get,
update, delete, refresh provider; remove mapping; revoke session).

auth_get_oidc_provider list-then-filter
---------------------------------------

The Phase-5 HTTP API doesn't expose a singular GET /v1/auth/oidc/providers/{id}
endpoint — the GUI's OIDCProviderDetailPage fetches the full list and
filters in-process. The MCP tool mirrors that pattern exactly: GET the
list, JSON-decode the providers envelope, walk the array filtering by
id, return the matching raw JSON object on hit or an explicit "oidc
provider not found: <id>" error on miss. This keeps the MCP surface
in lockstep with the GUI's permission boundary (auth.oidc.list grants
"see any provider", as it does on the GUI) without inventing a new HTTP
endpoint.

internal/mcp/types.go (MODIFIED): 8 new input types matching the
Phase-5 wire shapes (oidcProviderRequest at internal/api/handler/auth_session_oidc.go).
client_secret on Update is optional — empty preserves the existing
ciphertext on the server, providing a value rotates. Mirrors the GUI's
edit-without-rotate UX from web/src/pages/auth/OIDCProviderDetailPage.tsx.

internal/mcp/tools.go (MODIFIED): registerAuthBundle2Tools wired into
RegisterTools alongside the Bundle 1 Phase 11 registerAuthTools.

Test coverage
=============

internal/mcp/tools_auth_bundle2_test.go (NEW), 5 test cases:

* TestAuthBundle2MCP_AllToolsRegister — registerAuthBundle2Tools
  doesn't panic; catches duplicate-name regressions before CI.
* TestAuthBundle2MCP_PathsAndMethods — 11 cases (one per tool) +
  the admin-other-actor variant of list_sessions; asserts the right
  method + path + body + query string fires against the mock API.
* TestAuthBundle2MCP_ForbiddenSurfacesError — every tool's underlying
  HTTP path returns a propagated error containing "forbidden" / "403"
  when the mock returns 403, exercising the errorResult fence path.
* TestAuthBundle2MCP_GetProviderFiltersListByID — pins the list-then-
  filter shape end-to-end with both the hit-and-return (returns the
  matching raw JSON object) and miss-returns-error (sentinel string
  "oidc provider not found") branches.
* TestAuthBundle2MCP_EmptyIDInputShortCircuits — pins the
  strings.TrimSpace empty-id guard at the top of every path-id handler.
* TestAuthBundle2MCP_PromptCoverage — every tool the prompt enumerates
  is also present in tools_per_tool_test.go's allHappyPathCases (so
  the live-dispatch + 5xx error-path tests cover all 11 tools).

internal/mcp/tools_per_tool_test.go (MODIFIED): 11 new toolCase entries
in allHappyPathCases (live in-memory MCP dispatch + happy-path fence
shape + 5xx error-path fence shape) + a mock-API special case for
GET /api/v1/auth/oidc/providers that returns the right envelope shape
({"providers":[{"id":"op-okta",...}]}) so the get_oidc_provider tool's
in-process filter resolves under the live dispatch.

Verification
============

* gofmt + go vet — clean across internal/mcp/...
* go test -short -count=1 — green across internal/mcp + internal/auth/...
  + internal/api/handler + internal/api/router (13 packages, 0 failures).
* MCP tool count re-derive (CLAUDE.md command):
    grep -cE 'mcp\.AddTool\(' internal/mcp/tools*.go
  → tools.go=121, tools_auth.go=12, tools_auth_bundle2.go=11 (new),
  tools_est.go=6 — total 150. Matches the live count
  TestMCP_RegisterTools_DispatchableToolCount asserts.
* staticcheck deferred — sandbox /tmp at 99% disk, can't install the
  binary; all SA*/ST* lints would have run via the staticcheck-CI step
  on push. go vet caught the only real issue (an unused context import)
  before commit.

Not in this commit (deferred)
=============================

* Break-glass admin MCP tools (4 endpoints from Phase 7.5). The Phase 9
  prompt does NOT enumerate break-glass tools; its exit criteria is
  "Every API endpoint from Phase 5 has an MCP tool". Phase 5 does not
  include the break-glass surface (Phase 7.5 ships those endpoints with
  surface-invisibility semantics: 404 when CERTCTL_BREAKGLASS_ENABLED=false,
  which complicates LLM tool-discovery UX). If the operator wants
  break-glass MCP parity, that's a follow-on bundle.
This commit is contained in:
shankar0123
2026-05-10 07:40:34 +00:00
parent 96db3e72c9
commit e9b3369d79
5 changed files with 804 additions and 0 deletions
+21
View File
@@ -90,6 +90,14 @@ func newHarness(t *testing.T) *mcpHarness {
w.Header().Set("Content-Type", "application/json")
switch {
// Bundle 2 Phase 9 — auth_get_oidc_provider tool calls the list
// endpoint and filters in-process; the canned default
// {"data":[...]} shape doesn't match providersListEnvelope's
// `providers` field. Return the right envelope shape with the
// id the tool's args target so the happy path resolves.
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/auth/oidc/providers":
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"providers":[{"id":"op-okta","name":"Okta"}]}`))
case r.Method == http.MethodDelete:
w.WriteHeader(http.StatusNoContent)
case strings.HasSuffix(r.URL.Path, "/renew") ||
@@ -431,6 +439,19 @@ var allHappyPathCases = []toolCase{
{"certctl_auth_list_keys", map[string]any{}, http.MethodGet, "/api/v1/auth/keys"},
{"certctl_auth_assign_role_to_key", map[string]any{"key_id": "alice", "role_id": "r-operator"}, http.MethodPost, "/api/v1/auth/keys/alice/roles"},
{"certctl_auth_revoke_role_from_key", map[string]any{"key_id": "alice", "role_id": "r-admin"}, http.MethodDelete, "/api/v1/auth/keys/alice/roles/r-admin"},
// Bundle 2 Phase 9 — OIDC + session tools (11 tools).
{"certctl_auth_list_oidc_providers", map[string]any{}, http.MethodGet, "/api/v1/auth/oidc/providers"},
{"certctl_auth_get_oidc_provider", map[string]any{"id": "op-okta"}, http.MethodGet, "/api/v1/auth/oidc/providers"},
{"certctl_auth_create_oidc_provider", map[string]any{"name": "Okta", "issuer_url": "https://example.okta.com", "client_id": "certctl", "client_secret": "s3cret", "redirect_uri": "https://certctl.example.com/auth/oidc/callback"}, http.MethodPost, "/api/v1/auth/oidc/providers"},
{"certctl_auth_update_oidc_provider", map[string]any{"id": "op-okta", "name": "Okta-renamed", "issuer_url": "https://example.okta.com", "client_id": "certctl", "redirect_uri": "https://certctl.example.com/auth/oidc/callback"}, http.MethodPut, "/api/v1/auth/oidc/providers/op-okta"},
{"certctl_auth_delete_oidc_provider", map[string]any{"id": "op-okta"}, http.MethodDelete, "/api/v1/auth/oidc/providers/op-okta"},
{"certctl_auth_refresh_oidc_provider", map[string]any{"id": "op-okta"}, http.MethodPost, "/api/v1/auth/oidc/providers/op-okta/refresh"},
{"certctl_auth_list_group_mappings", map[string]any{"provider_id": "op-okta"}, http.MethodGet, "/api/v1/auth/oidc/group-mappings"},
{"certctl_auth_add_group_mapping", map[string]any{"provider_id": "op-okta", "group_name": "engineers", "role_id": "r-operator"}, http.MethodPost, "/api/v1/auth/oidc/group-mappings"},
{"certctl_auth_remove_group_mapping", map[string]any{"id": "gm-1"}, http.MethodDelete, "/api/v1/auth/oidc/group-mappings/gm-1"},
{"certctl_auth_list_sessions", map[string]any{}, http.MethodGet, "/api/v1/auth/sessions"},
{"certctl_auth_revoke_session", map[string]any{"id": "ses-abc"}, http.MethodDelete, "/api/v1/auth/sessions/ses-abc"},
}
// TestMCP_AllTools_HappyPath dispatches every tool against the mock API in