All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
CI / Woodpecker: - Add explicit depends_on to all .woodpecker.yml steps (rdev + templates) - Fix skip_tls_verify -> skip-tls-verify (correct Kaniko flag name) - Add replicasets get/list to deployer RBAC for rollout status - Skeleton template: add failure:ignore on docs steps, Traefik TLS annotations on ingress, depends_on on verify step Component templates: - Fix container name in deploy steps (PROJECT_NAME-COMPONENT_NAME) - Replace kubectl scale with kubectl patch for replicas - Add post-deploy image verification and rollout status checks - Applied consistently across all 5 component templates Adapters: - gitea: Add HTTP client timeout (30s), context cancellation checks, handle 404 on GetRepo/DeleteRepo - zot: Add retry with exponential backoff (doWithRetry), limit response body reads to 10MB - cockroach: Use net.JoinHostPort for IPv6-safe DSN construction - woodpecker: Fix error wrapping (%v -> %w) - redis: Fix error wrapping (%v -> %w) - deployer: Add context cancellation checks Services: - apikey_service: Fix error wrapping (%v -> %w) - component_deploy: Fix error wrapping (%v -> %w) - project_infra: Fix error wrapping (%v -> %w) - webhook/dispatcher: Fix error wrapping (%v -> %w) Other: - CLAUDE.md: Add guide links for Gitea, Go 1.25, Woodpecker v3, Traefik v3, Zot registry - circuitbreaker: Add test for error wrapping - docs: Update deployment, troubleshooting, and runbook docs - health: Fix error wrapping (%v -> %w) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
194 lines
5.2 KiB
Go
194 lines
5.2 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/orchard9/rdev/internal/domain"
|
|
"github.com/orchard9/rdev/internal/port"
|
|
)
|
|
|
|
// APIKeyService handles API key business logic.
|
|
type APIKeyService struct {
|
|
repo port.APIKeyRepository
|
|
adminKey string
|
|
}
|
|
|
|
// NewAPIKeyService creates a new API key service.
|
|
func NewAPIKeyService(repo port.APIKeyRepository, adminKey string) *APIKeyService {
|
|
return &APIKeyService{
|
|
repo: repo,
|
|
adminKey: adminKey,
|
|
}
|
|
}
|
|
|
|
// CreateKeyRequest contains parameters for creating a new API key.
|
|
type CreateKeyRequest struct {
|
|
Name string
|
|
Scopes []domain.Scope
|
|
ProjectIDs []domain.ProjectID
|
|
AllowedIPs []string // CIDR notation; nil = no restriction
|
|
ExpiresIn time.Duration
|
|
CreatedBy string
|
|
}
|
|
|
|
// CreateKeyResult contains the newly created key and its secret.
|
|
type CreateKeyResult struct {
|
|
Key *domain.APIKey
|
|
Secret string
|
|
}
|
|
|
|
// Create generates a new API key.
|
|
func (s *APIKeyService) Create(ctx context.Context, req CreateKeyRequest) (*CreateKeyResult, error) {
|
|
// Generate key components using auth-compatible format:
|
|
// identifier: 4 random bytes → 8 hex chars
|
|
// random: 16 random bytes → 32 hex chars
|
|
// Full key: rdev_sk_<identifier>_<random>
|
|
idBytes := make([]byte, 4)
|
|
if _, err := rand.Read(idBytes); err != nil {
|
|
return nil, fmt.Errorf("generate identifier: %w", err)
|
|
}
|
|
identifier := hex.EncodeToString(idBytes)
|
|
|
|
randomBytes := make([]byte, 16)
|
|
if _, err := rand.Read(randomBytes); err != nil {
|
|
return nil, fmt.Errorf("generate random: %w", err)
|
|
}
|
|
random := hex.EncodeToString(randomBytes)
|
|
|
|
fullKey := fmt.Sprintf("rdev_sk_%s_%s", identifier, random)
|
|
|
|
// Hash the full key (what the user receives and sends back for auth)
|
|
keyHash := hashKey(fullKey)
|
|
|
|
// Calculate expiration
|
|
var expiresAt *time.Time
|
|
if req.ExpiresIn > 0 {
|
|
t := time.Now().Add(req.ExpiresIn)
|
|
expiresAt = &t
|
|
}
|
|
|
|
// Create key
|
|
key := &domain.APIKey{
|
|
Name: req.Name,
|
|
KeyPrefix: identifier,
|
|
Scopes: req.Scopes,
|
|
ProjectIDs: req.ProjectIDs,
|
|
AllowedIPs: req.AllowedIPs,
|
|
ExpiresAt: expiresAt,
|
|
CreatedBy: req.CreatedBy,
|
|
}
|
|
|
|
if err := s.repo.Create(ctx, key, keyHash); err != nil {
|
|
return nil, fmt.Errorf("store key: %w", err)
|
|
}
|
|
|
|
return &CreateKeyResult{
|
|
Key: key,
|
|
Secret: fullKey,
|
|
}, nil
|
|
}
|
|
|
|
// GetByHash retrieves an API key by its raw key value.
|
|
func (s *APIKeyService) GetByHash(ctx context.Context, rawKey string) (*domain.APIKey, error) {
|
|
keyHash := hashKey(rawKey)
|
|
return s.repo.GetByHash(ctx, keyHash)
|
|
}
|
|
|
|
// Get retrieves an API key by ID.
|
|
func (s *APIKeyService) Get(ctx context.Context, id domain.APIKeyID) (*domain.APIKey, error) {
|
|
return s.repo.Get(ctx, id)
|
|
}
|
|
|
|
// List returns all API keys.
|
|
func (s *APIKeyService) List(ctx context.Context) ([]*domain.APIKey, error) {
|
|
return s.repo.List(ctx)
|
|
}
|
|
|
|
// Revoke marks an API key as revoked.
|
|
func (s *APIKeyService) Revoke(ctx context.Context, id domain.APIKeyID) error {
|
|
return s.repo.Revoke(ctx, id)
|
|
}
|
|
|
|
// UpdateLastUsed updates the last used timestamp for a key.
|
|
func (s *APIKeyService) UpdateLastUsed(ctx context.Context, id domain.APIKeyID) error {
|
|
return s.repo.UpdateLastUsed(ctx, id)
|
|
}
|
|
|
|
// Validate checks a raw API key and returns the associated APIKey if valid.
|
|
// It checks for admin key, looks up by hash, and verifies the key is active.
|
|
// On success it asynchronously updates the last-used timestamp.
|
|
func (s *APIKeyService) Validate(ctx context.Context, rawKey string) (*domain.APIKey, error) {
|
|
// Check admin key first
|
|
if s.adminKey != "" && rawKey == s.adminKey {
|
|
return &domain.APIKey{
|
|
ID: "admin",
|
|
Name: "Super Admin",
|
|
KeyPrefix: "admin",
|
|
Scopes: []domain.Scope{domain.ScopeAdmin},
|
|
}, nil
|
|
}
|
|
|
|
keyHash := hashKey(rawKey)
|
|
|
|
apiKey, err := s.repo.GetByHash(ctx, keyHash)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if apiKey.IsRevoked() {
|
|
return nil, domain.ErrKeyRevoked
|
|
}
|
|
if apiKey.IsExpired() {
|
|
return nil, domain.ErrKeyExpired
|
|
}
|
|
|
|
// Update last_used_at asynchronously (fire-and-forget: intentionally detached from
|
|
// request context since this is a non-critical audit update that should not block
|
|
// validation or be cancelled when request completes)
|
|
go func() {
|
|
_ = s.repo.UpdateLastUsed(context.WithoutCancel(ctx), apiKey.ID)
|
|
}()
|
|
|
|
return apiKey, nil
|
|
}
|
|
|
|
// ValidateAdminKey checks if the provided key matches the admin key.
|
|
func (s *APIKeyService) ValidateAdminKey(key string) bool {
|
|
return s.adminKey != "" && key == s.adminKey
|
|
}
|
|
|
|
// AdminKey returns the admin key (for creating admin APIKey struct).
|
|
func (s *APIKeyService) AdminKey() string {
|
|
return s.adminKey
|
|
}
|
|
|
|
// hashKey creates a SHA-256 hash of a key.
|
|
func hashKey(key string) string {
|
|
hash := sha256.Sum256([]byte(key))
|
|
return hex.EncodeToString(hash[:])
|
|
}
|
|
|
|
// ParseExpiration converts a duration string to time.Duration.
|
|
// Supported formats: "30d", "60d", "90d", "1y", "never" (or empty)
|
|
func ParseExpiration(s string) (time.Duration, error) {
|
|
switch s {
|
|
case "", "never":
|
|
return 0, nil
|
|
case "30d":
|
|
return 30 * 24 * time.Hour, nil
|
|
case "60d":
|
|
return 60 * 24 * time.Hour, nil
|
|
case "90d":
|
|
return 90 * 24 * time.Hour, nil
|
|
case "1y":
|
|
return 365 * 24 * time.Hour, nil
|
|
default:
|
|
return 0, fmt.Errorf("invalid expiration format: %s", s)
|
|
}
|
|
}
|