- Add auth.RequireScope() to all handler routes for proper authorization - Add SDLC OpenAPI endpoint documentation (state, features, tasks, branches, merge, archive, orchestrator) - Add SDLC documentation guides (getting-started, cli-reference, api-reference, command-catalog) - Add artifact_test.go for SDLC artifact coverage - Add CLAUDE.md rules: auth scopes requirement, error wrapping with %w - Fix error wrapping to use %w instead of %v throughout codebase - Improve CLI merge command with conflict detection and resolution - Fix handler tests to include auth middleware for RequireScope - Add cookbook tree runner scripts for automated testing Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
253 lines
6.7 KiB
Go
253 lines
6.7 KiB
Go
// Package handlers provides HTTP handlers for the rdev API.
|
|
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
|
|
"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"
|
|
)
|
|
|
|
// CredentialsHandler handles credential management endpoints.
|
|
// These endpoints require superadmin authentication.
|
|
type CredentialsHandler struct {
|
|
store port.CredentialStore
|
|
}
|
|
|
|
// NewCredentialsHandler creates a new credentials handler.
|
|
func NewCredentialsHandler(store port.CredentialStore) *CredentialsHandler {
|
|
return &CredentialsHandler{store: store}
|
|
}
|
|
|
|
// updatedBy extracts the authenticated identity from the request context.
|
|
// Returns the API key name for regular keys, or "superadmin" for admin key auth
|
|
// (which has ID "admin") to preserve consistency with existing database records.
|
|
func updatedBy(ctx context.Context) string {
|
|
if key := auth.GetAPIKey(ctx); key != nil && key.ID != "admin" {
|
|
return key.Name
|
|
}
|
|
return "superadmin"
|
|
}
|
|
|
|
// Mount registers the credentials routes.
|
|
// All routes require admin authentication for security.
|
|
func (h *CredentialsHandler) Mount(r api.Router) {
|
|
r.Route("/credentials", func(r chi.Router) {
|
|
// All credential operations require admin scope
|
|
r.With(auth.RequireScope(auth.ScopeAdmin)).Get("/", h.List)
|
|
r.With(auth.RequireScope(auth.ScopeAdmin)).Post("/", h.Set)
|
|
r.With(auth.RequireScope(auth.ScopeAdmin)).Post("/batch", h.SetBatch)
|
|
r.With(auth.RequireScope(auth.ScopeAdmin)).Get("/{key}", h.Get)
|
|
r.With(auth.RequireScope(auth.ScopeAdmin)).Delete("/{key}", h.Delete)
|
|
})
|
|
}
|
|
|
|
// SetCredentialRequest is the request body for POST /credentials.
|
|
type SetCredentialRequest struct {
|
|
Key string `json:"key"`
|
|
Value string `json:"value"`
|
|
Description string `json:"description,omitempty"`
|
|
Category string `json:"category,omitempty"`
|
|
}
|
|
|
|
// SetBatchRequest is the request body for POST /credentials/batch.
|
|
type SetBatchRequest struct {
|
|
Credentials []SetCredentialRequest `json:"credentials"`
|
|
}
|
|
|
|
// CredentialResponse is the response for credential endpoints.
|
|
type CredentialResponse struct {
|
|
Key string `json:"key"`
|
|
Value string `json:"value,omitempty"` // Only included for Get, masked for List
|
|
Description string `json:"description,omitempty"`
|
|
Category string `json:"category,omitempty"`
|
|
CreatedAt string `json:"created_at,omitempty"`
|
|
UpdatedAt string `json:"updated_at,omitempty"`
|
|
UpdatedBy string `json:"updated_by,omitempty"`
|
|
}
|
|
|
|
// List returns all credentials with values masked.
|
|
// GET /credentials
|
|
func (h *CredentialsHandler) List(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
|
|
// Check for category filter
|
|
category := r.URL.Query().Get("category")
|
|
|
|
var creds []domain.Credential
|
|
var err error
|
|
if category != "" {
|
|
creds, err = h.store.ListByCategory(ctx, category)
|
|
} else {
|
|
creds, err = h.store.List(ctx)
|
|
}
|
|
if err != nil {
|
|
api.WriteInternalError(w, r, "failed to list credentials")
|
|
return
|
|
}
|
|
|
|
response := make([]CredentialResponse, len(creds))
|
|
for i, c := range creds {
|
|
response[i] = CredentialResponse{
|
|
Key: c.Key,
|
|
Value: c.Value, // Already masked by store
|
|
Description: c.Description,
|
|
Category: c.Category,
|
|
CreatedAt: c.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
|
UpdatedAt: c.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
|
UpdatedBy: c.UpdatedBy,
|
|
}
|
|
}
|
|
|
|
api.WriteSuccess(w, r, response)
|
|
}
|
|
|
|
// Get retrieves a single credential by key.
|
|
// GET /credentials/{key}
|
|
func (h *CredentialsHandler) Get(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
key := chi.URLParam(r, "key")
|
|
|
|
if key == "" {
|
|
api.WriteBadRequest(w, r, "key is required")
|
|
return
|
|
}
|
|
|
|
value, err := h.store.Get(ctx, key)
|
|
if err != nil {
|
|
api.WriteInternalError(w, r, "failed to get credential")
|
|
return
|
|
}
|
|
if value == "" {
|
|
api.WriteNotFound(w, r, "credential not found")
|
|
return
|
|
}
|
|
|
|
api.WriteSuccess(w, r, CredentialResponse{
|
|
Key: key,
|
|
Value: value,
|
|
})
|
|
}
|
|
|
|
// Set creates or updates a single credential.
|
|
// POST /credentials
|
|
func (h *CredentialsHandler) Set(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
|
|
var req SetCredentialRequest
|
|
if err := api.DecodeJSON(r, &req); err != nil {
|
|
api.WriteBadRequest(w, r, "invalid request body")
|
|
return
|
|
}
|
|
|
|
v := validate.New()
|
|
v.Required(req.Key, "key")
|
|
v.Required(req.Value, "value")
|
|
if err := v.Error(); err != nil {
|
|
api.WriteBadRequest(w, r, err.Error())
|
|
return
|
|
}
|
|
|
|
cred := domain.Credential{
|
|
Key: req.Key,
|
|
Value: req.Value,
|
|
Description: req.Description,
|
|
Category: req.Category,
|
|
UpdatedBy: updatedBy(ctx),
|
|
}
|
|
|
|
if err := h.store.Set(ctx, cred); err != nil {
|
|
api.WriteInternalError(w, r, "failed to set credential")
|
|
return
|
|
}
|
|
|
|
api.WriteCreated(w, r, map[string]string{
|
|
"status": "stored",
|
|
"key": req.Key,
|
|
})
|
|
}
|
|
|
|
// SetBatch creates or updates multiple credentials.
|
|
// POST /credentials/batch
|
|
func (h *CredentialsHandler) SetBatch(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
|
|
var req SetBatchRequest
|
|
if err := api.DecodeJSON(r, &req); err != nil {
|
|
api.WriteBadRequest(w, r, "invalid request body")
|
|
return
|
|
}
|
|
|
|
if len(req.Credentials) == 0 {
|
|
api.WriteBadRequest(w, r, "credentials array is required")
|
|
return
|
|
}
|
|
|
|
creds := make([]domain.Credential, len(req.Credentials))
|
|
for i, c := range req.Credentials {
|
|
v := validate.New()
|
|
v.Required(c.Key, fmt.Sprintf("credentials[%d].key", i))
|
|
v.Required(c.Value, fmt.Sprintf("credentials[%d].value", i))
|
|
if err := v.Error(); err != nil {
|
|
api.WriteBadRequest(w, r, err.Error())
|
|
return
|
|
}
|
|
creds[i] = domain.Credential{
|
|
Key: c.Key,
|
|
Value: c.Value,
|
|
Description: c.Description,
|
|
Category: c.Category,
|
|
UpdatedBy: updatedBy(ctx),
|
|
}
|
|
}
|
|
|
|
if err := h.store.SetMultiple(ctx, creds); err != nil {
|
|
api.WriteInternalError(w, r, "failed to set credentials")
|
|
return
|
|
}
|
|
|
|
keys := make([]string, len(creds))
|
|
for i, c := range creds {
|
|
keys[i] = c.Key
|
|
}
|
|
|
|
api.WriteCreated(w, r, map[string]any{
|
|
"status": "stored",
|
|
"count": len(creds),
|
|
"keys": keys,
|
|
})
|
|
}
|
|
|
|
// Delete removes a credential by key.
|
|
// DELETE /credentials/{key}
|
|
func (h *CredentialsHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
key := chi.URLParam(r, "key")
|
|
|
|
if key == "" {
|
|
api.WriteBadRequest(w, r, "key is required")
|
|
return
|
|
}
|
|
|
|
if err := h.store.Delete(ctx, key); err != nil {
|
|
if errors.Is(err, domain.ErrCredentialNotFound) {
|
|
api.WriteNotFound(w, r, "credential not found")
|
|
return
|
|
}
|
|
api.WriteInternalError(w, r, "failed to delete credential")
|
|
return
|
|
}
|
|
|
|
api.WriteSuccess(w, r, map[string]string{
|
|
"status": "deleted",
|
|
"key": key,
|
|
})
|
|
}
|