rdev/internal/auth/service.go
jordan d2de49a591 feat: Add API key authentication with auto-migrations
Implements API key authentication for all rdev endpoints:

## Database (internal/db)
- Auto-migrating postgres connection
- Embedded SQL migrations via go:embed
- api_keys table with scopes, expiration, project restrictions

## Auth Package (internal/auth)
- Key generation: rdev_sk_<prefix>_<random> format
- Scopes: projects:read, projects:execute, keys:read, keys:write, admin
- SHA-256 key hashing (secrets never stored)
- Expiration options: 30d, 60d, 90d, 1y, never
- Middleware skips /health, /ready, /docs, /openapi.json

## Key Management API
- GET /keys - List keys (keys:read)
- POST /keys - Create key (keys:write)
- GET /keys/{id} - Get key details (keys:read)
- DELETE /keys/{id} - Revoke key (keys:write)

## Environment Variables
- DB_HOST, DB_PORT, DB_USER, DB_PASSWORD, DB_NAME
- RDEV_ADMIN_KEY - Super admin key for bootstrapping

Version bumped to 0.5.0.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 21:26:26 -07:00

297 lines
6.4 KiB
Go

package auth
import (
"context"
"database/sql"
"errors"
"fmt"
"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")
)
// APIKey represents a stored API key.
type APIKey struct {
ID string
Name string
KeyPrefix string
Scopes []Scope
ProjectIDs []string // nil = all projects
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()
}
// CreateKeyRequest is the input for creating a new key.
type CreateKeyRequest struct {
Name string
Scopes []Scope
ProjectIDs []string // nil = all projects
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, expires_at, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id
`, req.Name, keyHash, prefix, pq.Array(scopeStrings), pq.Array(req.ProjectIDs), 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,
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, 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),
&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, 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 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),
&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, 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),
&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
}