mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-07-12 19:04:49 +00:00
6d7473b56f
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.
244 lines
6.1 KiB
Go
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)
|