rdev/internal/adapter/postgres/apikey_repository.go
jordan 538ea57ed4 feat: Add claude-config API, security hardening, and testing infrastructure
Claude Config API (v0.6):
- Add CRUD endpoints for commands, skills, and agents
- Commands/skills/agents stored in /workspace/.claude/ (per-project, in git)
- Credentials shared via PVC at /root/.claude/ (shared across pods)
- Use base64 encoding for file writes (prevents shell injection)
- Add content size limits (1MB max)

Security Hardening:
- Add sanitize package for command/prompt validation
- Add rate limiting middleware (token bucket algorithm)
- Add concurrent command limiting
- Add input sanitization to all command handlers
- Gitignore secrets.yaml and credentials.yaml
- Add *.example templates for secrets

Testing Infrastructure:
- Add testutil package with mocks and fixtures
- Add unit tests for auth package (63% coverage)
- Add unit tests for executor (47% coverage)
- Add handler integration tests (40% coverage)
- Add 100% coverage for sanitize, cmdlimit packages
- Add 96% coverage for ratelimit package

Infrastructure:
- Shared Claude credentials PVC (ReadWriteMany)
- Reduced workspace PVC size from 20Gi to 5Gi
- Add init container cleanup before git clone
- Document Longhorn RWX requirements

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 01:29:13 -07:00

241 lines
5.7 KiB
Go

