package handlers import ( "errors" "net" "net/http" "time" "github.com/go-chi/chi/v5" "github.com/orchard9/rdev/internal/auth" "github.com/orchard9/rdev/internal/domain" "github.com/orchard9/rdev/internal/port" "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)).Patch("/{id}", h.Update) 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, }) } // UpdateKeyRequest is the JSON body for PATCH /keys/{id}. // A null JSON value for project_ids or allowed_ips sets them to unrestricted. type UpdateKeyRequest struct { Name *string `json:"name,omitempty"` Scopes []string `json:"scopes,omitempty"` ProjectIDs *[]string `json:"project_ids"` // null = unrestricted; array = restrict to these projects AllowedIPs *[]string `json:"allowed_ips"` // null = no restriction; array = restrict to these IPs ExpiresIn *string `json:"expires_in,omitempty"` // "30d", "60d", "90d", "1y", "never" } // Update modifies a mutable API key fields. // PATCH /keys/{id} func (h *KeysHandler) Update(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") var req UpdateKeyRequest if err := api.DecodeJSON(r, &req); err != nil { api.WriteBadRequest(w, r, "invalid request body") return } update := port.APIKeyUpdate{} if req.Name != nil { if *req.Name == "" { api.WriteBadRequest(w, r, "name cannot be empty") return } update.Name = req.Name } if req.Scopes != nil { scopes := auth.ScopesFromStrings(req.Scopes) if !auth.ValidateScopes(scopes) { api.WriteBadRequest(w, r, "invalid scope(s)") return } update.Scopes = scopes } if req.ProjectIDs != nil { pids := make([]domain.ProjectID, len(*req.ProjectIDs)) for i, s := range *req.ProjectIDs { pids[i] = domain.ProjectID(s) } update.ProjectIDs = &pids } if req.AllowedIPs != nil { 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 } } update.AllowedIPs = req.AllowedIPs } if req.ExpiresIn != nil { expiresIn, err := auth.ParseExpiration(*req.ExpiresIn) if err != nil { api.WriteBadRequest(w, r, err.Error()) return } if expiresIn == 0 { // "never" — remove expiry var nilTime *time.Time update.ExpiresAt = &nilTime } else { t := time.Now().Add(expiresIn) tPtr := &t update.ExpiresAt = &tPtr } } if err := h.authService.Update(r.Context(), id, update); err != nil { if errors.Is(err, auth.ErrKeyNotFound) { api.WriteNotFound(w, r, "Key not found") return } api.WriteInternalError(w, r, "Failed to update key") return } // Return updated key key, err := h.authService.Get(r.Context(), id) if err != nil { api.WriteInternalError(w, r, "Failed to fetch updated key") return } api.WriteSuccess(w, r, apiKeyToResponse(key)) }