mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-07-12 17:04:21 +00:00
6d7473b56f
Phase 0b of tenant tool config refactor. Closes 3 privilege-escalation
vulnerabilities in the same bug class as commit b419f352 (Phase 1
config.* hotfix):
- CRITICAL: PUT /v1/tools/builtin/{name} — non-master tenant admin
could overwrite global builtin_tools.settings, corrupting tool
defaults for every tenant.
- CRITICAL: POST /v1/packages/install|uninstall — non-master tenant
admin could run pip/npm/apk server-wide. Supply-chain vector.
- HIGH: POST /v1/api-keys/{id}/revoke (HTTP + WS) — tenant admin
could revoke NULL-tenant system keys because store SQL matches
(tenant_id = \$N OR tenant_id IS NULL).
Implementation:
- Export store.IsMasterScope as the single predicate; rewire Phase 1
config.* middleware to delegate (no behaviour change).
- Add http.requireMasterScope helper symmetric to requireTenantAdmin.
- Guard handleUpdate (builtin_tools) and handleInstall/handleUninstall
(packages) with master-scope check before any mutation or shell exec.
- Fix api_keys.Revoke at the handler layer: fetch key via new
APIKeyStore.Get, verify key.TenantID matches caller tenant for
non-owner callers. Applies to both HTTP and WS paths.
- Harden WS router to inject role into ctx so store.IsOwnerRole works
from WS handlers (closes a latent drift between the HTTP and WS
layers that broke the initial WS api_keys.revoke fix).
- Drop unused APIKeyStore.Delete (YAGNI + removes dormant vuln with
the same tenant_id IS NULL arm).
- Emit security.tenant_scope_violation and security.api_key_revoke_
forbidden slog events on every rejection for future SIEM alerting.
- New MsgMasterScopeRequired i18n key + en/vi/zh catalogs.
Tests cover the guard predicate, all 3 HTTP endpoints, and the full
WS api_keys.revoke matrix (cross-tenant deny, system-key deny for
tenant admins, system owner bypass, own-tenant happy path). 14 other
admin-gated write endpoints verified safe by static audit.
215 lines
7.2 KiB
Go
215 lines
7.2 KiB
Go
package http
|
|
|
|
import (
|
|
"log/slog"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
|
"github.com/nextlevelbuilder/goclaw/internal/crypto"
|
|
"github.com/nextlevelbuilder/goclaw/internal/i18n"
|
|
"github.com/nextlevelbuilder/goclaw/internal/permissions"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
|
)
|
|
|
|
// APIKeysHandler handles API key management endpoints.
|
|
type APIKeysHandler struct {
|
|
apiKeys store.APIKeyStore
|
|
msgBus *bus.MessageBus // for cache invalidation events
|
|
}
|
|
|
|
// NewAPIKeysHandler creates a handler for API key management endpoints.
|
|
func NewAPIKeysHandler(apiKeys store.APIKeyStore, msgBus *bus.MessageBus) *APIKeysHandler {
|
|
return &APIKeysHandler{apiKeys: apiKeys, msgBus: msgBus}
|
|
}
|
|
|
|
// RegisterRoutes registers all API key management routes on the given mux.
|
|
func (h *APIKeysHandler) RegisterRoutes(mux *http.ServeMux) {
|
|
mux.HandleFunc("GET /v1/api-keys", h.adminAuth(h.handleList))
|
|
mux.HandleFunc("POST /v1/api-keys", h.adminAuth(h.handleCreate))
|
|
mux.HandleFunc("POST /v1/api-keys/{id}/revoke", h.adminAuth(h.handleRevoke))
|
|
}
|
|
|
|
// adminAuth ensures the caller has admin access (gateway token or API key with admin scope).
|
|
func (h *APIKeysHandler) adminAuth(next http.HandlerFunc) http.HandlerFunc {
|
|
return requireAuth(permissions.RoleAdmin, next)
|
|
}
|
|
|
|
func (h *APIKeysHandler) handleList(w http.ResponseWriter, r *http.Request) {
|
|
locale := extractLocale(r)
|
|
// HTTP API key list is admin-only (adminAuth middleware), so no owner filter needed.
|
|
keys, err := h.apiKeys.List(r.Context(), "")
|
|
if err != nil {
|
|
slog.Error("api_keys.list failed", "error", err)
|
|
writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "API keys"))
|
|
return
|
|
}
|
|
if keys == nil {
|
|
keys = []store.APIKeyData{}
|
|
}
|
|
writeJSON(w, http.StatusOK, keys)
|
|
}
|
|
|
|
func (h *APIKeysHandler) handleCreate(w http.ResponseWriter, r *http.Request) {
|
|
locale := extractLocale(r)
|
|
|
|
var input struct {
|
|
Name string `json:"name"`
|
|
Scopes []string `json:"scopes"`
|
|
ExpiresIn *int `json:"expires_in"` // seconds; nil = never
|
|
TenantID string `json:"tenant_id"` // optional UUID; cross-tenant callers may specify or omit (NULL = system key)
|
|
}
|
|
if !bindJSON(w, r, locale, &input) {
|
|
return
|
|
}
|
|
|
|
if input.Name == "" {
|
|
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "name"))
|
|
return
|
|
}
|
|
if len(input.Name) > 100 {
|
|
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, "name must be 100 characters or less"))
|
|
return
|
|
}
|
|
if len(input.Scopes) == 0 {
|
|
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "scopes"))
|
|
return
|
|
}
|
|
|
|
// Validate scopes
|
|
for _, s := range input.Scopes {
|
|
if !permissions.ValidScope(s) {
|
|
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, "invalid scope: "+s))
|
|
return
|
|
}
|
|
}
|
|
|
|
raw, hash, prefix, err := crypto.GenerateAPIKey()
|
|
if err != nil {
|
|
slog.Error("api_keys.generate failed", "error", err)
|
|
writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, "key generation"))
|
|
return
|
|
}
|
|
|
|
// Resolve tenant_id based on caller type.
|
|
var tenantID uuid.UUID // uuid.Nil = system-level (NULL in DB)
|
|
if store.IsOwnerRole(r.Context()) {
|
|
if input.TenantID != "" {
|
|
tid, err := uuid.Parse(input.TenantID)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "tenant_id"))
|
|
return
|
|
}
|
|
tenantID = tid
|
|
}
|
|
// else: uuid.Nil stays → system-level key
|
|
} else {
|
|
tenantID = store.TenantIDFromContext(r.Context())
|
|
}
|
|
|
|
now := time.Now()
|
|
key := &store.APIKeyData{
|
|
ID: store.GenNewID(),
|
|
Name: input.Name,
|
|
Prefix: prefix,
|
|
KeyHash: hash,
|
|
Scopes: input.Scopes,
|
|
TenantID: tenantID,
|
|
CreatedBy: extractUserID(r),
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
|
|
if input.ExpiresIn != nil && *input.ExpiresIn > 0 {
|
|
exp := now.Add(time.Duration(*input.ExpiresIn) * time.Second)
|
|
key.ExpiresAt = &exp
|
|
}
|
|
|
|
if err := h.apiKeys.Create(r.Context(), key); err != nil {
|
|
slog.Error("api_keys.create failed", "error", err)
|
|
writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToCreate, "API key", "internal error"))
|
|
return
|
|
}
|
|
|
|
h.emitCacheInvalidate("api_keys", key.ID.String())
|
|
|
|
// Return key with raw secret (shown only once)
|
|
writeJSON(w, http.StatusCreated, map[string]any{
|
|
"id": key.ID,
|
|
"name": key.Name,
|
|
"prefix": key.Prefix,
|
|
"key": raw, // shown only once!
|
|
"scopes": key.Scopes,
|
|
"expires_at": key.ExpiresAt,
|
|
"created_at": key.CreatedAt,
|
|
})
|
|
}
|
|
|
|
// handleRevoke revokes an API key after verifying the caller owns it.
|
|
//
|
|
// Phase 0b hotfix: the store layer's Revoke SQL matches rows where
|
|
// `tenant_id = $N OR tenant_id IS NULL`, which let tenant admins revoke
|
|
// system-level (NULL-tenant) API keys belonging to CI/CD or integrations.
|
|
// The fix pre-fetches the key and rejects any non-owner caller whose tenant
|
|
// does not exactly match the key's tenant. System owners bypass.
|
|
// See plans/reports/debugger-260412-0922-tenant-scope-audit.md HIGH finding.
|
|
func (h *APIKeysHandler) handleRevoke(w http.ResponseWriter, r *http.Request) {
|
|
locale := extractLocale(r)
|
|
idStr := r.PathValue("id")
|
|
id, err := uuid.Parse(idStr)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "API key"))
|
|
return
|
|
}
|
|
|
|
ctx := r.Context()
|
|
|
|
// Fetch first so ownership can be verified before any mutation.
|
|
key, err := h.apiKeys.Get(ctx, id)
|
|
if err != nil || key == nil {
|
|
writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "API key", idStr))
|
|
return
|
|
}
|
|
|
|
// Ownership rule:
|
|
// - System owner (bypass-all) → may revoke any key
|
|
// - Tenant admin in caller's tenant → may revoke only keys owned by that tenant (strict match)
|
|
// - NULL-tenant (system) keys → revocable only by system owners
|
|
if !store.IsOwnerRole(ctx) {
|
|
callerTID := store.TenantIDFromContext(ctx)
|
|
if key.TenantID == uuid.Nil || key.TenantID != callerTID {
|
|
slog.Warn("security.api_key_revoke_forbidden",
|
|
"key_id", idStr,
|
|
"caller_tenant", callerTID,
|
|
"key_tenant", key.TenantID,
|
|
"user_id", store.UserIDFromContext(ctx),
|
|
)
|
|
writeError(w, http.StatusForbidden, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "API key"))
|
|
return
|
|
}
|
|
}
|
|
|
|
// HTTP revoke is admin-only (adminAuth middleware), so no owner filter needed.
|
|
if err := h.apiKeys.Revoke(ctx, id, ""); err != nil {
|
|
slog.Error("api_keys.revoke failed", "error", err, "id", idStr)
|
|
writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "API key", idStr))
|
|
return
|
|
}
|
|
|
|
h.emitCacheInvalidate("api_keys", idStr)
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"})
|
|
}
|
|
|
|
func (h *APIKeysHandler) emitCacheInvalidate(kind, key string) {
|
|
if h.msgBus == nil {
|
|
return
|
|
}
|
|
h.msgBus.Broadcast(bus.Event{
|
|
Name: protocol.EventCacheInvalidate,
|
|
Payload: bus.CacheInvalidatePayload{Kind: kind, Key: key},
|
|
})
|
|
}
|