Files
goclaw/internal/gateway/methods/api_keys_tenant_guard_test.go
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

237 lines
7.3 KiB
Go

package methods
import (
"context"
"encoding/json"
"errors"
"slices"
"sync"
"testing"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/gateway"
"github.com/nextlevelbuilder/goclaw/internal/permissions"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
// Phase 0b review regression coverage: WS api_keys.revoke.
//
// The HTTP fix in internal/http/api_keys.go has its own test file, but the
// original Phase 0b WS fix (internal/gateway/methods/api_keys.go) had zero
// test coverage — and a Major bug slipped past: store.IsOwnerRole(ctx)
// always returns false in WS handlers because the router does not inject
// role into ctx. The bug was caught by code review, not tests. These tests
// lock the fix in place and cover the four critical paths.
// ---- minimal stub store ----
type stubAPIKeyStore struct {
mu sync.Mutex
byID map[uuid.UUID]*store.APIKeyData
revokedCalls []uuid.UUID
}
func newStubAPIKeyStore() *stubAPIKeyStore {
return &stubAPIKeyStore{byID: make(map[uuid.UUID]*store.APIKeyData)}
}
func (s *stubAPIKeyStore) Create(_ context.Context, k *store.APIKeyData) error {
s.mu.Lock()
defer s.mu.Unlock()
s.byID[k.ID] = k
return nil
}
func (s *stubAPIKeyStore) Get(_ context.Context, id uuid.UUID) (*store.APIKeyData, error) {
s.mu.Lock()
defer s.mu.Unlock()
if k, ok := s.byID[id]; ok {
return k, nil
}
return nil, errors.New("not found")
}
func (s *stubAPIKeyStore) GetByHash(_ context.Context, _ string) (*store.APIKeyData, error) {
return nil, nil
}
func (s *stubAPIKeyStore) List(_ context.Context, _ string) ([]store.APIKeyData, error) {
return nil, nil
}
func (s *stubAPIKeyStore) Revoke(_ context.Context, id uuid.UUID, _ string) error {
s.mu.Lock()
defer s.mu.Unlock()
s.revokedCalls = append(s.revokedCalls, id)
if k, ok := s.byID[id]; ok {
k.Revoked = true
}
return nil
}
func (s *stubAPIKeyStore) TouchLastUsed(_ context.Context, _ uuid.UUID) error { return nil }
func (s *stubAPIKeyStore) wasRevoked(id uuid.UUID) bool {
s.mu.Lock()
defer s.mu.Unlock()
return slices.Contains(s.revokedCalls, id)
}
// wsCallCtx mirrors what router.handleRequest injects into ctx for method
// handlers: locale, tenantID (from client.TenantID()), and role (added
// by Phase 0b hardening). Tests bypass the router, so we reproduce the
// minimum set the handler reads.
func wsCallCtx(client *gateway.Client) context.Context {
ctx := context.Background()
if tid := client.TenantID(); tid != uuid.Nil {
ctx = store.WithTenantID(ctx, tid)
}
if role := client.Role(); role != "" {
ctx = store.WithRole(ctx, string(role))
}
return ctx
}
// ---- harness ----
func buildRevokeRequest(t *testing.T, keyID uuid.UUID) *protocol.RequestFrame {
t.Helper()
raw, err := json.Marshal(map[string]string{"id": keyID.String()})
if err != nil {
t.Fatalf("marshal: %v", err)
}
return &protocol.RequestFrame{
Type: protocol.FrameTypeRequest,
ID: "revoke-req-1",
Method: protocol.MethodAPIKeysRevoke,
Params: raw,
}
}
// ---- tests ----
// TestWSRevoke_TenantAdmin_CrossTenantKey_Denied is the core HIGH finding:
// a tenant admin attempting to revoke a key owned by another tenant must
// be rejected, and Revoke must not be called on the store.
func TestWSRevoke_TenantAdmin_CrossTenantKey_Denied(t *testing.T) {
callerTID := uuid.MustParse("11111111-1111-1111-1111-111111111111")
keyOwnerTID := uuid.MustParse("22222222-2222-2222-2222-222222222222")
keyID := uuid.New()
stub := newStubAPIKeyStore()
_ = stub.Create(context.Background(), &store.APIKeyData{
ID: keyID,
Name: "other-tenant-key",
TenantID: keyOwnerTID,
})
m := &APIKeysMethods{apiKeys: stub}
client := gateway.NewTestClient(permissions.RoleAdmin, callerTID, "user-1")
m.handleRevoke(wsCallCtx(client), client, buildRevokeRequest(t, keyID))
if stub.wasRevoked(keyID) {
t.Fatalf("cross-tenant key should NOT have been revoked")
}
}
// TestWSRevoke_TenantAdmin_SystemKey_Denied is the second HIGH path: the
// NULL-tenant (system) key must not be revocable by a tenant admin.
func TestWSRevoke_TenantAdmin_SystemKey_Denied(t *testing.T) {
callerTID := uuid.MustParse("33333333-3333-3333-3333-333333333333")
keyID := uuid.New()
stub := newStubAPIKeyStore()
_ = stub.Create(context.Background(), &store.APIKeyData{
ID: keyID,
Name: "system-ci-key",
TenantID: uuid.Nil, // NULL-tenant system key
})
m := &APIKeysMethods{apiKeys: stub}
client := gateway.NewTestClient(permissions.RoleAdmin, callerTID, "user-1")
m.handleRevoke(wsCallCtx(client), client, buildRevokeRequest(t, keyID))
if stub.wasRevoked(keyID) {
t.Fatalf("system (NULL-tenant) key should NOT have been revoked by tenant admin")
}
}
// TestWSRevoke_SystemOwner_SystemKey_Allowed is the regression caught by
// code review: the original Phase 0b fix used store.IsOwnerRole(ctx),
// which returns false in WS because the router does not inject role into
// ctx. This test exercises the now-correct client.IsOwner() path.
func TestWSRevoke_SystemOwner_SystemKey_Allowed(t *testing.T) {
keyID := uuid.New()
stub := newStubAPIKeyStore()
_ = stub.Create(context.Background(), &store.APIKeyData{
ID: keyID,
Name: "system-ci-key",
TenantID: uuid.Nil,
})
m := &APIKeysMethods{apiKeys: stub}
// Owner client — narrowed-to-master tenant scope is the default for
// owner logins per router.applyTenantScope.
client := gateway.NewTestClient(permissions.RoleOwner, store.MasterTenantID, "owner-1")
m.handleRevoke(wsCallCtx(client), client, buildRevokeRequest(t, keyID))
if !stub.wasRevoked(keyID) {
t.Fatalf("system owner must be allowed to revoke NULL-tenant system key")
}
}
// TestWSRevoke_TenantAdmin_OwnTenantKey_Allowed is the happy path: admin
// revoking a key inside their own tenant — no regression for legitimate
// scope-matching revocations.
func TestWSRevoke_TenantAdmin_OwnTenantKey_Allowed(t *testing.T) {
tid := uuid.MustParse("44444444-4444-4444-4444-444444444444")
keyID := uuid.New()
stub := newStubAPIKeyStore()
_ = stub.Create(context.Background(), &store.APIKeyData{
ID: keyID,
Name: "own-tenant-key",
TenantID: tid,
})
m := &APIKeysMethods{apiKeys: stub}
client := gateway.NewTestClient(permissions.RoleAdmin, tid, "user-1")
m.handleRevoke(wsCallCtx(client), client, buildRevokeRequest(t, keyID))
if !stub.wasRevoked(keyID) {
t.Fatalf("own-tenant key must be revocable by same-tenant admin")
}
}
// TestWSRevoke_SystemOwner_CrossTenantKey_Allowed documents the intentional
// behaviour: a system owner narrowed to the master scope can still revoke
// keys belonging to other tenants (operator-level housekeeping). If this
// is ever tightened, update both the test and the handler comment.
func TestWSRevoke_SystemOwner_CrossTenantKey_Allowed(t *testing.T) {
otherTID := uuid.MustParse("55555555-5555-5555-5555-555555555555")
keyID := uuid.New()
stub := newStubAPIKeyStore()
_ = stub.Create(context.Background(), &store.APIKeyData{
ID: keyID,
Name: "tenant-specific-key",
TenantID: otherTID,
})
m := &APIKeysMethods{apiKeys: stub}
client := gateway.NewTestClient(permissions.RoleOwner, store.MasterTenantID, "owner-1")
m.handleRevoke(wsCallCtx(client), client, buildRevokeRequest(t, keyID))
if !stub.wasRevoked(keyID) {
t.Fatalf("system owner must be allowed to revoke cross-tenant keys")
}
}