diff --git a/server/internal_api.go b/server/internal_api.go index be3f227..405b3f4 100644 --- a/server/internal_api.go +++ b/server/internal_api.go @@ -25,8 +25,7 @@ import ( ) const ( - internalCapabilityAdminQuery = "admin_query" - internalCapabilityCultivate = "cultivate" + internalSharedCredentialService = "hotgo-admin" internalHeaderService = "X-Service-Name" internalHeaderTimestamp = "X-Timestamp" @@ -85,7 +84,7 @@ func serveInternalAdminQuery(wrt http.ResponseWriter, req *http.Request, prefix writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) return } - if !authorizeInternalAPI(wrt, req, internalCapabilityAdminQuery) { + if !authorizeInternalAPI(wrt, req) { return } @@ -363,7 +362,7 @@ func serveInternalLoginLockoutRelease(wrt http.ResponseWriter, req *http.Request writeIMSuccess(wrt, req, map[string]any{"account_hash": accountHash, "status": "released"}) } -func authorizeInternalAPI(wrt http.ResponseWriter, req *http.Request, capability string) bool { +func authorizeInternalAPI(wrt http.ResponseWriter, req *http.Request) bool { if !internalAPI.config.Enabled { writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "resource not found", nil) return false @@ -372,15 +371,11 @@ func authorizeInternalAPI(wrt http.ResponseWriter, req *http.Request, capability timestampRaw := strings.TrimSpace(req.Header.Get(internalHeaderTimestamp)) nonce := strings.TrimSpace(req.Header.Get(internalHeaderNonce)) signature := strings.TrimSpace(req.Header.Get(internalHeaderSignature)) - service, ok := internalAPI.config.Services[serviceName] - if !ok || service.Secret == "" || timestampRaw == "" || nonce == "" || signature == "" { + secret := internalSharedSecret() + if serviceName == "" || secret == "" || timestampRaw == "" || nonce == "" || signature == "" { writeIMError(wrt, req, http.StatusUnauthorized, 30043, imReasonAuthRequired, "internal signature required", nil) return false } - if !internalServiceAllows(service, capability) { - writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil) - return false - } timestamp, err := strconv.ParseInt(timestampRaw, 10, 64) if err != nil { writeIMError(wrt, req, http.StatusUnauthorized, 30043, imReasonAuthRequired, "invalid internal timestamp", nil) @@ -403,7 +398,7 @@ func authorizeInternalAPI(wrt http.ResponseWriter, req *http.Request, capability timestampRaw, nonce, }, "\n") - if !verifyInternalSignature(service.Secret, signingText, signature) { + if !verifyInternalSignature(secret, signingText, signature) { writeIMError(wrt, req, http.StatusUnauthorized, 30043, imReasonAuthRequired, "invalid internal signature", nil) return false } @@ -415,6 +410,15 @@ func authorizeInternalAPI(wrt http.ResponseWriter, req *http.Request, capability return true } +// internalSharedSecret returns the single credential used by all trusted +// backend callers. Service names remain request labels for audit and nonce +// isolation only; they no longer select separate credentials or capabilities. +// Reuse the existing hotgo-admin credential so current deployments require no +// additional secret or configuration changes. +func internalSharedSecret() string { + return strings.TrimSpace(internalAPI.config.Services[internalSharedCredentialService].Secret) +} + func splitInternalPath(tail string) []string { if tail == "" { return nil @@ -765,16 +769,6 @@ func parseInternalUid(raw string) types.Uid { return store.EncodeUid(id) } -func internalServiceAllows(service internalAPIServiceConfig, capability string) bool { - for _, allowed := range service.Allow { - allowed = strings.TrimSpace(allowed) - if allowed == "*" || allowed == capability { - return true - } - } - return false -} - func hashInternalRequestBody(req *http.Request) (string, error) { if req.Body == nil { sum := sha256.Sum256(nil) diff --git a/server/internal_cultivate.go b/server/internal_cultivate.go index dde521c..8e96621 100644 --- a/server/internal_cultivate.go +++ b/server/internal_cultivate.go @@ -51,7 +51,7 @@ func serveInternalCultivate(w http.ResponseWriter, r *http.Request, prefix strin writeIMError(w, r, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) return } - if !authorizeInternalAPI(w, r, internalCapabilityCultivate) { + if !authorizeInternalAPI(w, r) { return } parts := splitInternalPath(strings.Trim(strings.TrimPrefix(r.URL.Path, prefix), "/")) diff --git a/server/internal_cultivate_test.go b/server/internal_cultivate_test.go index 461e724..6cc56df 100644 --- a/server/internal_cultivate_test.go +++ b/server/internal_cultivate_test.go @@ -1,16 +1,21 @@ package main import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" "net/http" "net/http/httptest" + "strconv" "strings" "testing" + "time" ) func TestInternalCultivateRequiresSignedServiceRequest(t *testing.T) { previous := internalAPI defer func() { internalAPI = previous }() - internalAPI = internalAPIRuntime{config: internalAPIConfig{Enabled: true, Services: map[string]internalAPIServiceConfig{"cultivate-server": {Secret: "secret", Allow: []string{internalCapabilityCultivate}}}}} + internalAPI = internalAPIRuntime{config: internalAPIConfig{Enabled: true, Services: map[string]internalAPIServiceConfig{"hotgo-admin": {Secret: "secret"}}}} req := httptest.NewRequest(http.MethodPost, "/internal/cultivate/tenants/default/users/batch", strings.NewReader(`{"user_ids":[1]}`)) rec := httptest.NewRecorder() serveInternalCultivate(rec, req, "/internal/cultivate/") @@ -19,18 +24,25 @@ func TestInternalCultivateRequiresSignedServiceRequest(t *testing.T) { } } -func TestInternalCultivateRejectsWrongCapability(t *testing.T) { +func TestInternalCultivateUsesSharedInternalCredential(t *testing.T) { previous := internalAPI defer func() { internalAPI = previous }() - internalAPI = internalAPIRuntime{config: internalAPIConfig{Enabled: true, Services: map[string]internalAPIServiceConfig{"cultivate-server": {Secret: "secret", Allow: []string{internalCapabilityAdminQuery}}}}} + internalAPI = internalAPIRuntime{config: internalAPIConfig{Enabled: true, TimeWindowSeconds: 300, Services: map[string]internalAPIServiceConfig{ + "hotgo-admin": {Secret: "shared-secret", Allow: []string{"admin_query"}}, + "cultivate-server": {Secret: "ignored-separate-secret", Allow: []string{"cultivate"}}, + }}} req := httptest.NewRequest(http.MethodPost, "/internal/cultivate/tenants/default/users/batch", strings.NewReader(`{"user_ids":[1]}`)) req.Header.Set(internalHeaderService, "cultivate-server") - req.Header.Set(internalHeaderTimestamp, "1") + timestamp := strconv.FormatInt(time.Now().Unix(), 10) + req.Header.Set(internalHeaderTimestamp, timestamp) req.Header.Set(internalHeaderNonce, "nonce") - req.Header.Set(internalHeaderSignature, "signature") + bodyHash := sha256.Sum256([]byte(`{"user_ids":[1]}`)) + signingText := strings.Join([]string{req.Method, req.URL.RequestURI(), hex.EncodeToString(bodyHash[:]), timestamp, "nonce"}, "\n") + mac := hmac.New(sha256.New, []byte("shared-secret")) + _, _ = mac.Write([]byte(signingText)) + req.Header.Set(internalHeaderSignature, hex.EncodeToString(mac.Sum(nil))) rec := httptest.NewRecorder() - serveInternalCultivate(rec, req, "/internal/cultivate/") - if rec.Code != http.StatusForbidden { - t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + if !authorizeInternalAPI(rec, req) { + t.Fatalf("shared internal credential was rejected: status=%d body=%s", rec.Code, rec.Body.String()) } }