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:
h z
2026-05-23 14:53:39 +01:00
parent 03b89a547c
commit 15bb942d9b
4 changed files with 139 additions and 1 deletions

View File

@@ -52,6 +52,13 @@ type Config struct {
AgentAPIKeyPepper string
OIDCDevBypassToken string
// DialecticAdminAPIKey gates POST /api/admin/agent-keys (raw key
// minting). Held on the operator side only — kept on the openclaw
// host at /root/.openclaw/system-secrets/dialectic-admin-key for
// `dialectic-ctrl` script to read. Empty in env → admin endpoint
// fully closed.
DialecticAdminAPIKey string
// OIDC issuer URL (Keycloak realm endpoint). e.g.
// https://auth.hangman-lab.top/realms/hangman-lab
// Phase 2C ships this as configured-but-not-verified; Phase 4 wires
@@ -85,6 +92,7 @@ func LoadFromEnv() (*Config, error) {
SystemAPIKey: os.Getenv("SYSTEM_API_KEY"),
AgentAPIKeyPepper: os.Getenv("AGENT_API_KEY_PEPPER"),
OIDCDevBypassToken: os.Getenv("OIDC_DEV_BYPASS_TOKEN"),
DialecticAdminAPIKey: os.Getenv("DIALECTIC_ADMIN_API_KEY"),
OIDCIssuer: os.Getenv("OIDC_ISSUER"),
OIDCClientID: os.Getenv("OIDC_CLIENT_ID"),
FabricGuildBaseURL: os.Getenv("FABRIC_GUILD_BASE_URL"),

View 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
}

View File

@@ -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

View File

@@ -234,4 +234,3 @@ func (t *Ticker) broadcastAnnouncement(topic *models.Topic) {
log.Printf("orchestrator: announce topic=%s failed: %v", topic.ID, err)
}
}