feat: POST /api/admin/agent-keys — system-keyed raw key minting
New admin endpoint for provisioning per-agent dialectic API keys
during recruitment. Auth via separate x-dialectic-admin-key header
matching env DIALECTIC_ADMIN_API_KEY (not bearer — admin lifecycle
is independent of agent identity).
Behavior:
- Body {agent_id, force?}; generates 32-byte hex raw key; stores
sha256-peppered hash in agent_keys; returns raw key (ONLY time
exposed — caller stores in agent secret-mgr)
- 409 on existing agent_id unless force:true (rotates the hash,
clears last_used_at + revoked_at)
- Closed-by-default: if DIALECTIC_ADMIN_API_KEY env is empty, every
request 401s
Caller pattern: skills/dialectic-hangman-lab/scripts/dialectic-ctrl
(to be added) reads admin key from
/root/.openclaw/system-secrets/dialectic-admin-key on the openclaw
host, POSTs to admin endpoint, stores returned raw key in the proxy-
for agent secret-mgr (inherits the proxy-pcexec context from
recruitment/onboard).
Unblocks Phase 3.5 plan to provision all prod agents and integrate
into recruitment skill.
This commit is contained in:
125
internal/httpapi/handlers/admin.go
Normal file
125
internal/httpapi/handlers/admin.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-sql-driver/mysql"
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
"git.hangman-lab.top/hzhang/Dialectic.Backend/internal/auth"
|
||||
)
|
||||
|
||||
type AdminHandler struct {
|
||||
db *sqlx.DB
|
||||
pepper string
|
||||
adminAPIKey string
|
||||
}
|
||||
|
||||
func NewAdminHandler(db *sqlx.DB, pepper string, adminAPIKey string) *AdminHandler {
|
||||
return &AdminHandler{db: db, pepper: pepper, adminAPIKey: adminAPIKey}
|
||||
}
|
||||
|
||||
type provisionAgentKeyBody struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
Force bool `json:"force"`
|
||||
}
|
||||
|
||||
// POST /api/admin/agent-keys
|
||||
//
|
||||
// System-auth (header `x-dialectic-admin-key` matching env
|
||||
// `DIALECTIC_ADMIN_API_KEY`). Generates a fresh 32-byte hex random
|
||||
// raw key, stores its peppered-sha256 hash in `agent_keys`, and
|
||||
// returns the raw key in the response. This is the ONLY time the raw
|
||||
// key is exposed — caller must capture it and place in the target
|
||||
// agent's secret-mgr.
|
||||
//
|
||||
// On duplicate (agent_id already has a key): 409 unless `force: true`,
|
||||
// in which case the old hash is replaced.
|
||||
//
|
||||
// Caller pattern: `dialectic-ctrl create-key` (run via proxy-pcexec
|
||||
// with proxy-for set to the agent being onboarded — see
|
||||
// `skills/dialectic-hangman-lab/scripts/dialectic-ctrl`).
|
||||
func (h *AdminHandler) ProvisionAgentKey(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.checkAdminAuth(r) {
|
||||
http.Error(w, "admin auth required (x-dialectic-admin-key)", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if h.pepper == "" {
|
||||
http.Error(w, "server misconfigured: AGENT_API_KEY_PEPPER unset", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var body provisionAgentKeyBody
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, "bad body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if body.AgentID == "" {
|
||||
http.Error(w, "agent_id required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
rawKey, err := randomHexKey(32)
|
||||
if err != nil {
|
||||
http.Error(w, "rng failed: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
hash := auth.HashKey(h.pepper, rawKey)
|
||||
|
||||
_, err = h.db.ExecContext(r.Context(),
|
||||
`INSERT INTO agent_keys (agent_id, key_hash) VALUES (?, ?)`,
|
||||
body.AgentID, hash)
|
||||
if err != nil {
|
||||
var mysqlErr *mysql.MySQLError
|
||||
if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 {
|
||||
// duplicate primary key
|
||||
if !body.Force {
|
||||
http.Error(w, fmt.Sprintf(
|
||||
"agent %q already has a dialectic api key; pass force:true to rotate",
|
||||
body.AgentID), http.StatusConflict)
|
||||
return
|
||||
}
|
||||
// rotate: replace the hash
|
||||
if _, err := h.db.ExecContext(r.Context(),
|
||||
`UPDATE agent_keys SET key_hash = ?, last_used_at = NULL, revoked_at = NULL WHERE agent_id = ?`,
|
||||
hash, body.AgentID); err != nil {
|
||||
http.Error(w, "rotate failed: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
http.Error(w, "insert failed: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, map[string]any{
|
||||
"agent_id": body.AgentID,
|
||||
"api_key": rawKey, // raw — caller must store in agent's secret-mgr; not returned again
|
||||
"rotated": body.Force,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) checkAdminAuth(r *http.Request) bool {
|
||||
if h.adminAPIKey == "" {
|
||||
return false // unset env = no admin caller is valid
|
||||
}
|
||||
got := r.Header.Get("x-dialectic-admin-key")
|
||||
if got == "" {
|
||||
return false
|
||||
}
|
||||
return subtle.ConstantTimeCompare([]byte(got), []byte(h.adminAPIKey)) == 1
|
||||
}
|
||||
|
||||
func randomHexKey(byteLen int) (string, error) {
|
||||
b := make([]byte, byteLen)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
@@ -63,6 +63,7 @@ func Mount(cfg *config.Config, db *sqlx.DB, version string) http.Handler {
|
||||
signupsH := handlers.NewSignupsHandler(topicStore, signupStore)
|
||||
argsH := handlers.NewArgumentsHandler(topicStore, campStore, roundStore, argStore)
|
||||
verdictH := handlers.NewVerdictHandler(topicStore, campStore, verdictStore)
|
||||
adminH := handlers.NewAdminHandler(db, cfg.AgentAPIKeyPepper, cfg.DialecticAdminAPIKey)
|
||||
|
||||
// Routes.
|
||||
r.Route("/api", func(r chi.Router) {
|
||||
@@ -95,6 +96,11 @@ func Mount(cfg *config.Config, db *sqlx.DB, version string) http.Handler {
|
||||
r.Use(requireAnyAuth)
|
||||
r.Get("/topics/{id}/signups", signupsH.List)
|
||||
})
|
||||
|
||||
// Admin: provision an agent api key. Auth is its own header
|
||||
// (x-dialectic-admin-key against env DIALECTIC_ADMIN_API_KEY),
|
||||
// not bearer — admin lifecycle is separate from agent identity.
|
||||
r.Post("/admin/agent-keys", adminH.ProvisionAgentKey)
|
||||
})
|
||||
|
||||
return r
|
||||
|
||||
Reference in New Issue
Block a user