// Package postgres provides PostgreSQL-based implementations of port interfaces.
package postgres
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/lib/pq"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/port"
)
// APIKeyRepository implements port.APIKeyRepository using PostgreSQL.
type APIKeyRepository struct {
db *sql.DB
}
// NewAPIKeyRepository creates a new PostgreSQL API key repository.
func NewAPIKeyRepository(db *sql.DB) *APIKeyRepository {
return &APIKeyRepository{db: db}
}
// Ensure APIKeyRepository implements port.APIKeyRepository at compile time.
var _ port.APIKeyRepository = (*APIKeyRepository)(nil)
// Create stores a new API key.
func (r *APIKeyRepository) Create(ctx context.Context, key *domain.APIKey, keyHash string) error {
scopeStrings := scopesToStrings(key.Scopes)
projectIDStrings := projectIDsToStrings(key.ProjectIDs)
var id string
err := r.db.QueryRowContext(ctx, `
INSERT INTO api_keys (name, key_hash, key_prefix, scopes, project_ids, expires_at, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id
`, key.Name, keyHash, key.KeyPrefix, pq.Array(scopeStrings), pq.Array(projectIDStrings), key.ExpiresAt, key.CreatedBy).Scan(&id)
if err != nil {
return fmt.Errorf("insert key: %w", err)
}
key.ID = domain.APIKeyID(id)
key.CreatedAt = time.Now()
return nil
}
// GetByHash retrieves an API key by its hash.
func (r *APIKeyRepository) GetByHash(ctx context.Context, keyHash string) (*domain.APIKey, error) {
var (
key domain.APIKey
id string
scopeStrings []string
projectIDs []string
)
err := r.db.QueryRowContext(ctx, `
SELECT id, name, key_prefix, scopes, project_ids, created_at, expires_at, last_used_at, revoked_at, created_by
FROM api_keys
WHERE key_hash = $1
`, keyHash).Scan(
&id,
&key.Name,
&key.KeyPrefix,
pq.Array(&scopeStrings),
pq.Array(&projectIDs),
&key.CreatedAt,
&key.ExpiresAt,
&key.LastUsedAt,
&key.RevokedAt,
&key.CreatedBy,
)
if errors.Is(err, sql.ErrNoRows) {
return nil, domain.ErrKeyNotFound
}
if err != nil {
return nil, fmt.Errorf("query key: %w", err)
}
key.ID = domain.APIKeyID(id)
key.Scopes = scopesFromStrings(scopeStrings)
key.ProjectIDs = projectIDsFromStrings(projectIDs)
return &key, nil
}
// Get retrieves an API key by ID.
func (r *APIKeyRepository) Get(ctx context.Context, id domain.APIKeyID) (*domain.APIKey, error) {
var (
key domain.APIKey
keyID string
scopeStrings []string
projectIDs []string
)
err := r.db.QueryRowContext(ctx, `
SELECT id, name, key_prefix, scopes, project_ids, created_at, expires_at, last_used_at, revoked_at, created_by
FROM api_keys
WHERE id = $1
`, string(id)).Scan(
&keyID,
&key.Name,
&key.KeyPrefix,
pq.Array(&scopeStrings),
pq.Array(&projectIDs),
&key.CreatedAt,
&key.ExpiresAt,
&key.LastUsedAt,
&key.RevokedAt,
&key.CreatedBy,
)
if errors.Is(err, sql.ErrNoRows) {
return nil, domain.ErrKeyNotFound
}
if err != nil {
return nil, fmt.Errorf("query key: %w", err)
}
key.ID = domain.APIKeyID(keyID)
key.Scopes = scopesFromStrings(scopeStrings)
key.ProjectIDs = projectIDsFromStrings(projectIDs)
return &key, nil
}
// List returns all API keys (without secrets).
func (r *APIKeyRepository) List(ctx context.Context) ([]*domain.APIKey, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, name, key_prefix, scopes, project_ids, created_at, expires_at, last_used_at, revoked_at, created_by
FROM api_keys
ORDER BY created_at DESC
`)
if err != nil {
return nil, fmt.Errorf("query keys: %w", err)
}
defer rows.Close()
var keys []*domain.APIKey
for rows.Next() {
var (
key domain.APIKey
id string
scopeStrings []string
projectIDs []string
)
if err := rows.Scan(
&id,
&key.Name,
&key.KeyPrefix,
pq.Array(&scopeStrings),
pq.Array(&projectIDs),
&key.CreatedAt,
&key.ExpiresAt,
&key.LastUsedAt,
&key.RevokedAt,
&key.CreatedBy,
); err != nil {
return nil, fmt.Errorf("scan key: %w", err)
}
key.ID = domain.APIKeyID(id)
key.Scopes = scopesFromStrings(scopeStrings)
key.ProjectIDs = projectIDsFromStrings(projectIDs)
keys = append(keys, &key)
}
return keys, nil
}
// Revoke marks an API key as revoked.
func (r *APIKeyRepository) Revoke(ctx context.Context, id domain.APIKeyID) error {
result, err := r.db.ExecContext(ctx, `
UPDATE api_keys SET revoked_at = NOW()
WHERE id = $1 AND revoked_at IS NULL
`, string(id))
if err != nil {
return fmt.Errorf("revoke key: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("rows affected: %w", err)
}
if rows == 0 {
return domain.ErrKeyNotFound
}
return nil
}
// UpdateLastUsed updates the last used timestamp for a key.
func (r *APIKeyRepository) UpdateLastUsed(ctx context.Context, id domain.APIKeyID) error {
_, err := r.db.ExecContext(ctx, `
UPDATE api_keys SET last_used_at = NOW() WHERE id = $1
`, string(id))
return err
}
// Helper functions for scope conversion
func scopesToStrings(scopes []domain.Scope) []string {
ss := make([]string, len(scopes))
for i, s := range scopes {
ss[i] = string(s)
}
return ss
}
func scopesFromStrings(ss []string) []domain.Scope {
scopes := make([]domain.Scope, len(ss))
for i, s := range ss {
scopes[i] = domain.Scope(s)
}
return scopes
}
func projectIDsToStrings(ids []domain.ProjectID) []string {
if ids == nil {
return nil
}
ss := make([]string, len(ids))
for i, id := range ids {
ss[i] = string(id)
}
return ss
}
func projectIDsFromStrings(ss []string) []domain.ProjectID {
if ss == nil {
return nil
}
ids := make([]domain.ProjectID, len(ss))
for i, s := range ss {
ids[i] = domain.ProjectID(s)
}
return ids
}