Files
goclaw/internal/store/sqlitestore/api_keys.go
T
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

244 lines
6.1 KiB
Go

//go:build sqlite || sqliteonly
package sqlitestore
import (
"context"
"database/sql"
"strings"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// SQLiteAPIKeyStore implements store.APIKeyStore backed by SQLite.
type SQLiteAPIKeyStore struct {
db *sql.DB
}
// NewSQLiteAPIKeyStore creates a new SQLite-backed API key store.
func NewSQLiteAPIKeyStore(db *sql.DB) *SQLiteAPIKeyStore {
return &SQLiteAPIKeyStore{db: db}
}
func (s *SQLiteAPIKeyStore) Create(ctx context.Context, key *store.APIKeyData) error {
var ownerID *string
if key.OwnerID != "" {
ownerID = &key.OwnerID
}
var tenantID *uuid.UUID
if key.TenantID != uuid.Nil {
tenantID = &key.TenantID
}
_, err := s.db.ExecContext(ctx,
`INSERT INTO api_keys (id, name, prefix, key_hash, scopes, owner_id, tenant_id, expires_at, created_by, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
key.ID, key.Name, key.Prefix, key.KeyHash, jsonStringArray(key.Scopes),
ownerID, tenantID, key.ExpiresAt, nilStr(key.CreatedBy), key.CreatedAt, key.UpdatedAt,
)
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
FROM api_keys
WHERE key_hash = ? AND NOT revoked AND (expires_at IS NULL OR expires_at > datetime('now'))`,
keyHash,
)
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) List(ctx context.Context, ownerID string) ([]store.APIKeyData, error) {
var conditions []string
var args []any
if ownerID != "" {
conditions = append(conditions, "owner_id = ?")
args = append(args, ownerID)
}
// Tenant filter: include tenant-scoped keys + system keys (NULL tenant_id).
if !store.IsCrossTenant(ctx) {
tid := store.TenantIDFromContext(ctx)
if tid != uuid.Nil {
conditions = append(conditions, "(tenant_id = ? OR tenant_id IS NULL)")
args = append(args, tid)
}
}
where := ""
if len(conditions) > 0 {
where = " WHERE " + strings.Join(conditions, " AND ")
}
rows, err := s.db.QueryContext(ctx,
`SELECT id, name, prefix, scopes, owner_id, tenant_id, expires_at, last_used_at, revoked, created_by, created_at, updated_at
FROM api_keys`+where+`
ORDER BY created_at DESC`,
args...,
)
if err != nil {
return nil, err
}
defer rows.Close()
var keys []store.APIKeyData
for rows.Next() {
var k store.APIKeyData
var createdBy *string
var oID *string
var tID *uuid.UUID
var scopesRaw []byte
var expiresAt, lastUsedAt nullSqliteTime
createdAt, updatedAt := scanTimePair()
if err := rows.Scan(
&k.ID, &k.Name, &k.Prefix, &scopesRaw,
&oID, &tID, &expiresAt, &lastUsedAt, &k.Revoked, &createdBy,
createdAt, updatedAt,
); 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 oID != nil {
k.OwnerID = *oID
}
if tID != nil {
k.TenantID = *tID
}
keys = append(keys, k)
}
return keys, rows.Err()
}
func (s *SQLiteAPIKeyStore) Revoke(ctx context.Context, id uuid.UUID, ownerID string) error {
q := "UPDATE api_keys SET revoked = 1, updated_at = ? WHERE id = ?"
args := []any{time.Now(), 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 = ?`,
time.Now(), id,
)
return err
}
// ensure interface is satisfied at compile time
var _ store.APIKeyStore = (*SQLiteAPIKeyStore)(nil)