Major refactoring to hexagonal (ports & adapters) architecture: - Add service layer (apikey_service, project_service) for business logic - Add webhook system with dispatcher and delivery tracking - Add command queue with priority-based processing - Add rate limiting with sliding window algorithm - Add audit logging for command execution - Add OpenTelemetry integration (traces, metrics, spans) - Add circuit breaker for fault tolerance - Add cached repository wrapper for performance - Add comprehensive validation package - Add Kubernetes client integration for pod management - Add database migrations (allowed_ips, audit_log, rate_limiting, queue, webhooks) - Add network policy and PodDisruptionBudget for k8s - Remove legacy executor and projects/registry packages - Untrack secrets.yaml (now managed via envault) - Add coverage.out to .gitignore - Add e2e test infrastructure with docker-compose - Add comprehensive documentation (API, architecture, operations, plans) - Add golangci-lint config and pre-commit hook Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
335 lines
7.6 KiB
Go
335 lines
7.6 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"time"
|
|
|
|
"github.com/lib/pq"
|
|
)
|
|
|
|
// Common errors.
|
|
var (
|
|
ErrKeyNotFound = errors.New("api key not found")
|
|
ErrKeyRevoked = errors.New("api key has been revoked")
|
|
ErrKeyExpired = errors.New("api key has expired")
|
|
ErrIPNotAllowed = errors.New("ip address not allowed")
|
|
)
|
|
|
|
// APIKey represents a stored API key.
|
|
type APIKey struct {
|
|
ID string
|
|
Name string
|
|
KeyPrefix string
|
|
Scopes []Scope
|
|
ProjectIDs []string // nil = all projects
|
|
AllowedIPs []string // CIDR notation, e.g., ["192.168.1.0/24"]; nil = no restriction
|
|
CreatedAt time.Time
|
|
ExpiresAt *time.Time
|
|
LastUsedAt *time.Time
|
|
RevokedAt *time.Time
|
|
CreatedBy string
|
|
}
|
|
|
|
// IsExpired checks if the key has expired.
|
|
func (k *APIKey) IsExpired() bool {
|
|
if k.ExpiresAt == nil {
|
|
return false
|
|
}
|
|
return time.Now().After(*k.ExpiresAt)
|
|
}
|
|
|
|
// IsRevoked checks if the key has been revoked.
|
|
func (k *APIKey) IsRevoked() bool {
|
|
return k.RevokedAt != nil
|
|
}
|
|
|
|
// IsActive checks if the key is valid for use.
|
|
func (k *APIKey) IsActive() bool {
|
|
return !k.IsRevoked() && !k.IsExpired()
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// CreateKeyRequest is the input for creating a new key.
|
|
type CreateKeyRequest struct {
|
|
Name string
|
|
Scopes []Scope
|
|
ProjectIDs []string // nil = all projects
|
|
AllowedIPs []string // CIDR notation; nil = no restriction
|
|
ExpiresIn time.Duration // 0 = never
|
|
CreatedBy string
|
|
}
|
|
|
|
// CreateKeyResponse is the output of creating a new key.
|
|
type CreateKeyResponse struct {
|
|
Key *APIKey
|
|
Secret string // Full key, shown only once
|
|
}
|
|
|
|
// Service handles API key operations.
|
|
type Service struct {
|
|
db *sql.DB
|
|
adminKey string // Super admin key from environment
|
|
}
|
|
|
|
// NewService creates a new auth service.
|
|
func NewService(db *sql.DB, adminKey string) *Service {
|
|
return &Service{
|
|
db: db,
|
|
adminKey: adminKey,
|
|
}
|
|
}
|
|
|
|
// IsAdminKey checks if the provided key is the super admin key.
|
|
func (s *Service) IsAdminKey(key string) bool {
|
|
return s.adminKey != "" && key == s.adminKey
|
|
}
|
|
|
|
// Create generates a new API key.
|
|
func (s *Service) Create(ctx context.Context, req CreateKeyRequest) (*CreateKeyResponse, error) {
|
|
// Validate scopes
|
|
if !ValidateScopes(req.Scopes) {
|
|
return nil, fmt.Errorf("invalid scopes")
|
|
}
|
|
|
|
// Generate key
|
|
fullKey, prefix, err := GenerateKey()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("generate key: %w", err)
|
|
}
|
|
|
|
keyHash := HashKey(fullKey)
|
|
expiresAt := ExpiresAt(req.ExpiresIn)
|
|
|
|
// Convert scopes to strings for postgres
|
|
scopeStrings := ScopesToStrings(req.Scopes)
|
|
|
|
var id string
|
|
err = s.db.QueryRowContext(ctx, `
|
|
INSERT INTO api_keys (name, key_hash, key_prefix, scopes, project_ids, allowed_ips, expires_at, created_by)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
RETURNING id
|
|
`, req.Name, keyHash, prefix, pq.Array(scopeStrings), pq.Array(req.ProjectIDs), pq.Array(req.AllowedIPs), expiresAt, req.CreatedBy).Scan(&id)
|
|
|
|
if err != nil {
|
|
return nil, fmt.Errorf("insert key: %w", err)
|
|
}
|
|
|
|
key := &APIKey{
|
|
ID: id,
|
|
Name: req.Name,
|
|
KeyPrefix: prefix,
|
|
Scopes: req.Scopes,
|
|
ProjectIDs: req.ProjectIDs,
|
|
AllowedIPs: req.AllowedIPs,
|
|
CreatedAt: time.Now(),
|
|
ExpiresAt: expiresAt,
|
|
CreatedBy: req.CreatedBy,
|
|
}
|
|
|
|
return &CreateKeyResponse{
|
|
Key: key,
|
|
Secret: fullKey,
|
|
}, nil
|
|
}
|
|
|
|
// Validate checks if a key is valid and returns the key details.
|
|
func (s *Service) Validate(ctx context.Context, key string) (*APIKey, error) {
|
|
// Check admin key first
|
|
if s.IsAdminKey(key) {
|
|
return &APIKey{
|
|
ID: "admin",
|
|
Name: "Super Admin",
|
|
KeyPrefix: "admin",
|
|
Scopes: []Scope{ScopeAdmin},
|
|
CreatedAt: time.Time{},
|
|
}, nil
|
|
}
|
|
|
|
// Validate format
|
|
if !ValidateKeyFormat(key) {
|
|
return nil, ErrKeyNotFound
|
|
}
|
|
|
|
keyHash := HashKey(key)
|
|
|
|
var (
|
|
apiKey APIKey
|
|
scopeStrings []string
|
|
)
|
|
|
|
err := s.db.QueryRowContext(ctx, `
|
|
SELECT id, name, key_prefix, scopes, project_ids, allowed_ips, created_at, expires_at, last_used_at, revoked_at, created_by
|
|
FROM api_keys
|
|
WHERE key_hash = $1
|
|
`, keyHash).Scan(
|
|
&apiKey.ID,
|
|
&apiKey.Name,
|
|
&apiKey.KeyPrefix,
|
|
pq.Array(&scopeStrings),
|
|
pq.Array(&apiKey.ProjectIDs),
|
|
pq.Array(&apiKey.AllowedIPs),
|
|
&apiKey.CreatedAt,
|
|
&apiKey.ExpiresAt,
|
|
&apiKey.LastUsedAt,
|
|
&apiKey.RevokedAt,
|
|
&apiKey.CreatedBy,
|
|
)
|
|
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, ErrKeyNotFound
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query key: %w", err)
|
|
}
|
|
|
|
apiKey.Scopes = ScopesFromStrings(scopeStrings)
|
|
|
|
if apiKey.IsRevoked() {
|
|
return nil, ErrKeyRevoked
|
|
}
|
|
|
|
if apiKey.IsExpired() {
|
|
return nil, ErrKeyExpired
|
|
}
|
|
|
|
// Update last_used_at asynchronously
|
|
go func() {
|
|
_, _ = s.db.ExecContext(context.Background(), `
|
|
UPDATE api_keys SET last_used_at = NOW() WHERE id = $1
|
|
`, apiKey.ID)
|
|
}()
|
|
|
|
return &apiKey, nil
|
|
}
|
|
|
|
// List returns all API keys (without secrets).
|
|
func (s *Service) List(ctx context.Context) ([]*APIKey, error) {
|
|
rows, err := s.db.QueryContext(ctx, `
|
|
SELECT id, name, key_prefix, scopes, project_ids, allowed_ips, 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 func() { _ = rows.Close() }()
|
|
|
|
var keys []*APIKey
|
|
for rows.Next() {
|
|
var (
|
|
key APIKey
|
|
scopeStrings []string
|
|
)
|
|
if err := rows.Scan(
|
|
&key.ID,
|
|
&key.Name,
|
|
&key.KeyPrefix,
|
|
pq.Array(&scopeStrings),
|
|
pq.Array(&key.ProjectIDs),
|
|
pq.Array(&key.AllowedIPs),
|
|
&key.CreatedAt,
|
|
&key.ExpiresAt,
|
|
&key.LastUsedAt,
|
|
&key.RevokedAt,
|
|
&key.CreatedBy,
|
|
); err != nil {
|
|
return nil, fmt.Errorf("scan key: %w", err)
|
|
}
|
|
key.Scopes = ScopesFromStrings(scopeStrings)
|
|
keys = append(keys, &key)
|
|
}
|
|
|
|
return keys, nil
|
|
}
|
|
|
|
// Get returns a single API key by ID.
|
|
func (s *Service) Get(ctx context.Context, id string) (*APIKey, error) {
|
|
var (
|
|
key APIKey
|
|
scopeStrings []string
|
|
)
|
|
|
|
err := s.db.QueryRowContext(ctx, `
|
|
SELECT id, name, key_prefix, scopes, project_ids, allowed_ips, created_at, expires_at, last_used_at, revoked_at, created_by
|
|
FROM api_keys
|
|
WHERE id = $1
|
|
`, id).Scan(
|
|
&key.ID,
|
|
&key.Name,
|
|
&key.KeyPrefix,
|
|
pq.Array(&scopeStrings),
|
|
pq.Array(&key.ProjectIDs),
|
|
pq.Array(&key.AllowedIPs),
|
|
&key.CreatedAt,
|
|
&key.ExpiresAt,
|
|
&key.LastUsedAt,
|
|
&key.RevokedAt,
|
|
&key.CreatedBy,
|
|
)
|
|
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, ErrKeyNotFound
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query key: %w", err)
|
|
}
|
|
|
|
key.Scopes = ScopesFromStrings(scopeStrings)
|
|
return &key, nil
|
|
}
|
|
|
|
// Revoke marks an API key as revoked.
|
|
func (s *Service) Revoke(ctx context.Context, id string) error {
|
|
result, err := s.db.ExecContext(ctx, `
|
|
UPDATE api_keys SET revoked_at = NOW()
|
|
WHERE id = $1 AND revoked_at IS NULL
|
|
`, 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 ErrKeyNotFound
|
|
}
|
|
|
|
return nil
|
|
}
|