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

51 lines
2.2 KiB
Go

package store
import (
"context"
"time"
"github.com/google/uuid"
)
// APIKeyData represents a gateway API key with scoped permissions.
type APIKeyData struct {
ID uuid.UUID `json:"id" db:"id"`
TenantID uuid.UUID `json:"tenant_id" db:"tenant_id"` // uuid.Nil when NULL in DB
Name string `json:"name" db:"name"`
Prefix string `json:"prefix" db:"prefix"` // first 8 chars for display
KeyHash string `json:"-" db:"key_hash"` // SHA-256 hex, never serialized
Scopes []string `json:"scopes" db:"scopes"` // e.g. ["operator.admin","operator.read"]
OwnerID string `json:"owner_id,omitempty" db:"owner_id"` // bound user; when set, auth forces user_id = owner_id
ExpiresAt *time.Time `json:"expires_at" db:"expires_at"` // nil = never
LastUsedAt *time.Time `json:"last_used_at" db:"last_used_at"`
Revoked bool `json:"revoked" db:"revoked"`
CreatedBy string `json:"created_by" db:"created_by"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// APIKeyStore manages gateway API keys.
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)
// List returns API keys. If ownerID is non-empty, filters to keys owned by that user.
List(ctx context.Context, ownerID string) ([]APIKeyData, error)
// 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
// TouchLastUsed updates the last_used_at timestamp.
TouchLastUsed(ctx context.Context, id uuid.UUID) error
}