rdev/internal/handlers/keys.go
jordan 8282d60c69 feat: implement composable monorepo template system with component architecture
Adds the composable monorepo template system that generates project skeletons
with pluggable components (service, worker, app-react, app-astro, cli).

Key changes:
- Monorepo skeleton templates with shared pkg/, scripts/, and git hooks
- Component templates (service, worker, app-react, app-astro, cli) with
  Dockerfiles, CI steps, and component.yaml manifests
- Component domain model with validation and dependency resolution
- Component handler endpoints for CRUD and composition
- Template provider extended with BuildComposableProject and component assembly
- Deployer extended with composable project deployment support
- Handler timeout constants (TimeoutFastLookup through TimeoutLongRunning)
- envutil package for centralized env var reads with defaults
- api.DecodeJSON helper for standardized request body decoding
- Standardized response helpers (WriteBadRequest, WriteNotFound, etc.)
- Replaced fullstack-app cookbook with composable-app cookbook
- Hardened handler timeouts, logging, and error responses across all handlers

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 19:11:42 -07:00

242 lines
6.3 KiB
Go

package handlers
import (
"errors"
"net"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/orchard9/rdev/internal/auth"
"github.com/orchard9/rdev/internal/validate"
"github.com/orchard9/rdev/pkg/api"
)
// KeysHandler handles API key management endpoints.
type KeysHandler struct {
authService *auth.Service
}
// NewKeysHandler creates a new keys handler.
func NewKeysHandler(authService *auth.Service) *KeysHandler {
return &KeysHandler{authService: authService}
}
// Mount registers the keys routes.
func (h *KeysHandler) Mount(r api.Router) {
r.Route("/keys", func(r chi.Router) {
// All key endpoints require authentication (handled by global middleware)
r.With(auth.RequireScope(auth.ScopeKeysRead, auth.ScopeAdmin)).Get("/", h.List)
r.With(auth.RequireScope(auth.ScopeKeysWrite, auth.ScopeAdmin)).Post("/", h.Create)
r.With(auth.RequireScope(auth.ScopeKeysRead, auth.ScopeAdmin)).Get("/{id}", h.Get)
r.With(auth.RequireScope(auth.ScopeKeysWrite, auth.ScopeAdmin)).Delete("/{id}", h.Revoke)
})
}
// CreateKeyRequest is the JSON body for creating a key.
type CreateKeyRequest struct {
Name string `json:"name"`
Scopes []string `json:"scopes"`
ProjectIDs []string `json:"project_ids,omitempty"` // null = all projects
ExpiresIn string `json:"expires_in,omitempty"` // "30d", "60d", "90d", "1y", "never"
AllowedIPs []string `json:"allowed_ips,omitempty"` // CIDR notation, e.g., ["192.168.1.0/24"]; null = no restriction
}
// KeyResponse is the JSON response for a key (without secret).
type KeyResponse struct {
ID string `json:"id"`
Name string `json:"name"`
KeyPrefix string `json:"key_prefix"`
Scopes []string `json:"scopes"`
ProjectIDs []string `json:"project_ids,omitempty"`
AllowedIPs []string `json:"allowed_ips,omitempty"`
CreatedAt string `json:"created_at"`
ExpiresAt *string `json:"expires_at,omitempty"`
LastUsedAt *string `json:"last_used_at,omitempty"`
RevokedAt *string `json:"revoked_at,omitempty"`
CreatedBy string `json:"created_by"`
Active bool `json:"active"`
}
// CreateKeyResponse includes the secret (shown only once).
type CreateKeyResponse struct {
Key KeyResponse `json:"key"`
Secret string `json:"secret"`
}
// apiKeyToResponse converts an APIKey to a JSON response.
func apiKeyToResponse(k *auth.APIKey) KeyResponse {
resp := KeyResponse{
ID: string(k.ID),
Name: k.Name,
KeyPrefix: k.KeyPrefix,
Scopes: auth.ScopesToStrings(k.Scopes),
CreatedAt: k.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
CreatedBy: k.CreatedBy,
Active: k.IsActive(),
}
if k.ProjectIDs != nil {
resp.ProjectIDs = make([]string, len(k.ProjectIDs))
for i, pid := range k.ProjectIDs {
resp.ProjectIDs[i] = string(pid)
}
}
if k.AllowedIPs != nil {
resp.AllowedIPs = k.AllowedIPs
}
if k.ExpiresAt != nil {
s := k.ExpiresAt.Format("2006-01-02T15:04:05Z07:00")
resp.ExpiresAt = &s
}
if k.LastUsedAt != nil {
s := k.LastUsedAt.Format("2006-01-02T15:04:05Z07:00")
resp.LastUsedAt = &s
}
if k.RevokedAt != nil {
s := k.RevokedAt.Format("2006-01-02T15:04:05Z07:00")
resp.RevokedAt = &s
}
return resp
}
// List returns all API keys.
// GET /keys
func (h *KeysHandler) List(w http.ResponseWriter, r *http.Request) {
keys, err := h.authService.List(r.Context())
if err != nil {
api.WriteInternalError(w, r, "Failed to list keys")
return
}
resp := make([]KeyResponse, len(keys))
for i, k := range keys {
resp[i] = apiKeyToResponse(k)
}
api.WriteSuccess(w, r, resp)
}
// Create generates a new API key.
// POST /keys
func (h *KeysHandler) Create(w http.ResponseWriter, r *http.Request) {
var req CreateKeyRequest
if err := api.DecodeJSON(r, &req); err != nil {
api.WriteBadRequest(w, r, "invalid request body")
return
}
v := validate.New()
v.Required(req.Name, "name")
v.RequiredSlice(req.Scopes, "scopes")
if err := v.Error(); err != nil {
api.WriteBadRequest(w, r, err.Error())
return
}
// Validate scopes
scopes := auth.ScopesFromStrings(req.Scopes)
if !auth.ValidateScopes(scopes) {
api.WriteBadRequest(w, r, "invalid scope(s)")
return
}
// Parse expiration
expiresIn, err := auth.ParseExpiration(req.ExpiresIn)
if err != nil {
api.WriteBadRequest(w, r, err.Error())
return
}
// Validate allowed_ips CIDR format
for _, cidr := range req.AllowedIPs {
if err := validateCIDROrIP(cidr); err != nil {
api.WriteBadRequest(w, r, "invalid allowed_ips: "+cidr+" is not a valid CIDR or IP address")
return
}
}
// Get creator from authenticated key
creator := "admin"
if apiKey := auth.GetAPIKey(r.Context()); apiKey != nil && string(apiKey.ID) != "admin" {
creator = string(apiKey.ID)
}
result, err := h.authService.Create(r.Context(), auth.CreateKeyRequest{
Name: req.Name,
Scopes: scopes,
ProjectIDs: req.ProjectIDs,
AllowedIPs: req.AllowedIPs,
ExpiresIn: expiresIn,
CreatedBy: creator,
})
if err != nil {
api.WriteInternalError(w, r, "Failed to create key")
return
}
api.WriteCreated(w, r, CreateKeyResponse{
Key: apiKeyToResponse(result.Key),
Secret: result.Secret,
})
}
// validateCIDROrIP validates that a string is either a valid CIDR notation or a valid IP address.
func validateCIDROrIP(cidr string) error {
// Try parsing as CIDR first
_, _, err := net.ParseCIDR(cidr)
if err == nil {
return nil
}
// Try parsing as a single IP address
ip := net.ParseIP(cidr)
if ip != nil {
return nil
}
return err
}
// Get returns a single API key.
// GET /keys/{id}
func (h *KeysHandler) Get(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
key, err := h.authService.Get(r.Context(), id)
if err != nil {
if errors.Is(err, auth.ErrKeyNotFound) {
api.WriteNotFound(w, r, "Key not found")
return
}
api.WriteInternalError(w, r, "Failed to get key")
return
}
api.WriteSuccess(w, r, apiKeyToResponse(key))
}
// Revoke marks an API key as revoked.
// DELETE /keys/{id}
func (h *KeysHandler) Revoke(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if err := h.authService.Revoke(r.Context(), id); err != nil {
if errors.Is(err, auth.ErrKeyNotFound) {
api.WriteNotFound(w, r, "Key not found")
return
}
api.WriteInternalError(w, r, "Failed to revoke key")
return
}
api.WriteSuccess(w, r, map[string]string{
"status": "revoked",
"id": id,
})
}