322 lines
9.5 KiB
Go
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)
|
|
}
|