// Package redis provides Redis cache provisioning for projects. // Uses Redis ACLs to isolate each project to its own key prefix. package redis import ( "context" "crypto/rand" "encoding/hex" "fmt" "log/slog" "net" "strconv" "strings" "time" "github.com/redis/go-redis/v9" "github.com/orchard9/rdev/internal/domain" ) // Provisioner implements port.CacheProvisioner using Redis ACLs. type Provisioner struct { client *redis.Client host string port int keyPrefix string logger *slog.Logger } // Config holds Redis provisioner configuration. type Config struct { Host string Port int Password string KeyPrefix string // Base prefix for project keys, default "project:" } // NewProvisioner creates a new Redis cache provisioner. func NewProvisioner(cfg Config, logger *slog.Logger) (*Provisioner, error) { if cfg.KeyPrefix == "" { cfg.KeyPrefix = "project:" } client := redis.NewClient(&redis.Options{ Addr: net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port)), Password: cfg.Password, DB: 0, }) // Verify connection ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := client.Ping(ctx).Err(); err != nil { return nil, fmt.Errorf("redis connection failed: %w", err) } return &Provisioner{ client: client, host: cfg.Host, port: cfg.Port, keyPrefix: cfg.KeyPrefix, logger: logger, }, nil } // CreateProjectCache provisions isolated cache access for a project. func (p *Provisioner) CreateProjectCache(ctx context.Context, projectID string) (*domain.CacheCredentials, error) { username := p.usernameFor(projectID) password, err := generateToken(32) if err != nil { return nil, fmt.Errorf("generate password: %w", err) } prefix := p.prefixFor(projectID) // Check if user already exists existing, err := p.client.Do(ctx, "ACL", "GETUSER", username).Result() if err == nil && existing != nil { p.logger.Warn("cache user already exists, recreating", "project_id", projectID, "username", username) // Delete existing user to recreate with new password if err := p.client.Do(ctx, "ACL", "DELUSER", username).Err(); err != nil { return nil, fmt.Errorf("delete existing user: %w", err) } } // Create ACL user with scoped permissions: // - on: user is active // - >password: set password // - ~prefix*: can only access keys matching this pattern // - +@all: allow all command categories // - -@dangerous: deny dangerous commands (FLUSHALL, SHUTDOWN, DEBUG, etc.) // - -@admin: deny admin commands (CONFIG, ACL, SLAVEOF, etc.) err = p.client.Do(ctx, "ACL", "SETUSER", username, "on", ">"+password, "~"+prefix+"*", "+@all", "-@dangerous", "-@admin", ).Err() if err != nil { return nil, fmt.Errorf("create ACL user: %w", err) } // Persist ACL changes to disk if err := p.client.Do(ctx, "ACL", "SAVE").Err(); err != nil { p.logger.Warn("failed to persist ACL to disk", "error", err) // Non-fatal: ACLs will still work until Redis restarts } p.logger.Info("created project cache", "project_id", projectID, "username", username, "prefix", prefix) url := fmt.Sprintf("redis://%s:%s@%s", username, password, net.JoinHostPort(p.host, strconv.Itoa(p.port))) return &domain.CacheCredentials{ ProjectID: projectID, URL: url, URLStaging: url, // Same for now; separate staging instance in future Prefix: prefix, Username: username, Host: p.host, Port: p.port, CreatedAt: time.Now().UTC(), }, nil } // DeleteProjectCache removes cache access for a project. func (p *Provisioner) DeleteProjectCache(ctx context.Context, projectID string, purgeKeys bool) error { username := p.usernameFor(projectID) prefix := p.prefixFor(projectID) // Delete ACL user result, err := p.client.Do(ctx, "ACL", "DELUSER", username).Result() if err != nil { return fmt.Errorf("delete ACL user: %w", err) } // ACL DELUSER returns number of users deleted deleted, ok := result.(int64) if !ok || deleted == 0 { p.logger.Warn("cache user did not exist", "project_id", projectID, "username", username) } // Optionally purge all project keys if purgeKeys { if err := p.purgeKeys(ctx, prefix); err != nil { p.logger.Warn("failed to purge project keys", "project_id", projectID, "prefix", prefix, "error", err) // Non-fatal: user is already deleted } } // Persist ACL changes if err := p.client.Do(ctx, "ACL", "SAVE").Err(); err != nil { p.logger.Warn("failed to persist ACL to disk", "error", err) } p.logger.Info("deleted project cache", "project_id", projectID, "username", username, "purged_keys", purgeKeys) return nil } // GetProjectCache retrieves cache credentials for a project. // Note: Password cannot be retrieved from Redis ACL, only verified. // Returns nil if user doesn't exist. func (p *Provisioner) GetProjectCache(ctx context.Context, projectID string) (*domain.CacheCredentials, error) { username := p.usernameFor(projectID) prefix := p.prefixFor(projectID) // Check if user exists result, err := p.client.Do(ctx, "ACL", "GETUSER", username).Result() if err != nil { if strings.Contains(err.Error(), "User") { return nil, nil // User doesn't exist } return nil, fmt.Errorf("get ACL user: %w", err) } if result == nil { return nil, nil } // User exists but we can't retrieve password // Caller should use stored credentials from credential store return &domain.CacheCredentials{ ProjectID: projectID, URL: "", // Password not available Prefix: prefix, Username: username, Host: p.host, Port: p.port, }, nil } // TestConnection verifies Redis connectivity. func (p *Provisioner) TestConnection(ctx context.Context) error { return p.client.Ping(ctx).Err() } // Close closes the Redis connection. func (p *Provisioner) Close() error { return p.client.Close() } // usernameFor returns the Redis username for a project. func (p *Provisioner) usernameFor(projectID string) string { // Sanitize project ID for Redis username (alphanumeric + hyphen) safe := strings.Map(func(r rune) rune { if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' { return r } return '-' }, projectID) return "proj-" + safe } // prefixFor returns the key prefix for a project. func (p *Provisioner) prefixFor(projectID string) string { return p.keyPrefix + projectID + ":" } // purgeKeys deletes all keys matching the project prefix. func (p *Provisioner) purgeKeys(ctx context.Context, prefix string) error { var cursor uint64 var deleted int64 for { keys, nextCursor, err := p.client.Scan(ctx, cursor, prefix+"*", 100).Result() if err != nil { return fmt.Errorf("scan keys: %w", err) } if len(keys) > 0 { n, err := p.client.Del(ctx, keys...).Result() if err != nil { return fmt.Errorf("delete keys: %w", err) } deleted += n } cursor = nextCursor if cursor == 0 { break } } p.logger.Debug("purged project keys", "prefix", prefix, "count", deleted) return nil } // generateToken generates a cryptographically secure random token. func generateToken(length int) (string, error) { bytes := make([]byte, length) if _, err := rand.Read(bytes); err != nil { return "", err } return hex.EncodeToString(bytes), nil }