Files
goclaw/internal/store/pg/secure_cli.go
T
Goon 75c570e951 feat(security): credentialed exec + HTTP RBAC + API key cache (#197)
- Secure CLI credential injection via AES-256-GCM encrypted env vars
- API key management with fine-grained RBAC scopes
- resolveAuth/requireAuth middleware across all 25+ HTTP handlers
- In-memory API key cache with TTL, negative caching, pubsub invalidation
- Sandbox-first execution (fails if unavailable, no silent fallback)
- Credential scrubbing, constant-time token comparison, Admin-only CLI creds
- SQL migration 000020: secure_cli_binaries + api_keys tables
- 14 unit tests for cache and RBAC with race detector

Closes #197
2026-03-15 20:13:18 +07:00

241 lines
7.0 KiB
Go

package pg
import (
"context"
"database/sql"
"fmt"
"log/slog"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/crypto"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// PGSecureCLIStore implements store.SecureCLIStore backed by Postgres.
type PGSecureCLIStore struct {
db *sql.DB
encKey string
}
func NewPGSecureCLIStore(db *sql.DB, encryptionKey string) *PGSecureCLIStore {
return &PGSecureCLIStore{db: db, encKey: encryptionKey}
}
const secureCLISelectCols = `id, binary_name, binary_path, description, encrypted_env,
deny_args, deny_verbose, timeout_seconds, tips, agent_id, enabled, created_by, created_at, updated_at`
func (s *PGSecureCLIStore) Create(ctx context.Context, b *store.SecureCLIBinary) error {
if err := store.ValidateUserID(b.CreatedBy); err != nil {
return err
}
if b.ID == uuid.Nil {
b.ID = store.GenNewID()
}
// Encrypt env if provided
var envBytes []byte
if len(b.EncryptedEnv) > 0 && s.encKey != "" {
encrypted, err := crypto.Encrypt(string(b.EncryptedEnv), s.encKey)
if err != nil {
return fmt.Errorf("encrypt env: %w", err)
}
envBytes = []byte(encrypted)
} else {
envBytes = b.EncryptedEnv
}
now := time.Now()
b.CreatedAt = now
b.UpdatedAt = now
_, err := s.db.ExecContext(ctx,
`INSERT INTO secure_cli_binaries (id, binary_name, binary_path, description, encrypted_env,
deny_args, deny_verbose, timeout_seconds, tips, agent_id, enabled, created_by, created_at, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`,
b.ID, b.BinaryName, nilStr(derefStr(b.BinaryPath)), b.Description,
envBytes,
jsonOrEmptyArray(b.DenyArgs), jsonOrEmptyArray(b.DenyVerbose),
b.TimeoutSeconds, b.Tips,
nilUUID(b.AgentID), b.Enabled,
b.CreatedBy, now, now,
)
return err
}
func (s *PGSecureCLIStore) Get(ctx context.Context, id uuid.UUID) (*store.SecureCLIBinary, error) {
row := s.db.QueryRowContext(ctx,
`SELECT `+secureCLISelectCols+` FROM secure_cli_binaries WHERE id = $1`, id)
return s.scanRow(row)
}
func (s *PGSecureCLIStore) scanRow(row *sql.Row) (*store.SecureCLIBinary, error) {
var b store.SecureCLIBinary
var binaryPath *string
var agentID *uuid.UUID
var denyArgs, denyVerbose *[]byte
var env []byte
err := row.Scan(
&b.ID, &b.BinaryName, &binaryPath, &b.Description, &env,
&denyArgs, &denyVerbose,
&b.TimeoutSeconds, &b.Tips, &agentID,
&b.Enabled, &b.CreatedBy, &b.CreatedAt, &b.UpdatedAt,
)
if err != nil {
return nil, err
}
b.BinaryPath = binaryPath
b.AgentID = agentID
if denyArgs != nil {
b.DenyArgs = *denyArgs
}
if denyVerbose != nil {
b.DenyVerbose = *denyVerbose
}
// Decrypt env
if len(env) > 0 && s.encKey != "" {
decrypted, err := crypto.Decrypt(string(env), s.encKey)
if err != nil {
slog.Warn("secure_cli: failed to decrypt env", "binary", b.BinaryName, "error", err)
} else {
b.EncryptedEnv = []byte(decrypted)
}
} else {
b.EncryptedEnv = env
}
return &b, nil
}
func (s *PGSecureCLIStore) scanRows(rows *sql.Rows) ([]store.SecureCLIBinary, error) {
defer rows.Close()
var result []store.SecureCLIBinary
for rows.Next() {
var b store.SecureCLIBinary
var binaryPath *string
var agentID *uuid.UUID
var denyArgs, denyVerbose *[]byte
var env []byte
if err := rows.Scan(
&b.ID, &b.BinaryName, &binaryPath, &b.Description, &env,
&denyArgs, &denyVerbose,
&b.TimeoutSeconds, &b.Tips, &agentID,
&b.Enabled, &b.CreatedBy, &b.CreatedAt, &b.UpdatedAt,
); err != nil {
continue
}
b.BinaryPath = binaryPath
b.AgentID = agentID
if denyArgs != nil {
b.DenyArgs = *denyArgs
}
if denyVerbose != nil {
b.DenyVerbose = *denyVerbose
}
if len(env) > 0 && s.encKey != "" {
if decrypted, err := crypto.Decrypt(string(env), s.encKey); err == nil {
b.EncryptedEnv = []byte(decrypted)
}
} else {
b.EncryptedEnv = env
}
result = append(result, b)
}
return result, nil
}
// secureCLIAllowedFields is the allowlist of columns that can be updated via execMapUpdate.
// Defense-in-depth: prevents column name injection even if caller skips validation.
var secureCLIAllowedFields = map[string]bool{
"binary_name": true, "binary_path": true, "description": true,
"encrypted_env": true, "deny_args": true, "deny_verbose": true,
"timeout_seconds": true, "tips": true, "agent_id": true, "enabled": true,
"updated_at": true,
}
func (s *PGSecureCLIStore) Update(ctx context.Context, id uuid.UUID, updates map[string]any) error {
// Filter unknown fields to prevent column name injection
for k := range updates {
if !secureCLIAllowedFields[k] {
delete(updates, k)
}
}
// Encrypt env if present in updates
if envVal, ok := updates["encrypted_env"]; ok {
if envStr, isStr := envVal.(string); isStr && envStr != "" && s.encKey != "" {
encrypted, err := crypto.Encrypt(envStr, s.encKey)
if err != nil {
return fmt.Errorf("encrypt env: %w", err)
}
updates["encrypted_env"] = []byte(encrypted)
}
}
updates["updated_at"] = time.Now()
return execMapUpdate(ctx, s.db, "secure_cli_binaries", id, updates)
}
func (s *PGSecureCLIStore) Delete(ctx context.Context, id uuid.UUID) error {
_, err := s.db.ExecContext(ctx, "DELETE FROM secure_cli_binaries WHERE id = $1", id)
return err
}
func (s *PGSecureCLIStore) List(ctx context.Context) ([]store.SecureCLIBinary, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT `+secureCLISelectCols+` FROM secure_cli_binaries ORDER BY binary_name, agent_id NULLS LAST`)
if err != nil {
return nil, err
}
return s.scanRows(rows)
}
func (s *PGSecureCLIStore) ListByAgent(ctx context.Context, agentID uuid.UUID) ([]store.SecureCLIBinary, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT `+secureCLISelectCols+` FROM secure_cli_binaries
WHERE (agent_id = $1 OR agent_id IS NULL) AND enabled = true
ORDER BY binary_name, agent_id NULLS LAST`, agentID)
if err != nil {
return nil, err
}
return s.scanRows(rows)
}
// LookupByBinary finds the best credential config for a binary name.
// Agent-specific config takes priority over global (agent_id IS NULL).
func (s *PGSecureCLIStore) LookupByBinary(ctx context.Context, binaryName string, agentID *uuid.UUID) (*store.SecureCLIBinary, error) {
var row *sql.Row
if agentID != nil {
row = s.db.QueryRowContext(ctx,
`SELECT `+secureCLISelectCols+` FROM secure_cli_binaries
WHERE binary_name = $1 AND (agent_id = $2 OR agent_id IS NULL) AND enabled = true
ORDER BY agent_id NULLS LAST LIMIT 1`, binaryName, *agentID)
} else {
row = s.db.QueryRowContext(ctx,
`SELECT `+secureCLISelectCols+` FROM secure_cli_binaries
WHERE binary_name = $1 AND agent_id IS NULL AND enabled = true
LIMIT 1`, binaryName)
}
b, err := s.scanRow(row)
if err == sql.ErrNoRows {
return nil, nil
}
return b, err
}
func (s *PGSecureCLIStore) ListEnabled(ctx context.Context) ([]store.SecureCLIBinary, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT `+secureCLISelectCols+` FROM secure_cli_binaries
WHERE enabled = true ORDER BY binary_name`)
if err != nil {
return nil, err
}
return s.scanRows(rows)
}