Files
notifier/api/rest/keys.go
T
2025-10-26 02:25:24 -07:00

322 lines
9.5 KiB
Go

package rest
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/igodwin/notifier/internal/auth"
"github.com/igodwin/notifier/internal/logging"
)
// KeyManagementHandler handles API key management endpoints
type KeyManagementHandler struct {
keyStore *auth.HybridKeyStore
logger *logging.Logger
}
// NewKeyManagementHandler creates a new key management handler
func NewKeyManagementHandler(keyStore *auth.HybridKeyStore, logger *logging.Logger) *KeyManagementHandler {
return &KeyManagementHandler{
keyStore: keyStore,
logger: logger,
}
}
// CreateKeyRequest is the request body for creating a new API key
type CreateKeyRequest struct {
ClientID string `json:"client_id"`
Roles []string `json:"roles"`
RateLimit int `json:"rate_limit,omitempty"`
ExpiresIn *time.Duration `json:"expires_in,omitempty"`
}
// CreateKeyResponse is the response body when creating an API key
type CreateKeyResponse struct {
Key string `json:"key"`
Name string `json:"name"`
ClientID string `json:"client_id"`
Roles []string `json:"roles"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
RateLimit int `json:"rate_limit"`
}
// ListKeysResponse is the response body for listing API keys
type ListKeysResponse struct {
Keys []*KeyInfo `json:"keys"`
}
// KeyInfo contains metadata about an API key (without the key itself)
type KeyInfo struct {
Key string `json:"key_preview"` // Only last 4 chars
Name string `json:"name"`
ClientID string `json:"client_id"`
Roles []string `json:"roles"`
CreatedAt time.Time `json:"created_at"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
IsActive bool `json:"is_active"`
RateLimit int `json:"rate_limit"`
}
// ErrorResponse is a standard error response
type ErrorResponse struct {
Error string `json:"error"`
Message string `json:"message,omitempty"`
}
// CreateKey creates a new API key
// POST /api/v1/admin/keys
// Requires: admin role
func (h *KeyManagementHandler) CreateKey(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Check authorization - must have admin role
authCtx, ok := ctx.Value("auth").(*auth.AuthContext)
if !ok || !h.hasRole(authCtx, "admin") {
h.respondError(w, http.StatusForbidden, "Insufficient permissions", "admin role required")
return
}
var req CreateKeyRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.respondError(w, http.StatusBadRequest, "Invalid request body", err.Error())
return
}
// Validate request
if req.ClientID == "" {
h.respondError(w, http.StatusBadRequest, "Missing client_id", "")
return
}
if len(req.Roles) == 0 {
h.respondError(w, http.StatusBadRequest, "At least one role is required", "")
return
}
// Default rate limit
if req.RateLimit == 0 {
req.RateLimit = 100
}
// Create the key
apiKey, err := h.keyStore.CreateKey(ctx, req.ClientID, req.Roles, req.RateLimit, req.ExpiresIn, authCtx.ClientID)
if err != nil {
h.logger.Errorf("Failed to create API key: %v", err)
h.respondError(w, http.StatusInternalServerError, "Failed to create API key", err.Error())
return
}
resp := CreateKeyResponse{
Key: apiKey.Key,
Name: apiKey.Name,
ClientID: apiKey.ClientID,
Roles: apiKey.Roles,
CreatedAt: apiKey.CreatedAt,
ExpiresAt: apiKey.ExpiresAt,
RateLimit: apiKey.RateLimit,
}
h.respondJSON(w, http.StatusCreated, resp)
h.logger.Infof("Created API key for client %s", req.ClientID)
}
// ListKeys lists all API keys for the authenticated client
// GET /api/v1/admin/keys
// Requires: admin role (to list other users' keys)
func (h *KeyManagementHandler) ListKeys(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
authCtx, ok := ctx.Value("auth").(*auth.AuthContext)
if !ok {
h.respondError(w, http.StatusUnauthorized, "Unauthorized", "")
return
}
// Get client_id from query param, default to authenticated client
clientID := r.URL.Query().Get("client_id")
if clientID == "" {
clientID = authCtx.ClientID
}
// If requesting other client's keys, require admin role
if clientID != authCtx.ClientID && !h.hasRole(authCtx, "admin") {
h.respondError(w, http.StatusForbidden, "Insufficient permissions", "admin role required to list other clients' keys")
return
}
keys, err := h.keyStore.ListKeys(ctx, clientID)
if err != nil {
h.logger.Errorf("Failed to list API keys: %v", err)
h.respondError(w, http.StatusInternalServerError, "Failed to list API keys", err.Error())
return
}
// Convert to response format (hide full key)
keyInfos := make([]*KeyInfo, len(keys))
for i, key := range keys {
keyInfos[i] = &KeyInfo{
Key: "nk_" + key.Key[len(key.Key)-4:], // Show only last 4 chars
Name: key.Name,
ClientID: key.ClientID,
Roles: key.Roles,
CreatedAt: key.CreatedAt,
LastUsedAt: key.LastUsedAt,
ExpiresAt: key.ExpiresAt,
IsActive: key.IsActive,
RateLimit: key.RateLimit,
}
}
h.respondJSON(w, http.StatusOK, ListKeysResponse{Keys: keyInfos})
}
// RevokeKeyRequest is the request body for revoking a key
type RevokeKeyRequest struct {
Reason string `json:"reason,omitempty"`
}
// RevokeKey deactivates an API key
// DELETE /api/v1/admin/keys/:key
// Requires: admin role
func (h *KeyManagementHandler) RevokeKey(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
authCtx, ok := ctx.Value("auth").(*auth.AuthContext)
if !ok || !h.hasRole(authCtx, "admin") {
h.respondError(w, http.StatusForbidden, "Insufficient permissions", "admin role required")
return
}
// Extract key from path parameter
keyStr := strings.TrimPrefix(r.URL.Path, "/api/v1/admin/keys/")
var req RevokeKeyRequest
_ = json.NewDecoder(r.Body).Decode(&req) // Ignore decode errors, reason is optional
err := h.keyStore.DeactivateKey(ctx, keyStr, authCtx.ClientID)
if err != nil {
if strings.Contains(err.Error(), "not found") {
h.respondError(w, http.StatusNotFound, "Key not found", "")
} else {
h.logger.Errorf("Failed to revoke API key: %v", err)
h.respondError(w, http.StatusInternalServerError, "Failed to revoke API key", err.Error())
}
return
}
w.WriteHeader(http.StatusNoContent)
h.logger.Infof("Revoked API key")
}
// RotateKeyRequest is the request body for rotating a key
type RotateKeyRequest struct {
PreserveRoles bool `json:"preserve_roles,omitempty"`
}
// RotateKey creates a new API key to replace the old one
// POST /api/v1/admin/keys/:key/rotate
// Requires: admin role
func (h *KeyManagementHandler) RotateKey(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
authCtx, ok := ctx.Value("auth").(*auth.AuthContext)
if !ok || !h.hasRole(authCtx, "admin") {
h.respondError(w, http.StatusForbidden, "Insufficient permissions", "admin role required")
return
}
oldKeyStr := strings.TrimPrefix(r.URL.Path, "/api/v1/admin/keys/")
oldKeyStr = strings.TrimSuffix(oldKeyStr, "/rotate")
var req RotateKeyRequest
_ = json.NewDecoder(r.Body).Decode(&req)
// For now, rotation means:
// 1. Get the old key metadata
// 2. Create a new key with same properties
// 3. Deactivate old key
// In a real implementation, you might want to keep both active for a grace period
// Since we don't have direct key lookup in cache, return error
h.respondError(w, http.StatusNotImplemented, "Key rotation not yet implemented", "Use revoke + create new key")
}
// GetAuditLogResponse is the response for audit log
type GetAuditLogResponse struct {
Key string `json:"key_preview"`
AuditLog []map[string]interface{} `json:"audit_log"`
}
// GetAuditLog retrieves the audit log for a key
// GET /api/v1/admin/keys/:key/audit
// Requires: admin role
func (h *KeyManagementHandler) GetAuditLog(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
authCtx, ok := ctx.Value("auth").(*auth.AuthContext)
if !ok || !h.hasRole(authCtx, "admin") {
h.respondError(w, http.StatusForbidden, "Insufficient permissions", "admin role required")
return
}
keyStr := strings.TrimPrefix(r.URL.Path, "/api/v1/admin/keys/")
keyStr = strings.TrimSuffix(keyStr, "/audit")
limit := 100
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 1000 {
limit = l
}
}
logs, err := h.keyStore.GetAuditLog(ctx, keyStr, limit)
if err != nil {
h.logger.Errorf("Failed to get audit log: %v", err)
h.respondError(w, http.StatusInternalServerError, "Failed to get audit log", err.Error())
return
}
resp := GetAuditLogResponse{
Key: "nk_" + keyStr[len(keyStr)-4:],
AuditLog: logs,
}
h.respondJSON(w, http.StatusOK, resp)
}
// Helper methods
// hasRole checks if the auth context has a specific role
func (h *KeyManagementHandler) hasRole(authCtx *auth.AuthContext, role string) bool {
for _, r := range authCtx.Roles {
if r == role {
return true
}
}
return false
}
// respondJSON writes a JSON response
func (h *KeyManagementHandler) respondJSON(w http.ResponseWriter, statusCode int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
json.NewEncoder(w).Encode(data)
}
// respondError writes an error JSON response
func (h *KeyManagementHandler) respondError(w http.ResponseWriter, statusCode int, error string, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
resp := ErrorResponse{
Error: error,
Message: message,
}
json.NewEncoder(w).Encode(resp)
}