auth-bundle-1 Phase 0: extract internal/auth/ from middleware package

Bundle 1 / Phase 0: pure refactor splitting auth surface out of internal/api/middleware so Bundle 2 (OIDC + sessions) and the broader RBAC primitive (roles, permissions, scoped grants) have a clean home.

Moved to internal/auth/: NamedAPIKey, HashAPIKey, AuthConfig, NewAuthWithNamedKeys, NewAuth, UserKey, AdminKey, GetUser, IsAdmin. Added testfixtures.go (WithActor / WithAdmin / WithActorAdmin) so handler tests don't construct context manually.

Stayed in internal/api/middleware/: RequestID, Logging, NewLogging, Recovery, RateLimitConfig, NewRateLimiter (now imports auth.GetUser for per-user keying per audit Category C), CORSConfig, NewCORS, ContentType, CORS, GetRequestID, responseWriter, Chain, audit middleware (now imports auth.GetUser).

Updated 22 caller files across cmd/, internal/api/handler/, internal/api/middleware/, internal/mcp/. Existing m008_admin_gate_test.go now scans for auth.IsAdmin( substring; Phase 3 will further evolve to track auth.RequirePermission. Behavior unchanged: all handler / middleware / service / connector / cmd / mcp tests pass with no test-logic edits, only import-path renames.

Phase 0 exit criteria: internal/auth/ exists with 6 files; middleware.go went 575 -> 422 lines (auth-related ~150 lines moved out); grep -rE 'middleware\.(GetUser|IsAdmin|UserKey|AdminKey|NamedAPIKey|HashAPIKey|NewAuth)' returns 0 hits; context.WithValue(.*middleware.UserKey/AdminKey) returns 0 hits; go vet ./... clean; go test -short ./... green across all packages tested.

Branch: dev/auth-bundle-1. Per cowork/auth-bundle-1-prompt.md, do not merge to master without (1) make verify green, (2) >= 2 external testers confirm, (3) >= 90% coverage on internal/auth/ in .github/coverage-thresholds.yml.
This commit is contained in:
shankar0123
2026-05-09 15:51:31 +00:00
parent aad95d315b
commit 69f860171e
32 changed files with 397 additions and 283 deletions
+6 -5
View File
@@ -21,6 +21,7 @@ import (
"github.com/certctl-io/certctl/internal/api/handler"
"github.com/certctl-io/certctl/internal/api/middleware"
"github.com/certctl-io/certctl/internal/api/router"
"github.com/certctl-io/certctl/internal/auth"
"github.com/certctl-io/certctl/internal/config"
discoveryawssm "github.com/certctl-io/certctl/internal/connector/discovery/awssm"
discoveryazurekv "github.com/certctl-io/certctl/internal/connector/discovery/azurekv"
@@ -1483,13 +1484,13 @@ func main() {
// Named keys come from CERTCTL_API_KEYS_NAMED (preferred). For backward
// compatibility CERTCTL_AUTH_SECRET is synthesized into legacy-key-N
// entries with Admin=false.
var namedKeys []middleware.NamedAPIKey
var namedKeys []auth.NamedAPIKey
if config.AuthType(cfg.Auth.Type) != config.AuthTypeNone {
// Translate typed config.NamedAPIKey -> middleware.NamedAPIKey. The
// Translate typed config.NamedAPIKey -> auth.NamedAPIKey. The
// two structs are field-compatible but live in different packages to
// preserve the config→middleware dependency direction.
for _, nk := range cfg.Auth.NamedKeys {
namedKeys = append(namedKeys, middleware.NamedAPIKey{
namedKeys = append(namedKeys, auth.NamedAPIKey{
Name: nk.Name,
Key: nk.Key,
Admin: nk.Admin,
@@ -1506,7 +1507,7 @@ func main() {
if p == "" {
continue
}
namedKeys = append(namedKeys, middleware.NamedAPIKey{
namedKeys = append(namedKeys, auth.NamedAPIKey{
Name: fmt.Sprintf("legacy-key-%d", idx),
Key: p,
Admin: false,
@@ -1519,7 +1520,7 @@ func main() {
}
}
}
authMiddleware := middleware.NewAuthWithNamedKeys(namedKeys)
authMiddleware := auth.NewAuthWithNamedKeys(namedKeys)
corsMiddleware := middleware.NewCORS(middleware.CORSConfig{
AllowedOrigins: cfg.CORS.AllowedOrigins,
})
+5 -4
View File
@@ -12,6 +12,7 @@ import (
"github.com/certctl-io/certctl/internal/api/middleware"
"github.com/certctl-io/certctl/internal/api/router"
"github.com/certctl-io/certctl/internal/auth"
"github.com/certctl-io/certctl/internal/config"
"github.com/certctl-io/certctl/internal/service"
)
@@ -44,7 +45,7 @@ func TestMain_HealthEndpointBypassesAuth(t *testing.T) {
})
// Build the handler chain the same way main.go does
authMiddleware := middleware.NewAuthWithNamedKeys([]middleware.NamedAPIKey{
authMiddleware := auth.NewAuthWithNamedKeys([]auth.NamedAPIKey{
{Name: "test", Key: "test-secret-key"},
})
@@ -159,7 +160,7 @@ func TestMain_AuthMiddlewareRejectsUnauthorized(t *testing.T) {
})
// Wrap with auth middleware
authMiddleware := middleware.NewAuthWithNamedKeys([]middleware.NamedAPIKey{
authMiddleware := auth.NewAuthWithNamedKeys([]auth.NamedAPIKey{
{Name: "test", Key: "test-secret-key"},
})
@@ -187,7 +188,7 @@ func TestMain_AuthMiddlewareAllowsWithValidKey(t *testing.T) {
})
// Wrap with auth middleware
authMiddleware := middleware.NewAuthWithNamedKeys([]middleware.NamedAPIKey{
authMiddleware := auth.NewAuthWithNamedKeys([]auth.NamedAPIKey{
{Name: "test", Key: testKey},
})
@@ -460,7 +461,7 @@ func TestMain_AuthNoneMode(t *testing.T) {
// Wrap with auth middleware in "none" mode
// auth=none equivalent: empty named-keys list is a no-op pass-through.
authMiddleware := middleware.NewAuthWithNamedKeys(nil)
authMiddleware := auth.NewAuthWithNamedKeys(nil)
chainedHandler := middleware.Chain(protectedHandler, authMiddleware)
+2 -2
View File
@@ -5,7 +5,7 @@ import (
"net/http"
"time"
"github.com/certctl-io/certctl/internal/api/middleware"
"github.com/certctl-io/certctl/internal/auth"
"github.com/certctl-io/certctl/internal/domain"
"github.com/certctl-io/certctl/internal/repository"
)
@@ -74,7 +74,7 @@ func (h AdminCRLCacheHandler) ListCache(w http.ResponseWriter, r *http.Request)
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
if !middleware.IsAdmin(r.Context()) {
if !auth.IsAdmin(r.Context()) {
Error(w, http.StatusForbidden, "Admin access required")
return
}
+6 -5
View File
@@ -10,6 +10,7 @@ import (
"testing"
"github.com/certctl-io/certctl/internal/api/middleware"
"github.com/certctl-io/certctl/internal/auth"
)
// fakeAdminCRLCacheService is the test stub for the
@@ -67,7 +68,7 @@ func TestAdminCRLCache_AdminExplicitFalse_Returns403(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crl/cache", nil)
ctx := context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id")
ctx = context.WithValue(ctx, middleware.AdminKey{}, false)
ctx = context.WithValue(ctx, auth.AdminKey{}, false)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
@@ -99,8 +100,8 @@ func TestAdminCRLCache_AdminPermitted_ForwardsActor(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crl/cache", nil)
ctx := context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id")
ctx = context.WithValue(ctx, middleware.AdminKey{}, true)
ctx = context.WithValue(ctx, middleware.UserKey{}, "ops-admin")
ctx = context.WithValue(ctx, auth.AdminKey{}, true)
ctx = context.WithValue(ctx, auth.UserKey{}, "ops-admin")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
@@ -131,7 +132,7 @@ func TestAdminCRLCache_RejectsNonGetMethod(t *testing.T) {
h := NewAdminCRLCacheHandler(&fakeAdminCRLCacheService{})
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crl/cache", nil)
ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true)
ctx := context.WithValue(context.Background(), auth.AdminKey{}, true)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
@@ -150,7 +151,7 @@ func TestAdminCRLCache_PropagatesServiceError(t *testing.T) {
h := NewAdminCRLCacheHandler(svc)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crl/cache", nil)
ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true)
ctx := context.WithValue(context.Background(), auth.AdminKey{}, true)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
+3 -3
View File
@@ -7,7 +7,7 @@ import (
"net/http"
"time"
"github.com/certctl-io/certctl/internal/api/middleware"
"github.com/certctl-io/certctl/internal/auth"
"github.com/certctl-io/certctl/internal/service"
)
@@ -76,7 +76,7 @@ func (h AdminESTHandler) Profiles(w http.ResponseWriter, r *http.Request) {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
if !middleware.IsAdmin(r.Context()) {
if !auth.IsAdmin(r.Context()) {
Error(w, http.StatusForbidden, "Admin access required")
return
}
@@ -104,7 +104,7 @@ func (h AdminESTHandler) ReloadTrust(w http.ResponseWriter, r *http.Request) {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
if !middleware.IsAdmin(r.Context()) {
if !auth.IsAdmin(r.Context()) {
Error(w, http.StatusForbidden, "Admin access required")
return
}
+10 -9
View File
@@ -11,6 +11,7 @@ import (
"time"
"github.com/certctl-io/certctl/internal/api/middleware"
"github.com/certctl-io/certctl/internal/auth"
"github.com/certctl-io/certctl/internal/service"
)
@@ -65,7 +66,7 @@ func TestAdminEST_Profiles_AdminExplicitFalse_Returns403(t *testing.T) {
h := NewAdminESTHandler(svc)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/est/profiles", nil)
ctx := context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id")
ctx = context.WithValue(ctx, middleware.AdminKey{}, false)
ctx = context.WithValue(ctx, auth.AdminKey{}, false)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
h.Profiles(w, req)
@@ -86,7 +87,7 @@ func TestAdminEST_Profiles_AdminTrue_Returns200(t *testing.T) {
h := NewAdminESTHandler(svc)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/est/profiles", nil)
ctx := context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id")
ctx = context.WithValue(ctx, middleware.AdminKey{}, true)
ctx = context.WithValue(ctx, auth.AdminKey{}, true)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
h.Profiles(w, req)
@@ -121,7 +122,7 @@ func TestAdminEST_Profiles_NilRowsSerializedAsEmptyArray(t *testing.T) {
h := NewAdminESTHandler(svc)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/est/profiles", nil)
ctx := context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id")
ctx = context.WithValue(ctx, middleware.AdminKey{}, true)
ctx = context.WithValue(ctx, auth.AdminKey{}, true)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
h.Profiles(w, req)
@@ -157,7 +158,7 @@ func TestAdminEST_ReloadTrust_AdminExplicitFalse_Returns403(t *testing.T) {
strings.NewReader(`{"path_id":"corp"}`))
req.ContentLength = int64(len(`{"path_id":"corp"}`))
ctx := context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id")
ctx = context.WithValue(ctx, middleware.AdminKey{}, false)
ctx = context.WithValue(ctx, auth.AdminKey{}, false)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
h.ReloadTrust(w, req)
@@ -177,7 +178,7 @@ func TestAdminEST_ReloadTrust_HappyPath(t *testing.T) {
strings.NewReader(body))
req.ContentLength = int64(len(body))
ctx := context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id")
ctx = context.WithValue(ctx, middleware.AdminKey{}, true)
ctx = context.WithValue(ctx, auth.AdminKey{}, true)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
h.ReloadTrust(w, req)
@@ -197,7 +198,7 @@ func TestAdminEST_ReloadTrust_UnknownPathID_Returns404(t *testing.T) {
strings.NewReader(body))
req.ContentLength = int64(len(body))
ctx := context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id")
ctx = context.WithValue(ctx, middleware.AdminKey{}, true)
ctx = context.WithValue(ctx, auth.AdminKey{}, true)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
h.ReloadTrust(w, req)
@@ -214,7 +215,7 @@ func TestAdminEST_ReloadTrust_MTLSDisabled_Returns409(t *testing.T) {
strings.NewReader(body))
req.ContentLength = int64(len(body))
ctx := context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id")
ctx = context.WithValue(ctx, middleware.AdminKey{}, true)
ctx = context.WithValue(ctx, auth.AdminKey{}, true)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
h.ReloadTrust(w, req)
@@ -231,7 +232,7 @@ func TestAdminEST_ReloadTrust_ParseError_Returns500(t *testing.T) {
strings.NewReader(body))
req.ContentLength = int64(len(body))
ctx := context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id")
ctx = context.WithValue(ctx, middleware.AdminKey{}, true)
ctx = context.WithValue(ctx, auth.AdminKey{}, true)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
h.ReloadTrust(w, req)
@@ -248,7 +249,7 @@ func TestAdminEST_ReloadTrust_MalformedJSON_Returns400(t *testing.T) {
strings.NewReader(body))
req.ContentLength = int64(len(body))
ctx := context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id")
ctx = context.WithValue(ctx, middleware.AdminKey{}, true)
ctx = context.WithValue(ctx, auth.AdminKey{}, true)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
h.ReloadTrust(w, req)
+4 -4
View File
@@ -7,7 +7,7 @@ import (
"net/http"
"time"
"github.com/certctl-io/certctl/internal/api/middleware"
"github.com/certctl-io/certctl/internal/auth"
"github.com/certctl-io/certctl/internal/service"
)
@@ -90,7 +90,7 @@ func (h AdminSCEPIntuneHandler) Profiles(w http.ResponseWriter, r *http.Request)
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
if !middleware.IsAdmin(r.Context()) {
if !auth.IsAdmin(r.Context()) {
Error(w, http.StatusForbidden, "Admin access required")
return
}
@@ -118,7 +118,7 @@ func (h AdminSCEPIntuneHandler) Stats(w http.ResponseWriter, r *http.Request) {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
if !middleware.IsAdmin(r.Context()) {
if !auth.IsAdmin(r.Context()) {
Error(w, http.StatusForbidden, "Admin access required")
return
}
@@ -146,7 +146,7 @@ func (h AdminSCEPIntuneHandler) ReloadTrust(w http.ResponseWriter, r *http.Reque
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
if !middleware.IsAdmin(r.Context()) {
if !auth.IsAdmin(r.Context()) {
Error(w, http.StatusForbidden, "Admin access required")
return
}
+20 -19
View File
@@ -11,6 +11,7 @@ import (
"time"
"github.com/certctl-io/certctl/internal/api/middleware"
"github.com/certctl-io/certctl/internal/auth"
"github.com/certctl-io/certctl/internal/service"
)
@@ -81,7 +82,7 @@ func TestAdminSCEPIntune_AdminExplicitFalse_Returns403(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/scep/intune/stats", nil)
ctx := context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id")
ctx = context.WithValue(ctx, middleware.AdminKey{}, false)
ctx = context.WithValue(ctx, auth.AdminKey{}, false)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
@@ -106,8 +107,8 @@ func TestAdminSCEPIntune_AdminPermitted_ForwardsActor(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/scep/intune/stats", nil)
ctx := context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id")
ctx = context.WithValue(ctx, middleware.AdminKey{}, true)
ctx = context.WithValue(ctx, middleware.UserKey{}, "ops-admin")
ctx = context.WithValue(ctx, auth.AdminKey{}, true)
ctx = context.WithValue(ctx, auth.UserKey{}, "ops-admin")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
@@ -160,7 +161,7 @@ func TestAdminSCEPIntuneReload_AdminExplicitFalse_Returns403(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/scep/intune/reload-trust",
strings.NewReader(`{"path_id":"corp"}`))
req.ContentLength = int64(len(`{"path_id":"corp"}`))
ctx := context.WithValue(context.Background(), middleware.AdminKey{}, false)
ctx := context.WithValue(context.Background(), auth.AdminKey{}, false)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
@@ -181,8 +182,8 @@ func TestAdminSCEPIntuneReload_AdminPermitted_ForwardsActor(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/scep/intune/reload-trust",
strings.NewReader(body))
req.ContentLength = int64(len(body))
ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true)
ctx = context.WithValue(ctx, middleware.UserKey{}, "ops-admin")
ctx := context.WithValue(context.Background(), auth.AdminKey{}, true)
ctx = context.WithValue(ctx, auth.UserKey{}, "ops-admin")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
@@ -211,7 +212,7 @@ func TestAdminSCEPIntuneReload_AdminPermitted_ForwardsActor(t *testing.T) {
func TestAdminSCEPIntuneStats_RejectsNonGetMethod(t *testing.T) {
h := NewAdminSCEPIntuneHandler(&fakeAdminSCEPIntuneService{})
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/scep/intune/stats", nil)
ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true)
ctx := context.WithValue(context.Background(), auth.AdminKey{}, true)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
h.Stats(w, req)
@@ -223,7 +224,7 @@ func TestAdminSCEPIntuneStats_RejectsNonGetMethod(t *testing.T) {
func TestAdminSCEPIntuneReload_RejectsNonPostMethod(t *testing.T) {
h := NewAdminSCEPIntuneHandler(&fakeAdminSCEPIntuneService{})
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/scep/intune/reload-trust", nil)
ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true)
ctx := context.WithValue(context.Background(), auth.AdminKey{}, true)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
h.ReloadTrust(w, req)
@@ -236,7 +237,7 @@ func TestAdminSCEPIntuneStats_PropagatesServiceError(t *testing.T) {
svc := &fakeAdminSCEPIntuneService{statsErr: errors.New("registry walk failed")}
h := NewAdminSCEPIntuneHandler(svc)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/scep/intune/stats", nil)
ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true)
ctx := context.WithValue(context.Background(), auth.AdminKey{}, true)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
h.Stats(w, req)
@@ -251,7 +252,7 @@ func TestAdminSCEPIntuneReload_ProfileNotFound_Returns404(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/scep/intune/reload-trust",
strings.NewReader(`{"path_id":"nonexistent"}`))
req.ContentLength = int64(len(`{"path_id":"nonexistent"}`))
ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true)
ctx := context.WithValue(context.Background(), auth.AdminKey{}, true)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
h.ReloadTrust(w, req)
@@ -266,7 +267,7 @@ func TestAdminSCEPIntuneReload_IntuneDisabled_Returns409(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/scep/intune/reload-trust",
strings.NewReader(`{"path_id":"iot"}`))
req.ContentLength = int64(len(`{"path_id":"iot"}`))
ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true)
ctx := context.WithValue(context.Background(), auth.AdminKey{}, true)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
h.ReloadTrust(w, req)
@@ -281,7 +282,7 @@ func TestAdminSCEPIntuneReload_BadReloadPropagates500(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/scep/intune/reload-trust",
strings.NewReader(`{"path_id":"corp"}`))
req.ContentLength = int64(len(`{"path_id":"corp"}`))
ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true)
ctx := context.WithValue(context.Background(), auth.AdminKey{}, true)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
h.ReloadTrust(w, req)
@@ -294,7 +295,7 @@ func TestAdminSCEPIntuneReload_EmptyBodyTargetsLegacyRoot(t *testing.T) {
svc := &fakeAdminSCEPIntuneService{}
h := NewAdminSCEPIntuneHandler(svc)
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/scep/intune/reload-trust", nil)
ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true)
ctx := context.WithValue(context.Background(), auth.AdminKey{}, true)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
h.ReloadTrust(w, req)
@@ -312,7 +313,7 @@ func TestAdminSCEPIntuneReload_RejectsMalformedJSON(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/scep/intune/reload-trust",
strings.NewReader(bad))
req.ContentLength = int64(len(bad))
ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true)
ctx := context.WithValue(context.Background(), auth.AdminKey{}, true)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
h.ReloadTrust(w, req)
@@ -379,7 +380,7 @@ func TestAdminSCEPProfiles_AdminExplicitFalse_Returns403(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/scep/profiles", nil)
ctx := context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id")
ctx = context.WithValue(ctx, middleware.AdminKey{}, false)
ctx = context.WithValue(ctx, auth.AdminKey{}, false)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
@@ -417,8 +418,8 @@ func TestAdminSCEPProfiles_AdminPermitted_ForwardsActor(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/scep/profiles", nil)
ctx := context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id")
ctx = context.WithValue(ctx, middleware.AdminKey{}, true)
ctx = context.WithValue(ctx, middleware.UserKey{}, "ops-admin")
ctx = context.WithValue(ctx, auth.AdminKey{}, true)
ctx = context.WithValue(ctx, auth.UserKey{}, "ops-admin")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
@@ -461,7 +462,7 @@ func TestAdminSCEPProfiles_AdminPermitted_ForwardsActor(t *testing.T) {
func TestAdminSCEPProfiles_RejectsNonGetMethod(t *testing.T) {
h := NewAdminSCEPIntuneHandler(&fakeAdminSCEPIntuneService{})
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/scep/profiles", nil)
ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true)
ctx := context.WithValue(context.Background(), auth.AdminKey{}, true)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
h.Profiles(w, req)
@@ -474,7 +475,7 @@ func TestAdminSCEPProfiles_PropagatesServiceError(t *testing.T) {
svc := &fakeAdminSCEPIntuneService{profilesErr: errors.New("registry walk failed")}
h := NewAdminSCEPIntuneHandler(svc)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/scep/profiles", nil)
ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true)
ctx := context.WithValue(context.Background(), auth.AdminKey{}, true)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
h.Profiles(w, req)
+3 -2
View File
@@ -8,6 +8,7 @@ import (
"strconv"
"github.com/certctl-io/certctl/internal/api/middleware"
"github.com/certctl-io/certctl/internal/auth"
"github.com/certctl-io/certctl/internal/domain"
"github.com/certctl-io/certctl/internal/repository"
"github.com/certctl-io/certctl/internal/service"
@@ -111,7 +112,7 @@ func (h ApprovalHandler) GetApproval(w http.ResponseWriter, r *http.Request) {
// Approve transitions a pending approval request to approved + transitions
// the linked Job from AwaitingApproval to Pending. RBAC: the authenticated
// actor extracted via middleware.UserKey must NOT equal the request's
// actor extracted via auth.UserKey must NOT equal the request's
// RequestedBy — the service-layer check enforces this and the handler
// surfaces it as HTTP 403.
//
@@ -153,7 +154,7 @@ func (h ApprovalHandler) decision(w http.ResponseWriter, r *http.Request, action
// Extract authenticated actor. The auth middleware sets UserKey to the
// API-key NamedAPIKey.Name (or empty for unauthenticated). RBAC at the
// service layer requires a non-empty actor.
actor, _ := r.Context().Value(middleware.UserKey{}).(string)
actor, _ := r.Context().Value(auth.UserKey{}).(string)
if actor == "" {
ErrorWithRequestID(w, http.StatusUnauthorized,
"authentication required to approve / reject", requestID)
+2 -2
View File
@@ -10,7 +10,7 @@ import (
"sync"
"testing"
"github.com/certctl-io/certctl/internal/api/middleware"
"github.com/certctl-io/certctl/internal/auth"
"github.com/certctl-io/certctl/internal/domain"
"github.com/certctl-io/certctl/internal/repository"
"github.com/certctl-io/certctl/internal/service"
@@ -117,7 +117,7 @@ func reqWithActor(t *testing.T, method, target string, body string, actor string
}
req.Header.Set("Content-Type", "application/json")
if actor != "" {
req = req.WithContext(context.WithValue(req.Context(), middleware.UserKey{}, actor))
req = req.WithContext(context.WithValue(req.Context(), auth.UserKey{}, actor))
}
if pathID != "" {
req.SetPathValue("id", pathID)
@@ -172,7 +172,7 @@ func authenticatedContext(actor string) context.Context {
type userKey struct{}
// The middleware UserKey is a private type in the middleware package, so
// in this handler test we can't construct one directly. Bulk-renew and
// bulk-reassign read the actor through the same middleware.GetUser path
// bulk-reassign read the actor through the same auth.GetUser path
// that bulk-revoke does — adminContext() in the existing test suite is
// the canonical helper. Reuse it (delivers both UserKey and AdminKey).
_ = userKey{}
@@ -11,6 +11,7 @@ import (
"testing"
"github.com/certctl-io/certctl/internal/api/middleware"
"github.com/certctl-io/certctl/internal/auth"
"github.com/certctl-io/certctl/internal/domain"
)
@@ -30,7 +31,7 @@ func (m *mockBulkRenewalService) BulkRenew(ctx context.Context, criteria domain.
// bulk-renew is NOT admin-gated, any authenticated caller can use it.
func authedContext() context.Context {
ctx := context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id-renew")
ctx = context.WithValue(ctx, middleware.UserKey{}, "alice")
ctx = context.WithValue(ctx, auth.UserKey{}, "alice")
return ctx
}
@@ -126,7 +127,7 @@ func TestBulkRenew_Handler_ActorAttribution(t *testing.T) {
h.BulkRenew(w, req)
if capturedActor != "alice" {
t.Errorf("actor not threaded from middleware.UserKey: got %q, want 'alice'", capturedActor)
t.Errorf("actor not threaded from auth.UserKey: got %q, want 'alice'", capturedActor)
}
}
+3 -2
View File
@@ -6,6 +6,7 @@ import (
"net/http"
"github.com/certctl-io/certctl/internal/api/middleware"
"github.com/certctl-io/certctl/internal/auth"
"github.com/certctl-io/certctl/internal/domain"
)
@@ -53,7 +54,7 @@ func (h BulkRevocationHandler) BulkRevoke(w http.ResponseWriter, r *http.Request
// M-003: admin-only gate. Non-admin callers are rejected before any
// criteria/body processing to avoid leaking validation behavior to
// unauthorized actors.
if !middleware.IsAdmin(r.Context()) {
if !auth.IsAdmin(r.Context()) {
ErrorWithRequestID(w, http.StatusForbidden,
"Bulk revocation requires admin privileges",
requestID)
@@ -127,7 +128,7 @@ func (h BulkRevocationHandler) BulkRevokeEST(w http.ResponseWriter, r *http.Requ
return
}
requestID := middleware.GetRequestID(r.Context())
if !middleware.IsAdmin(r.Context()) {
if !auth.IsAdmin(r.Context()) {
ErrorWithRequestID(w, http.StatusForbidden,
"EST bulk revocation requires admin privileges", requestID)
return
@@ -11,6 +11,7 @@ import (
"testing"
"github.com/certctl-io/certctl/internal/api/middleware"
"github.com/certctl-io/certctl/internal/auth"
"github.com/certctl-io/certctl/internal/domain"
)
@@ -31,7 +32,7 @@ func (m *mockBulkRevocationService) BulkRevoke(ctx context.Context, criteria dom
// M-003: bulk revocation handler requires admin context to reach the service.
func adminContext() context.Context {
ctx := context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id-bulk")
ctx = context.WithValue(ctx, middleware.AdminKey{}, true)
ctx = context.WithValue(ctx, auth.AdminKey{}, true)
return ctx
}
@@ -243,7 +244,7 @@ func TestBulkRevoke_AdminExplicitFalse_Returns403(t *testing.T) {
req.Header.Set("Content-Type", "application/json")
ctx := context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id")
ctx = context.WithValue(ctx, middleware.AdminKey{}, false)
ctx = context.WithValue(ctx, auth.AdminKey{}, false)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
@@ -273,8 +274,8 @@ func TestBulkRevoke_AdminPermitted_ForwardsActor(t *testing.T) {
req.Header.Set("Content-Type", "application/json")
ctx := context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id")
ctx = context.WithValue(ctx, middleware.AdminKey{}, true)
ctx = context.WithValue(ctx, middleware.UserKey{}, "ops-admin")
ctx = context.WithValue(ctx, auth.AdminKey{}, true)
ctx = context.WithValue(ctx, auth.UserKey{}, "ops-admin")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
+3 -3
View File
@@ -6,7 +6,7 @@ import (
"net/http"
"time"
"github.com/certctl-io/certctl/internal/api/middleware"
"github.com/certctl-io/certctl/internal/auth"
)
// HealthHandler handles health and readiness check endpoints.
@@ -152,8 +152,8 @@ func (h HealthHandler) AuthInfo(w http.ResponseWriter, r *http.Request) {
func (h HealthHandler) AuthCheck(w http.ResponseWriter, r *http.Request) {
response := map[string]interface{}{
"status": "authenticated",
"user": middleware.GetUser(r.Context()),
"admin": middleware.IsAdmin(r.Context()),
"user": auth.GetUser(r.Context()),
"admin": auth.IsAdmin(r.Context()),
}
JSON(w, http.StatusOK, response)
}
+5 -5
View File
@@ -9,7 +9,7 @@ import (
"testing"
"time"
"github.com/certctl-io/certctl/internal/api/middleware"
"github.com/certctl-io/certctl/internal/auth"
_ "github.com/lib/pq" // Bundle-5 / H-006: postgres driver for /ready DB-probe regression test
)
@@ -238,8 +238,8 @@ func TestAuthCheck_AdminCaller_ReportsAdminTrue(t *testing.T) {
handler := NewHealthHandler("api-key", nil)
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/check", nil)
ctx := context.WithValue(req.Context(), middleware.AdminKey{}, true)
ctx = context.WithValue(ctx, middleware.UserKey{}, "ops-admin")
ctx := context.WithValue(req.Context(), auth.AdminKey{}, true)
ctx = context.WithValue(ctx, auth.UserKey{}, "ops-admin")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
@@ -276,8 +276,8 @@ func TestAuthCheck_NonAdminCaller_ReportsAdminFalse(t *testing.T) {
handler := NewHealthHandler("api-key", nil)
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/check", nil)
ctx := context.WithValue(req.Context(), middleware.AdminKey{}, false)
ctx = context.WithValue(ctx, middleware.UserKey{}, "alice")
ctx := context.WithValue(req.Context(), auth.AdminKey{}, false)
ctx = context.WithValue(ctx, auth.UserKey{}, "alice")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
+8 -7
View File
@@ -9,6 +9,7 @@ import (
"time"
"github.com/certctl-io/certctl/internal/api/middleware"
"github.com/certctl-io/certctl/internal/auth"
"github.com/certctl-io/certctl/internal/crypto/signer"
"github.com/certctl-io/certctl/internal/domain"
"github.com/certctl-io/certctl/internal/service"
@@ -36,7 +37,7 @@ type IntermediateCAServicer interface {
// All routes are pinned at /api/v1/issuers/{id}/intermediates and
// /api/v1/intermediates/{id}.
//
// Admin gate: every method calls middleware.IsAdmin first and surfaces
// Admin gate: every method calls auth.IsAdmin first and surfaces
// HTTP 403 for non-admin Bearer callers (M-003 admin-gating pattern,
// matches AdminCRLCacheHandler / AdminESTHandler / AdminSCEPIntuneHandler).
// CA hierarchy management is a high-blast-radius surface — adding a
@@ -111,7 +112,7 @@ func (h IntermediateCAHandler) Create(w http.ResponseWriter, r *http.Request) {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
if !middleware.IsAdmin(r.Context()) {
if !auth.IsAdmin(r.Context()) {
Error(w, http.StatusForbidden, "Admin access required")
return
}
@@ -122,7 +123,7 @@ func (h IntermediateCAHandler) Create(w http.ResponseWriter, r *http.Request) {
ErrorWithRequestID(w, http.StatusBadRequest, "issuer id required", requestID)
return
}
actor, _ := r.Context().Value(middleware.UserKey{}).(string)
actor, _ := r.Context().Value(auth.UserKey{}).(string)
if actor == "" {
ErrorWithRequestID(w, http.StatusUnauthorized,
"authentication required", requestID)
@@ -211,7 +212,7 @@ func (h IntermediateCAHandler) List(w http.ResponseWriter, r *http.Request) {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
if !middleware.IsAdmin(r.Context()) {
if !auth.IsAdmin(r.Context()) {
Error(w, http.StatusForbidden, "Admin access required")
return
}
@@ -237,7 +238,7 @@ func (h IntermediateCAHandler) Get(w http.ResponseWriter, r *http.Request) {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
if !middleware.IsAdmin(r.Context()) {
if !auth.IsAdmin(r.Context()) {
Error(w, http.StatusForbidden, "Admin access required")
return
}
@@ -270,7 +271,7 @@ func (h IntermediateCAHandler) Retire(w http.ResponseWriter, r *http.Request) {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
if !middleware.IsAdmin(r.Context()) {
if !auth.IsAdmin(r.Context()) {
Error(w, http.StatusForbidden, "Admin access required")
return
}
@@ -281,7 +282,7 @@ func (h IntermediateCAHandler) Retire(w http.ResponseWriter, r *http.Request) {
ErrorWithRequestID(w, http.StatusBadRequest, "id required", requestID)
return
}
actor, _ := r.Context().Value(middleware.UserKey{}).(string)
actor, _ := r.Context().Value(auth.UserKey{}).(string)
if actor == "" {
ErrorWithRequestID(w, http.StatusUnauthorized,
"authentication required", requestID)
+5 -5
View File
@@ -16,7 +16,7 @@ import (
"testing"
"time"
"github.com/certctl-io/certctl/internal/api/middleware"
"github.com/certctl-io/certctl/internal/auth"
"github.com/certctl-io/certctl/internal/domain"
"github.com/certctl-io/certctl/internal/service"
)
@@ -80,8 +80,8 @@ func (m *mockIntermediateCAService) LoadHierarchy(ctx context.Context, issuerID
// authenticated user — the standard "admin caller" shape for these
// tests.
func withAdmin(actor string, admin bool) context.Context {
ctx := context.WithValue(context.Background(), middleware.UserKey{}, actor)
ctx = context.WithValue(ctx, middleware.AdminKey{}, admin)
ctx := context.WithValue(context.Background(), auth.UserKey{}, actor)
ctx = context.WithValue(ctx, auth.AdminKey{}, admin)
return ctx
}
@@ -177,8 +177,8 @@ func TestIntermediateCA_Handler_AdminExplicitFalse_Returns403(t *testing.T) {
bytes.NewReader([]byte(`{"name":"r"}`)))
req.SetPathValue("id", "iss-1")
// AdminKey explicitly set to false — distinct from missing key.
ctx := context.WithValue(context.Background(), middleware.UserKey{}, "alice")
ctx = context.WithValue(ctx, middleware.AdminKey{}, false)
ctx := context.WithValue(context.Background(), auth.UserKey{}, "alice")
ctx = context.WithValue(ctx, auth.AdminKey{}, false)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
h.Create(w, req)
+8 -8
View File
@@ -14,19 +14,19 @@ import (
//
// The audit's request is "Admin-gated operation role-gate test coverage
// needs verification". Verified-already-clean recon: only one handler
// in internal/api/handler/ calls middleware.IsAdmin to gate access:
// in internal/api/handler/ calls auth.IsAdmin to gate access:
// bulk_revocation.go — which has 3 dedicated tests
// (NonAdmin_Returns403, AdminExplicitFalse_Returns403,
// AdminPermitted_ForwardsActor) covering all three branches.
//
// This test enforces the invariant going forward by walking every
// .go file in this package, finding every middleware.IsAdmin call
// .go file in this package, finding every auth.IsAdmin call
// site, and asserting the file appears in AdminGatedHandlers below.
// Adding a new middleware.IsAdmin call without updating the constant
// Adding a new auth.IsAdmin call without updating the constant
// AND adding a parallel test triplet fails CI.
// AdminGatedHandlers is the documented allowlist of handler files that
// gate access on middleware.IsAdmin. Every entry MUST have:
// gate access on auth.IsAdmin. Every entry MUST have:
// - a non-admin-rejection test ("_NonAdmin_Returns403")
// - an explicit-false-admin-rejection test ("_AdminExplicitFalse_Returns403")
// - an admin-allowed actor-attribution test ("_AdminPermitted_ForwardsActor")
@@ -43,7 +43,7 @@ var AdminGatedHandlers = map[string]string{
}
// InformationalIsAdminCallers is the documented allowlist of files that
// call middleware.IsAdmin without using the result to gate access. The
// call auth.IsAdmin without using the result to gate access. The
// only legitimate use of an informational call is reporting the flag to
// a downstream consumer (e.g. health.go::AuthCheck reports admin to the
// GUI so it can hide admin-only buttons).
@@ -64,7 +64,7 @@ func TestM008_AdminGatedHandlers_PinExpectedSet(t *testing.T) {
if !slicesEqual008(actual, expected) {
t.Errorf(
"middleware.IsAdmin call sites changed:\n"+
"auth.IsAdmin call sites changed:\n"+
" actual: %v\n"+
" expected: %v\n"+
"\n"+
@@ -143,10 +143,10 @@ func scanIsAdminCallers(dir string) ([]string, error) {
if parseErr != nil {
continue
}
// Substring-match middleware.IsAdmin — cheap and sufficient
// Substring-match auth.IsAdmin — cheap and sufficient
// because the import path is fixed and there's no aliasing
// shenanigans elsewhere in this package.
if strings.Contains(string(body), "middleware.IsAdmin(") {
if strings.Contains(string(body), "auth.IsAdmin(") {
out = append(out, name)
}
}
+2 -2
View File
@@ -9,7 +9,7 @@ import (
"strings"
"time"
"github.com/certctl-io/certctl/internal/api/middleware"
"github.com/certctl-io/certctl/internal/auth"
)
// resolveActor extracts the authenticated named-key identity from the request
@@ -23,7 +23,7 @@ import (
// or "api" — always go through this helper so the named-key identity flows to
// services and the audit trail.
func resolveActor(ctx context.Context) string {
if user := middleware.GetUser(ctx); user != "" {
if user := auth.GetUser(ctx); user != "" {
return user
}
return "api"
+3 -1
View File
@@ -12,6 +12,8 @@ import (
"strings"
"sync"
"time"
"github.com/certctl-io/certctl/internal/auth"
)
// AuditRecorder is the interface that the audit middleware uses to record API calls.
@@ -115,7 +117,7 @@ func (a *AuditMiddleware) Middleware(next http.Handler) http.Handler {
// Extract actor from auth context
actor := "anonymous"
if user := GetUser(r.Context()); user != "" {
if user := auth.GetUser(r.Context()); user != "" {
actor = user
}
+3 -1
View File
@@ -11,6 +11,8 @@ import (
"sync"
"testing"
"time"
"github.com/certctl-io/certctl/internal/auth"
)
// mockAuditRecorder captures RecordAPICall invocations for testing.
@@ -271,7 +273,7 @@ func TestAuditLog_ExtractsAuthenticatedActor(t *testing.T) {
req := httptest.NewRequest(http.MethodDelete, "/api/v1/certificates/mc-1", nil)
// Simulate auth middleware having set the named-key identity in context
// (post-M-002: actor is the named-key name, not the old "api-key-user").
ctx := context.WithValue(req.Context(), UserKey{}, "ops-admin")
ctx := context.WithValue(req.Context(), auth.UserKey{}, "ops-admin")
req = req.WithContext(ctx)
rr := httptest.NewRecorder()
+25 -178
View File
@@ -2,9 +2,6 @@ package middleware
import (
"context"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"fmt"
"log"
"log/slog"
@@ -14,24 +11,22 @@ import (
"time"
"github.com/google/uuid"
"github.com/certctl-io/certctl/internal/auth"
)
// Bundle 1 / Phase 0: the auth surface (NamedAPIKey, HashAPIKey, AuthConfig,
// NewAuthWithNamedKeys, NewAuth, UserKey, AdminKey, GetUser, IsAdmin) moved
// to internal/auth/. The rate limiter below still keys per-user via
// auth.GetUser(ctx); other middlewares in this package are auth-agnostic.
//
// Existing callers continue to import internal/auth/middleware "as
// middleware" only for the non-auth helpers below; auth-related references
// have been migrated to the new package.
// RequestIDKey is the context key for storing request IDs.
type RequestIDKey struct{}
// UserKey is the context key for storing authenticated user information.
type UserKey struct{}
// AdminKey is the context key for storing admin flag information.
type AdminKey struct{}
// NamedAPIKey represents a named API key with optional admin flag.
type NamedAPIKey struct {
Name string
Key string
Admin bool
}
// RequestID middleware generates a unique request ID and adds it to the request context and response headers.
func RequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -46,7 +41,7 @@ func RequestID(next http.Handler) http.Handler {
// Deprecated: Use NewLogging for structured logging with slog.
//
// CWE-117 log-injection defense: r.Method and r.URL.Path are
// attacker-controllable (request-line bytes Go's net/http leaves
// attacker-controllable (request-line bytes; Go's net/http leaves
// percent-decoded path segments in r.URL.Path, which can include CR/LF
// in the decoded form even though the raw HTTP request line cannot).
// strings.ReplaceAll on CR/LF/NUL strips the forgery vector before the
@@ -54,7 +49,7 @@ func RequestID(next http.Handler) http.Handler {
//
// The replacement is intentionally inlined at the call site (literal
// strings.ReplaceAll chains) because CodeQL's go/log-injection
// taint tracker only recognizes that exact pattern as a sanitizer
// taint tracker only recognizes that exact pattern as a sanitizer;
// strings.NewReplacer / wrapper helpers don't trigger the recognition,
// reopening the alert. The OWASP example in the CodeQL rule docs uses
// the same pattern.
@@ -71,7 +66,7 @@ func Logging(next http.Handler) http.Handler {
requestID := getRequestID(r.Context())
// Strip CR/LF/NUL from attacker-controllable request fields
// before logging. Inlined per CodeQL #32 the ReplaceAll
// before logging. Inlined per CodeQL #32; the ReplaceAll
// chain is the pattern the analyzer pattern-matches as a
// sanitizer.
method := strings.ReplaceAll(r.Method, "\n", "")
@@ -133,143 +128,11 @@ func Recovery(next http.Handler) http.Handler {
})
}
// HashAPIKey computes the SHA-256 hash of an API key for secure storage.
// We use SHA-256 rather than bcrypt because API keys are high-entropy
// random strings (not user-chosen passwords), so rainbow tables and
// brute-force attacks are not a practical concern.
func HashAPIKey(key string) string {
h := sha256.Sum256([]byte(key))
return hex.EncodeToString(h[:])
}
// AuthConfig holds configuration for the Auth middleware.
//
// G-1 (P1): valid Type values are "api-key" or "none" only. "jwt" was
// removed because no JWT middleware ships with certctl (silent auth
// downgrade pre-G-1). The single source of truth for the allowed set
// lives at internal/config.AuthType / config.ValidAuthTypes() — prefer
// those constants over string literals when comparing.
type AuthConfig struct {
Type string // "api-key" or "none" (see config.AuthType constants)
Secret string // The raw API key or comma-separated list of valid API keys
}
// NewAuthWithNamedKeys creates an authentication middleware that validates
// Bearer tokens against a set of named API keys. Each key carries a name
// (propagated as the actor via context) and an admin flag (consulted by
// authorization gates such as bulk revocation).
//
// When namedKeys is empty the returned middleware is a no-op pass-through,
// which is used in demo/development mode (CERTCTL_AUTH_TYPE=none). When one
// or more keys are provided, requests must include a matching Bearer token
// or they are rejected with 401.
func NewAuthWithNamedKeys(namedKeys []NamedAPIKey) func(http.Handler) http.Handler {
if len(namedKeys) == 0 {
return func(next http.Handler) http.Handler {
return next
}
}
// Pre-compute hashes of all valid keys for constant-time comparison.
type keyEntry struct {
hash string
name string
admin bool
}
var entries []keyEntry
for _, nk := range namedKeys {
entries = append(entries, keyEntry{
hash: HashAPIKey(nk.Key),
name: nk.Name,
admin: nk.Admin,
})
}
// Warn if only one key is configured in production mode
if len(entries) == 1 {
slog.Warn("only one API key configured — consider adding a rotation key for zero-downtime rotation")
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("WWW-Authenticate", `Bearer realm="certctl"`)
http.Error(w, `{"error":"Authorization header required"}`, http.StatusUnauthorized)
return
}
// Extract Bearer token
if len(authHeader) < 8 || authHeader[:7] != "Bearer " {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
http.Error(w, `{"error":"Invalid Authorization header format, expected: Bearer <token>"}`, http.StatusUnauthorized)
return
}
token := authHeader[7:]
tokenHash := HashAPIKey(token)
// Check against all valid keys using constant-time comparison
var matched *keyEntry
for i := range entries {
if subtle.ConstantTimeCompare([]byte(tokenHash), []byte(entries[i].hash)) == 1 {
matched = &entries[i]
break
}
}
if matched == nil {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
http.Error(w, `{"error":"Invalid API key"}`, http.StatusUnauthorized)
return
}
// Store the authenticated identity and admin flag in context
ctx := context.WithValue(r.Context(), UserKey{}, matched.name)
ctx = context.WithValue(ctx, AdminKey{}, matched.admin)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// NewAuth is a legacy shim that converts a comma-separated Secret list into
// synthesized legacy-key-N named entries and delegates to NewAuthWithNamedKeys.
// It preserves the pre-M-002 behavior for callers that still pass raw AuthConfig
// (primarily cmd/server/main_test.go). The synthesized actor is "legacy-key-N"
// rather than the old hardcoded "api-key-user" so audit events carry
// meaningful identity even on the legacy path.
//
// Deprecated: Use NewAuthWithNamedKeys with explicit NamedAPIKey entries.
func NewAuth(cfg AuthConfig) func(http.Handler) http.Handler {
if cfg.Type == "none" {
return func(next http.Handler) http.Handler {
return next
}
}
var namedKeys []NamedAPIKey
idx := 0
for _, k := range strings.Split(cfg.Secret, ",") {
k = strings.TrimSpace(k)
if k == "" {
continue
}
namedKeys = append(namedKeys, NamedAPIKey{
Name: fmt.Sprintf("legacy-key-%d", idx),
Key: k,
Admin: false,
})
idx++
}
return NewAuthWithNamedKeys(namedKeys)
}
// RateLimitConfig holds configuration for the rate limiter.
//
// Bundle B / Audit M-025 (OWASP ASVS L2 §11.2.1) extends this with per-user
// and per-IP keying. The historic RPS / BurstSize fields are preserved for
// source compatibility they now describe the per-key budget rather than
// source compatibility; they now describe the per-key budget rather than
// the global budget. PerUserRPS / PerUserBurstSize, when non-zero, override
// RPS / BurstSize for authenticated callers; the IP-keyed fallback
// continues to use RPS / BurstSize so unauthenticated callers don't get
@@ -278,8 +141,9 @@ type RateLimitConfig struct {
RPS float64 // Tokens per second per key (default applies to IP-keyed buckets)
BurstSize int // Max tokens per key (default applies to IP-keyed buckets)
// PerUserRPS overrides RPS for authenticated callers (keyed by UserKey
// in context). Zero means "use RPS as the authenticated budget too".
// PerUserRPS overrides RPS for authenticated callers (keyed by
// auth.UserKey in context). Zero means "use RPS as the authenticated
// budget too".
PerUserRPS float64
// PerUserBurstSize overrides BurstSize for authenticated callers.
@@ -295,11 +159,11 @@ type RateLimitConfig struct {
// authenticated user and each unauthenticated IP gets its own bucket. Keys
// are computed per request:
//
// - Authenticated: "user:" + middleware.GetUser(ctx)
// - Authenticated: "user:" + auth.GetUser(ctx)
// - Unauthenticated: "ip:" + r.RemoteAddr's host portion
//
// The bucket map is sync.RWMutex-guarded; create-on-demand for new keys.
// There is no eviction for a long-running server with millions of unique
// There is no eviction; for a long-running server with millions of unique
// IPs this can leak memory. A future enhancement is per-key TTL via a
// lazy sweeper. For now the leak is bounded by realistic operator IP
// fan-out and is acceptable per OWASP ASVS L2 (the threat model is abuse
@@ -339,9 +203,9 @@ func NewRateLimiter(cfg RateLimitConfig) func(http.Handler) http.Handler {
// rateLimitKey computes the per-request bucket key. Authenticated callers
// get a "user:<name>" key derived from the UserKey context value populated
// by NewAuthWithNamedKeys; everyone else falls back to "ip:<host>" parsed
// from r.RemoteAddr (X-Forwarded-For is intentionally NOT consulted here
// operators behind a trusted proxy must configure that proxy to set
// by auth.NewAuthWithNamedKeys; everyone else falls back to "ip:<host>"
// parsed from r.RemoteAddr (X-Forwarded-For is intentionally NOT consulted
// here; operators behind a trusted proxy must configure that proxy to set
// RemoteAddr correctly, or the rate limiter would be trivially bypassable
// by spoofing the header).
//
@@ -349,7 +213,7 @@ func NewRateLimiter(cfg RateLimitConfig) func(http.Handler) http.Handler {
// unauthenticated so a misconfigured auth middleware doesn't grant the
// same bucket to every anonymous request.
func rateLimitKey(r *http.Request) (string, bool) {
if user := GetUser(r.Context()); user != "" {
if user := auth.GetUser(r.Context()); user != "" {
return "user:" + user, true
}
host := r.RemoteAddr
@@ -463,7 +327,7 @@ func NewCORS(cfg CORSConfig) func(http.Handler) http.Handler {
// Security default: deny CORS when no origins are configured.
// This prevents CSRF attacks from arbitrary origins.
if len(cfg.AllowedOrigins) == 0 {
// No CORS headers set only same-origin requests can read response
// No CORS headers set; only same-origin requests can read response
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
@@ -538,23 +402,6 @@ func getRequestID(ctx context.Context) string {
return id
}
// GetUser extracts the authenticated user from context.
// Returns the name of the matched API key and whether it was found.
func GetUser(ctx context.Context) string {
user, ok := ctx.Value(UserKey{}).(string)
if !ok {
return ""
}
return user
}
// IsAdmin extracts the admin flag from context.
// Returns true if the authenticated user has admin privileges.
func IsAdmin(ctx context.Context) bool {
admin, ok := ctx.Value(AdminKey{}).(bool)
return ok && admin
}
// responseWriter wraps http.ResponseWriter to capture the status code.
type responseWriter struct {
http.ResponseWriter
@@ -5,6 +5,8 @@ import (
"net/http"
"net/http/httptest"
"testing"
"github.com/certctl-io/certctl/internal/auth"
)
// Bundle B / Audit M-025 (OWASP ASVS L2 §11.2.1): per-key rate-limiter
@@ -61,7 +63,7 @@ func TestRateLimiter_M025_SameUserDifferentIPsShareBucket(t *testing.T) {
mkReq := func(remote string) *http.Request {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = remote
ctx := context.WithValue(req.Context(), UserKey{}, "alice")
ctx := context.WithValue(req.Context(), auth.UserKey{}, "alice")
return req.WithContext(ctx)
}
@@ -88,7 +90,7 @@ func TestRateLimiter_M025_TwoUsersHaveIndependentBuckets(t *testing.T) {
mkReq := func(user string) *http.Request {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "10.0.0.1:54321"
ctx := context.WithValue(req.Context(), UserKey{}, user)
ctx := context.WithValue(req.Context(), auth.UserKey{}, user)
return req.WithContext(ctx)
}
@@ -145,7 +147,7 @@ func TestRateLimiter_M025_PerUserBudgetOverride(t *testing.T) {
userReq := func() *http.Request {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "10.0.0.42:54321"
ctx := context.WithValue(req.Context(), UserKey{}, "carol")
ctx := context.WithValue(req.Context(), auth.UserKey{}, "carol")
return req.WithContext(ctx)
}
for i := 1; i <= 5; i++ {
@@ -171,7 +173,7 @@ func TestRateLimiter_M025_EmptyUserKeyTreatedAsAnonymous(t *testing.T) {
mkReq := func(remote string) *http.Request {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = remote
ctx := context.WithValue(req.Context(), UserKey{}, "")
ctx := context.WithValue(req.Context(), auth.UserKey{}, "")
return req.WithContext(ctx)
}
+28
View File
@@ -0,0 +1,28 @@
package auth
import (
"crypto/sha256"
"encoding/hex"
)
// NamedAPIKey represents a named API key with optional admin flag.
//
// Name is the canonical actor identity propagated through the request
// context (UserKey) and into the audit trail. Two NamedAPIKey rows
// MAY share a Name during a rotation overlap window per audit L-004
// (CWE-924); both keys validate to the same actor + admin flag so the
// per-user rate-limit bucket stays consistent during rotation.
type NamedAPIKey struct {
Name string
Key string
Admin bool
}
// HashAPIKey computes the SHA-256 hash of an API key for secure storage.
// We use SHA-256 rather than bcrypt because API keys are high-entropy
// random strings (not user-chosen passwords), so rainbow tables and
// brute-force attacks are not a practical concern.
func HashAPIKey(key string) string {
h := sha256.Sum256([]byte(key))
return hex.EncodeToString(h[:])
}
+50
View File
@@ -0,0 +1,50 @@
// Package auth holds the certctl auth surface: API-key validation, the
// authenticated-actor context keys, and the helpers that consumers across
// the codebase use to read the actor identity (rate limiter, audit
// recorder, handler-level admin gates, GUI affordance hints).
//
// Bundle 1 / Phase 0 split this code out of internal/api/middleware so
// Bundle 2 (OIDC + sessions) and the broader RBAC primitive (roles +
// permissions + scoped grants) have a clean home that doesn't bloat the
// generic-middleware package. Phase 0 is a pure refactor; behaviour
// matches the pre-extract NewAuthWithNamedKeys / NewAuth surface
// byte-for-byte.
package auth
import "context"
// UserKey is the context key for storing the authenticated actor's
// canonical name. Populated by Middleware (a.k.a. NewAuthWithNamedKeys)
// from the matched NamedAPIKey.Name. Read by GetUser.
type UserKey struct{}
// AdminKey is the context key for storing the admin flag. Populated by
// Middleware from the matched NamedAPIKey.Admin. Read by IsAdmin.
//
// Bundle 1 keeps the boolean shape for backwards compatibility with the
// pre-RBAC handler gates. Phase 3 introduces RequirePermission and the
// boolean becomes informational only (admin role membership ↔ this flag).
type AdminKey struct{}
// GetUser extracts the authenticated user from context. Returns the name
// of the matched API key, or "" if the request was not authenticated
// (none mode, missing Bearer, or a misconfigured chain).
func GetUser(ctx context.Context) string {
user, ok := ctx.Value(UserKey{}).(string)
if !ok {
return ""
}
return user
}
// IsAdmin extracts the admin flag from context. Returns true only when
// the authenticated actor's NamedAPIKey carried Admin=true.
//
// Bundle 1 maintains the boolean for back-compat. Bundle 1 Phase 3
// introduces auth.RequirePermission as the load-bearing authorization
// gate; legacy IsAdmin callers (5 admin handlers tracked in M-008)
// migrate to RequirePermission in that phase.
func IsAdmin(ctx context.Context) bool {
admin, ok := ctx.Value(AdminKey{}).(bool)
return ok && admin
}
+141
View File
@@ -0,0 +1,141 @@
package auth
import (
"context"
"crypto/subtle"
"fmt"
"log/slog"
"net/http"
"strings"
)
// AuthConfig holds configuration for the legacy NewAuth shim.
//
// G-1 (P1): valid Type values are "api-key" or "none" only. "jwt" was
// removed because no JWT middleware ships with certctl (silent auth
// downgrade pre-G-1). The single source of truth for the allowed set
// lives at internal/config.AuthType / config.ValidAuthTypes(); prefer
// those constants over string literals when comparing.
//
// Bundle 2 will extend ValidAuthTypes() with "oidc"; Bundle 1 leaves
// the surface unchanged.
type AuthConfig struct {
Type string // "api-key" or "none" (see config.AuthType constants)
Secret string // The raw API key or comma-separated list of valid API keys
}
// NewAuthWithNamedKeys creates an authentication middleware that validates
// Bearer tokens against a set of named API keys. Each key carries a name
// (propagated as the actor via context) and an admin flag (consulted by
// authorization gates such as bulk revocation).
//
// When namedKeys is empty the returned middleware is a no-op pass-through,
// which is used in demo/development mode (CERTCTL_AUTH_TYPE=none). When one
// or more keys are provided, requests must include a matching Bearer token
// or they are rejected with 401.
//
// Bundle 1 Phase 3 extends Middleware with the RBAC primitive. This
// function continues to exist as the API-key validator; Phase 3 wraps it
// with the role lookup that populates the future ActorIDKey / RolesKey
// context values.
func NewAuthWithNamedKeys(namedKeys []NamedAPIKey) func(http.Handler) http.Handler {
if len(namedKeys) == 0 {
return func(next http.Handler) http.Handler {
return next
}
}
// Pre-compute hashes of all valid keys for constant-time comparison.
type keyEntry struct {
hash string
name string
admin bool
}
var entries []keyEntry
for _, nk := range namedKeys {
entries = append(entries, keyEntry{
hash: HashAPIKey(nk.Key),
name: nk.Name,
admin: nk.Admin,
})
}
// Warn if only one key is configured in production mode
if len(entries) == 1 {
slog.Warn("only one API key configured — consider adding a rotation key for zero-downtime rotation")
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("WWW-Authenticate", `Bearer realm="certctl"`)
http.Error(w, `{"error":"Authorization header required"}`, http.StatusUnauthorized)
return
}
// Extract Bearer token
if len(authHeader) < 8 || authHeader[:7] != "Bearer " {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
http.Error(w, `{"error":"Invalid Authorization header format, expected: Bearer <token>"}`, http.StatusUnauthorized)
return
}
token := authHeader[7:]
tokenHash := HashAPIKey(token)
// Check against all valid keys using constant-time comparison
var matched *keyEntry
for i := range entries {
if subtle.ConstantTimeCompare([]byte(tokenHash), []byte(entries[i].hash)) == 1 {
matched = &entries[i]
break
}
}
if matched == nil {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
http.Error(w, `{"error":"Invalid API key"}`, http.StatusUnauthorized)
return
}
// Store the authenticated identity and admin flag in context
ctx := context.WithValue(r.Context(), UserKey{}, matched.name)
ctx = context.WithValue(ctx, AdminKey{}, matched.admin)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// NewAuth is a legacy shim that converts a comma-separated Secret list into
// synthesized legacy-key-N named entries and delegates to NewAuthWithNamedKeys.
// It preserves the pre-M-002 behavior for callers that still pass raw AuthConfig
// (primarily cmd/server/main_test.go). The synthesized actor is "legacy-key-N"
// rather than the old hardcoded "api-key-user" so audit events carry
// meaningful identity even on the legacy path.
//
// Deprecated: Use NewAuthWithNamedKeys with explicit NamedAPIKey entries.
func NewAuth(cfg AuthConfig) func(http.Handler) http.Handler {
if cfg.Type == "none" {
return func(next http.Handler) http.Handler {
return next
}
}
var namedKeys []NamedAPIKey
idx := 0
for _, k := range strings.Split(cfg.Secret, ",") {
k = strings.TrimSpace(k)
if k == "" {
continue
}
namedKeys = append(namedKeys, NamedAPIKey{
Name: fmt.Sprintf("legacy-key-%d", idx),
Key: k,
Admin: false,
})
idx++
}
return NewAuthWithNamedKeys(namedKeys)
}
@@ -1,4 +1,4 @@
package middleware
package auth
import (
"net/http"
@@ -1,4 +1,4 @@
package middleware
package auth
import (
"net/http"
+32
View File
@@ -0,0 +1,32 @@
package auth
import "context"
// WithActor builds a context with UserKey populated, mirroring what
// NewAuthWithNamedKeys produces for a real authenticated request. Used
// by handler / service / middleware tests so they don't construct the
// context manually with internal context-key types.
//
// Phase 0 ships UserKey + AdminKey only; Phase 3 of Bundle 1 introduces
// the RBAC context (ActorIDKey, ActorTypeKey, RolesKey) and this helper
// will be extended to populate those too. Until then, admin should be
// passed via WithAdmin (separate helper below) to mirror the matched-key
// flag.
func WithActor(ctx context.Context, name string) context.Context {
return context.WithValue(ctx, UserKey{}, name)
}
// WithAdmin sets the AdminKey flag on the supplied context. Tests calling
// WithActor + WithAdmin together produce a context indistinguishable from
// what NewAuthWithNamedKeys produces for an admin-flagged NamedAPIKey.
func WithAdmin(ctx context.Context, admin bool) context.Context {
return context.WithValue(ctx, AdminKey{}, admin)
}
// WithActorAdmin is a convenience for the common "admin caller named X"
// pattern across handler tests.
func WithActorAdmin(ctx context.Context, name string, admin bool) context.Context {
ctx = WithActor(ctx, name)
ctx = WithAdmin(ctx, admin)
return ctx
}
+2 -2
View File
@@ -1310,7 +1310,7 @@ func registerHealthTools(s *gomcp.Server, c *Client) {
// assistants for cert-renewal in regulated environments need natural-language
// approve/reject. The service layer enforces ErrApproveBySameActor (the
// requesting actor cannot self-approve) and the handler extracts the
// decided_by actor from middleware.UserKey — so the MCP server's API key
// decided_by actor from auth.UserKey — so the MCP server's API key
// identity becomes the audit-trail actor automatically. Two-person integrity
// is preserved as long as the MCP server's key is distinct from the
// requesting actor's; the tool inputs deliberately omit any actor_id field
@@ -1706,7 +1706,7 @@ func registerDiscoveryReadTools(s *gomcp.Server, c *Client) {
//
// 2026-05-05 CLI/API/MCP↔GUI parity audit closure. Rank 8 primitive
// (multi-level CA hierarchy management). The handlers are admin-gated via
// middleware.IsAdmin — non-admin callers see HTTP 403 regardless of MCP
// auth.IsAdmin — non-admin callers see HTTP 403 regardless of MCP
// surface. We expose the full management API rather than carving it off
// because the operator ran the original Rank 8 deliverable to make this
// a first-class managed primitive; gating by API key role at the handler
+1 -1
View File
@@ -361,7 +361,7 @@ type ListApprovalsInput struct {
// ApprovalDecisionInput is the MCP tool input for approve / reject endpoints.
// The decided_by actor is derived server-side from the authenticated API-key
// name (middleware.UserKey) — NOT from this body. The two-person-integrity
// name (auth.UserKey) — NOT from this body. The two-person-integrity
// contract (ErrApproveBySameActor) is enforced regardless of who pushes the
// decision through MCP, so as long as the MCP server's API key identity is
// distinct from the requesting actor, the contract holds.