All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- 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>
340 lines
8.9 KiB
Go
340 lines
8.9 KiB
Go
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))
|
|
}
|