Files
certctl/internal/api/handler/auth_bootstrap.go
T
shankar0123 21aeed4f4e legal: addlicense headers + normalize legacy variants (Phase 0 RED-4)
Phase 0 closure (Path B2, post-rewrite):

addlicense sweep — adds the canonical certctl LLC copyright + BUSL-1.1
SPDX header to every production Go file. Template:

  // Copyright 2026 certctl LLC. All rights reserved.
  // SPDX-License-Identifier: BUSL-1.1

Coverage: 338 / 338 production Go files (cmd/ + internal/, excluding
*_test.go and **/testdata/**). Pre-sweep coverage was 22 / 338 (6.5%);
post-sweep is 338 / 338 (100%).

Normalized 22 pre-existing legacy headers (`// Copyright (c) certctl`
+ `// SPDX-License-Identifier: BSL-1.1`) and 1 file using a
`Certctl Contributors` attribution. The legacy SPDX ID `BSL-1.1`
is non-standard; the official SPDX identifier for Business Source
License 1.1 is `BUSL-1.1` (capital U). All 338 files now share the
canonical form.

Generated via:
  addlicense -c "certctl LLC" -y 2026 \
    -f cowork/legal/copyright-header.tpl \
    -ignore '**/testdata/**' -ignore '**/*_test.go' \
    cmd/ internal/

Verification:
  find cmd internal -name '*.go' -not -name '*_test.go' \
    -not -path '*/testdata/*' \
    -exec grep -L '^// Copyright 2026 certctl LLC' {} \; | wc -l

  Returns: 0

gofmt clean. Header additions are comments only, no compile impact.

Closes: cowork/certctl-architecture-diligence-audit.html#fix-RED-4
2026-05-13 21:23:35 +00:00

131 lines
4.5 KiB
Go

// Copyright 2026 certctl LLC. All rights reserved.
// SPDX-License-Identifier: BUSL-1.1
package handler
import (
"encoding/json"
"errors"
"net/http"
"strings"
"github.com/certctl-io/certctl/internal/auth/bootstrap"
)
// BootstrapHandler exposes the Bundle 1 Phase 6 day-0 admin path.
//
// Threat model (from cowork/auth-bundle-1-prompt.md): the control
// plane comes up with no admin actors. The operator hands the
// CERTCTL_BOOTSTRAP_TOKEN to a single curl call; the server mints
// the first admin key and locks the door. No subsequent invocation
// can mint another admin via this path — the strategy state and the
// "admin already exists" probe both close it. After bootstrap the
// operator manages keys via /v1/auth/keys/...
//
// Handler shape:
//
// GET /v1/auth/bootstrap → 200 {available:true|false}
// POST /v1/auth/bootstrap → 201 {api_key, key_value, actor_id}
//
// The GET surface is intentionally probable from any caller; it
// returns availability (no token, no admin probe) so the GUI and the
// install one-liner can decide whether to render the bootstrap
// affordance. The POST surface requires the bootstrap token and
// returns the plaintext key value once.
type BootstrapHandler struct {
svc *bootstrap.Service
}
// NewBootstrapHandler constructs a BootstrapHandler. svc may be nil
// to disable both methods (handler returns 410 Gone on every call).
func NewBootstrapHandler(svc *bootstrap.Service) BootstrapHandler {
return BootstrapHandler{svc: svc}
}
type bootstrapAvailableResponse struct {
Available bool `json:"available"`
}
type bootstrapRequest struct {
Token string `json:"token"`
ActorName string `json:"actor_name"`
}
type bootstrapResponse struct {
ActorID string `json:"actor_id"`
APIKeyID string `json:"api_key_id"`
KeyValue string `json:"key_value"`
CreatedAt string `json:"created_at"`
Message string `json:"message"`
}
// Available is the GET probe. Returns {available: true} when the
// strategy is callable AND no admin actors exist; otherwise {available:
// false}. The endpoint never reveals the bootstrap token's existence
// independently of admin actor state — the GUI uses this to decide
// whether to render the "first-time setup" wizard.
func (h BootstrapHandler) Available(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
available := false
if h.svc != nil {
ok, err := h.svc.Available(r.Context())
if err == nil {
available = ok
}
}
JSON(w, http.StatusOK, bootstrapAvailableResponse{Available: available})
}
// Mint is the POST handler that consumes the token + creates the
// first admin key.
//
// Status mapping:
//
// 410 Gone → strategy disabled (no token, admin exists, or one-shot already consumed)
// 401 Unauthorized → token mismatch
// 400 Bad Request → invalid actor_name
// 201 Created → key minted; response carries the plaintext key value
func (h BootstrapHandler) Mint(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
if h.svc == nil {
// No service wired = endpoint disabled. Same status as the
// "already consumed" path so callers can't differentiate
// configuration from state.
Error(w, http.StatusGone, "bootstrap endpoint disabled")
return
}
var body bootstrapRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&body); err != nil {
Error(w, http.StatusBadRequest, "Invalid JSON body")
return
}
body.ActorName = strings.TrimSpace(body.ActorName)
result, err := h.svc.ValidateAndMint(r.Context(), body.Token, body.ActorName)
if err != nil {
switch {
case errors.Is(err, bootstrap.ErrDisabled):
Error(w, http.StatusGone, "bootstrap endpoint disabled")
case errors.Is(err, bootstrap.ErrInvalidToken):
Error(w, http.StatusUnauthorized, "Invalid bootstrap token")
case errors.Is(err, bootstrap.ErrInvalidActorName):
Error(w, http.StatusBadRequest, "Invalid actor_name (3-64 chars, lowercase alnum + - + _)")
default:
Error(w, http.StatusInternalServerError, "Bootstrap failed")
}
return
}
JSON(w, http.StatusCreated, bootstrapResponse{
ActorID: result.APIKey.Name,
APIKeyID: result.APIKey.ID,
KeyValue: result.KeyValue,
CreatedAt: result.APIKey.CreatedAt.UTC().Format("2006-01-02T15:04:05Z07:00"),
Message: "Admin API key created. This is the only time the key value is shown — capture it now.",
})
}