rdev/internal/service/apikey_service.go
jordan 4f01015132
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
feat: implement project access enforcement and management API
- Fix no-op RequireProjectAccess middleware to enforce project_ids
- Apply project access middleware to all project-scoped routes
- Filter GET /projects by allowed project IDs for restricted keys
- Add GET /me endpoint with key identity, scopes, and project access info
- Add PATCH /keys/{id} for partial key updates (name, scopes, project_ids, allowed_ips, expires_in)
- Add GET/POST/DELETE /projects/{id}/access for project-centric access management
- Auto-grant creating key access when using POST /project/create-and-build
- Accept grant_to_key_ids in create-and-build to grant multiple keys on project creation
- Move newProvisionerWithDeps test helper from production code to test file

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 15:38:37 -07:00

204 lines
5.6 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)
}
// Update applies a partial update to an API key.
func (s *APIKeyService) Update(ctx context.Context, id domain.APIKeyID, update port.APIKeyUpdate) error {
return s.repo.Update(ctx, id, update)
}
// ListByProjectID returns all active keys that have the given project ID in their project_ids.
func (s *APIKeyService) ListByProjectID(ctx context.Context, projectID domain.ProjectID) ([]*domain.APIKey, error) {
return s.repo.ListByProjectID(ctx, projectID)
}
// 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)
}
}