Some checks failed
Build Pipeline / Build Binaries (push) Failing after 3m39s
Build Pipeline / Build Docker Images (push) Has been skipped
Build Pipeline / Sign HIPAA Config (push) Has been skipped
Build Pipeline / Generate SLSA Provenance (push) Has been skipped
Checkout test / test (push) Successful in 6s
CI Pipeline / Test (ubuntu-latest on self-hosted) (push) Failing after 1s
CI Pipeline / Dev Compose Smoke Test (push) Has been skipped
CI Pipeline / Security Scan (push) Has been skipped
CI Pipeline / Test Scripts (push) Has been skipped
CI Pipeline / Test Native Libraries (push) Has been skipped
CI Pipeline / Native Library Build Matrix (push) Has been skipped
Contract Tests / Spec Drift Detection (push) Failing after 11s
Contract Tests / API Contract Tests (push) Has been skipped
Deploy API Docs / Build API Documentation (push) Failing after 5s
Deploy API Docs / Deploy to GitHub Pages (push) Has been skipped
Documentation / build-and-publish (push) Failing after 40s
Test Matrix / test-native-vs-pure (cgo) (push) Failing after 14s
Test Matrix / test-native-vs-pure (native) (push) Failing after 35s
Test Matrix / test-native-vs-pure (pure) (push) Failing after 18s
CI Pipeline / Trigger Build Workflow (push) Failing after 1s
Build CLI with Embedded SQLite / build (arm64, aarch64-linux) (push) Has been cancelled
Build CLI with Embedded SQLite / build (x86_64, x86_64-linux) (push) Has been cancelled
Build CLI with Embedded SQLite / build-macos (arm64) (push) Has been cancelled
Build CLI with Embedded SQLite / build-macos (x86_64) (push) Has been cancelled
Security Scan / Security Analysis (push) Has been cancelled
Security Scan / Native Library Security (push) Has been cancelled
Verification & Maintenance / V.1 - Schema Drift Detection (push) Has been cancelled
Verification & Maintenance / V.4 - Custom Go Vet Analyzers (push) Has been cancelled
Verification & Maintenance / V.7 - Audit Chain Integrity (push) Has been cancelled
Verification & Maintenance / V.6 - Extended Security Scanning (push) Has been cancelled
Verification & Maintenance / V.10 - OpenSSF Scorecard (push) Has been cancelled
Verification & Maintenance / Verification Summary (push) Has been cancelled
- Introduce audit, plugin, and scheduler API handlers - Add spec_embed.go for OpenAPI spec embedding - Create modular build scripts (cli, go, native, cross-platform) - Add deployment cleanup and health-check utilities - New ADRs: hot reload, audit store, SSE updates, RBAC, caching, offline mode, KMS regions, tenant offboarding - Add KMS configuration schema and worker variants - Include KMS benchmark tests
289 lines
8.3 KiB
Go
289 lines
8.3 KiB
Go
// Package plugins provides HTTP handlers for plugin management
|
|
package plugins
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/jfraeys/fetch_ml/internal/auth"
|
|
"github.com/jfraeys/fetch_ml/internal/logging"
|
|
"github.com/jfraeys/fetch_ml/internal/tracking"
|
|
)
|
|
|
|
// Handler provides plugin-related HTTP handlers
|
|
type Handler struct {
|
|
logger *logging.Logger
|
|
registry *tracking.Registry
|
|
config map[string]PluginConfig // Plugin configurations
|
|
}
|
|
|
|
// PluginConfig represents the configuration for a plugin
|
|
type PluginConfig struct {
|
|
Enabled bool `json:"enabled"`
|
|
Mode string `json:"mode"` // sidecar, remote, disabled
|
|
Image string `json:"image,omitempty"`
|
|
Settings map[string]interface{} `json:"settings,omitempty"`
|
|
LogBasePath string `json:"log_base_path,omitempty"`
|
|
ArtifactPath string `json:"artifact_path,omitempty"`
|
|
}
|
|
|
|
// PluginInfo represents plugin information returned by the API
|
|
type PluginInfo struct {
|
|
Name string `json:"name"`
|
|
Enabled bool `json:"enabled"`
|
|
Mode string `json:"mode"`
|
|
Status string `json:"status"` // healthy, unhealthy, starting, stopped
|
|
Config PluginConfig `json:"config"`
|
|
RequiresRestart bool `json:"requires_restart"`
|
|
Version string `json:"version,omitempty"`
|
|
}
|
|
|
|
// PluginStatus represents the status of a plugin instance
|
|
type PluginStatus struct {
|
|
Name string `json:"name"`
|
|
Status string `json:"status"`
|
|
URL string `json:"url,omitempty"`
|
|
LastCheck time.Time `json:"last_check,omitempty"`
|
|
}
|
|
|
|
// NewHandler creates a new plugins handler
|
|
func NewHandler(
|
|
logger *logging.Logger,
|
|
registry *tracking.Registry,
|
|
config map[string]PluginConfig,
|
|
) *Handler {
|
|
return &Handler{
|
|
logger: logger,
|
|
registry: registry,
|
|
config: config,
|
|
}
|
|
}
|
|
|
|
// GetV1Plugins handles GET /v1/plugins
|
|
func (h *Handler) GetV1Plugins(w http.ResponseWriter, r *http.Request) {
|
|
user := auth.GetUserFromContext(r.Context())
|
|
if !h.checkPermission(user, "plugins:read") {
|
|
http.Error(w, `{"error":"Insufficient permissions","code":"FORBIDDEN"}`, http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
plugins := []PluginInfo{}
|
|
|
|
// If registry is available, get plugin status
|
|
if h.registry != nil {
|
|
for name := range h.config {
|
|
cfg := h.config[name]
|
|
info := PluginInfo{
|
|
Name: name,
|
|
Enabled: cfg.Enabled,
|
|
Mode: cfg.Mode,
|
|
Status: "unknown",
|
|
Config: cfg,
|
|
RequiresRestart: false, // Default: plugins support hot-reload
|
|
Version: "1.0.0", // Placeholder
|
|
}
|
|
|
|
// Check if plugin is registered
|
|
if plugin, ok := h.registry.Get(name); ok {
|
|
// Check plugin health if available
|
|
if cfg.Enabled && cfg.Mode != "disabled" {
|
|
info.Status = "healthy" // Default, would check actual health
|
|
_ = plugin // Use plugin for actual health check
|
|
}
|
|
}
|
|
|
|
plugins = append(plugins, info)
|
|
}
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(plugins)
|
|
}
|
|
|
|
// GetV1PluginsPluginName handles GET /v1/plugins/{pluginName}
|
|
func (h *Handler) GetV1PluginsPluginName(w http.ResponseWriter, r *http.Request) {
|
|
user := auth.GetUserFromContext(r.Context())
|
|
if !h.checkPermission(user, "plugins:read") {
|
|
http.Error(w, `{"error":"Insufficient permissions","code":"FORBIDDEN"}`, http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
pluginName := r.PathValue("pluginName")
|
|
if pluginName == "" {
|
|
http.Error(w, `{"error":"Missing plugin name","code":"BAD_REQUEST"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
cfg, ok := h.config[pluginName]
|
|
if !ok {
|
|
http.Error(w, `{"error":"Plugin not found","code":"NOT_FOUND"}`, http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
info := PluginInfo{
|
|
Name: pluginName,
|
|
Enabled: cfg.Enabled,
|
|
Mode: cfg.Mode,
|
|
Status: "unknown",
|
|
Config: cfg,
|
|
RequiresRestart: false,
|
|
Version: "1.0.0",
|
|
}
|
|
|
|
if cfg.Enabled && cfg.Mode != "disabled" {
|
|
info.Status = "healthy"
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(info)
|
|
}
|
|
|
|
// GetV1PluginsPluginNameConfig handles GET /v1/plugins/{pluginName}/config
|
|
func (h *Handler) GetV1PluginsPluginNameConfig(w http.ResponseWriter, r *http.Request) {
|
|
user := auth.GetUserFromContext(r.Context())
|
|
if !h.checkPermission(user, "plugins:read") {
|
|
http.Error(w, `{"error":"Insufficient permissions","code":"FORBIDDEN"}`, http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
pluginName := r.PathValue("pluginName")
|
|
if pluginName == "" {
|
|
http.Error(w, `{"error":"Missing plugin name","code":"BAD_REQUEST"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
cfg, ok := h.config[pluginName]
|
|
if !ok {
|
|
http.Error(w, `{"error":"Plugin not found","code":"NOT_FOUND"}`, http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(cfg)
|
|
}
|
|
|
|
// PutV1PluginsPluginNameConfig handles PUT /v1/plugins/{pluginName}/config
|
|
func (h *Handler) PutV1PluginsPluginNameConfig(w http.ResponseWriter, r *http.Request) {
|
|
user := auth.GetUserFromContext(r.Context())
|
|
if !h.checkPermission(user, "plugins:write") {
|
|
http.Error(w, `{"error":"Insufficient permissions","code":"FORBIDDEN"}`, http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
pluginName := r.PathValue("pluginName")
|
|
if pluginName == "" {
|
|
http.Error(w, `{"error":"Missing plugin name","code":"BAD_REQUEST"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var newConfig PluginConfig
|
|
if err := json.NewDecoder(r.Body).Decode(&newConfig); err != nil {
|
|
http.Error(w, `{"error":"Invalid request body","code":"BAD_REQUEST"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Update config
|
|
h.config[pluginName] = newConfig
|
|
h.logger.Info("updated plugin config", "plugin", pluginName, "user", user.Name)
|
|
|
|
// Return updated plugin info
|
|
info := PluginInfo{
|
|
Name: pluginName,
|
|
Enabled: newConfig.Enabled,
|
|
Mode: newConfig.Mode,
|
|
Status: "healthy",
|
|
Config: newConfig,
|
|
RequiresRestart: false,
|
|
Version: "1.0.0",
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(info)
|
|
}
|
|
|
|
// DeleteV1PluginsPluginNameConfig handles DELETE /v1/plugins/{pluginName}/config
|
|
func (h *Handler) DeleteV1PluginsPluginNameConfig(w http.ResponseWriter, r *http.Request) {
|
|
user := auth.GetUserFromContext(r.Context())
|
|
if !h.checkPermission(user, "plugins:write") {
|
|
http.Error(w, `{"error":"Insufficient permissions","code":"FORBIDDEN"}`, http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
pluginName := r.PathValue("pluginName")
|
|
if pluginName == "" {
|
|
http.Error(w, `{"error":"Missing plugin name","code":"BAD_REQUEST"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
cfg, ok := h.config[pluginName]
|
|
if !ok {
|
|
http.Error(w, `{"error":"Plugin not found","code":"NOT_FOUND"}`, http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
// Disable the plugin
|
|
cfg.Enabled = false
|
|
h.config[pluginName] = cfg
|
|
|
|
h.logger.Info("disabled plugin", "plugin", pluginName, "user", user.Name)
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// GetV1PluginsPluginNameHealth handles GET /v1/plugins/{pluginName}/health
|
|
func (h *Handler) GetV1PluginsPluginNameHealth(w http.ResponseWriter, r *http.Request) {
|
|
user := auth.GetUserFromContext(r.Context())
|
|
if !h.checkPermission(user, "plugins:read") {
|
|
http.Error(w, `{"error":"Insufficient permissions","code":"FORBIDDEN"}`, http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
pluginName := r.PathValue("pluginName")
|
|
if pluginName == "" {
|
|
http.Error(w, `{"error":"Missing plugin name","code":"BAD_REQUEST"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
cfg, ok := h.config[pluginName]
|
|
if !ok {
|
|
http.Error(w, `{"error":"Plugin not found","code":"NOT_FOUND"}`, http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
status := "healthy"
|
|
if !cfg.Enabled || cfg.Mode == "disabled" {
|
|
status = "stopped"
|
|
}
|
|
|
|
response := map[string]interface{}{
|
|
"status": status,
|
|
"version": "1.0.0",
|
|
"timestamp": time.Now().UTC(),
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(response)
|
|
}
|
|
|
|
// checkPermission checks if the user has the required permission
|
|
func (h *Handler) checkPermission(user *auth.User, permission string) bool {
|
|
if user == nil {
|
|
return false
|
|
}
|
|
|
|
// Admin has all permissions
|
|
for _, role := range user.Roles {
|
|
if role == "admin" {
|
|
return true
|
|
}
|
|
}
|
|
|
|
// Check specific permission
|
|
for perm, hasPerm := range user.Permissions {
|
|
if hasPerm && perm == permission {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|