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:
@@ -52,6 +52,13 @@ type Config struct {
|
|||||||
AgentAPIKeyPepper string
|
AgentAPIKeyPepper string
|
||||||
OIDCDevBypassToken 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.
|
// OIDC issuer URL (Keycloak realm endpoint). e.g.
|
||||||
// https://auth.hangman-lab.top/realms/hangman-lab
|
// https://auth.hangman-lab.top/realms/hangman-lab
|
||||||
// Phase 2C ships this as configured-but-not-verified; Phase 4 wires
|
// 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"),
|
SystemAPIKey: os.Getenv("SYSTEM_API_KEY"),
|
||||||
AgentAPIKeyPepper: os.Getenv("AGENT_API_KEY_PEPPER"),
|
AgentAPIKeyPepper: os.Getenv("AGENT_API_KEY_PEPPER"),
|
||||||
OIDCDevBypassToken: os.Getenv("OIDC_DEV_BYPASS_TOKEN"),
|
OIDCDevBypassToken: os.Getenv("OIDC_DEV_BYPASS_TOKEN"),
|
||||||
|
DialecticAdminAPIKey: os.Getenv("DIALECTIC_ADMIN_API_KEY"),
|
||||||
OIDCIssuer: os.Getenv("OIDC_ISSUER"),
|
OIDCIssuer: os.Getenv("OIDC_ISSUER"),
|
||||||
OIDCClientID: os.Getenv("OIDC_CLIENT_ID"),
|
OIDCClientID: os.Getenv("OIDC_CLIENT_ID"),
|
||||||
FabricGuildBaseURL: os.Getenv("FABRIC_GUILD_BASE_URL"),
|
FabricGuildBaseURL: os.Getenv("FABRIC_GUILD_BASE_URL"),
|
||||||
|
|||||||
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)
|
signupsH := handlers.NewSignupsHandler(topicStore, signupStore)
|
||||||
argsH := handlers.NewArgumentsHandler(topicStore, campStore, roundStore, argStore)
|
argsH := handlers.NewArgumentsHandler(topicStore, campStore, roundStore, argStore)
|
||||||
verdictH := handlers.NewVerdictHandler(topicStore, campStore, verdictStore)
|
verdictH := handlers.NewVerdictHandler(topicStore, campStore, verdictStore)
|
||||||
|
adminH := handlers.NewAdminHandler(db, cfg.AgentAPIKeyPepper, cfg.DialecticAdminAPIKey)
|
||||||
|
|
||||||
// Routes.
|
// Routes.
|
||||||
r.Route("/api", func(r chi.Router) {
|
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.Use(requireAnyAuth)
|
||||||
r.Get("/topics/{id}/signups", signupsH.List)
|
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
|
return r
|
||||||
|
|||||||
@@ -234,4 +234,3 @@ func (t *Ticker) broadcastAnnouncement(topic *models.Topic) {
|
|||||||
log.Printf("orchestrator: announce topic=%s failed: %v", topic.ID, err)
|
log.Printf("orchestrator: announce topic=%s failed: %v", topic.ID, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user