rdev/internal/domain/apikey.go
jordan 9226454b85
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
feat: label-based undeploy, GC reconciliation, checkout/sessions, pool status
- Add UndeployAll() using label selectors to clean up monorepo components
  on project deletion (replaces name-based Undeploy in DeleteProject and
  the direct undeploy handler)
- Add ResourceGC background worker that periodically finds K8s resources
  whose project label has no matching DB record, deletes after 1h safety
  window
- Widen deployer client type from *kubernetes.Clientset to
  kubernetes.Interface for testability
- UndeployAll accumulates errors via errors.Join instead of failing fast
- Add checkout/checkin sidecar dev flow: temporary git tokens, branch
  checkout, review on checkin with cleanup workers
- Add interactive sessions: pod binding, command execution, SSE streaming,
  ephemeral preview URLs with session cleanup workers
- Add GET /workers/pool endpoint for aggregate capacity and queue depth
- Add sessions:read and sessions:execute auth scopes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 19:11:28 -07:00

240 lines
6.3 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"
ScopeSessionsRead Scope = "sessions:read"
ScopeSessionsExecute Scope = "sessions:execute"
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,
ScopeSessionsRead,
ScopeSessionsExecute,
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",
ScopeSessionsRead: "View interactive development sessions",
ScopeSessionsExecute: "Create and end interactive development sessions",
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
}