rdev/internal/domain/apikey.go
jordan b093a4b26d feat: implement Visual Verification API layer (Week 2)
Add REST API endpoints for submitting visual verification tasks,
tracking progress via SSE, and retrieving screenshot/video artifacts.

Changes:
- Add ScopeVerifyRead/ScopeVerifyWrite auth scopes
- Create VerifyService for task submission and lifecycle management
- Create VerifyHandler with POST/GET/DELETE/SSE endpoints:
  - POST /verify - Submit capture task
  - GET /verify/{taskId} - Get task status and artifacts
  - GET /verify/{taskId}/stream - SSE progress stream
  - DELETE /verify/{taskId} - Cancel pending task
  - GET /projects/{id}/verify - List verify tasks
- Wire VerifyExecutor in main.go for Playwright pod execution
- Fix work.go validation to include "verify" task type
- Add comprehensive handler tests

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 19:29:40 -07:00

234 lines
6.0 KiB
Go

package domain
import (
"net"
"time"
)
// APIKeyID is a strongly-typed identifier for API keys.
type APIKeyID string
// Scope represents a permission scope for API keys.
type Scope string
const (
ScopeProjectsRead Scope = "projects:read"
ScopeProjectsExecute Scope = "projects:execute"
ScopeKeysRead Scope = "keys:read"
ScopeKeysWrite Scope = "keys:write"
ScopeAuditRead Scope = "audit:read"
ScopeQueueRead Scope = "queue:read"
ScopeQueueWrite Scope = "queue:write"
ScopeWebhookRead Scope = "webhook:read"
ScopeWebhookWrite Scope = "webhook:write"
ScopeWorkersRead Scope = "workers:read"
ScopeWorkersWrite Scope = "workers:write"
ScopeBuildRead Scope = "build:read"
ScopeBuildWrite Scope = "build:write"
ScopeVerifyRead Scope = "verify:read"
ScopeVerifyWrite Scope = "verify:write"
ScopeAdmin Scope = "admin"
)
// AllScopes is the list of all valid scopes.
var AllScopes = []Scope{
ScopeProjectsRead,
ScopeProjectsExecute,
ScopeKeysRead,
ScopeKeysWrite,
ScopeAuditRead,
ScopeQueueRead,
ScopeQueueWrite,
ScopeWebhookRead,
ScopeWebhookWrite,
ScopeWorkersRead,
ScopeWorkersWrite,
ScopeBuildRead,
ScopeBuildWrite,
ScopeVerifyRead,
ScopeVerifyWrite,
ScopeAdmin,
}
// ScopeDescriptions provides human-readable descriptions.
var ScopeDescriptions = map[Scope]string{
ScopeProjectsRead: "List and view project details",
ScopeProjectsExecute: "Execute commands (claude, shell, git) on projects",
ScopeKeysRead: "List API keys (metadata only, not secrets)",
ScopeKeysWrite: "Create and revoke API keys",
ScopeAuditRead: "View audit logs for command executions",
ScopeQueueRead: "View queued commands and queue status",
ScopeQueueWrite: "Enqueue and cancel queued commands",
ScopeWebhookRead: "View webhooks and delivery history",
ScopeWebhookWrite: "Create, update, and delete webhooks",
ScopeWorkersRead: "View workers and worker status",
ScopeWorkersWrite: "Manage workers (drain, register)",
ScopeBuildRead: "View build status and history",
ScopeBuildWrite: "Start and manage builds",
ScopeVerifyRead: "View verify tasks and capture results",
ScopeVerifyWrite: "Submit and cancel verify tasks",
ScopeAdmin: "Full administrative access (includes all scopes)",
}
// IsValid checks if a scope is valid.
func (s Scope) IsValid() bool {
for _, scope := range AllScopes {
if scope == s {
return true
}
}
return false
}
// String returns the scope as a string.
func (s Scope) String() string {
return string(s)
}
// ScopesFromStrings converts string slice to Scope slice.
func ScopesFromStrings(ss []string) []Scope {
scopes := make([]Scope, len(ss))
for i, s := range ss {
scopes[i] = Scope(s)
}
return scopes
}
// ScopesToStrings converts Scope slice to string slice.
func ScopesToStrings(scopes []Scope) []string {
ss := make([]string, len(scopes))
for i, s := range scopes {
ss[i] = string(s)
}
return ss
}
// ValidateScopes checks if all scopes are valid.
func ValidateScopes(scopes []Scope) bool {
for _, s := range scopes {
if !s.IsValid() {
return false
}
}
return true
}
// HasScope checks if a scope list contains a required scope.
// Admin scope grants access to everything.
func HasScope(scopes []Scope, required Scope) bool {
for _, s := range scopes {
if s == ScopeAdmin || s == required {
return true
}
}
return false
}
// HasAnyScope checks if a scope list contains any of the required scopes.
func HasAnyScope(scopes []Scope, required ...Scope) bool {
for _, r := range required {
if HasScope(scopes, r) {
return true
}
}
return false
}
// APIKey represents an API key for authentication.
type APIKey struct {
ID APIKeyID
Name string
KeyPrefix string // First 8 chars of key for identification
Scopes []Scope
ProjectIDs []ProjectID // nil = access to all projects
AllowedIPs []string // CIDR notation, e.g., ["192.168.1.0/24", "10.0.0.0/8"]; nil = no restriction
CreatedAt time.Time
ExpiresAt *time.Time
LastUsedAt *time.Time
RevokedAt *time.Time
CreatedBy string
}
// IsExpired returns true if the key has expired.
func (k *APIKey) IsExpired() bool {
if k.ExpiresAt == nil {
return false
}
return time.Now().After(*k.ExpiresAt)
}
// IsRevoked returns true if the key has been revoked.
func (k *APIKey) IsRevoked() bool {
return k.RevokedAt != nil
}
// IsActive returns true if the key is valid for use.
func (k *APIKey) IsActive() bool {
return !k.IsRevoked() && !k.IsExpired()
}
// HasScope returns true if the key has the specified scope.
func (k *APIKey) HasScope(scope Scope) bool {
// Admin scope grants all permissions
for _, s := range k.Scopes {
if s == ScopeAdmin || s == scope {
return true
}
}
return false
}
// HasAnyScope returns true if the key has any of the specified scopes.
func (k *APIKey) HasAnyScope(scopes ...Scope) bool {
for _, scope := range scopes {
if k.HasScope(scope) {
return true
}
}
return false
}
// HasProjectAccess returns true if the key can access the given project.
func (k *APIKey) HasProjectAccess(projectID ProjectID) bool {
// Admin or nil project list means access to all projects
if k.HasScope(ScopeAdmin) || k.ProjectIDs == nil {
return true
}
for _, pid := range k.ProjectIDs {
if pid == projectID {
return true
}
}
return false
}
// IsIPAllowed checks if the given IP address is allowed by the key's IP restrictions.
// Returns true if no IP restrictions are set or if the IP matches any allowed CIDR.
func (k *APIKey) IsIPAllowed(clientIP string) bool {
// No restrictions means all IPs are allowed
if len(k.AllowedIPs) == 0 {
return true
}
ip := net.ParseIP(clientIP)
if ip == nil {
return false
}
for _, cidr := range k.AllowedIPs {
_, network, err := net.ParseCIDR(cidr)
if err != nil {
// If not a CIDR, try parsing as single IP
allowedIP := net.ParseIP(cidr)
if allowedIP != nil && allowedIP.Equal(ip) {
return true
}
continue
}
if network.Contains(ip) {
return true
}
}
return false
}