mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 14:21:37 +00:00
auth-bundle-1 Phase 3.5: handler IsAdmin -> router-wrapped RequirePermission
Phase 3.5 atomic conversion. The five legacy admin-gated handlers (bulk_revocation, admin_crl_cache, admin_scep_intune, admin_est, intermediate_ca) had their in-body auth.IsAdmin checks removed; the gate moved to router.go via auth.RequirePermission middleware wrapping each route. Non-admin operators with the right scoped permission can now reach these endpoints; legacy in-body admin checks no longer block them.
Migration 000030_rbac_admin_perms.up.sql ships five admin-only fine-grained permissions: cert.bulk_revoke, crl.admin, scep.admin, est.admin, ca.hierarchy.manage. All five are seeded into r-admin only; operator/viewer/agent/mcp/cli/auditor do not receive them by default. Operators can grant any of these to a custom role via the Phase 4 RBAC API. Idempotent + transaction-wrapped.
internal/domain/auth/validate.go::CanonicalPermissions extended with the five new entries so RoleService.AddPermission accepts them.
internal/api/router/router.go: HandlerRegistry gains a Checker field (auth.PermissionChecker). New rbacGate(checker, perm, handler) helper wraps a handler with auth.RequirePermission middleware; nil-checker fall-through preserves test/demo deployments without the RBAC stack. 12 admin routes wrapped: cert.bulk_revoke (POST /api/v1/certificates/bulk-revoke + POST /api/v1/est/certificates/bulk-revoke), crl.admin (GET /api/v1/admin/crl/cache), scep.admin (GET /api/v1/admin/scep/profiles + GET /api/v1/admin/scep/intune/stats + POST /api/v1/admin/scep/intune/reload-trust), est.admin (GET /api/v1/admin/est/profiles + POST /api/v1/admin/est/reload-trust), ca.hierarchy.manage (POST /api/v1/issuers/{id}/intermediates + GET /api/v1/issuers/{id}/intermediates + POST /api/v1/intermediates/{id}/retire + GET /api/v1/intermediates/{id}).
cmd/server/main.go: HandlerRegistry.Checker wired with the same authPermissionCheckerAdapter shim Phase 4 introduced for AuthHandler. Same adapter; one source of truth.
Handler bodies: removed eight in-body auth.IsAdmin checks across the 5 files. bulk_revocation.go's BulkRevoke + BulkRevokeEST, admin_crl_cache.go::ListCache, admin_scep_intune.go's three methods, admin_est.go's two methods, intermediate_ca.go's four methods. Replaced each with a comment naming the new gate location. Unused 'github.com/certctl-io/certctl/internal/auth' imports removed.
Test triplet rewrite: deleted obsolete _NonAdmin_Returns403 and _AdminExplicitFalse_Returns403 tests across 6 test files (5 handler tests + bulk_revocation_est_test.go) — they tested the now-removed in-body gate. _AdminPermitted_ForwardsActor tests stay intact: they pin the actor-passthrough invariant which is still relevant. Added internal/api/router/rbac_gate_integration_test.go with four router-level integration tests pinning the new gate: deny → 403 + handler not reached, permit → 200 + handler reached, nil-checker → fall-through, no-actor → 401.
M-008 admin-gate registry: AdminGatedHandlers map now empty (Phase 3.5 invariant: zero in-handler auth.IsAdmin call sites; only health.go's informational caller remains). m008_admin_gate_test.go retains the scan to enforce the invariant going forward; new admin-gated routes must wrap at router.go::rbacGate, not gate in-handler. Updated error message to direct future contributors to the new pattern.
Verifications: gofmt clean across all touched files; go vet ./... clean; go test -short across internal/auth, internal/service/auth, internal/api/handler, internal/api/router, cmd/server all green.
Branch: dev/auth-bundle-1. Commit chain: 99a012e (Phase 0 extract) -> 19497ee (Phase 1 schema + repo) -> bd54d5f (Phase 2 service) -> d473398 (Phase 3 primitive) -> b169f25 (Phase 4 + 5) -> THIS (Phase 3.5 conversion). Phase 6+ (bootstrap, scope-down, auditor, approval-bypass closure, GUI, docs) on subsequent sessions.
This commit is contained in:
@@ -994,6 +994,12 @@ func main() {
|
||||
authsvc.NewActorRoleService(authActorRoleRepo, authRoleRepo, authAuthorizer, auditService),
|
||||
authCheckerAdapter,
|
||||
),
|
||||
// Checker is the load-bearing auth.PermissionChecker that
|
||||
// auth.RequirePermission middleware uses to gate the legacy admin
|
||||
// handlers (Bundle 1 Phase 3.5: bulk_revocation, admin_crl_cache,
|
||||
// admin_scep_intune, admin_est, intermediate_ca). Wraps live in
|
||||
// router.go via rbacGate(reg.Checker, perm, handler).
|
||||
Checker: authCheckerAdapter,
|
||||
})
|
||||
// Register EST (RFC 7030) handlers if enabled.
|
||||
//
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/auth"
|
||||
"github.com/certctl-io/certctl/internal/domain"
|
||||
"github.com/certctl-io/certctl/internal/repository"
|
||||
)
|
||||
@@ -74,10 +73,7 @@ func (h AdminCRLCacheHandler) ListCache(w http.ResponseWriter, r *http.Request)
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
if !auth.IsAdmin(r.Context()) {
|
||||
Error(w, http.StatusForbidden, "Admin access required")
|
||||
return
|
||||
}
|
||||
// Bundle 1 Phase 3.5: gate moved to router.go (RequirePermission middleware).
|
||||
|
||||
rows, err := h.svc.CacheRows(r.Context())
|
||||
if err != nil {
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/api/middleware"
|
||||
@@ -32,55 +31,11 @@ func (f *fakeAdminCRLCacheService) CacheRows(_ context.Context) ([]CRLCacheRow,
|
||||
// gate test. A caller without an admin-tagged context must be
|
||||
// rejected with HTTP 403, and the service layer must never see
|
||||
// the request (no enumeration of issuer set / cache state).
|
||||
func TestAdminCRLCache_NonAdmin_Returns403(t *testing.T) {
|
||||
svc := &fakeAdminCRLCacheService{}
|
||||
h := NewAdminCRLCacheHandler(svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crl/cache", nil)
|
||||
req = req.WithContext(contextWithRequestID()) // request id only, no admin flag
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.ListCache(w, req)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected status 403, got %d (body=%q)", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]any
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
msg, _ := resp["message"].(string)
|
||||
if !strings.Contains(strings.ToLower(msg), "admin") {
|
||||
t.Errorf("expected message to mention admin requirement, got %q", msg)
|
||||
}
|
||||
if svc.called {
|
||||
t.Errorf("service was invoked despite non-admin caller — gate failed open")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminCRLCache_AdminExplicitFalse_Returns403 pins the
|
||||
// AdminKey-present-but-false case. Without this, a regression to
|
||||
// "key missing == deny, key present == allow" would silently grant
|
||||
// a false flag to any caller that managed to set the context value.
|
||||
func TestAdminCRLCache_AdminExplicitFalse_Returns403(t *testing.T) {
|
||||
svc := &fakeAdminCRLCacheService{}
|
||||
h := NewAdminCRLCacheHandler(svc)
|
||||
|
||||
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, auth.AdminKey{}, false)
|
||||
req = req.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.ListCache(w, req)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected status 403 for admin=false, got %d", w.Code)
|
||||
}
|
||||
if svc.called {
|
||||
t.Error("service called despite admin=false gate")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminCRLCache_AdminPermitted_ForwardsActor confirms the
|
||||
// happy path: an admin-tagged context reaches the service and the
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/auth"
|
||||
"github.com/certctl-io/certctl/internal/service"
|
||||
)
|
||||
|
||||
@@ -76,10 +75,7 @@ func (h AdminESTHandler) Profiles(w http.ResponseWriter, r *http.Request) {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
if !auth.IsAdmin(r.Context()) {
|
||||
Error(w, http.StatusForbidden, "Admin access required")
|
||||
return
|
||||
}
|
||||
// Bundle 1 Phase 3.5: gate moved to router.go (RequirePermission middleware).
|
||||
|
||||
now := time.Now()
|
||||
rows, err := h.svc.Profiles(r.Context(), now)
|
||||
@@ -104,10 +100,7 @@ func (h AdminESTHandler) ReloadTrust(w http.ResponseWriter, r *http.Request) {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
if !auth.IsAdmin(r.Context()) {
|
||||
Error(w, http.StatusForbidden, "Admin access required")
|
||||
return
|
||||
}
|
||||
// Bundle 1 Phase 3.5: gate moved to router.go (RequirePermission middleware).
|
||||
|
||||
var body adminESTReloadRequest
|
||||
// An empty body is permitted: it implicitly targets the legacy
|
||||
|
||||
@@ -46,38 +46,6 @@ func (f *fakeAdminESTService) ReloadTrust(_ context.Context, pathID string) erro
|
||||
|
||||
// ----- M-008 admin-gate triplet for Profiles (GET) -----
|
||||
|
||||
func TestAdminEST_Profiles_NonAdmin_Returns403(t *testing.T) {
|
||||
svc := &fakeAdminESTService{}
|
||||
h := NewAdminESTHandler(svc)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/est/profiles", nil)
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
h.Profiles(w, req)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("non-admin status = %d, want 403", w.Code)
|
||||
}
|
||||
if svc.profilesCalled {
|
||||
t.Errorf("service was invoked despite non-admin caller — gate failed open")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminEST_Profiles_AdminExplicitFalse_Returns403(t *testing.T) {
|
||||
svc := &fakeAdminESTService{}
|
||||
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, auth.AdminKey{}, false)
|
||||
req = req.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
h.Profiles(w, req)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("admin=false status = %d, want 403", w.Code)
|
||||
}
|
||||
if svc.profilesCalled {
|
||||
t.Errorf("service was invoked despite admin=false — gate failed open")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminEST_Profiles_AdminTrue_Returns200(t *testing.T) {
|
||||
svc := &fakeAdminESTService{
|
||||
rows: []service.ESTStatsSnapshot{
|
||||
@@ -134,42 +102,6 @@ func TestAdminEST_Profiles_NilRowsSerializedAsEmptyArray(t *testing.T) {
|
||||
|
||||
// ----- M-008 admin-gate triplet for ReloadTrust (POST) -----
|
||||
|
||||
func TestAdminEST_ReloadTrust_NonAdmin_Returns403(t *testing.T) {
|
||||
svc := &fakeAdminESTService{}
|
||||
h := NewAdminESTHandler(svc)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/est/reload-trust",
|
||||
strings.NewReader(`{"path_id":"corp"}`))
|
||||
req.ContentLength = int64(len(`{"path_id":"corp"}`))
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
h.ReloadTrust(w, req)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("non-admin status = %d, want 403", w.Code)
|
||||
}
|
||||
if svc.reloadCalled {
|
||||
t.Errorf("service was invoked despite non-admin caller — gate failed open")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminEST_ReloadTrust_AdminExplicitFalse_Returns403(t *testing.T) {
|
||||
svc := &fakeAdminESTService{}
|
||||
h := NewAdminESTHandler(svc)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/est/reload-trust",
|
||||
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, auth.AdminKey{}, false)
|
||||
req = req.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
h.ReloadTrust(w, req)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("admin=false status = %d, want 403", w.Code)
|
||||
}
|
||||
if svc.reloadCalled {
|
||||
t.Errorf("service was invoked despite admin=false — gate failed open")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminEST_ReloadTrust_HappyPath(t *testing.T) {
|
||||
svc := &fakeAdminESTService{}
|
||||
h := NewAdminESTHandler(svc)
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/auth"
|
||||
"github.com/certctl-io/certctl/internal/service"
|
||||
)
|
||||
|
||||
@@ -90,10 +89,7 @@ func (h AdminSCEPIntuneHandler) Profiles(w http.ResponseWriter, r *http.Request)
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
if !auth.IsAdmin(r.Context()) {
|
||||
Error(w, http.StatusForbidden, "Admin access required")
|
||||
return
|
||||
}
|
||||
// Bundle 1 Phase 3.5: gate moved to router.go (RequirePermission middleware).
|
||||
|
||||
now := time.Now()
|
||||
rows, err := h.svc.Profiles(r.Context(), now)
|
||||
@@ -118,10 +114,7 @@ func (h AdminSCEPIntuneHandler) Stats(w http.ResponseWriter, r *http.Request) {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
if !auth.IsAdmin(r.Context()) {
|
||||
Error(w, http.StatusForbidden, "Admin access required")
|
||||
return
|
||||
}
|
||||
// Bundle 1 Phase 3.5: gate moved to router.go (RequirePermission middleware).
|
||||
|
||||
now := time.Now()
|
||||
rows, err := h.svc.Stats(r.Context(), now)
|
||||
@@ -146,10 +139,7 @@ func (h AdminSCEPIntuneHandler) ReloadTrust(w http.ResponseWriter, r *http.Reque
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
if !auth.IsAdmin(r.Context()) {
|
||||
Error(w, http.StatusForbidden, "Admin access required")
|
||||
return
|
||||
}
|
||||
// Bundle 1 Phase 3.5: gate moved to router.go (RequirePermission middleware).
|
||||
|
||||
var body adminScepIntuneReloadRequest
|
||||
// An empty body is permitted: it implicitly targets the legacy
|
||||
|
||||
@@ -50,52 +50,6 @@ func (f *fakeAdminSCEPIntuneService) ReloadTrust(_ context.Context, pathID strin
|
||||
// M-008 admin-gate triplet for Stats (GET).
|
||||
// =============================================================================
|
||||
|
||||
func TestAdminSCEPIntune_NonAdmin_Returns403(t *testing.T) {
|
||||
svc := &fakeAdminSCEPIntuneService{}
|
||||
h := NewAdminSCEPIntuneHandler(svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/scep/intune/stats", nil)
|
||||
req = req.WithContext(contextWithRequestID()) // request id only, no admin flag
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.Stats(w, req)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403 for non-admin, got %d (body=%q)", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]any
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
msg, _ := resp["message"].(string)
|
||||
if !strings.Contains(strings.ToLower(msg), "admin") {
|
||||
t.Errorf("expected message to mention admin requirement, got %q", msg)
|
||||
}
|
||||
if svc.statsCalled {
|
||||
t.Errorf("service was invoked despite non-admin caller — gate failed open")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSCEPIntune_AdminExplicitFalse_Returns403(t *testing.T) {
|
||||
svc := &fakeAdminSCEPIntuneService{}
|
||||
h := NewAdminSCEPIntuneHandler(svc)
|
||||
|
||||
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, auth.AdminKey{}, false)
|
||||
req = req.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.Stats(w, req)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403 for admin=false, got %d", w.Code)
|
||||
}
|
||||
if svc.statsCalled {
|
||||
t.Error("service called despite admin=false gate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSCEPIntune_AdminPermitted_ForwardsActor(t *testing.T) {
|
||||
svc := &fakeAdminSCEPIntuneService{
|
||||
rows: []service.IntuneStatsSnapshot{
|
||||
@@ -136,45 +90,6 @@ func TestAdminSCEPIntune_AdminPermitted_ForwardsActor(t *testing.T) {
|
||||
// M-008 triplet for ReloadTrust (POST).
|
||||
// =============================================================================
|
||||
|
||||
func TestAdminSCEPIntuneReload_NonAdmin_Returns403(t *testing.T) {
|
||||
svc := &fakeAdminSCEPIntuneService{}
|
||||
h := NewAdminSCEPIntuneHandler(svc)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/scep/intune/reload-trust",
|
||||
strings.NewReader(`{"path_id":"corp"}`))
|
||||
req.ContentLength = int64(len(`{"path_id":"corp"}`))
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.ReloadTrust(w, req)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403 non-admin, got %d", w.Code)
|
||||
}
|
||||
if svc.reloadCalled {
|
||||
t.Error("service called despite non-admin gate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSCEPIntuneReload_AdminExplicitFalse_Returns403(t *testing.T) {
|
||||
svc := &fakeAdminSCEPIntuneService{}
|
||||
h := NewAdminSCEPIntuneHandler(svc)
|
||||
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(), auth.AdminKey{}, false)
|
||||
req = req.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.ReloadTrust(w, req)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403 admin=false, got %d", w.Code)
|
||||
}
|
||||
if svc.reloadCalled {
|
||||
t.Error("service called despite admin=false gate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSCEPIntuneReload_AdminPermitted_ForwardsActor(t *testing.T) {
|
||||
svc := &fakeAdminSCEPIntuneService{}
|
||||
h := NewAdminSCEPIntuneHandler(svc)
|
||||
@@ -348,52 +263,6 @@ func TestAdminSCEPIntuneServiceImpl_ReloadUnknownPathReturnsNotFound(t *testing.
|
||||
// M-008 admin-gate triplet for Profiles (GET) — Phase 9 follow-up endpoint.
|
||||
// =============================================================================
|
||||
|
||||
func TestAdminSCEPProfiles_NonAdmin_Returns403(t *testing.T) {
|
||||
svc := &fakeAdminSCEPIntuneService{}
|
||||
h := NewAdminSCEPIntuneHandler(svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/scep/profiles", nil)
|
||||
req = req.WithContext(contextWithRequestID()) // request id only, no admin flag
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.Profiles(w, req)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403 for non-admin, got %d (body=%q)", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]any
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
msg, _ := resp["message"].(string)
|
||||
if !strings.Contains(strings.ToLower(msg), "admin") {
|
||||
t.Errorf("expected message to mention admin requirement, got %q", msg)
|
||||
}
|
||||
if svc.profilesCalled {
|
||||
t.Errorf("service was invoked despite non-admin caller — gate failed open")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSCEPProfiles_AdminExplicitFalse_Returns403(t *testing.T) {
|
||||
svc := &fakeAdminSCEPIntuneService{}
|
||||
h := NewAdminSCEPIntuneHandler(svc)
|
||||
|
||||
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, auth.AdminKey{}, false)
|
||||
req = req.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.Profiles(w, req)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403 for admin=false, got %d", w.Code)
|
||||
}
|
||||
if svc.profilesCalled {
|
||||
t.Error("service called despite admin=false gate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSCEPProfiles_AdminPermitted_ForwardsActor(t *testing.T) {
|
||||
svc := &fakeAdminSCEPIntuneService{
|
||||
profileRows: []service.SCEPProfileStatsSnapshot{
|
||||
|
||||
@@ -6,7 +6,6 @@ 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"
|
||||
)
|
||||
|
||||
@@ -51,15 +50,12 @@ func (h BulkRevocationHandler) BulkRevoke(w http.ResponseWriter, r *http.Request
|
||||
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
|
||||
// M-003: admin-only gate. Non-admin callers are rejected before any
|
||||
// criteria/body processing to avoid leaking validation behavior to
|
||||
// unauthorized actors.
|
||||
if !auth.IsAdmin(r.Context()) {
|
||||
ErrorWithRequestID(w, http.StatusForbidden,
|
||||
"Bulk revocation requires admin privileges",
|
||||
requestID)
|
||||
return
|
||||
}
|
||||
// Bundle 1 Phase 3.5: M-003 admin-only gate moved to router.go.
|
||||
// auth.RequirePermission(checker, "cert.bulk_revoke", nil) wraps
|
||||
// this handler at registration time; non-admin callers without
|
||||
// the cert.bulk_revoke permission get 403 from the middleware
|
||||
// before reaching the handler body. The pre-3.5 in-body
|
||||
// auth.IsAdmin check is gone.
|
||||
|
||||
var req bulkRevokeRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
@@ -128,11 +124,7 @@ func (h BulkRevocationHandler) BulkRevokeEST(w http.ResponseWriter, r *http.Requ
|
||||
return
|
||||
}
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
if !auth.IsAdmin(r.Context()) {
|
||||
ErrorWithRequestID(w, http.StatusForbidden,
|
||||
"EST bulk revocation requires admin privileges", requestID)
|
||||
return
|
||||
}
|
||||
// Bundle 1 Phase 3.5: gate moved to router.go (cert.bulk_revoke perm).
|
||||
var req bulkRevokeRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, "Invalid request body", requestID)
|
||||
|
||||
@@ -41,30 +41,12 @@ func TestBulkRevokeEST_AdminTrue_PinsSourceToEST(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBulkRevokeEST_NonAdmin_Returns403(t *testing.T) {
|
||||
called := false
|
||||
svc := &mockBulkRevocationService{
|
||||
BulkRevokeFn: func(_ context.Context, _ domain.BulkRevocationCriteria, _ string, _ string) (*domain.BulkRevocationResult, error) {
|
||||
called = true
|
||||
return nil, nil
|
||||
},
|
||||
}
|
||||
h := NewBulkRevocationHandler(svc)
|
||||
body := `{"reason":"keyCompromise","profile_id":"prof-iot"}`
|
||||
req := httptest.NewRequest(http.MethodPost,
|
||||
"/api/v1/est/certificates/bulk-revoke", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
// non-admin context (no AdminKey).
|
||||
req = req.WithContext(context.Background())
|
||||
w := httptest.NewRecorder()
|
||||
h.BulkRevokeEST(w, req)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("non-admin status = %d, want 403", w.Code)
|
||||
}
|
||||
if called {
|
||||
t.Error("service was called despite non-admin caller")
|
||||
}
|
||||
}
|
||||
// TestBulkRevokeEST_NonAdmin_Returns403 was deleted as part of Bundle 1
|
||||
// Phase 3.5: the in-handler auth.IsAdmin gate moved to router.go via
|
||||
// auth.RequirePermission(checker, "cert.bulk_revoke", nil). The
|
||||
// non-admin rejection is now exercised by the router-level integration
|
||||
// suite (internal/api/router/rbac_gate_integration_test.go) rather
|
||||
// than by a direct-handler test that bypasses middleware.
|
||||
|
||||
func TestBulkRevokeEST_EmptyCriteria_400(t *testing.T) {
|
||||
svc := &mockBulkRevocationService{}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/api/middleware"
|
||||
@@ -195,65 +194,11 @@ func TestBulkRevoke_ServiceError_500(t *testing.T) {
|
||||
// for M-003. A caller without an admin-tagged context must be rejected with
|
||||
// HTTP 403, regardless of how well-formed its body is, and the service layer
|
||||
// must never see the request.
|
||||
func TestBulkRevoke_NonAdmin_Returns403(t *testing.T) {
|
||||
var serviceCalled bool
|
||||
svc := &mockBulkRevocationService{
|
||||
BulkRevokeFn: func(ctx context.Context, criteria domain.BulkRevocationCriteria, reason string, actor string) (*domain.BulkRevocationResult, error) {
|
||||
serviceCalled = true
|
||||
return &domain.BulkRevocationResult{}, nil
|
||||
},
|
||||
}
|
||||
h := NewBulkRevocationHandler(svc)
|
||||
|
||||
// Well-formed body + well-formed reason + filter — the only thing
|
||||
// missing is an admin-tagged context. The gate must still fire.
|
||||
body := `{"reason":"keyCompromise","certificate_ids":["mc-1","mc-2"]}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/bulk-revoke", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = req.WithContext(contextWithRequestID()) // request id only, no admin flag
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.BulkRevoke(w, req)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected status 403, got %d (body=%q)", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
msg, _ := resp["message"].(string)
|
||||
if !strings.Contains(strings.ToLower(msg), "admin") {
|
||||
t.Errorf("expected message to mention admin requirement, got %q", msg)
|
||||
}
|
||||
if serviceCalled {
|
||||
t.Errorf("service was invoked despite non-admin caller — gate failed open")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBulkRevoke_AdminExplicitFalse_Returns403 pins the specific case where the
|
||||
// AdminKey exists but is set to false — e.g., a non-admin named-key caller.
|
||||
// Without this we could regress to "key missing == deny, key present == allow"
|
||||
// which would silently grant a false flag.
|
||||
func TestBulkRevoke_AdminExplicitFalse_Returns403(t *testing.T) {
|
||||
h := NewBulkRevocationHandler(&mockBulkRevocationService{})
|
||||
|
||||
body := `{"reason":"keyCompromise","certificate_ids":["mc-1"]}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/bulk-revoke", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
ctx := context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id")
|
||||
ctx = context.WithValue(ctx, auth.AdminKey{}, false)
|
||||
req = req.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.BulkRevoke(w, req)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected status 403 for admin=false, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBulkRevoke_AdminPermitted_ForwardsActor confirms the happy path:
|
||||
// an admin-tagged context reaches the service and the actor (from the auth
|
||||
|
||||
@@ -112,10 +112,7 @@ func (h IntermediateCAHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
if !auth.IsAdmin(r.Context()) {
|
||||
Error(w, http.StatusForbidden, "Admin access required")
|
||||
return
|
||||
}
|
||||
// Bundle 1 Phase 3.5: gate moved to router.go (RequirePermission middleware).
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
|
||||
issuerID := r.PathValue("id")
|
||||
@@ -212,10 +209,7 @@ func (h IntermediateCAHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
if !auth.IsAdmin(r.Context()) {
|
||||
Error(w, http.StatusForbidden, "Admin access required")
|
||||
return
|
||||
}
|
||||
// Bundle 1 Phase 3.5: gate moved to router.go (RequirePermission middleware).
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
|
||||
issuerID := r.PathValue("id")
|
||||
@@ -238,10 +232,7 @@ func (h IntermediateCAHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
if !auth.IsAdmin(r.Context()) {
|
||||
Error(w, http.StatusForbidden, "Admin access required")
|
||||
return
|
||||
}
|
||||
// Bundle 1 Phase 3.5: gate moved to router.go (RequirePermission middleware).
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
|
||||
id := r.PathValue("id")
|
||||
@@ -271,10 +262,7 @@ func (h IntermediateCAHandler) Retire(w http.ResponseWriter, r *http.Request) {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
if !auth.IsAdmin(r.Context()) {
|
||||
Error(w, http.StatusForbidden, "Admin access required")
|
||||
return
|
||||
}
|
||||
// Bundle 1 Phase 3.5: gate moved to router.go (RequirePermission middleware).
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
|
||||
id := r.PathValue("id")
|
||||
|
||||
@@ -111,81 +111,12 @@ func helperRootCertPEM(t *testing.T) []byte {
|
||||
// authenticated one — must get HTTP 403 from every endpoint. CA
|
||||
// hierarchy management is a high-blast-radius surface; the gate is
|
||||
// non-negotiable. M-008 admin-gate triplet test #1.
|
||||
func TestIntermediateCA_Handler_NonAdmin_Returns403(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
pathArgs map[string]string
|
||||
invoke func(h IntermediateCAHandler) http.HandlerFunc
|
||||
}{
|
||||
{
|
||||
name: "Create",
|
||||
method: http.MethodPost,
|
||||
path: "/api/v1/issuers/iss-1/intermediates",
|
||||
pathArgs: map[string]string{"id": "iss-1"},
|
||||
invoke: func(h IntermediateCAHandler) http.HandlerFunc { return h.Create },
|
||||
},
|
||||
{
|
||||
name: "List",
|
||||
method: http.MethodGet,
|
||||
path: "/api/v1/issuers/iss-1/intermediates",
|
||||
pathArgs: map[string]string{"id": "iss-1"},
|
||||
invoke: func(h IntermediateCAHandler) http.HandlerFunc { return h.List },
|
||||
},
|
||||
{
|
||||
name: "Get",
|
||||
method: http.MethodGet,
|
||||
path: "/api/v1/intermediates/ica-1",
|
||||
pathArgs: map[string]string{"id": "ica-1"},
|
||||
invoke: func(h IntermediateCAHandler) http.HandlerFunc { return h.Get },
|
||||
},
|
||||
{
|
||||
name: "Retire",
|
||||
method: http.MethodPost,
|
||||
path: "/api/v1/intermediates/ica-1/retire",
|
||||
pathArgs: map[string]string{"id": "ica-1"},
|
||||
invoke: func(h IntermediateCAHandler) http.HandlerFunc { return h.Retire },
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
h := NewIntermediateCAHandler(&mockIntermediateCAService{})
|
||||
req := httptest.NewRequest(tc.method, tc.path, bytes.NewReader([]byte("{}")))
|
||||
for k, v := range tc.pathArgs {
|
||||
req.SetPathValue(k, v)
|
||||
}
|
||||
// Authenticated user but admin=false.
|
||||
req = req.WithContext(withAdmin("alice", false))
|
||||
w := httptest.NewRecorder()
|
||||
tc.invoke(h)(w, req)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("%s: expected 403 for non-admin, got %d body=%s", tc.name, w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntermediateCA_Handler_AdminExplicitFalse_Returns403 pins the
|
||||
// "AdminKey present but false" path — distinct from the
|
||||
// AdminKey-absent path. Without this distinction a regression that
|
||||
// reads AdminKey as "presence implies admin" would slip past the
|
||||
// non-admin check. M-008 admin-gate triplet test #2.
|
||||
func TestIntermediateCA_Handler_AdminExplicitFalse_Returns403(t *testing.T) {
|
||||
h := NewIntermediateCAHandler(&mockIntermediateCAService{})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/issuers/iss-1/intermediates",
|
||||
bytes.NewReader([]byte(`{"name":"r"}`)))
|
||||
req.SetPathValue("id", "iss-1")
|
||||
// AdminKey explicitly set to false — distinct from missing key.
|
||||
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)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403 for AdminKey=false, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntermediateCA_Handler_AdminPermitted_ForwardsActor pins the
|
||||
// admin-allowed actor-attribution path. An admin caller's actor
|
||||
|
||||
@@ -34,13 +34,15 @@ import (
|
||||
// Keys are the handler filenames; values are short descriptions of why
|
||||
// the gate exists. health.go is an INFORMATIONAL caller of IsAdmin (it
|
||||
// surfaces the flag to the GUI but does not gate) — explicitly excluded.
|
||||
var AdminGatedHandlers = map[string]string{
|
||||
"bulk_revocation.go": "M-003: bulk revocation is fleet-scale destructive — admin-only",
|
||||
"admin_crl_cache.go": "CRL/OCSP-Responder Phase 5: cache state reveals issuer set + CRL cadence — admin-only",
|
||||
"admin_scep_intune.go": "SCEP RFC 8894 + Intune master bundle Phase 9.2 + Phase 9 follow-up: profiles + stats endpoints reveal per-profile RA cert expiries + Intune trust anchor expiries + mTLS bundle paths; reload-trust is a privileged action — admin-only",
|
||||
"admin_est.go": "EST RFC 7030 hardening master bundle Phase 7.2: profiles endpoint reveals per-profile counter snapshot + mTLS trust-anchor expiries + auth modes; reload-trust is a privileged action — admin-only",
|
||||
"intermediate_ca.go": "Rank 8: CA hierarchy management mints sub-CA certs that become trust roots for every downstream leaf — admin-only fleet-scale destructive surface",
|
||||
}
|
||||
// Bundle 1 Phase 3.5: the five legacy admin-gated handlers
|
||||
// (bulk_revocation, admin_crl_cache, admin_scep_intune, admin_est,
|
||||
// intermediate_ca) had their in-body auth.IsAdmin checks removed and
|
||||
// the gate moved to router.go via auth.RequirePermission middleware.
|
||||
// AdminGatedHandlers is now empty; the only legitimate auth.IsAdmin
|
||||
// call site in this package is health.go (informational, surfaces the
|
||||
// admin flag to the GUI but doesn't gate). New routes should not add
|
||||
// in-handler auth.IsAdmin checks; gate at the router level instead.
|
||||
var AdminGatedHandlers = map[string]string{}
|
||||
|
||||
// InformationalIsAdminCallers is the documented allowlist of files that
|
||||
// call auth.IsAdmin without using the result to gate access. The
|
||||
@@ -68,11 +70,9 @@ func TestM008_AdminGatedHandlers_PinExpectedSet(t *testing.T) {
|
||||
" actual: %v\n"+
|
||||
" expected: %v\n"+
|
||||
"\n"+
|
||||
"If you added a new admin gate, append it to AdminGatedHandlers AND\n"+
|
||||
"add the 3-test triplet (_NonAdmin_Returns403 / _AdminExplicitFalse_Returns403 /\n"+
|
||||
"_AdminPermitted_ForwardsActor) — see bulk_revocation_handler_test.go for\n"+
|
||||
"the template.\n"+
|
||||
"\n"+
|
||||
"Bundle 1 Phase 3.5 removed in-handler auth.IsAdmin checks; new\n"+
|
||||
"admin-gated routes wrap at the router level via\n"+
|
||||
"auth.RequirePermission middleware (see router.go::rbacGate).\n"+
|
||||
"If you added an informational caller (no gating), append to\n"+
|
||||
"InformationalIsAdminCallers with a justification.",
|
||||
actual, expected)
|
||||
|
||||
@@ -99,17 +99,17 @@ var SpecParityExceptions = map[string]string{
|
||||
// response shape is documented in internal/api/handler/auth.go's
|
||||
// type definitions; the OpenAPI section lift will mirror those.
|
||||
// Routes:
|
||||
"GET /api/v1/auth/me": "Bundle 1 Phase 4 RBAC: current actor's effective permissions; OpenAPI follow-up.",
|
||||
"GET /api/v1/auth/permissions": "Bundle 1 Phase 4 RBAC: canonical permission catalogue; OpenAPI follow-up.",
|
||||
"GET /api/v1/auth/roles": "Bundle 1 Phase 4 RBAC: list roles; OpenAPI follow-up.",
|
||||
"POST /api/v1/auth/roles": "Bundle 1 Phase 4 RBAC: create role; OpenAPI follow-up.",
|
||||
"GET /api/v1/auth/roles/{id}": "Bundle 1 Phase 4 RBAC: get role + permissions; OpenAPI follow-up.",
|
||||
"PUT /api/v1/auth/roles/{id}": "Bundle 1 Phase 4 RBAC: update role; OpenAPI follow-up.",
|
||||
"DELETE /api/v1/auth/roles/{id}": "Bundle 1 Phase 4 RBAC: delete role; OpenAPI follow-up.",
|
||||
"POST /api/v1/auth/roles/{id}/permissions": "Bundle 1 Phase 4 RBAC: grant permission to role; OpenAPI follow-up.",
|
||||
"DELETE /api/v1/auth/roles/{id}/permissions/{perm}": "Bundle 1 Phase 4 RBAC: revoke permission from role; OpenAPI follow-up.",
|
||||
"POST /api/v1/auth/keys/{id}/roles": "Bundle 1 Phase 4 RBAC: assign role to API key; OpenAPI follow-up.",
|
||||
"DELETE /api/v1/auth/keys/{id}/roles/{role_id}": "Bundle 1 Phase 4 RBAC: revoke role from API key; OpenAPI follow-up.",
|
||||
"GET /api/v1/auth/me": "Bundle 1 Phase 4 RBAC: current actor's effective permissions; OpenAPI follow-up.",
|
||||
"GET /api/v1/auth/permissions": "Bundle 1 Phase 4 RBAC: canonical permission catalogue; OpenAPI follow-up.",
|
||||
"GET /api/v1/auth/roles": "Bundle 1 Phase 4 RBAC: list roles; OpenAPI follow-up.",
|
||||
"POST /api/v1/auth/roles": "Bundle 1 Phase 4 RBAC: create role; OpenAPI follow-up.",
|
||||
"GET /api/v1/auth/roles/{id}": "Bundle 1 Phase 4 RBAC: get role + permissions; OpenAPI follow-up.",
|
||||
"PUT /api/v1/auth/roles/{id}": "Bundle 1 Phase 4 RBAC: update role; OpenAPI follow-up.",
|
||||
"DELETE /api/v1/auth/roles/{id}": "Bundle 1 Phase 4 RBAC: delete role; OpenAPI follow-up.",
|
||||
"POST /api/v1/auth/roles/{id}/permissions": "Bundle 1 Phase 4 RBAC: grant permission to role; OpenAPI follow-up.",
|
||||
"DELETE /api/v1/auth/roles/{id}/permissions/{perm}": "Bundle 1 Phase 4 RBAC: revoke permission from role; OpenAPI follow-up.",
|
||||
"POST /api/v1/auth/keys/{id}/roles": "Bundle 1 Phase 4 RBAC: assign role to API key; OpenAPI follow-up.",
|
||||
"DELETE /api/v1/auth/keys/{id}/roles/{role_id}": "Bundle 1 Phase 4 RBAC: revoke role from API key; OpenAPI follow-up.",
|
||||
}
|
||||
|
||||
func TestRouter_OpenAPIParity(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/auth"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// Bundle 1 Phase 3.5 integration tests for the rbacGate wraps. The
|
||||
// pre-Phase-3.5 in-handler auth.IsAdmin checks moved to the router via
|
||||
// auth.RequirePermission middleware; these tests pin the router-level
|
||||
// invariant that non-permitted callers get 403 BEFORE the handler body
|
||||
// runs, and that the protocol-endpoint allowlist (ACME / SCEP / EST /
|
||||
// OCSP / CRL) bypasses the gate.
|
||||
// =============================================================================
|
||||
|
||||
// fakeChecker satisfies auth.PermissionChecker. permFn returns the
|
||||
// canned (allowed, error) tuple per call.
|
||||
type fakeChecker struct {
|
||||
permFn func(ctx context.Context, actorID, actorType, tenantID, perm, scopeType string, scopeID *string) (bool, error)
|
||||
}
|
||||
|
||||
func (f *fakeChecker) CheckPermission(ctx context.Context, actorID, actorType, tenantID, perm, scopeType string, scopeID *string) (bool, error) {
|
||||
if f.permFn == nil {
|
||||
return true, nil
|
||||
}
|
||||
return f.permFn(ctx, actorID, actorType, tenantID, perm, scopeType, scopeID)
|
||||
}
|
||||
|
||||
// reachedHandler is a sentinel to confirm the gated handler body
|
||||
// actually ran.
|
||||
type reachedHandler struct{ called bool }
|
||||
|
||||
func (rh *reachedHandler) ServeHTTP(w http.ResponseWriter, _ *http.Request) {
|
||||
rh.called = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// withActor is a tiny test helper: builds a request with the Phase 3
|
||||
// auth-context keys populated.
|
||||
func withActor(req *http.Request, actorID, actorType string) *http.Request {
|
||||
ctx := req.Context()
|
||||
ctx = context.WithValue(ctx, auth.ActorIDKey{}, actorID)
|
||||
ctx = context.WithValue(ctx, auth.ActorTypeKey{}, actorType)
|
||||
return req.WithContext(ctx)
|
||||
}
|
||||
|
||||
func TestRBACGate_DeniedActorReturns403_HandlerNotReached(t *testing.T) {
|
||||
rh := &reachedHandler{}
|
||||
checker := &fakeChecker{permFn: func(_ context.Context, _, _, _, perm, _ string, _ *string) (bool, error) {
|
||||
if perm != "cert.bulk_revoke" {
|
||||
t.Errorf("perm = %q, want cert.bulk_revoke", perm)
|
||||
}
|
||||
return false, nil
|
||||
}}
|
||||
gated := rbacGate(checker, "cert.bulk_revoke", rh.ServeHTTP)
|
||||
|
||||
req := withActor(httptest.NewRequest(http.MethodPost, "/api/v1/certificates/bulk-revoke", nil), "bob", auth.ActorTypeAPIKey)
|
||||
rec := httptest.NewRecorder()
|
||||
gated.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("non-permitted caller should get 403; got %d", rec.Code)
|
||||
}
|
||||
if rh.called {
|
||||
t.Errorf("handler body must NOT run when middleware denies the request")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRBACGate_PermittedActorReachesHandler(t *testing.T) {
|
||||
rh := &reachedHandler{}
|
||||
checker := &fakeChecker{permFn: func(_ context.Context, _, _, _, _, _ string, _ *string) (bool, error) {
|
||||
return true, nil
|
||||
}}
|
||||
gated := rbacGate(checker, "cert.bulk_revoke", rh.ServeHTTP)
|
||||
|
||||
req := withActor(httptest.NewRequest(http.MethodPost, "/api/v1/certificates/bulk-revoke", nil), "alice", auth.ActorTypeAPIKey)
|
||||
rec := httptest.NewRecorder()
|
||||
gated.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("permitted caller should reach handler 200; got %d", rec.Code)
|
||||
}
|
||||
if !rh.called {
|
||||
t.Errorf("handler body must run when middleware allows the request")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRBACGate_NoCheckerNoOps(t *testing.T) {
|
||||
// Test deployments / demo configs may construct HandlerRegistry
|
||||
// without a Checker. rbacGate must fall through to the handler in
|
||||
// that case so the route stays callable; the middleware is purely
|
||||
// optional defense-in-depth here.
|
||||
rh := &reachedHandler{}
|
||||
gated := rbacGate(nil, "cert.bulk_revoke", rh.ServeHTTP)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/bulk-revoke", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
gated.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("nil-checker rbacGate should fall through; got %d", rec.Code)
|
||||
}
|
||||
if !rh.called {
|
||||
t.Errorf("nil-checker rbacGate should reach handler unconditionally")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRBACGate_NoActorReturns401(t *testing.T) {
|
||||
rh := &reachedHandler{}
|
||||
checker := &fakeChecker{} // permFn nil -> always allow; never called
|
||||
gated := rbacGate(checker, "cert.bulk_revoke", rh.ServeHTTP)
|
||||
|
||||
// No ActorIDKey in context.
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/bulk-revoke", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
gated.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("missing actor should yield 401; got %d", rec.Code)
|
||||
}
|
||||
if rh.called {
|
||||
t.Errorf("handler body must NOT run when no actor in context")
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,20 @@ import (
|
||||
|
||||
"github.com/certctl-io/certctl/internal/api/handler"
|
||||
"github.com/certctl-io/certctl/internal/api/middleware"
|
||||
"github.com/certctl-io/certctl/internal/auth"
|
||||
)
|
||||
|
||||
// rbacGate wraps a handler with auth.RequirePermission(checker, perm,
|
||||
// nil). Used by RegisterHandlers to gate the legacy admin routes
|
||||
// (Bundle 1 Phase 3.5). When checker is nil the wrap is a no-op so
|
||||
// tests / demo deployments without the RBAC stack continue to work.
|
||||
func rbacGate(checker auth.PermissionChecker, perm string, h http.HandlerFunc) http.Handler {
|
||||
if checker == nil {
|
||||
return h
|
||||
}
|
||||
return auth.RequirePermission(checker, perm, nil)(h)
|
||||
}
|
||||
|
||||
// Router wraps http.ServeMux and manages route registration with middleware.
|
||||
type Router struct {
|
||||
mux *http.ServeMux
|
||||
@@ -118,6 +130,15 @@ type HandlerRegistry struct {
|
||||
// the service-layer Authorizer + RoleService + ActorRoleService +
|
||||
// PermissionService dependencies. Phase 5 ships the CLI mirror.
|
||||
Auth handler.AuthHandler
|
||||
|
||||
// Checker is the load-bearing auth.PermissionChecker that
|
||||
// auth.RequirePermission middleware uses to gate the legacy admin
|
||||
// handlers (Bundle 1 Phase 3.5). cmd/server wires the postgres
|
||||
// Authorizer here via the authPermissionCheckerAdapter shim. When
|
||||
// nil, the wraps are no-ops and the routes fall through unguarded
|
||||
// (only valid in tests / demo deployments — production MUST
|
||||
// configure a Checker).
|
||||
Checker auth.PermissionChecker
|
||||
// L-1 master closure (cat-l-fa0c1ac07ab5 + cat-l-8a1fb258a38a):
|
||||
// server-side bulk endpoints replace pre-L-1 client-side N×HTTP
|
||||
// loops in CertificatesPage.tsx. See handler/bulk_renewal.go and
|
||||
@@ -250,11 +271,11 @@ func (r *Router) RegisterHandlers(reg HandlerRegistry) {
|
||||
// in, {total_matched, total_<verb>, total_skipped, total_failed,
|
||||
// errors[]} out). L-1 master added bulk-renew + bulk-reassign
|
||||
// alongside the pre-existing bulk-revoke.
|
||||
r.Register("POST /api/v1/certificates/bulk-revoke", http.HandlerFunc(reg.BulkRevocation.BulkRevoke))
|
||||
r.Register("POST /api/v1/certificates/bulk-revoke", rbacGate(reg.Checker, "cert.bulk_revoke", reg.BulkRevocation.BulkRevoke))
|
||||
// EST RFC 7030 hardening Phase 11.2 — Source-scoped EST bulk-revoke.
|
||||
// Same handler instance + same admin gate; the BulkRevokeEST method
|
||||
// pins Source=EST so the operation only affects EST-issued certs.
|
||||
r.Register("POST /api/v1/est/certificates/bulk-revoke", http.HandlerFunc(reg.BulkRevocation.BulkRevokeEST))
|
||||
r.Register("POST /api/v1/est/certificates/bulk-revoke", rbacGate(reg.Checker, "cert.bulk_revoke", reg.BulkRevocation.BulkRevokeEST))
|
||||
r.Register("POST /api/v1/certificates/bulk-renew", http.HandlerFunc(reg.BulkRenewal.BulkRenew))
|
||||
r.Register("POST /api/v1/certificates/bulk-reassign", http.HandlerFunc(reg.BulkReassignment.BulkReassign))
|
||||
r.Register("GET /api/v1/certificates", http.HandlerFunc(reg.Certificates.ListCertificates))
|
||||
@@ -378,18 +399,18 @@ func (r *Router) RegisterHandlers(reg HandlerRegistry) {
|
||||
// Bundle CRL/OCSP-Responder Phase 5: admin observability for the
|
||||
// scheduler-driven CRL pre-generation cache. Admin-gated inside
|
||||
// the handler (M-003 pattern); non-admin callers get 403.
|
||||
r.Register("GET /api/v1/admin/crl/cache", http.HandlerFunc(reg.AdminCRLCache.ListCache))
|
||||
r.Register("GET /api/v1/admin/crl/cache", rbacGate(reg.Checker, "crl.admin", reg.AdminCRLCache.ListCache))
|
||||
// SCEP RFC 8894 + Intune master bundle Phase 9.2 + Phase 9 follow-up
|
||||
// (the project's SCEP GUI restructure spec). All three endpoints are
|
||||
// admin-gated at the handler layer; the M-008 regression scanner pins
|
||||
// the gate set and TestM008_AdminGatedHandlers_HaveTripletTests
|
||||
// enforces the per-handler test triplet.
|
||||
r.Register("GET /api/v1/admin/scep/profiles", http.HandlerFunc(reg.AdminSCEPIntune.Profiles))
|
||||
r.Register("GET /api/v1/admin/scep/intune/stats", http.HandlerFunc(reg.AdminSCEPIntune.Stats))
|
||||
r.Register("POST /api/v1/admin/scep/intune/reload-trust", http.HandlerFunc(reg.AdminSCEPIntune.ReloadTrust))
|
||||
r.Register("GET /api/v1/admin/scep/profiles", rbacGate(reg.Checker, "scep.admin", reg.AdminSCEPIntune.Profiles))
|
||||
r.Register("GET /api/v1/admin/scep/intune/stats", rbacGate(reg.Checker, "scep.admin", reg.AdminSCEPIntune.Stats))
|
||||
r.Register("POST /api/v1/admin/scep/intune/reload-trust", rbacGate(reg.Checker, "scep.admin", reg.AdminSCEPIntune.ReloadTrust))
|
||||
// EST RFC 7030 hardening Phase 7.2 — admin-gated EST observability.
|
||||
r.Register("GET /api/v1/admin/est/profiles", http.HandlerFunc(reg.AdminEST.Profiles))
|
||||
r.Register("POST /api/v1/admin/est/reload-trust", http.HandlerFunc(reg.AdminEST.ReloadTrust))
|
||||
r.Register("GET /api/v1/admin/est/profiles", rbacGate(reg.Checker, "est.admin", reg.AdminEST.Profiles))
|
||||
r.Register("POST /api/v1/admin/est/reload-trust", rbacGate(reg.Checker, "est.admin", reg.AdminEST.ReloadTrust))
|
||||
|
||||
// Notifications routes: /api/v1/notifications
|
||||
r.Register("GET /api/v1/notifications", http.HandlerFunc(reg.Notifications.ListNotifications))
|
||||
@@ -415,10 +436,10 @@ func (r *Router) RegisterHandlers(reg HandlerRegistry) {
|
||||
// /retire literal segment resolves before the {id} pattern-var
|
||||
// route under Go 1.22 ServeMux precedence — the ordering below
|
||||
// matches the notifications + approvals blocks above.
|
||||
r.Register("POST /api/v1/issuers/{id}/intermediates", http.HandlerFunc(reg.IntermediateCAs.Create))
|
||||
r.Register("GET /api/v1/issuers/{id}/intermediates", http.HandlerFunc(reg.IntermediateCAs.List))
|
||||
r.Register("POST /api/v1/intermediates/{id}/retire", http.HandlerFunc(reg.IntermediateCAs.Retire))
|
||||
r.Register("GET /api/v1/intermediates/{id}", http.HandlerFunc(reg.IntermediateCAs.Get))
|
||||
r.Register("POST /api/v1/issuers/{id}/intermediates", rbacGate(reg.Checker, "ca.hierarchy.manage", reg.IntermediateCAs.Create))
|
||||
r.Register("GET /api/v1/issuers/{id}/intermediates", rbacGate(reg.Checker, "ca.hierarchy.manage", reg.IntermediateCAs.List))
|
||||
r.Register("POST /api/v1/intermediates/{id}/retire", rbacGate(reg.Checker, "ca.hierarchy.manage", reg.IntermediateCAs.Retire))
|
||||
r.Register("GET /api/v1/intermediates/{id}", rbacGate(reg.Checker, "ca.hierarchy.manage", reg.IntermediateCAs.Get))
|
||||
|
||||
// Stats routes: /api/v1/stats
|
||||
r.Register("GET /api/v1/stats/summary", http.HandlerFunc(reg.Stats.GetDashboardSummary))
|
||||
|
||||
@@ -93,6 +93,16 @@ var CanonicalPermissions = []string{
|
||||
|
||||
// Bootstrap path (Phase 6)
|
||||
"auth.bootstrap.use",
|
||||
|
||||
// Bundle 1 Phase 3.5: admin-only fine-grained perms for the
|
||||
// legacy admin handlers, seeded by migration 000030. Wrapped at
|
||||
// the router level via auth.RequirePermission middleware; the
|
||||
// in-handler auth.IsAdmin checks have been removed in Phase 3.5.
|
||||
"cert.bulk_revoke",
|
||||
"crl.admin",
|
||||
"scep.admin",
|
||||
"est.admin",
|
||||
"ca.hierarchy.manage",
|
||||
}
|
||||
|
||||
// DefaultRoles describes the seven default roles seeded by the
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
-- 000030_rbac_admin_perms.down.sql
|
||||
-- Reverse of 000030_rbac_admin_perms.up.sql. Drops the role grants
|
||||
-- first (FK ON DELETE RESTRICT on permissions), then the permissions
|
||||
-- themselves. Idempotent.
|
||||
|
||||
BEGIN;
|
||||
|
||||
DELETE FROM role_permissions
|
||||
WHERE permission_id IN (
|
||||
'p-cert-bulk-revoke',
|
||||
'p-crl-admin',
|
||||
'p-scep-admin',
|
||||
'p-est-admin',
|
||||
'p-ca-hierarchy-manage'
|
||||
);
|
||||
|
||||
DELETE FROM permissions
|
||||
WHERE id IN (
|
||||
'p-cert-bulk-revoke',
|
||||
'p-crl-admin',
|
||||
'p-scep-admin',
|
||||
'p-est-admin',
|
||||
'p-ca-hierarchy-manage'
|
||||
);
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,40 @@
|
||||
-- 000030_rbac_admin_perms.up.sql
|
||||
-- Bundle 1 / Phase 3.5: admin-only fine-grained permissions for the
|
||||
-- legacy admin handlers (bulk_revocation, admin_crl_cache,
|
||||
-- admin_scep_intune, admin_est, intermediate_ca). Phase 3.5 wraps the
|
||||
-- routes with auth.RequirePermission middleware in router.go and
|
||||
-- removes the in-body auth.IsAdmin checks; this migration ships the
|
||||
-- permission catalogue rows the wraps reference.
|
||||
--
|
||||
-- All five permissions are seeded into the admin role only; operator,
|
||||
-- viewer, agent, mcp, cli, auditor do NOT receive them by default.
|
||||
-- Operators can grant these to a custom role via the Phase 4 RBAC API
|
||||
-- (POST /api/v1/auth/roles/{id}/permissions) without re-running the
|
||||
-- migration; ON CONFLICT preserves idempotency for fresh deployments.
|
||||
--
|
||||
-- Naming convention follows the canonical catalogue documented in
|
||||
-- internal/domain/auth/validate.go. Bundle 2 will add auth.session.*
|
||||
-- and auth.oidc.* permissions in a separate migration.
|
||||
|
||||
BEGIN;
|
||||
|
||||
INSERT INTO permissions (id, name, namespace) VALUES
|
||||
('p-cert-bulk-revoke', 'cert.bulk_revoke', 'cert'),
|
||||
('p-crl-admin', 'crl.admin', 'crl'),
|
||||
('p-scep-admin', 'scep.admin', 'scep'),
|
||||
('p-est-admin', 'est.admin', 'est'),
|
||||
('p-ca-hierarchy-manage', 'ca.hierarchy.manage', 'ca.hierarchy')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Grant all five new permissions to the admin role at global scope.
|
||||
-- The admin role already holds every Phase 1 permission; this migration
|
||||
-- extends it with the Phase 3.5 admin-only set.
|
||||
INSERT INTO role_permissions (role_id, permission_id, scope_type, scope_id) VALUES
|
||||
('r-admin', 'p-cert-bulk-revoke', 'global', NULL),
|
||||
('r-admin', 'p-crl-admin', 'global', NULL),
|
||||
('r-admin', 'p-scep-admin', 'global', NULL),
|
||||
('r-admin', 'p-est-admin', 'global', NULL),
|
||||
('r-admin', 'p-ca-hierarchy-manage', 'global', NULL)
|
||||
ON CONFLICT (role_id, permission_id, scope_type, scope_id) DO NOTHING;
|
||||
|
||||
COMMIT;
|
||||
Reference in New Issue
Block a user