mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 19:21:29 +00:00
34adcfbbe5
Rank 8 commit 4 of 5. The API + RBAC layer that operators drive
the new hierarchy management surface from.
Endpoints (all admin-gated via middleware.IsAdmin; non-admin Bearer
callers get 403):
POST /api/v1/issuers/{id}/intermediates
Discriminator on body shape:
empty parent_ca_id + root_cert_pem + key_driver_id
→ CreateRoot (registers operator-supplied root CA).
parent_ca_id non-empty
→ CreateChild (signs new sub-CA cert under parent).
Service-layer error → HTTP code mapping:
ErrCANotSelfSigned → 400
ErrCAKeyMismatch → 400
ErrPathLenExceeded → 400
ErrNameConstraintExceeded → 400
ErrInvalidCertPEM → 400
ErrParentCANotActive → 409
ErrIntermediateCANotFound → 404
(other) → 500
GET /api/v1/issuers/{id}/intermediates
Returns flat list ordered by created_at; caller renders the
tree from each row's parent_ca_id (nil = root).
GET /api/v1/intermediates/{id}
Single-row detail.
POST /api/v1/intermediates/{id}/retire
Two-phase: confirm=false → active→retiring; confirm=true →
retiring→retired with active-children check (drain-first
semantics; ErrCAStillHasActiveChildren → 409).
Files changed:
internal/api/handler/intermediate_ca.go — 4 handlers
+ handler-defined
service interface
(dependency
inversion).
internal/api/handler/intermediate_ca_test.go — 8 test variants
(M-008 admin-
gate triplet
complete).
internal/api/handler/m008_admin_gate_test.go — register the
new admin-gated
handler in
AdminGatedHandlers
so the M-008
coherence
scanner stays
green.
internal/api/router/router.go — 4 r.Register
calls + new
IntermediateCAs
field on
HandlerRegistry.
cmd/server/main.go — wire the
postgres repo +
service +
handler. Reuses
the same
signer.FileDriver
instance the
OCSP responder
bootstrap path
feeds.
api/openapi.yaml — 4 new
operationIds,
full body
schema + status-
code dispatch.
Tests (8 in this commit):
TestIntermediateCA_Handler_NonAdmin_Returns403 (admin gate
— table-driven across all 4 endpoints)
TestIntermediateCA_Handler_AdminExplicitFalse_Returns403
(defensive: AdminKey present but false ≠ AdminKey absent)
TestIntermediateCA_Handler_AdminPermitted_ForwardsActor
(admin actor forwarded to service for audit attribution)
TestIntermediateCA_HandlerCreate_RootDispatch
(body discriminator: empty parent_ca_id → CreateRoot)
TestIntermediateCA_HandlerCreate_ChildDispatch
(body discriminator: parent_ca_id present → CreateChild)
TestIntermediateCA_HandlerCreate_BadRequestOnMissingRootBundle
(validation: no parent + no root bundle → 400)
TestIntermediateCA_HandlerCreate_ServiceErrorMappings
(table-driven: 7 service errors → expected HTTP codes)
TestIntermediateCA_HandlerRetire_TwoPhaseConfirm
(confirm=false then confirm=true forwarded correctly)
TestIntermediateCA_HandlerRetire_StillHasActiveChildren_Returns409
(drain-first contract — 409 not 500)
Verified locally:
gofmt: clean.
go vet ./...: exit 0.
go test -short -count=1 ./internal/api/handler/...: ok 4.498s.
bash scripts/ci-guards/openapi-handler-parity.sh: clean
(router routes: 182, openapi operations: 148; the +4 new routes
have +4 new operationIds — parity preserved).
bash scripts/ci-guards/* (all 24 guards): clean.
Out of scope of THIS commit (commit 5):
- web/src/pages/IssuerHierarchyPage.tsx (recursive tree render).
- docs/intermediate-ca-hierarchy.md sysadmin runbook (FedRAMP /
financial-services / internal-PKI patterns).
- docs/connectors.md hierarchy_mode row.
- WORKSPACE-ROADMAP entries (HSM-backed roots, automated
rotation, CRL chaining, NameConstraints templates, D3
dendrogram).
Reference: cowork/rank-8-intermediate-ca-hierarchy-prompt.md, commit 4.
175 lines
6.3 KiB
Go
175 lines
6.3 KiB
Go
package handler
|
|
|
|
import (
|
|
"go/parser"
|
|
"go/token"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// Bundle C / Audit M-008: pin the admin-gated handler set.
|
|
//
|
|
// 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:
|
|
// 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
|
|
// site, and asserting the file appears in AdminGatedHandlers below.
|
|
// Adding a new middleware.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:
|
|
// - a non-admin-rejection test ("_NonAdmin_Returns403")
|
|
// - an explicit-false-admin-rejection test ("_AdminExplicitFalse_Returns403")
|
|
// - an admin-allowed actor-attribution test ("_AdminPermitted_ForwardsActor")
|
|
//
|
|
// 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",
|
|
}
|
|
|
|
// InformationalIsAdminCallers is the documented allowlist of files that
|
|
// call middleware.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).
|
|
var InformationalIsAdminCallers = map[string]string{
|
|
"health.go": "informational: reports admin flag to GUI for affordance gating, no server-side gate",
|
|
}
|
|
|
|
func TestM008_AdminGatedHandlers_PinExpectedSet(t *testing.T) {
|
|
actual, err := scanIsAdminCallers(".")
|
|
if err != nil {
|
|
t.Fatalf("scan handler dir: %v", err)
|
|
}
|
|
|
|
expected := append([]string(nil), keys(AdminGatedHandlers)...)
|
|
expected = append(expected, keys(InformationalIsAdminCallers)...)
|
|
sort.Strings(actual)
|
|
sort.Strings(expected)
|
|
|
|
if !slicesEqual008(actual, expected) {
|
|
t.Errorf(
|
|
"middleware.IsAdmin call sites changed:\n"+
|
|
" 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"+
|
|
"If you added an informational caller (no gating), append to\n"+
|
|
"InformationalIsAdminCallers with a justification.",
|
|
actual, expected)
|
|
}
|
|
}
|
|
|
|
func TestM008_AdminGatedHandlers_HaveTripletTests(t *testing.T) {
|
|
for handlerFile := range AdminGatedHandlers {
|
|
base := strings.TrimSuffix(handlerFile, ".go")
|
|
// Look for the 3-test triplet in the corresponding _test.go file
|
|
// or in any test file in the package — bulk_revocation_handler_test.go
|
|
// follows a slightly different naming convention.
|
|
matches, err := filepath.Glob("*_test.go")
|
|
if err != nil {
|
|
t.Fatalf("glob: %v", err)
|
|
}
|
|
var foundNonAdmin, foundExplicitFalse, foundAdminPermitted bool
|
|
for _, m := range matches {
|
|
body, err := os.ReadFile(m)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
s := string(body)
|
|
// Look for tests that mention the handler base name + the
|
|
// expected suffix. Loose match because some test files use
|
|
// _Handler_NonAdmin and others use _NonAdmin.
|
|
if strings.Contains(s, "NonAdmin_Returns403") {
|
|
foundNonAdmin = true
|
|
}
|
|
if strings.Contains(s, "AdminExplicitFalse_Returns403") {
|
|
foundExplicitFalse = true
|
|
}
|
|
if strings.Contains(s, "AdminPermitted_ForwardsActor") {
|
|
foundAdminPermitted = true
|
|
}
|
|
}
|
|
if !foundNonAdmin {
|
|
t.Errorf("admin-gated handler %s lacks a *_NonAdmin_Returns403 test", base)
|
|
}
|
|
if !foundExplicitFalse {
|
|
t.Errorf("admin-gated handler %s lacks a *_AdminExplicitFalse_Returns403 test", base)
|
|
}
|
|
if !foundAdminPermitted {
|
|
t.Errorf("admin-gated handler %s lacks a *_AdminPermitted_ForwardsActor test", base)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- helpers --------------------------------------------------------------
|
|
|
|
func scanIsAdminCallers(dir string) ([]string, error) {
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var out []string
|
|
fset := token.NewFileSet()
|
|
for _, e := range entries {
|
|
name := e.Name()
|
|
if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
|
|
continue
|
|
}
|
|
body, err := os.ReadFile(filepath.Join(dir, name))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
_, parseErr := parser.ParseFile(fset, filepath.Join(dir, name), body, parser.SkipObjectResolution)
|
|
if parseErr != nil {
|
|
continue
|
|
}
|
|
// Substring-match middleware.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(") {
|
|
out = append(out, name)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func keys(m map[string]string) []string {
|
|
out := make([]string, 0, len(m))
|
|
for k := range m {
|
|
out = append(out, k)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func slicesEqual008(a, b []string) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for i := range a {
|
|
if a[i] != b[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|