Files
viettranx 6d7473b56f fix(http): master-scope guards on builtin_tools, packages, api-keys
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.
2026-04-12 10:24:07 +07:00

235 lines
8.1 KiB
Go

package methods
import (
"context"
"encoding/json"
"log/slog"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/crypto"
"github.com/nextlevelbuilder/goclaw/internal/gateway"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/permissions"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
// APIKeysMethods handles api_keys.list, api_keys.create, api_keys.revoke.
type APIKeysMethods struct {
apiKeys store.APIKeyStore
}
// NewAPIKeysMethods creates a new API keys method handler.
func NewAPIKeysMethods(apiKeys store.APIKeyStore) *APIKeysMethods {
return &APIKeysMethods{apiKeys: apiKeys}
}
// Register registers API key management RPC methods.
func (m *APIKeysMethods) Register(router *gateway.MethodRouter) {
router.Register(protocol.MethodAPIKeysList, m.handleList)
router.Register(protocol.MethodAPIKeysCreate, m.handleCreate)
router.Register(protocol.MethodAPIKeysRevoke, m.handleRevoke)
}
func (m *APIKeysMethods) handleList(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
// Non-admin callers only see their own keys.
ownerID := ""
if !permissions.HasMinRole(client.Role(), permissions.RoleAdmin) {
ownerID = client.UserID()
}
keys, err := m.apiKeys.List(ctx, ownerID)
if err != nil {
slog.Error("api_keys.list failed", "error", err)
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "API keys")))
return
}
if keys == nil {
keys = []store.APIKeyData{}
}
client.SendResponse(protocol.NewOKResponse(req.ID, keys))
}
func (m *APIKeysMethods) handleCreate(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
var params struct {
Name string `json:"name"`
Scopes []string `json:"scopes"`
ExpiresIn *int `json:"expires_in"` // seconds; nil = never
OwnerID string `json:"owner_id"` // optional; non-admin callers always get their own user_id
TenantID string `json:"tenant_id"` // optional UUID; cross-tenant callers may specify or omit (NULL = system key)
}
if req.Params != nil {
if err := json.Unmarshal(req.Params, &params); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
return
}
}
if params.Name == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "name")))
return
}
if len(params.Scopes) == 0 {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "scopes")))
return
}
// Validate scopes
for _, s := range params.Scopes {
if !permissions.ValidScope(s) {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, "invalid scope: "+s)))
return
}
}
// Non-admin callers always bind the key to their own user_id.
ownerID := params.OwnerID
if !permissions.HasMinRole(client.Role(), permissions.RoleAdmin) {
ownerID = client.UserID()
}
raw, hash, prefix, err := crypto.GenerateAPIKey()
if err != nil {
slog.Error("api_keys.generate failed", "error", err)
client.SendResponse(protocol.NewErrorResponse(req.ID, 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 client.IsOwner() {
if params.TenantID != "" {
tid, err := uuid.Parse(params.TenantID)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "tenant_id")))
return
}
tenantID = tid
}
// else: uuid.Nil stays → system-level key
} else if client.HasScope(permissions.ScopeProvision) {
// Provision-scoped callers may create tenant-bound keys only (not system-level).
if params.TenantID != "" {
tid, err := uuid.Parse(params.TenantID)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "tenant_id")))
return
}
tenantID = tid
} else {
tenantID = client.TenantID()
}
} else {
tenantID = client.TenantID()
}
now := time.Now()
key := &store.APIKeyData{
ID: store.GenNewID(),
Name: params.Name,
Prefix: prefix,
KeyHash: hash,
Scopes: params.Scopes,
OwnerID: ownerID,
TenantID: tenantID,
CreatedBy: store.UserIDFromContext(ctx),
CreatedAt: now,
UpdatedAt: now,
}
if params.ExpiresIn != nil && *params.ExpiresIn > 0 {
exp := now.Add(time.Duration(*params.ExpiresIn) * time.Second)
key.ExpiresAt = &exp
}
if err := m.apiKeys.Create(ctx, key); err != nil {
slog.Error("api_keys.create failed", "error", err)
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToCreate, "API key", "internal error")))
return
}
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
"id": key.ID,
"name": key.Name,
"prefix": key.Prefix,
"key": raw,
"scopes": key.Scopes,
"expires_at": key.ExpiresAt,
"created_at": key.CreatedAt,
}))
}
// handleRevoke revokes an API key after verifying ownership.
//
// Phase 0b hotfix: store-layer Revoke SQL matches on
// `tenant_id = $N OR tenant_id IS NULL`, which previously allowed any
// tenant admin to revoke system-level (NULL-tenant) API keys. The fix
// pre-fetches the key and enforces strict tenant match for non-owner
// callers. Non-admin callers continue to use the ownerID filter path
// (unchanged behaviour for personal keys).
func (m *APIKeysMethods) handleRevoke(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
var params struct {
ID string `json:"id"`
}
if req.Params != nil {
if err := json.Unmarshal(req.Params, &params); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
return
}
}
id, err := uuid.Parse(params.ID)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "API key")))
return
}
// Non-admin callers can only revoke their own keys — personal-key path,
// ownerID filter enforced by the store layer.
ownerID := ""
if !permissions.HasMinRole(client.Role(), permissions.RoleAdmin) {
ownerID = client.UserID()
}
// Admin path: verify the target key belongs to the caller's tenant (or
// caller is a system owner) before revoking. Personal-key path skips
// this because the ownerID filter already scopes to the caller.
//
// NOTE: Use client.IsOwner() — NOT store.IsOwnerRole(ctx). The WS router
// does not inject role into ctx (see router.go handleRequest), so the
// ctx-based helper is dead here. Client carries the authoritative role
// from connect.
if ownerID == "" && !client.IsOwner() {
key, gerr := m.apiKeys.Get(ctx, id)
if gerr != nil || key == nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "API key", params.ID)))
return
}
callerTID := store.TenantIDFromContext(ctx)
if key.TenantID == uuid.Nil || key.TenantID != callerTID {
slog.Warn("security.api_key_revoke_forbidden",
"key_id", params.ID,
"caller_tenant", callerTID,
"key_tenant", key.TenantID,
"user_id", client.UserID(),
)
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "API key")))
return
}
}
if err := m.apiKeys.Revoke(ctx, id, ownerID); err != nil {
slog.Error("api_keys.revoke failed", "error", err, "id", params.ID)
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "API key", params.ID)))
return
}
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]string{"status": "revoked"}))
}