mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-09-10 04:19:48 +00:00
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.
51 lines
2.2 KiB
Go
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
|
|
}
|