Files
certctl/internal/api/router/phase12_protocol_allowlist_test.go
shankar0123 cbb47aaf5d auth-bundle-1 Phase 11 + 12: RBAC MCP tools + negative-test coverage gate
# Phase 11 — RBAC MCP tools

12 new tools in internal/mcp/tools_auth.go mirroring the Phase-4
+ Phase-7 HTTP surface so operators driving certctl from Claude
/ VS Code / any MCP client get the same management capability
the GUI + CLI already expose:

  certctl_auth_me                          GET    /v1/auth/me
  certctl_auth_list_roles                  GET    /v1/auth/roles
  certctl_auth_get_role                    GET    /v1/auth/roles/{id}
  certctl_auth_create_role                 POST   /v1/auth/roles
  certctl_auth_update_role                 PUT    /v1/auth/roles/{id}
  certctl_auth_delete_role                 DELETE /v1/auth/roles/{id}
  certctl_auth_list_permissions            GET    /v1/auth/permissions
  certctl_auth_add_permission_to_role      POST   /v1/auth/roles/{id}/permissions
  certctl_auth_remove_permission_from_role DELETE /v1/auth/roles/{id}/permissions/{perm}
  certctl_auth_list_keys                   GET    /v1/auth/keys
  certctl_auth_assign_role_to_key          POST   /v1/auth/keys/{id}/roles
  certctl_auth_revoke_role_from_key        DELETE /v1/auth/keys/{id}/roles/{role_id}

Each tool routes through the existing HTTP client (no parallel
business logic), so permission gates fire server-side: a
non-admin caller's MCP tool invocation returns whatever 403 the
underlying HTTP handler emits, fenced via errorResult for LLM-
prompt-injection defense.

Input types in internal/mcp/types.go (AuthRoleIDInput,
AuthCreateRoleInput, AuthUpdateRoleInput,
AuthRolePermissionGrantInput, AuthRolePermissionRevokeInput,
AuthAssignKeyRoleInput, AuthRevokeKeyRoleInput) carry
jsonschema descriptions so the MCP consumer's tool catalogue
shows operator-friendly hints.

