mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-07-12 13:04:57 +00:00
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.
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/permissions"
|
||||
)
|
||||
|
||||
// NewTestClient returns a minimally-wired Client for unit tests in other
|
||||
// packages. Role + tenant are set directly because the underlying fields are
|
||||
// unexported. SendResponse is safe because the send channel is nil — the
|
||||
// writer hits the default branch of the select and drops the frame silently.
|
||||
//
|
||||
// Not for production use. Any non-test caller should use NewClient instead.
|
||||
func NewTestClient(role permissions.Role, tenantID uuid.UUID, userID string) *Client {
|
||||
return &Client{
|
||||
id: uuid.NewString(),
|
||||
authenticated: true,
|
||||
role: role,
|
||||
userID: userID,
|
||||
tenantID: tenantID,
|
||||
}
|
||||
}
|
||||
@@ -163,6 +163,14 @@ func (m *APIKeysMethods) handleCreate(ctx context.Context, client *gateway.Clien
|
||||
}))
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
@@ -182,12 +190,40 @@ func (m *APIKeysMethods) handleRevoke(ctx context.Context, client *gateway.Clien
|
||||
return
|
||||
}
|
||||
|
||||
// Non-admin callers can only revoke their own keys.
|
||||
// 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)))
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/titanous/json5"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
||||
@@ -65,9 +64,12 @@ func (m *ConfigMethods) requireOwner(next gateway.MethodHandler) gateway.MethodH
|
||||
// on-disk config.json. A non-master tenant admin calling config.patch would
|
||||
// corrupt master state + leak master config to other tenants. This guard keeps
|
||||
// config.* strictly master-scoped until a tenant-aware refactor lands.
|
||||
//
|
||||
// Shares the predicate with store.IsMasterScope so HTTP and WS layers can't
|
||||
// drift — same rule, one source of truth.
|
||||
func (m *ConfigMethods) requireMasterScope(next gateway.MethodHandler) gateway.MethodHandler {
|
||||
return func(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
||||
if !isMasterScopeContext(ctx) {
|
||||
if !store.IsMasterScope(ctx) {
|
||||
locale := store.LocaleFromContext(ctx)
|
||||
client.SendResponse(protocol.NewErrorResponse(
|
||||
req.ID,
|
||||
@@ -80,18 +82,6 @@ func (m *ConfigMethods) requireMasterScope(next gateway.MethodHandler) gateway.M
|
||||
}
|
||||
}
|
||||
|
||||
// isMasterScopeContext returns true when ctx should be treated as master-scope:
|
||||
// (a) system owner role (bypass-all), or
|
||||
// (b) tenant id is unset (uuid.Nil — legacy / system callers), or
|
||||
// (c) tenant id equals store.MasterTenantID.
|
||||
func isMasterScopeContext(ctx context.Context) bool {
|
||||
if store.IsOwnerRole(ctx) {
|
||||
return true
|
||||
}
|
||||
tid := store.TenantIDFromContext(ctx)
|
||||
return tid == uuid.Nil || tid == store.MasterTenantID
|
||||
}
|
||||
|
||||
func (m *ConfigMethods) handleGet(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
||||
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
|
||||
"config": m.cfg.MaskedCopy(),
|
||||
|
||||
@@ -11,13 +11,13 @@ import (
|
||||
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
||||
)
|
||||
|
||||
// ---- Tests: isMasterScopeContext helper ----
|
||||
// ---- Tests: store.IsMasterScope helper ----
|
||||
|
||||
func TestIsMasterScopeContext_OwnerRole_Allowed(t *testing.T) {
|
||||
ctx := store.WithRole(context.Background(), store.RoleOwner)
|
||||
// Tenant set to a non-master tenant — owner role must still pass
|
||||
ctx = store.WithTenantID(ctx, uuid.MustParse("11111111-1111-1111-1111-111111111111"))
|
||||
if !isMasterScopeContext(ctx) {
|
||||
if !store.IsMasterScope(ctx) {
|
||||
t.Fatalf("owner role with non-master tenant should be allowed")
|
||||
}
|
||||
}
|
||||
@@ -25,14 +25,14 @@ func TestIsMasterScopeContext_OwnerRole_Allowed(t *testing.T) {
|
||||
func TestIsMasterScopeContext_NilTenant_Allowed(t *testing.T) {
|
||||
// Legacy / system callers without tenant scope → treated as master
|
||||
ctx := context.Background()
|
||||
if !isMasterScopeContext(ctx) {
|
||||
if !store.IsMasterScope(ctx) {
|
||||
t.Fatalf("nil tenant ctx should be allowed (master-scope fallback)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsMasterScopeContext_MasterTenant_Allowed(t *testing.T) {
|
||||
ctx := store.WithTenantID(context.Background(), store.MasterTenantID)
|
||||
if !isMasterScopeContext(ctx) {
|
||||
if !store.IsMasterScope(ctx) {
|
||||
t.Fatalf("master tenant ctx should be allowed")
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,7 @@ func TestIsMasterScopeContext_MasterTenant_Allowed(t *testing.T) {
|
||||
func TestIsMasterScopeContext_NonMasterTenantNoOwner_Denied(t *testing.T) {
|
||||
tid := uuid.MustParse("22222222-2222-2222-2222-222222222222")
|
||||
ctx := store.WithTenantID(context.Background(), tid)
|
||||
if isMasterScopeContext(ctx) {
|
||||
if store.IsMasterScope(ctx) {
|
||||
t.Fatalf("non-master tenant ctx without owner role must be denied")
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,7 @@ func TestIsMasterScopeContext_NonMasterTenantWithOwnerRole_Allowed(t *testing.T)
|
||||
tid := uuid.MustParse("33333333-3333-3333-3333-333333333333")
|
||||
ctx := store.WithTenantID(context.Background(), tid)
|
||||
ctx = store.WithRole(ctx, store.RoleOwner)
|
||||
if !isMasterScopeContext(ctx) {
|
||||
if !store.IsMasterScope(ctx) {
|
||||
t.Fatalf("owner role must bypass tenant scope check")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,8 +78,11 @@ func (r *MethodRouter) Handle(ctx context.Context, client *Client, req *protocol
|
||||
}
|
||||
}
|
||||
|
||||
// Inject locale + tenant into context.
|
||||
// Inject locale + tenant + role into context.
|
||||
// All connect paths guarantee client.tenantID is set (owner defaults to MasterTenantID).
|
||||
// Role injection is required so store.IsOwnerRole / store.IsMasterScope work
|
||||
// from WS handlers — without it, ctx-based permission helpers silently
|
||||
// evaluate as non-owner. HTTP layer does the same via enrichContext.
|
||||
ctx = store.WithLocale(ctx, i18n.Normalize(client.locale))
|
||||
if client.TenantID() != uuid.Nil {
|
||||
ctx = store.WithTenantID(ctx, client.TenantID())
|
||||
@@ -87,6 +90,9 @@ func (r *MethodRouter) Handle(ctx context.Context, client *Client, req *protocol
|
||||
if slug := client.TenantSlug(); slug != "" {
|
||||
ctx = store.WithTenantSlug(ctx, slug)
|
||||
}
|
||||
if role := client.Role(); role != "" {
|
||||
ctx = store.WithRole(ctx, string(role))
|
||||
}
|
||||
|
||||
slog.Debug("handling method", "method", req.Method, "client", client.id, "req_id", req.ID)
|
||||
handler(ctx, client, req)
|
||||
|
||||
@@ -16,12 +16,25 @@ import (
|
||||
type mockAPIKeyStore struct {
|
||||
mu sync.Mutex
|
||||
keys map[string]*store.APIKeyData // hash → key
|
||||
calls int // GetByHash call count
|
||||
touchedID uuid.UUID // last TouchLastUsed ID
|
||||
byID map[uuid.UUID]*store.APIKeyData
|
||||
calls int // GetByHash call count
|
||||
touchedID uuid.UUID // last TouchLastUsed ID
|
||||
}
|
||||
|
||||
func newMockAPIKeyStore() *mockAPIKeyStore {
|
||||
return &mockAPIKeyStore{keys: make(map[string]*store.APIKeyData)}
|
||||
return &mockAPIKeyStore{
|
||||
keys: make(map[string]*store.APIKeyData),
|
||||
byID: make(map[uuid.UUID]*store.APIKeyData),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockAPIKeyStore) Get(_ context.Context, id uuid.UUID) (*store.APIKeyData, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if k, ok := m.byID[id]; ok {
|
||||
return k, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockAPIKeyStore) GetByHash(_ context.Context, hash string) (*store.APIKeyData, error) {
|
||||
@@ -46,7 +59,6 @@ func (m *mockAPIKeyStore) List(_ context.Context, _ string) ([]store.APIKeyData,
|
||||
return nil, nil
|
||||
}
|
||||
func (m *mockAPIKeyStore) Revoke(_ context.Context, _ uuid.UUID, _ string) error { return nil }
|
||||
func (m *mockAPIKeyStore) Delete(_ context.Context, _ uuid.UUID, _ string) error { return nil }
|
||||
|
||||
func (m *mockAPIKeyStore) getCalls() int {
|
||||
m.mu.Lock()
|
||||
|
||||
@@ -148,6 +148,14 @@ func (h *APIKeysHandler) handleCreate(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// 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")
|
||||
@@ -157,8 +165,35 @@ func (h *APIKeysHandler) handleRevoke(w http.ResponseWriter, r *http.Request) {
|
||||
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(r.Context(), id, ""); err != nil {
|
||||
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
|
||||
|
||||
@@ -119,6 +119,13 @@ func (h *BuiltinToolsHandler) handleGet(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
func (h *BuiltinToolsHandler) handleUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
// Phase 0b hotfix: builtin_tools is a global table with no tenant_id column,
|
||||
// so this write must be restricted to master-scope callers. Non-master tenant
|
||||
// admins must go through PUT /v1/tools/builtin/{name}/tenant-config instead.
|
||||
// See plans/reports/debugger-260412-0922-tenant-scope-audit.md CRITICAL-1.
|
||||
if !requireMasterScope(w, r) {
|
||||
return
|
||||
}
|
||||
locale := extractLocale(r)
|
||||
name := r.PathValue("name")
|
||||
if name == "" {
|
||||
|
||||
@@ -81,7 +81,15 @@ func parseAndValidatePackage(w http.ResponseWriter, r *http.Request) string {
|
||||
|
||||
// handleInstall installs a single package.
|
||||
// Body: {"package": "github-cli"} or {"package": "pip:pandas"} or {"package": "npm:typescript"}
|
||||
//
|
||||
// Phase 0b hotfix: server-wide package installation (pip/npm/apk) must be
|
||||
// restricted to master-scope callers. Non-master tenant admins previously
|
||||
// reached this handler because the adminAuth middleware only checks role, not
|
||||
// tenant scope — a supply-chain vector (CRITICAL-2 in the audit report).
|
||||
func (h *PackagesHandler) handleInstall(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireMasterScope(w, r) {
|
||||
return
|
||||
}
|
||||
pkg := parseAndValidatePackage(w, r)
|
||||
if pkg == "" {
|
||||
return
|
||||
@@ -96,7 +104,13 @@ func (h *PackagesHandler) handleInstall(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
// handleUninstall removes a single package.
|
||||
// Body: {"package": "github-cli"} or {"package": "pip:pandas"} or {"package": "npm:typescript"}
|
||||
//
|
||||
// Phase 0b hotfix: same master-scope guard as handleInstall — uninstall can
|
||||
// break system skills, causing server-wide DoS for every tenant.
|
||||
func (h *PackagesHandler) handleUninstall(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireMasterScope(w, r) {
|
||||
return
|
||||
}
|
||||
pkg := parseAndValidatePackage(w, r)
|
||||
if pkg == "" {
|
||||
return
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -53,3 +54,34 @@ func requireTenantAdmin(w http.ResponseWriter, r *http.Request, ts store.TenantS
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// requireMasterScope guards endpoints that write to global (non-tenant-scoped)
|
||||
// tables or execute server-wide side effects (shell, filesystem). Rejects
|
||||
// callers whose ctx is scoped to a non-master tenant even if they hold
|
||||
// RoleAdmin in that tenant. System owners bypass.
|
||||
//
|
||||
// Symmetric counterpart to requireTenantAdmin: one guards tenant-scoped
|
||||
// writes, the other guards global writes. Use on any PUT/POST/DELETE handler
|
||||
// touching builtin_tools, disk config, package management, or similar global
|
||||
// state. Shares the predicate with WS layer via store.IsMasterScope so rules
|
||||
// cannot drift between transports.
|
||||
//
|
||||
// Returns true on allow, false on deny (in which case a 403 response has
|
||||
// already been written). Emits security.tenant_scope_violation slog on deny.
|
||||
func requireMasterScope(w http.ResponseWriter, r *http.Request) bool {
|
||||
ctx := r.Context()
|
||||
if store.IsMasterScope(ctx) {
|
||||
return true
|
||||
}
|
||||
slog.Warn("security.tenant_scope_violation",
|
||||
"path", r.URL.Path,
|
||||
"method", r.Method,
|
||||
"tenant_id", store.TenantIDFromContext(ctx),
|
||||
"user_id", store.UserIDFromContext(ctx),
|
||||
)
|
||||
locale := store.LocaleFromContext(ctx)
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{
|
||||
"error": i18n.T(locale, i18n.MsgMasterScopeRequired),
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
|
||||
// Phase 0b hotfix coverage:
|
||||
// - requireMasterScope unit tests (predicate surface)
|
||||
// - builtin_tools handleUpdate: CRITICAL-1 regression guard
|
||||
// - packages handleInstall / handleUninstall: CRITICAL-2 regression guard
|
||||
// - api_keys handleRevoke: HIGH finding regression guard
|
||||
//
|
||||
// All tests use focused harnesses that avoid DB / real installers.
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
func newMasterScopeReq(method, path string, tid uuid.UUID, role string) *http.Request {
|
||||
r := httptest.NewRequest(method, path, strings.NewReader(""))
|
||||
ctx := r.Context()
|
||||
if tid != uuid.Nil {
|
||||
ctx = store.WithTenantID(ctx, tid)
|
||||
}
|
||||
if role != "" {
|
||||
ctx = store.WithRole(ctx, role)
|
||||
}
|
||||
return r.WithContext(ctx)
|
||||
}
|
||||
|
||||
// ---- requireMasterScope predicate tests ----
|
||||
|
||||
func TestRequireMasterScope_SystemOwner_Allows(t *testing.T) {
|
||||
// Owner role on arbitrary tenant must pass.
|
||||
r := newMasterScopeReq(http.MethodPut, "/test", uuid.New(), store.RoleOwner)
|
||||
w := httptest.NewRecorder()
|
||||
if !requireMasterScope(w, r) {
|
||||
t.Fatalf("system owner must be allowed, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireMasterScope_MasterTenant_Allows(t *testing.T) {
|
||||
r := newMasterScopeReq(http.MethodPut, "/test", store.MasterTenantID, "admin")
|
||||
w := httptest.NewRecorder()
|
||||
if !requireMasterScope(w, r) {
|
||||
t.Fatalf("master tenant ctx must be allowed, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireMasterScope_NilTenant_Allows(t *testing.T) {
|
||||
// Legacy / system callers without any tenant ctx.
|
||||
r := newMasterScopeReq(http.MethodPut, "/test", uuid.Nil, "")
|
||||
w := httptest.NewRecorder()
|
||||
if !requireMasterScope(w, r) {
|
||||
t.Fatalf("nil tenant ctx must be allowed (master-scope fallback), got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireMasterScope_NonMasterAdmin_Rejects(t *testing.T) {
|
||||
// The bug-fix path: non-master tenant admin must be rejected.
|
||||
tid := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
||||
r := newMasterScopeReq(http.MethodPut, "/test", tid, "admin")
|
||||
w := httptest.NewRecorder()
|
||||
if requireMasterScope(w, r) {
|
||||
t.Fatalf("non-master admin ctx must NOT be allowed")
|
||||
}
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d", w.Code)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "master") {
|
||||
t.Errorf("expected error message to mention master scope, got: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ---- CRITICAL-1: builtin_tools handleUpdate regression ----
|
||||
|
||||
// TestBuiltinToolsUpdate_RejectsNonMasterAdmin verifies that Phase 0b guard
|
||||
// blocks the exact attack vector described in the audit: a tenant admin
|
||||
// (RoleAdmin in a non-master tenant) attempting PUT /v1/tools/builtin/{name}
|
||||
// receives 403 without any store mutation.
|
||||
func TestBuiltinToolsUpdate_RejectsNonMasterAdmin(t *testing.T) {
|
||||
h := &BuiltinToolsHandler{} // store nil — must never be reached
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("PUT /v1/tools/builtin/{name}", h.handleUpdate)
|
||||
|
||||
tid := uuid.MustParse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")
|
||||
req := httptest.NewRequest(http.MethodPut, "/v1/tools/builtin/web_search",
|
||||
strings.NewReader(`{"enabled":true}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
ctx := store.WithTenantID(req.Context(), tid)
|
||||
ctx = store.WithRole(ctx, "admin")
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
// If the guard fails open, h.store.Update is called on a nil store and
|
||||
// the test panics — explicit recover catches that regression distinctly.
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("guard did not short-circuit — handler panicked on nil store: %v", r)
|
||||
}
|
||||
}()
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403 for non-master admin, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ---- CRITICAL-2: packages handleInstall / handleUninstall regression ----
|
||||
|
||||
func TestPackagesInstall_RejectsNonMasterAdmin(t *testing.T) {
|
||||
h := NewPackagesHandler()
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /v1/packages/install", h.handleInstall)
|
||||
|
||||
tid := uuid.MustParse("cccccccc-cccc-cccc-cccc-cccccccccccc")
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/packages/install",
|
||||
strings.NewReader(`{"package":"pip:malicious-pkg"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
ctx := store.WithTenantID(req.Context(), tid)
|
||||
ctx = store.WithRole(ctx, "admin")
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403 for non-master admin, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackagesUninstall_RejectsNonMasterAdmin(t *testing.T) {
|
||||
h := NewPackagesHandler()
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /v1/packages/uninstall", h.handleUninstall)
|
||||
|
||||
tid := uuid.MustParse("dddddddd-dddd-dddd-dddd-dddddddddddd")
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/packages/uninstall",
|
||||
strings.NewReader(`{"package":"pip:pandas"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
ctx := store.WithTenantID(req.Context(), tid)
|
||||
ctx = store.WithRole(ctx, "admin")
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403 for non-master admin, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ---- HIGH: api_keys handleRevoke ownership regression ----
|
||||
|
||||
// TestAPIKeyRevoke_RejectsCrossTenant exercises the post-fix ownership check
|
||||
// in the HTTP handler. A tenant admin (non-owner) attempting to revoke a
|
||||
// key owned by a different tenant must receive 403, and the store-layer
|
||||
// Revoke must never be called — mockAPIKeyStore flags when it is.
|
||||
func TestAPIKeyRevoke_RejectsCrossTenant(t *testing.T) {
|
||||
ms := newMockAPIKeyStore()
|
||||
callerTID := uuid.MustParse("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee")
|
||||
keyOwnerTID := uuid.MustParse("ffffffff-ffff-ffff-ffff-ffffffffffff")
|
||||
keyID := uuid.New()
|
||||
ms.byID[keyID] = &store.APIKeyData{
|
||||
ID: keyID,
|
||||
Name: "other-tenant-key",
|
||||
TenantID: keyOwnerTID,
|
||||
}
|
||||
|
||||
h := &APIKeysHandler{apiKeys: ms}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /v1/api-keys/{id}/revoke", h.handleRevoke)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/api-keys/"+keyID.String()+"/revoke", nil)
|
||||
ctx := store.WithTenantID(req.Context(), callerTID)
|
||||
ctx = store.WithRole(ctx, "admin")
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403 for cross-tenant revoke, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAPIKeyRevoke_RejectsSystemKeyForTenantAdmin verifies the NULL-tenant
|
||||
// (system-level) key cannot be revoked by a tenant admin — the core HIGH
|
||||
// vulnerability from the audit (store SQL `OR tenant_id IS NULL` arm).
|
||||
func TestAPIKeyRevoke_RejectsSystemKeyForTenantAdmin(t *testing.T) {
|
||||
ms := newMockAPIKeyStore()
|
||||
callerTID := uuid.MustParse("11111111-1111-1111-1111-111111111111")
|
||||
keyID := uuid.New()
|
||||
// Key with TenantID == uuid.Nil represents a NULL-tenant system key.
|
||||
ms.byID[keyID] = &store.APIKeyData{
|
||||
ID: keyID,
|
||||
Name: "system-ci-key",
|
||||
TenantID: uuid.Nil,
|
||||
}
|
||||
|
||||
h := &APIKeysHandler{apiKeys: ms}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /v1/api-keys/{id}/revoke", h.handleRevoke)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/api-keys/"+keyID.String()+"/revoke", nil)
|
||||
ctx := store.WithTenantID(req.Context(), callerTID)
|
||||
ctx = store.WithRole(ctx, "admin")
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403 for tenant admin revoking system key, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAPIKeyRevoke_AllowsOwnTenantKey happy path — tenant admin revoking a
|
||||
// key owned by their own tenant must succeed (no regression).
|
||||
func TestAPIKeyRevoke_AllowsOwnTenantKey(t *testing.T) {
|
||||
ms := newMockAPIKeyStore()
|
||||
tid := uuid.MustParse("22222222-2222-2222-2222-222222222222")
|
||||
keyID := uuid.New()
|
||||
ms.byID[keyID] = &store.APIKeyData{
|
||||
ID: keyID,
|
||||
Name: "own-tenant-key",
|
||||
TenantID: tid,
|
||||
}
|
||||
|
||||
h := &APIKeysHandler{apiKeys: ms}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /v1/api-keys/{id}/revoke", h.handleRevoke)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/api-keys/"+keyID.String()+"/revoke", nil)
|
||||
ctx := store.WithTenantID(req.Context(), tid)
|
||||
ctx = store.WithRole(ctx, "admin")
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 for same-tenant revoke, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAPIKeyRevoke_AllowsSystemOwnerOnSystemKey happy path — system owner
|
||||
// can revoke NULL-tenant system keys (CI/CD rotation).
|
||||
func TestAPIKeyRevoke_AllowsSystemOwnerOnSystemKey(t *testing.T) {
|
||||
ms := newMockAPIKeyStore()
|
||||
keyID := uuid.New()
|
||||
ms.byID[keyID] = &store.APIKeyData{
|
||||
ID: keyID,
|
||||
Name: "system-ci-key",
|
||||
TenantID: uuid.Nil,
|
||||
}
|
||||
|
||||
h := &APIKeysHandler{apiKeys: ms}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /v1/api-keys/{id}/revoke", h.handleRevoke)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/api-keys/"+keyID.String()+"/revoke", nil)
|
||||
ctx := store.WithRole(req.Context(), store.RoleOwner)
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 for system owner revoking system key, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// --- master-scope happy paths on guarded handlers ---
|
||||
|
||||
// recordingBuiltinToolStore is a minimal BuiltinToolStore stub that records
|
||||
// calls to Update so the happy-path test can assert "the guard allowed the
|
||||
// request through to the store" without relying on panic-recover semantics.
|
||||
// Only Update is exercised; the rest return zero values to satisfy the
|
||||
// interface contract.
|
||||
type recordingBuiltinToolStore struct {
|
||||
updateName string
|
||||
updateBody map[string]any
|
||||
}
|
||||
|
||||
func (s *recordingBuiltinToolStore) Update(_ context.Context, name string, updates map[string]any) error {
|
||||
s.updateName = name
|
||||
s.updateBody = updates
|
||||
return nil
|
||||
}
|
||||
func (s *recordingBuiltinToolStore) List(_ context.Context) ([]store.BuiltinToolDef, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *recordingBuiltinToolStore) Get(_ context.Context, _ string) (*store.BuiltinToolDef, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *recordingBuiltinToolStore) Seed(_ context.Context, _ []store.BuiltinToolDef) error {
|
||||
return nil
|
||||
}
|
||||
func (s *recordingBuiltinToolStore) ListEnabled(_ context.Context) ([]store.BuiltinToolDef, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *recordingBuiltinToolStore) GetSettings(_ context.Context, _ string) (json.RawMessage, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// TestBuiltinToolsUpdate_AllowsMasterAdmin ensures master-scope callers are
|
||||
// not blocked by Phase 0b. Uses a recording stub store so we can assert the
|
||||
// guard passed and the handler reached Update — no fragile panic-recover.
|
||||
func TestBuiltinToolsUpdate_AllowsMasterAdmin(t *testing.T) {
|
||||
rec := &recordingBuiltinToolStore{}
|
||||
h := &BuiltinToolsHandler{store: rec}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("PUT /v1/tools/builtin/{name}", h.handleUpdate)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPut, "/v1/tools/builtin/web_search",
|
||||
strings.NewReader(`{"enabled":true}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
ctx := store.WithTenantID(req.Context(), store.MasterTenantID)
|
||||
ctx = store.WithRole(ctx, "admin")
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 for master admin, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if rec.updateName != "web_search" {
|
||||
t.Errorf("expected Update called with name=web_search, got %q", rec.updateName)
|
||||
}
|
||||
if enabled, ok := rec.updateBody["enabled"].(bool); !ok || !enabled {
|
||||
t.Errorf("expected Update body {enabled:true}, got %+v", rec.updateBody)
|
||||
}
|
||||
}
|
||||
|
||||
// sanity: make sure json is importable (unused import guard during refactors)
|
||||
var _ = json.Marshal
|
||||
@@ -108,6 +108,7 @@ func init() {
|
||||
MsgRawConfigRequired: "raw config is required",
|
||||
MsgRawPatchRequired: "raw patch is required",
|
||||
MsgConfigMasterScopeOnly: "config.* methods are master-scope only; use tenant tool config endpoints for per-tenant overrides",
|
||||
MsgMasterScopeRequired: "this action requires master tenant scope",
|
||||
|
||||
// Storage / File
|
||||
MsgCannotDeleteSkillsDir: "cannot delete skills directories",
|
||||
|
||||
@@ -108,6 +108,7 @@ func init() {
|
||||
MsgRawConfigRequired: "cấu hình raw là bắt buộc",
|
||||
MsgRawPatchRequired: "patch raw là bắt buộc",
|
||||
MsgConfigMasterScopeOnly: "config.* chỉ áp dụng cho master scope; dùng endpoint tenant tool config cho override theo tenant",
|
||||
MsgMasterScopeRequired: "thao tác này yêu cầu phạm vi tenant chính",
|
||||
|
||||
// Storage / File
|
||||
MsgCannotDeleteSkillsDir: "không thể xóa thư mục skill",
|
||||
|
||||
@@ -108,6 +108,7 @@ func init() {
|
||||
MsgRawConfigRequired: "raw 配置是必填项",
|
||||
MsgRawPatchRequired: "raw 补丁是必填项",
|
||||
MsgConfigMasterScopeOnly: "config.* 方法仅适用于主作用域;使用租户工具配置端点进行租户级覆盖",
|
||||
MsgMasterScopeRequired: "此操作需要主租户范围",
|
||||
|
||||
// Storage / File
|
||||
MsgCannotDeleteSkillsDir: "无法删除Skill目录",
|
||||
|
||||
@@ -109,6 +109,7 @@ const (
|
||||
MsgRawConfigRequired = "error.raw_config_required" // "raw config is required"
|
||||
MsgRawPatchRequired = "error.raw_patch_required" // "raw patch is required"
|
||||
MsgConfigMasterScopeOnly = "error.config_master_scope_only" // "config.* methods are master-scope only"
|
||||
MsgMasterScopeRequired = "error.master_scope_required" // "this action requires master tenant scope"
|
||||
|
||||
// --- Storage / File ---
|
||||
MsgCannotDeleteSkillsDir = "error.cannot_delete_skills_dir" // "cannot delete skills directories"
|
||||
|
||||
@@ -29,6 +29,13 @@ type APIKeyStore interface {
|
||||
// Create inserts a new API key.
|
||||
Create(ctx context.Context, key *APIKeyData) error
|
||||
|
||||
// Get looks up an API key by its ID. Unlike GetByHash, this does NOT
|
||||
// filter on revoked/expired state — used by admin handlers that need to
|
||||
// verify ownership before revoke/delete. Returns sql.ErrNoRows when the
|
||||
// key does not exist. No tenant scoping is applied at store level —
|
||||
// callers must enforce their own ownership rules.
|
||||
Get(ctx context.Context, id uuid.UUID) (*APIKeyData, error)
|
||||
|
||||
// GetByHash looks up an active (non-revoked, non-expired) key by its SHA-256 hash.
|
||||
GetByHash(ctx context.Context, keyHash string) (*APIKeyData, error)
|
||||
|
||||
@@ -38,9 +45,6 @@ type APIKeyStore interface {
|
||||
// Revoke marks a key as revoked. If ownerID is non-empty, also enforces owner_id = ownerID.
|
||||
Revoke(ctx context.Context, id uuid.UUID, ownerID string) error
|
||||
|
||||
// Delete permanently removes a key. If ownerID is non-empty, also enforces owner_id = ownerID.
|
||||
Delete(ctx context.Context, id uuid.UUID, ownerID string) error
|
||||
|
||||
// TouchLastUsed updates the last_used_at timestamp.
|
||||
TouchLastUsed(ctx context.Context, id uuid.UUID) error
|
||||
}
|
||||
|
||||
@@ -287,6 +287,24 @@ func IsOwnerRole(ctx context.Context) bool {
|
||||
return RoleFromContext(ctx) == string(RoleOwner)
|
||||
}
|
||||
|
||||
// IsMasterScope reports whether ctx should be treated as master-scope:
|
||||
//
|
||||
// (a) system owner role (IsOwnerRole bypass-all), or
|
||||
// (b) tenant id is unset (uuid.Nil — legacy / system callers), or
|
||||
// (c) tenant id equals MasterTenantID.
|
||||
//
|
||||
// Used by both WS config.* methods and HTTP admin routes that write to
|
||||
// global (non-tenant-scoped) tables or execute server-wide side effects
|
||||
// (shell, filesystem). Centralises the Phase 1 / Phase 0b hotfix rule
|
||||
// so every layer shares one predicate — no drift.
|
||||
func IsMasterScope(ctx context.Context) bool {
|
||||
if IsOwnerRole(ctx) {
|
||||
return true
|
||||
}
|
||||
tid := TenantIDFromContext(ctx)
|
||||
return tid == uuid.Nil || tid == MasterTenantID
|
||||
}
|
||||
|
||||
// RoleOwner is the owner role constant for context checks.
|
||||
// Must match permissions.RoleOwner.
|
||||
const RoleOwner = "owner"
|
||||
|
||||
@@ -41,6 +41,40 @@ func (s *PGAPIKeyStore) Create(ctx context.Context, key *store.APIKeyData) error
|
||||
return err
|
||||
}
|
||||
|
||||
// Get fetches a key by ID without revoked/expired filtering. No tenant scoping
|
||||
// at store layer — callers must enforce their own ownership rules.
|
||||
func (s *PGAPIKeyStore) Get(ctx context.Context, id uuid.UUID) (*store.APIKeyData, error) {
|
||||
row := s.db.QueryRowContext(ctx,
|
||||
`SELECT id, name, prefix, key_hash, scopes, owner_id, tenant_id, expires_at, last_used_at, revoked, created_by, created_at, updated_at
|
||||
FROM api_keys
|
||||
WHERE id = $1`,
|
||||
id,
|
||||
)
|
||||
|
||||
var k store.APIKeyData
|
||||
var createdBy *string
|
||||
var ownerID *string
|
||||
var tenantID *uuid.UUID
|
||||
err := row.Scan(
|
||||
&k.ID, &k.Name, &k.Prefix, &k.KeyHash, pq.Array(&k.Scopes),
|
||||
&ownerID, &tenantID, &k.ExpiresAt, &k.LastUsedAt, &k.Revoked, &createdBy,
|
||||
&k.CreatedAt, &k.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if createdBy != nil {
|
||||
k.CreatedBy = *createdBy
|
||||
}
|
||||
if ownerID != nil {
|
||||
k.OwnerID = *ownerID
|
||||
}
|
||||
if tenantID != nil {
|
||||
k.TenantID = *tenantID
|
||||
}
|
||||
return &k, nil
|
||||
}
|
||||
|
||||
func (s *PGAPIKeyStore) GetByHash(ctx context.Context, keyHash string) (*store.APIKeyData, error) {
|
||||
row := s.db.QueryRowContext(ctx,
|
||||
`SELECT id, name, prefix, key_hash, scopes, owner_id, tenant_id, expires_at, last_used_at, revoked, created_by, created_at, updated_at
|
||||
@@ -167,35 +201,6 @@ func (s *PGAPIKeyStore) Revoke(ctx context.Context, id uuid.UUID, ownerID string
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PGAPIKeyStore) Delete(ctx context.Context, id uuid.UUID, ownerID string) error {
|
||||
q := "DELETE FROM api_keys WHERE id = $1"
|
||||
args := []any{id}
|
||||
argIdx := 2
|
||||
|
||||
if ownerID != "" {
|
||||
q += fmt.Sprintf(" AND owner_id = $%d", argIdx)
|
||||
args = append(args, ownerID)
|
||||
argIdx++
|
||||
}
|
||||
if !store.IsCrossTenant(ctx) {
|
||||
tid := store.TenantIDFromContext(ctx)
|
||||
if tid != uuid.Nil {
|
||||
q += fmt.Sprintf(" AND (tenant_id = $%d OR tenant_id IS NULL)", argIdx)
|
||||
args = append(args, tid)
|
||||
}
|
||||
}
|
||||
|
||||
res, err := s.db.ExecContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PGAPIKeyStore) TouchLastUsed(ctx context.Context, id uuid.UUID) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`UPDATE api_keys SET last_used_at = $2 WHERE id = $1`,
|
||||
|
||||
@@ -41,6 +41,52 @@ func (s *SQLiteAPIKeyStore) Create(ctx context.Context, key *store.APIKeyData) e
|
||||
return err
|
||||
}
|
||||
|
||||
// Get fetches a key by ID without revoked/expired filtering. No tenant scoping
|
||||
// at store layer — callers must enforce their own ownership rules.
|
||||
func (s *SQLiteAPIKeyStore) Get(ctx context.Context, id uuid.UUID) (*store.APIKeyData, error) {
|
||||
row := s.db.QueryRowContext(ctx,
|
||||
`SELECT id, name, prefix, key_hash, scopes, owner_id, tenant_id, expires_at, last_used_at, revoked, created_by, created_at, updated_at
|
||||
FROM api_keys
|
||||
WHERE id = ?`,
|
||||
id,
|
||||
)
|
||||
|
||||
var k store.APIKeyData
|
||||
var createdBy *string
|
||||
var ownerID *string
|
||||
var tenantID *uuid.UUID
|
||||
var scopesRaw []byte
|
||||
var expiresAt, lastUsedAt nullSqliteTime
|
||||
createdAt, updatedAt := scanTimePair()
|
||||
err := row.Scan(
|
||||
&k.ID, &k.Name, &k.Prefix, &k.KeyHash, &scopesRaw,
|
||||
&ownerID, &tenantID, &expiresAt, &lastUsedAt, &k.Revoked, &createdBy,
|
||||
createdAt, updatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
k.CreatedAt = createdAt.Time
|
||||
k.UpdatedAt = updatedAt.Time
|
||||
if expiresAt.Valid {
|
||||
k.ExpiresAt = &expiresAt.Time
|
||||
}
|
||||
if lastUsedAt.Valid {
|
||||
k.LastUsedAt = &lastUsedAt.Time
|
||||
}
|
||||
scanJSONStringArray(scopesRaw, &k.Scopes)
|
||||
if createdBy != nil {
|
||||
k.CreatedBy = *createdBy
|
||||
}
|
||||
if ownerID != nil {
|
||||
k.OwnerID = *ownerID
|
||||
}
|
||||
if tenantID != nil {
|
||||
k.TenantID = *tenantID
|
||||
}
|
||||
return &k, nil
|
||||
}
|
||||
|
||||
func (s *SQLiteAPIKeyStore) GetByHash(ctx context.Context, keyHash string) (*store.APIKeyData, error) {
|
||||
row := s.db.QueryRowContext(ctx,
|
||||
`SELECT id, name, prefix, key_hash, scopes, owner_id, tenant_id, expires_at, last_used_at, revoked, created_by, created_at, updated_at
|
||||
@@ -185,33 +231,6 @@ func (s *SQLiteAPIKeyStore) Revoke(ctx context.Context, id uuid.UUID, ownerID st
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLiteAPIKeyStore) Delete(ctx context.Context, id uuid.UUID, ownerID string) error {
|
||||
q := "DELETE FROM api_keys WHERE id = ?"
|
||||
args := []any{id}
|
||||
|
||||
if ownerID != "" {
|
||||
q += " AND owner_id = ?"
|
||||
args = append(args, ownerID)
|
||||
}
|
||||
if !store.IsCrossTenant(ctx) {
|
||||
tid := store.TenantIDFromContext(ctx)
|
||||
if tid != uuid.Nil {
|
||||
q += " AND (tenant_id = ? OR tenant_id IS NULL)"
|
||||
args = append(args, tid)
|
||||
}
|
||||
}
|
||||
|
||||
res, err := s.db.ExecContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLiteAPIKeyStore) TouchLastUsed(ctx context.Context, id uuid.UUID) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`UPDATE api_keys SET last_used_at = ? WHERE id = ?`,
|
||||
|
||||
Reference in New Issue
Block a user