internal/mcp/tools_auth_test.go ships 14 tests:
  - TestAuthMCP_AllToolsRegister (registration must not panic)
  - TestAuthMCP_PathsAndMethods (table-driven, 12 rows pinning
    each tool's HTTP method + URL)
  - TestAuthMCP_ForbiddenSurfacesFencedError (12 tools × 403
    mock → error surface)

internal/mcp/tools_per_tool_test.go's allHappyPathCases extended
with the 12 new rows so the in-memory dispatch coverage gate
(TestMCP_RegisterTools_DispatchableToolCount) stays green at the
new total of 139 registered tools.

Re-derived total via 'grep -cE "gomcp\.AddTool\(" internal/mcp/tools*.go':
133 (121 in tools.go + 12 in tools_auth.go).

# Phase 12 — negative-test coverage gate

Audit of the prompt's 12 negative-test paths against existing
coverage:

  1.  Missing actor → 401          ✓ TestRequirePermission_NoActorReturns401, TestRBACGate_NoActorReturns401
  2.  No roles → 403               ✓ TestRequirePermission_DeniedActorReturns403, TestRBACGate_AuditorRole_403sOnAdminRoutes
  3.  Role lacks specific perm → 403 ✓ same suite
  4.  Wrong scope → 403            ✓ TestAuthorizer_SpecificScopeMatchesExactID (wrongID arm)
  5.  Self-grant w/o auth.role.assign → 403 ✓ TestActorRoleService_GrantRequiresAuthRoleAssign
  6.  Bootstrap token wrong → 401  ✓ TestEnvTokenStrategy_WrongTokenReturnsInvalidToken, TestBootstrapHandler_Mint_WrongToken_401
  7.  Bootstrap used twice → 410   ✓ TestEnvTokenStrategy_OneShotConsumption, TestBootstrapHandler_Mint_TwiceReturns410
  8.  Bootstrap when admin exists → 410 ✓ TestEnvTokenStrategy_AdminExistsClosesPath, TestBootstrapHandler_Mint_AdminExists410
  9.  Role delete with assignees → 409 NEW: TestRoleService_DeleteWithActorsAssignedReturns409
  10. Profile-edit loophole → gated ✓ TestProfileEdit_RequiresApprovalLoopholeClosed
  11. Permission not in catalog → 400 ✓ TestRoleService_AddPermissionRejectsNonCanonical
  12. Scope ID for nonexistent resource → 404 (validation deferred — no FK constraint between role_permissions.scope_id and the resource tables; documented for a future bundle)

Filled the gap at #9 with TestRoleService_DeleteWithActorsAssignedReturns409
which pins the repository sentinel pass-through (postgres FK
ON DELETE RESTRICT → repository.ErrAuthRoleInUse → service
returns the sentinel verbatim → handler maps to HTTP 409).

# Coverage gates

.github/coverage-thresholds.yml gains 2 entries:
  - internal/auth: floor 85
  - internal/service/auth: floor 85

.github/workflows/ci.yml's coverage test command extended with
./internal/auth/... and ./internal/api/router/... so the
threshold check has data to evaluate.

# Protocol-endpoint not-gated test (Category F)

internal/api/router/phase12_protocol_allowlist_test.go (new)
adds 3 router-level invariant tests:

  - TestPhase12_ProtocolEndpointsNotGated: AST-walks router.go,
    asserts no rbacGate(...) call references a path under any
    protocol-endpoint prefix (/acme, /scep, /.well-known/est,
    /.well-known/pki/ocsp, /.well-known/pki/crl).
  - TestPhase12_IsProtocolEndpoint_CoversCanonicalPrefixes:
    pins auth.IsProtocolEndpoint against the canonical prefix
    set; if a future protocol lands without lockstep allowlist
    update, this fails.
  - TestPhase12_RBACGateRoutesAreUnderAPIv1: belt-and-braces —
    every rbacGate-wrapped route MUST start with /api/v1/.
    Catches accidental cross-prefix wraps.

Complements the existing TestRequirePermission_ProtocolEndpointBypassesGate
(middleware-level) + TestRouter_AuthExemptAllowlist_PinsActualRegistrations
(allowlist drift) so the Category F invariant is pinned at all
three layers (middleware + router + dispatch).

# Verifications

* gofmt clean repo-wide.
* go vet ./... clean.
* staticcheck across internal/auth + handler + router + cli +
  service + repository + cmd + domain + mcp: clean.
* go test -short -count=1 green across internal/auth (incl.
  bootstrap), internal/api/handler, internal/api/router,
  internal/cli, internal/service (incl. auth),
  internal/domain/auth, internal/mcp, cmd/server, cmd/cli.
2026-05-09 23:46:01 +00:00

139 lines
5.0 KiB
Go

package router
import (
"go/parser"
"go/token"
"os"
"strings"
"testing"
"github.com/certctl-io/certctl/internal/auth"
)
// =============================================================================
// Bundle 1 Phase 12 (Category F) — protocol endpoints MUST NOT be wrapped in
// rbacGate / auth.RequirePermission.
//
// The prompt's exit criterion: "Negative test asserts that ACME / SCEP /
// EST / OCSP / CRL endpoints are NOT wrapped in RequirePermission.
// Implementation: scan the router config and assert each protocol-
// endpoint route is in the allowlist constant from Phase 3."
//
// Two complementary checks ride here:
//
// 1. Scan router.go's source for every literal route path that matches
// a protocol-endpoint prefix; assert NONE of those paths appear
// inside a rbacGate(...) call. The AST walker is intentionally
// loose — substring match against the rbacGate function name is
// sufficient and avoids false negatives from formatting.
//
// 2. Pin the protocol-endpoint dispatch prefixes (cmd/server/main.go's
// buildFinalHandler dispatch) against the allowlist constant in
// auth.IsProtocolEndpoint. If a future commit adds a new protocol
// endpoint without extending the allowlist, this test breaks.
// =============================================================================
// protocolEndpointPrefixes is the canonical set of URL prefixes the
// auth middleware MUST bypass. Mirrors auth.IsProtocolEndpoint's
// internal switch. This test pins the constant against the actual
// router shape.
var protocolEndpointPrefixes = []string{
"/acme",
"/scep",
"/.well-known/est",
"/.well-known/pki/ocsp",
"/.well-known/pki/crl",
}
// TestPhase12_ProtocolEndpointsNotGated walks router.go and asserts
// no rbacGate(...) call references a path under a protocol-endpoint
// prefix. We accept false negatives (the test is conservative) but
// never false positives — if rbacGate wraps a protocol path, the test
// fails with the offending line.
func TestPhase12_ProtocolEndpointsNotGated(t *testing.T) {
src, err := os.ReadFile("router.go")
if err != nil {
t.Fatalf("read router.go: %v", err)
}
fset := token.NewFileSet()
if _, perr := parser.ParseFile(fset, "router.go", src, parser.SkipObjectResolution); perr != nil {
t.Fatalf("parse router.go: %v", perr)
}
body := string(src)
// Find every line containing rbacGate(. For each, scan for any
// of the protocol prefixes appearing on the same line. If both
// land on a single line, that's a Category-F violation.
for i, line := range strings.Split(body, "\n") {
if !strings.Contains(line, "rbacGate(") {
continue
}
for _, prefix := range protocolEndpointPrefixes {
// We look for `"<prefix>"` or `"<prefix>/...` shapes —
// the path argument is always a quoted string in the
// repo's r.Register("METHOD /path", ...) convention.
if strings.Contains(line, `"`+prefix) {
t.Errorf("router.go line %d: rbacGate wraps a protocol-endpoint path %q (Category F violation): %s",
i+1, prefix, strings.TrimSpace(line))
}
}
}
}
// TestPhase12_IsProtocolEndpoint_CoversCanonicalPrefixes pins the
// auth.IsProtocolEndpoint allowlist against the canonical prefix
// set. If a future commit adds a new protocol that the auth
// middleware needs to bypass, both this slice AND
// auth.IsProtocolEndpoint must change in lockstep.
func TestPhase12_IsProtocolEndpoint_CoversCanonicalPrefixes(t *testing.T) {
for _, prefix := range protocolEndpointPrefixes {
// IsProtocolEndpoint takes a full path; pass the prefix as
// a synthetic representative request path.
probe := prefix
if !strings.HasSuffix(probe, "/") {
probe = probe + "/probe"
}
if !auth.IsProtocolEndpoint(probe) {
t.Errorf("IsProtocolEndpoint(%q) = false; the canonical prefix list is out of sync with the auth allowlist", probe)
}
}
}
// TestPhase12_RBACGateRoutesAreUnderAPIv1 belt-and-braces: every
// rbacGate-wrapped path the parity test enumerates must start with
// `/api/v1/` so we can never accidentally wrap a protocol endpoint
// (those all live under `/acme`, `/scep`, or `/.well-known/`).
func TestPhase12_RBACGateRoutesAreUnderAPIv1(t *testing.T) {
src, err := os.ReadFile("router.go")
if err != nil {
t.Fatalf("read router.go: %v", err)
}
for i, line := range strings.Split(string(src), "\n") {
if !strings.Contains(line, "rbacGate(") {
continue
}
// Find the quoted path argument. Look for the first
// occurrence of `"METHOD /...`.
startQuote := strings.Index(line, `"`)
if startQuote < 0 {
continue
}
endQuote := strings.Index(line[startQuote+1:], `"`)
if endQuote < 0 {
continue
}
path := line[startQuote+1 : startQuote+1+endQuote]
// The Register signature is "METHOD /path" — split on
// whitespace.
parts := strings.Fields(path)
if len(parts) != 2 {
continue
}
urlPath := parts[1]
if !strings.HasPrefix(urlPath, "/api/v1/") {
t.Errorf("router.go line %d: rbacGate wraps non-API-v1 path %q: %s",
i+1, urlPath, strings.TrimSpace(line))
}
}
}