diff --git a/cmd/gateway/admin_api.go b/cmd/gateway/admin_api.go index 416e645..bf78f3d 100644 --- a/cmd/gateway/admin_api.go +++ b/cmd/gateway/admin_api.go @@ -29,19 +29,21 @@ func newAdminAPIHandler() http.HandlerFunc { AuditLogs: handleAdminAuditLogs, - PluginArtifacts: handleAdminPluginArtifacts, - PluginArtifact: handleAdminPluginArtifact, - PluginSources: handleAdminPluginSources, - PluginBuilds: handleAdminPluginBuilds, - PluginBuild: handleAdminPluginBuild, - PluginGC: handleAdminPluginGC, - PluginsList: handleAdminPluginsList, - PluginItem: handleAdminPluginItem, - PluginAction: handleAdminPluginAction, - PluginConfig: handleAdminPluginConfig, - PluginSecrets: handleAdminPluginSecrets, - PluginRollback: handleAdminPluginRollback, - PluginDraining: handleAdminPluginDraining, - PluginDispatch: handleAdminPluginDispatchPlan, + PluginArtifacts: handleAdminPluginArtifacts, + PluginArtifact: handleAdminPluginArtifact, + PluginSources: handleAdminPluginSources, + PluginBuilds: handleAdminPluginBuilds, + PluginBuild: handleAdminPluginBuild, + PluginGC: handleAdminPluginGC, + PluginsList: handleAdminPluginsList, + PluginItem: handleAdminPluginItem, + PluginAction: handleAdminPluginAction, + PluginConfig: handleAdminPluginConfig, + PluginSecrets: handleAdminPluginSecrets, + PluginRollback: handleAdminPluginRollback, + PluginDraining: handleAdminPluginDraining, + PluginDispatch: handleAdminPluginDispatchPlan, + PluginGovernance: handleAdminPluginGovernance, + PluginAdvisories: handleAdminPluginAdvisories, }) } diff --git a/cmd/gateway/admin_api_test.go b/cmd/gateway/admin_api_test.go index e1299d5..e4963b4 100644 --- a/cmd/gateway/admin_api_test.go +++ b/cmd/gateway/admin_api_test.go @@ -314,6 +314,105 @@ func TestAdminPluginPhase4API(t *testing.T) { } } +func TestAdminPluginPhase5GovernanceAPI(t *testing.T) { + handler := newAdminTestHandlerWithAdmin(t) + pluginsManager = pluginmanager.New(pluginmanager.Options{ + DB: adminDB, + ArtifactRoot: filepath.Join(filepath.Dir(adminDBPath), "plugins", "artifacts"), + Adapter: gatewayTestPluginAdapter{}, + }) + adminToken := adminTestLogin(t, handler, "admin", "secret") + resp := adminTestRequest(t, handler, http.MethodPost, "/admin/api/users", adminToken, map[string]any{ + "username": "member", + "role": "member", + "password": "member-secret", + }) + if resp.Code != http.StatusCreated { + t.Fatalf("create member status = %d, body=%s", resp.Code, resp.Body.String()) + } + memberToken := adminTestLogin(t, handler, "member", "member-secret") + + artifact := uploadGatewayPhase5ProtocolProxyArtifact(t, "phase5-proxy") + if _, err := pluginsManager.SetDesired(context.Background(), "admin", "phase5-proxy", artifact.ID, pluginmanager.DesiredEnabled, `{}`, 10); err != nil { + t.Fatalf("SetDesired() error = %v", err) + } + resp = adminTestRequest(t, handler, http.MethodGet, "/admin/api/plugins/phase5-proxy/governance?profile=prod", memberToken, nil) + if resp.Code != http.StatusOK { + t.Fatalf("member governance status = %d, body=%s", resp.Code, resp.Body.String()) + } + if !strings.Contains(resp.Body.String(), "review_required") { + t.Fatalf("governance status body = %s, want review_required", resp.Body.String()) + } + resp = adminTestRequest(t, handler, http.MethodPost, "/admin/api/plugins/phase5-proxy/governance/review", memberToken, map[string]any{ + "artifact_id": artifact.ID, + "profile": pluginmanager.PolicyProfileProd, + }) + if resp.Code != http.StatusForbidden { + t.Fatalf("member review write status = %d, want forbidden; body=%s", resp.Code, resp.Body.String()) + } + resp = adminTestRequest(t, handler, http.MethodPost, "/admin/api/plugins/phase5-proxy/governance/review", adminToken, map[string]any{ + "artifact_id": artifact.ID, + "profile": pluginmanager.PolicyProfileProd, + "decision": pluginmanager.ReviewDecisionApproved, + "notes": "phase 5 approval", + }) + if resp.Code != http.StatusOK { + t.Fatalf("admin review write status = %d, body=%s", resp.Code, resp.Body.String()) + } + resp = adminTestRequest(t, handler, http.MethodPost, "/admin/api/plugins/phase5-proxy/governance/preflight", adminToken, map[string]any{ + "artifact_id": artifact.ID, + "profile": pluginmanager.PolicyProfileProd, + "action": pluginmanager.GovernanceActionEnable, + }) + if resp.Code != http.StatusOK { + t.Fatalf("preflight status = %d, body=%s", resp.Code, resp.Body.String()) + } + resp = adminTestRequest(t, handler, http.MethodPost, "/admin/api/plugins/phase5-proxy/governance/benchmark", adminToken, map[string]any{ + "artifact_id": artifact.ID, + "profile": pluginmanager.PolicyProfileProd, + "benchmark_profile": "release", + "p95_ms": 10, + "p99_ms": 20, + "baseline_diff": 0.25, + "active_proxy_capacity": 100, + }) + if resp.Code != http.StatusOK { + t.Fatalf("benchmark status = %d, body=%s", resp.Code, resp.Body.String()) + } + resp = adminTestRequest(t, handler, http.MethodPost, "/admin/api/plugins/phase5-proxy/governance/override", adminToken, map[string]any{ + "artifact_id": artifact.ID, + "profile": pluginmanager.PolicyProfileProd, + "action": pluginmanager.GovernanceActionEnable, + "reason": "accepted warning for rollout", + "ttl_seconds": 3600, + }) + if resp.Code != http.StatusOK { + t.Fatalf("override status = %d, body=%s", resp.Code, resp.Body.String()) + } + resp = adminTestRequest(t, handler, http.MethodPost, "/admin/api/plugin-advisories", adminToken, map[string]any{ + "advisory_id": "MCG-2026-ADMIN", + "status": pluginmanager.AdvisoryStatusRevoked, + "action": pluginmanager.AdvisoryActionRevoke, + "artifact_sha256": artifact.SHA256, + }) + if resp.Code != http.StatusOK { + t.Fatalf("advisory status = %d, body=%s", resp.Code, resp.Body.String()) + } + resp = adminTestRequest(t, handler, http.MethodGet, "/admin/api/plugin-advisories?plugin_id=phase5-proxy", memberToken, nil) + if resp.Code != http.StatusOK || !strings.Contains(resp.Body.String(), "MCG-2026-ADMIN") { + t.Fatalf("member advisory read status = %d, body=%s", resp.Code, resp.Body.String()) + } + resp = adminTestRequest(t, handler, http.MethodGet, "/admin/api/audit-logs", adminToken, nil) + if resp.Code != http.StatusOK { + t.Fatalf("audit status = %d, body=%s", resp.Code, resp.Body.String()) + } + for _, action := range []string{"plugin_governance_review", "plugin_governance_override", "plugin_governance_advisory"} { + if !strings.Contains(resp.Body.String(), action) { + t.Fatalf("audit body missing %s: %s", action, resp.Body.String()) + } + } +} + func uploadGatewayPhase4Artifact(t *testing.T, pluginID string) pluginmanager.ArtifactRecord { t.Helper() var manifest pluginmanager.Manifest @@ -347,6 +446,31 @@ func uploadGatewayPhase4Artifact(t *testing.T, pluginID string) pluginmanager.Ar return artifact } +func uploadGatewayPhase5ProtocolProxyArtifact(t *testing.T, pluginID string) pluginmanager.ArtifactRecord { + t.Helper() + var manifest pluginmanager.Manifest + if err := json.Unmarshal(gatewayTestManifestWithCapabilities(t, pluginID, gatewayProtocolProxyCapabilities()), &manifest); err != nil { + t.Fatalf("Unmarshal manifest error = %v", err) + } + manifest.RuntimeLimits = pluginmanager.RuntimeLimits{HandlerTimeoutMS: 3000} + manifestBytes, err := json.Marshal(manifest) + if err != nil { + t.Fatalf("Marshal manifest error = %v", err) + } + artifact, err := pluginsManager.UploadArtifact(context.Background(), pluginmanager.ArtifactUpload{ + SourcePath: writeGatewayTestMCGPEntries(t, map[string][]byte{ + "manifest.json": manifestBytes, + "plugin.so": []byte("fake plugin bytes " + pluginID), + }), + FileName: pluginID + ".mcgp", + Actor: "admin", + }) + if err != nil { + t.Fatalf("UploadArtifact() error = %v", err) + } + return artifact +} + func newAdminTestHandler(t *testing.T) http.Handler { t.Helper() t.Cleanup(saveGatewayState(t)) diff --git a/cmd/gateway/admin_frontend/src/types.ts b/cmd/gateway/admin_frontend/src/types.ts index c3409d8..217b996 100644 --- a/cmd/gateway/admin_frontend/src/types.ts +++ b/cmd/gateway/admin_frontend/src/types.ts @@ -117,6 +117,42 @@ export interface PluginProxyConnection { draining: boolean; } +export interface GovernanceIssue { + code: string; + severity: string; + message: string; + plugin_id?: string; + artifact_id?: string; + details?: Record; +} + +export interface GovernanceDecision { + ok: boolean; + action: string; + profile: string; + risk_level: string; + policy_hash: string; + review_required: boolean; + warning_override_used: boolean; + issues?: GovernanceIssue[]; + checks?: GovernanceIssue[]; +} + +export interface GovernanceStatus { + decision?: GovernanceDecision; + policy?: Record; + reviews?: Record[]; + warning_overrides?: Record[]; + preflights?: Record[]; + benchmarks?: Record[]; + advisories?: Record[]; + conflicts?: { + ok: boolean; + issues?: GovernanceIssue[]; + plan?: unknown; + }; +} + export interface PluginView { id: string; name?: string; @@ -151,6 +187,8 @@ export interface PluginView { manifest?: Record; active_proxy_connections?: number; proxy_connections?: PluginProxyConnection[]; + governance?: GovernanceStatus; + governance_error?: string; updated_at?: number; } diff --git a/cmd/gateway/admin_frontend/src/views/plugins.ts b/cmd/gateway/admin_frontend/src/views/plugins.ts index fbe11b9..7784325 100644 --- a/cmd/gateway/admin_frontend/src/views/plugins.ts +++ b/cmd/gateway/admin_frontend/src/views/plugins.ts @@ -167,6 +167,10 @@ export function renderPluginDetail(plugin: PluginView | null = selectedPlugin())
Minecraft
${escapeHTML(formatJSON(plugin.minecraft))}
+
+

Governance

+ ${governancePanel(plugin, canWrite)} +

Config

@@ -262,6 +266,12 @@ function bindPluginDetailEvents(plugin: PluginView): void { document.querySelectorAll("[data-snapshot-diff]").forEach((button) => { button.addEventListener("click", () => showSnapshotDiff(plugin.id, Number(button.dataset.snapshotDiff || "0"))); }); + document.getElementById("pluginGovernanceReviewBtn")?.addEventListener("click", () => createGovernanceReview(plugin)); + document.getElementById("pluginGovernanceOverrideBtn")?.addEventListener("click", () => createGovernanceOverride(plugin)); + document.getElementById("pluginGovernancePreflightBtn")?.addEventListener("click", () => runGovernancePreflight(plugin)); + document.getElementById("pluginGovernanceSelfTestBtn")?.addEventListener("click", () => runGovernanceSelfTest(plugin)); + document.getElementById("pluginGovernanceBenchmarkBtn")?.addEventListener("click", () => recordGovernanceBenchmark(plugin)); + document.getElementById("pluginGovernanceAdvisoryBtn")?.addEventListener("click", () => createArtifactRevokeAdvisory(plugin)); } async function dryRunConfig(plugin: PluginView): Promise { @@ -413,6 +423,117 @@ async function showSnapshotDiff(pluginID: string, snapshotID: number): Promise { + try { + await api(`/plugins/${encodeURIComponent(plugin.id)}/governance/review`, { + method: "POST", + body: { artifact_id: plugin.desired_artifact_id, profile: "prod", decision: "approved" }, + }); + await loadPluginDetail(plugin.id); + showAlert(""); + } catch (err) { + showAlert((err as Error).message); + } +} + +async function createGovernanceOverride(plugin: PluginView): Promise { + const reason = window.prompt("Reason"); + if (!reason) { + return; + } + try { + await api(`/plugins/${encodeURIComponent(plugin.id)}/governance/override`, { + method: "POST", + body: { artifact_id: plugin.desired_artifact_id, profile: "prod", action: "enable", reason, ttl_seconds: 3600 }, + }); + await loadPluginDetail(plugin.id); + showAlert(""); + } catch (err) { + showAlert((err as Error).message); + } +} + +async function runGovernancePreflight(plugin: PluginView): Promise { + try { + const data = await api>(`/plugins/${encodeURIComponent(plugin.id)}/governance/preflight`, { + method: "POST", + body: { artifact_id: plugin.desired_artifact_id, config_json: configEditorValue() }, + }); + el("pluginDryRunResult").textContent = formatJSON(data); + await loadPluginDetail(plugin.id); + showAlert(""); + } catch (err) { + showAlert((err as Error).message); + } +} + +async function runGovernanceSelfTest(plugin: PluginView): Promise { + try { + const data = await api>(`/plugins/${encodeURIComponent(plugin.id)}/governance/self-test`, { + method: "POST", + body: { artifact_id: plugin.desired_artifact_id }, + }); + el("pluginDryRunResult").textContent = formatJSON(data); + await loadPluginDetail(plugin.id); + showAlert(""); + } catch (err) { + showAlert((err as Error).message); + } +} + +async function recordGovernanceBenchmark(plugin: PluginView): Promise { + const diff = Number(window.prompt("Baseline diff, e.g. 0.25", "0.25")); + if (!Number.isFinite(diff)) { + return; + } + try { + await api(`/plugins/${encodeURIComponent(plugin.id)}/governance/benchmark`, { + method: "POST", + body: { + artifact_id: plugin.desired_artifact_id, + profile: "prod", + benchmark_profile: "manual", + p95_ms: 0, + p99_ms: 0, + error_rate: 0, + active_proxy_capacity: 0, + baseline_diff: diff, + }, + }); + await loadPluginDetail(plugin.id); + showAlert(""); + } catch (err) { + showAlert((err as Error).message); + } +} + +async function createArtifactRevokeAdvisory(plugin: PluginView): Promise { + const artifact = plugin.desired_artifact; + if (!artifact) { + return; + } + const advisoryID = window.prompt("Advisory ID", `local-${shortID(artifact.sha256)}`); + if (!advisoryID) { + return; + } + try { + await api("/plugin-advisories", { + method: "POST", + body: { + advisory_id: advisoryID, + status: "revoked", + action: "revoke", + artifact_sha256: artifact.sha256, + recommended_action: "rollback or upgrade", + }, + }); + await loadPluginDetail(plugin.id); + showAlert(""); + } catch (err) { + showAlert((err as Error).message); + } +} + function uploadInventoryDetail(): string { const artifact = selectedArtifact(); if (!artifact) { @@ -530,6 +651,50 @@ function pluginActionButtons(plugin: PluginView): string { `; } +function governancePanel(plugin: PluginView, canWrite: boolean): string { + if (plugin.governance_error) { + return `
${escapeHTML(plugin.governance_error)}
`; + } + const governance = plugin.governance; + const decision = governance?.decision; + const issues = decision?.issues || []; + return ` +
+
Profile
${escapeHTML(decision?.profile || "")}
+
Risk
${escapeHTML(decision?.risk_level || "")}
+
Policy
${escapeHTML(shortID(decision?.policy_hash || ""))}
+
Decision
${badge(decision?.ok ? "allowed" : "blocked", !decision?.ok)}
+
Review
${badge(decision?.review_required ? "required" : "not required", Boolean(decision?.review_required))}
+
Override
${badge(decision?.warning_override_used ? "used" : "not used", Boolean(decision?.warning_override_used))}
+
+ ${issues.length ? `
${issues.map((issue) => ` +
+ ${badge(issue.severity, issue.severity !== "info")} + ${escapeHTML(issue.code)} + ${escapeHTML(issue.message)} +
+ `).join("")}
` : `
No governance issues
`} + ${canWrite ? ` +
+ + + + + + +
+ ` : ""} +
${escapeHTML(formatJSON({
+      conflicts: governance?.conflicts,
+      reviews: governance?.reviews || [],
+      warning_overrides: governance?.warning_overrides || [],
+      preflights: governance?.preflights || [],
+      benchmarks: governance?.benchmarks || [],
+      advisories: governance?.advisories || [],
+    }))}
+ `; +} + function artifactList(artifacts: PluginArtifact[], plugin: PluginView): string { if (artifacts.length === 0) { return `
No artifacts
`; diff --git a/cmd/gateway/admin_plugin_handlers.go b/cmd/gateway/admin_plugin_handlers.go index 8125b56..2a536d5 100644 --- a/cmd/gateway/admin_plugin_handlers.go +++ b/cmd/gateway/admin_plugin_handlers.go @@ -671,6 +671,175 @@ func handleAdminPluginDispatchPlan(w http.ResponseWriter, r *http.Request) { adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"dispatch_plan": pluginsManager.DispatchPlan(r.Context())}) } +func handleAdminPluginGovernance(w http.ResponseWriter, r *http.Request, rawSegment string) { + session, ok := requireRole(w, r, adminRoleMember) + if !ok { + return + } + if pluginsManager == nil { + adminhttp.WriteAPIError(w, http.StatusServiceUnavailable, "plugin manager is not initialized") + return + } + pluginID, action, ok := splitPluginSubresource(w, rawSegment, "governance") + if !ok { + return + } + switch { + case r.Method == http.MethodGet && action == "": + status, err := pluginsManager.GovernanceStatus(r.Context(), pluginID, r.URL.Query().Get("artifact_id"), r.URL.Query().Get("profile")) + if err != nil { + writePluginManagerError(w, err) + return + } + adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"governance": status}) + case r.Method == http.MethodPost && action == "review": + if session.Role != adminRoleAdmin { + adminhttp.WriteAPIError(w, http.StatusForbidden, "forbidden") + return + } + var req pluginmanager.GovernanceReviewRequest + if !adminhttp.DecodeJSONRequest(w, r, &req) { + return + } + review, err := pluginsManager.CreateReview(r.Context(), session.Username, pluginID, req) + if err != nil { + recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "plugin_governance_review", "plugin", pluginID, false, err.Error()) + writePluginManagerError(w, err) + return + } + recordAuditMetadata(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "plugin_governance_review", "plugin", pluginID, true, "governance review recorded", map[string]any{ + "artifact_id": review.ArtifactID, + "profile": review.Profile, + "policy_hash": review.PolicyHash, + "decision": review.Decision, + }) + adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"review": review}) + case r.Method == http.MethodPost && action == "override": + if session.Role != adminRoleAdmin { + adminhttp.WriteAPIError(w, http.StatusForbidden, "forbidden") + return + } + var req pluginmanager.WarningOverrideRequest + if !adminhttp.DecodeJSONRequest(w, r, &req) { + return + } + override, err := pluginsManager.CreateWarningOverride(r.Context(), session.Username, pluginID, req) + if err != nil { + recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "plugin_governance_override", "plugin", pluginID, false, err.Error()) + writePluginManagerError(w, err) + return + } + recordAuditMetadata(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "plugin_governance_override", "plugin", pluginID, true, "governance warning override recorded", map[string]any{ + "artifact_id": override.ArtifactID, + "profile": override.Profile, + "action": override.Action, + "expires_at": override.ExpiresAt, + }) + adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"override": override}) + case r.Method == http.MethodPost && action == "preflight": + if session.Role != adminRoleAdmin { + adminhttp.WriteAPIError(w, http.StatusForbidden, "forbidden") + return + } + var req pluginmanager.PreflightRequest + if !adminhttp.DecodeJSONRequest(w, r, &req) { + return + } + result, err := pluginsManager.RunPreflight(r.Context(), session.Username, pluginID, req) + if err != nil { + recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "plugin_governance_preflight", "plugin", pluginID, false, err.Error()) + writePluginManagerError(w, err) + return + } + recordAuditMetadata(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "plugin_governance_preflight", "plugin", pluginID, result.OK, "governance preflight completed", map[string]any{"profile": result.Profile}) + adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"preflight": result}) + case r.Method == http.MethodPost && action == "self-test": + if session.Role != adminRoleAdmin { + adminhttp.WriteAPIError(w, http.StatusForbidden, "forbidden") + return + } + var req pluginmanager.SelfTestRequest + if !adminhttp.DecodeJSONRequest(w, r, &req) { + return + } + result, err := pluginsManager.RunSelfTest(r.Context(), session.Username, pluginID, req) + if err != nil { + recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "plugin_governance_self_test", "plugin", pluginID, false, err.Error()) + writePluginManagerError(w, err) + return + } + recordAuditMetadata(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "plugin_governance_self_test", "plugin", pluginID, result.OK, "governance self-test completed", map[string]any{"profile": result.Profile}) + adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"self_test": result}) + case r.Method == http.MethodPost && action == "benchmark": + if session.Role != adminRoleAdmin { + adminhttp.WriteAPIError(w, http.StatusForbidden, "forbidden") + return + } + var req pluginmanager.BenchmarkRequest + if !adminhttp.DecodeJSONRequest(w, r, &req) { + return + } + benchmark, err := pluginsManager.SaveBenchmark(r.Context(), session.Username, req) + if err != nil { + recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "plugin_governance_benchmark", "plugin", pluginID, false, err.Error()) + writePluginManagerError(w, err) + return + } + recordAuditMetadata(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "plugin_governance_benchmark", "plugin", pluginID, true, "governance benchmark recorded", map[string]any{ + "artifact_id": benchmark.ArtifactID, + "profile": benchmark.Profile, + "baseline_diff": benchmark.BaselineDiff, + }) + adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"benchmark": benchmark}) + default: + adminhttp.WriteAPIError(w, http.StatusMethodNotAllowed, "method not allowed") + } +} + +func handleAdminPluginAdvisories(w http.ResponseWriter, r *http.Request) { + session, ok := requireRole(w, r, adminRoleMember) + if !ok { + return + } + if pluginsManager == nil { + adminhttp.WriteAPIError(w, http.StatusServiceUnavailable, "plugin manager is not initialized") + return + } + switch r.Method { + case http.MethodGet: + advisories, err := pluginsManager.ListAdvisories(r.Context(), r.URL.Query().Get("plugin_id")) + if err != nil { + adminhttp.WriteAPIError(w, http.StatusInternalServerError, err.Error()) + return + } + adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"advisories": advisories}) + case http.MethodPost: + if session.Role != adminRoleAdmin { + adminhttp.WriteAPIError(w, http.StatusForbidden, "forbidden") + return + } + var req pluginmanager.AdvisoryRequest + if !adminhttp.DecodeJSONRequest(w, r, &req) { + return + } + advisory, err := pluginsManager.UpsertAdvisory(r.Context(), session.Username, req) + if err != nil { + recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "plugin_governance_advisory", "plugin_advisory", req.AdvisoryID, false, err.Error()) + writePluginManagerError(w, err) + return + } + recordAuditMetadata(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "plugin_governance_advisory", "plugin_advisory", advisory.AdvisoryID, true, "plugin advisory upserted", map[string]any{ + "action": advisory.Action, + "status": advisory.Status, + "artifact_sha256": advisory.ArtifactSHA256, + "plugin_id": advisory.PluginID, + }) + adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"advisory": advisory}) + default: + adminhttp.WriteAPIError(w, http.StatusMethodNotAllowed, "method not allowed") + } +} + func receivePluginArtifact(r *http.Request, actor string) (pluginmanager.ArtifactRecord, error) { if err := r.ParseMultipartForm(64 << 20); err != nil { return pluginmanager.ArtifactRecord{}, err @@ -870,6 +1039,12 @@ func pluginView(r *http.Request, plugin pluginmanager.PluginRecord, detail bool) if detail { view["manifest"] = manifest view["operations_path"] = "/plugin-artifacts?plugin_id=" + plugin.ID + governance, err := pluginsManager.GovernanceStatus(r.Context(), plugin.ID, plugin.DesiredArtifactID, pluginsManager.PolicyProfile()) + if err != nil { + view["governance_error"] = err.Error() + } else { + view["governance"] = governance + } view["active_proxy_connections"] = activeProxyConnections(plugin) connections, err := pluginsManager.ActiveProxyConnections(r.Context(), plugin.ID) if err != nil { diff --git a/cmd/gateway/handle_request_test.go b/cmd/gateway/handle_request_test.go index 51cfea9..82b3243 100644 --- a/cmd/gateway/handle_request_test.go +++ b/cmd/gateway/handle_request_test.go @@ -90,10 +90,11 @@ func TestHandleRequestProtocolProxyReplaysInitialDataOnce(t *testing.T) { return gatewayEnd, nil }}, }) - artifact := uploadGatewayTestArtifactWithCapabilities(t, pluginsManager, "proxy-plugin", `{"upstream_connect":{"mode":"protocol-proxy"}}`) + artifact := uploadGatewayTestArtifactWithCapabilities(t, pluginsManager, "proxy-plugin", gatewayProtocolProxyCapabilities()) if _, err := pluginsManager.SetDesired(context.Background(), "admin", "proxy-plugin", artifact.ID, pluginmanager.DesiredEnabled, `{}`, 10); err != nil { t.Fatalf("SetDesired() error = %v", err) } + approveGatewayPluginGovernanceForTest(t, "proxy-plugin", artifact.ID) if _, err := pluginsManager.Enable(context.Background(), "admin", "proxy-plugin"); err != nil { t.Fatalf("Enable() error = %v", err) } @@ -216,10 +217,11 @@ func TestHandleRequestProtocolProxyDisableSkipsNewConnections(t *testing.T) { return gatewayEnd, nil }}, }) - artifact := uploadGatewayTestArtifactWithCapabilities(t, pluginsManager, "proxy-plugin", `{"upstream_connect":{"mode":"protocol-proxy"}}`) + artifact := uploadGatewayTestArtifactWithCapabilities(t, pluginsManager, "proxy-plugin", gatewayProtocolProxyCapabilities()) if _, err := pluginsManager.SetDesired(context.Background(), "admin", "proxy-plugin", artifact.ID, pluginmanager.DesiredEnabled, `{}`, 10); err != nil { t.Fatalf("SetDesired() error = %v", err) } + approveGatewayPluginGovernanceForTest(t, "proxy-plugin", artifact.ID) if _, err := pluginsManager.Enable(context.Background(), "admin", "proxy-plugin"); err != nil { t.Fatalf("Enable() error = %v", err) } diff --git a/cmd/gateway/main_test.go b/cmd/gateway/main_test.go index 0cc1090..68ee17a 100644 --- a/cmd/gateway/main_test.go +++ b/cmd/gateway/main_test.go @@ -279,6 +279,18 @@ func uploadGatewayTestArtifactWithCapabilities(t *testing.T, manager *pluginmana return artifact } +func approveGatewayPluginGovernanceForTest(t *testing.T, pluginID, artifactID string) { + t.Helper() + if _, err := pluginsManager.CreateReview(context.Background(), "admin", pluginID, pluginmanager.GovernanceReviewRequest{ + ArtifactID: artifactID, + Profile: pluginmanager.PolicyProfileProd, + Decision: pluginmanager.ReviewDecisionApproved, + Notes: "test approval", + }); err != nil { + t.Fatalf("CreateReview(%s) error = %v", pluginID, err) + } +} + func writeGatewayTestMCGP(t *testing.T, pluginID string) string { return writeGatewayTestMCGPWithCapabilities(t, pluginID, "") } @@ -322,6 +334,18 @@ func gatewayTestManifest(t *testing.T, pluginID string) []byte { return gatewayTestManifestWithCapabilities(t, pluginID, "") } +func gatewayProtocolProxyCapabilities() string { + return `{ + "upstream_connect":{"mode":"protocol-proxy"}, + "scope":{"type":"host","values":["play.example"]}, + "rollout":{"mode":"canary"}, + "minecraft":{ + "protocol_versions":{"tested":[767]}, + "forwarding":{"supported":["none"],"default":"none"} + } + }` +} + func gatewayTestManifestWithCapabilities(t *testing.T, pluginID string, capabilities string) []byte { t.Helper() if capabilities == "" { diff --git a/internal/admindb/db.go b/internal/admindb/db.go index 47dcd49..7371b26 100644 --- a/internal/admindb/db.go +++ b/internal/admindb/db.go @@ -209,6 +209,81 @@ CREATE TABLE IF NOT EXISTS plugin_secrets ( PRIMARY KEY(plugin_id, name) ); +CREATE TABLE IF NOT EXISTS plugin_reviews ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plugin_id TEXT NOT NULL, + artifact_id TEXT NOT NULL, + profile TEXT NOT NULL DEFAULT 'dev', + risk_level TEXT NOT NULL DEFAULT 'low', + config_hash TEXT NOT NULL DEFAULT '', + scope_hash TEXT NOT NULL DEFAULT '', + rollout_hash TEXT NOT NULL DEFAULT '', + runtime_limits_hash TEXT NOT NULL DEFAULT '', + features_hash TEXT NOT NULL DEFAULT '', + policy_hash TEXT NOT NULL DEFAULT '', + decision TEXT NOT NULL DEFAULT 'approved', + notes TEXT NOT NULL DEFAULT '', + reviewed_by TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS plugin_warning_overrides ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plugin_id TEXT NOT NULL, + artifact_id TEXT NOT NULL, + profile TEXT NOT NULL DEFAULT 'dev', + action TEXT NOT NULL DEFAULT '', + policy_hash TEXT NOT NULL DEFAULT '', + reason TEXT NOT NULL DEFAULT '', + created_by TEXT NOT NULL DEFAULT '', + expires_at INTEGER NOT NULL, + created_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS plugin_advisories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + advisory_id TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + action TEXT NOT NULL DEFAULT 'denylist', + artifact_sha256 TEXT NOT NULL DEFAULT '', + plugin_id TEXT NOT NULL DEFAULT '', + version_range TEXT NOT NULL DEFAULT '', + dependency_name TEXT NOT NULL DEFAULT '', + dependency_range TEXT NOT NULL DEFAULT '', + recommended_action TEXT NOT NULL DEFAULT '', + fixed_version TEXT NOT NULL DEFAULT '', + mitigation TEXT NOT NULL DEFAULT '', + created_by TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS plugin_preflight_results ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plugin_id TEXT NOT NULL, + artifact_id TEXT NOT NULL, + profile TEXT NOT NULL DEFAULT 'dev', + status TEXT NOT NULL DEFAULT '', + result_json TEXT NOT NULL DEFAULT '{}', + created_by TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS plugin_benchmarks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plugin_id TEXT NOT NULL, + artifact_id TEXT NOT NULL, + profile TEXT NOT NULL DEFAULT 'dev', + benchmark_profile TEXT NOT NULL DEFAULT '', + p95_ms REAL NOT NULL DEFAULT 0, + p99_ms REAL NOT NULL DEFAULT 0, + error_rate REAL NOT NULL DEFAULT 0, + active_proxy_capacity INTEGER NOT NULL DEFAULT 0, + baseline_diff REAL NOT NULL DEFAULT 0, + created_by TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL +); + CREATE INDEX IF NOT EXISTS idx_routes_enabled ON routes(enabled); CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at); CREATE INDEX IF NOT EXISTS idx_plugin_artifacts_plugin_id ON plugin_artifacts(plugin_id, created_at); @@ -218,6 +293,12 @@ CREATE INDEX IF NOT EXISTS idx_plugin_builds_plugin_id ON plugin_builds(plugin_i CREATE INDEX IF NOT EXISTS idx_plugin_builds_source_id ON plugin_builds(source_id, created_at); CREATE INDEX IF NOT EXISTS idx_plugin_config_snapshots_plugin_id ON plugin_config_snapshots(plugin_id, created_at); CREATE INDEX IF NOT EXISTS idx_plugin_secrets_plugin_id ON plugin_secrets(plugin_id, updated_at); +CREATE INDEX IF NOT EXISTS idx_plugin_reviews_lookup ON plugin_reviews(plugin_id, artifact_id, profile, policy_hash, created_at); +CREATE INDEX IF NOT EXISTS idx_plugin_warning_overrides_lookup ON plugin_warning_overrides(plugin_id, artifact_id, profile, action, expires_at); +CREATE INDEX IF NOT EXISTS idx_plugin_advisories_artifact ON plugin_advisories(artifact_sha256, status); +CREATE INDEX IF NOT EXISTS idx_plugin_advisories_plugin ON plugin_advisories(plugin_id, status); +CREATE INDEX IF NOT EXISTS idx_plugin_preflight_lookup ON plugin_preflight_results(plugin_id, artifact_id, profile, created_at); +CREATE INDEX IF NOT EXISTS idx_plugin_benchmarks_lookup ON plugin_benchmarks(plugin_id, artifact_id, profile, created_at); INSERT OR IGNORE INTO schema_migrations(version, applied_at) VALUES (1, strftime('%s','now')); ` if _, err := db.Exec(schema); err != nil { diff --git a/internal/adminhttp/api.go b/internal/adminhttp/api.go index c26e6f6..526d7b1 100644 --- a/internal/adminhttp/api.go +++ b/internal/adminhttp/api.go @@ -29,20 +29,22 @@ type APIHandlers struct { AuditLogs http.HandlerFunc - PluginArtifacts http.HandlerFunc - PluginArtifact SegmentHandlerFunc - PluginSources http.HandlerFunc - PluginBuilds http.HandlerFunc - PluginBuild SegmentHandlerFunc - PluginGC http.HandlerFunc - PluginsList http.HandlerFunc - PluginItem SegmentHandlerFunc - PluginAction SegmentHandlerFunc - PluginConfig SegmentHandlerFunc - PluginSecrets SegmentHandlerFunc - PluginRollback SegmentHandlerFunc - PluginDraining SegmentHandlerFunc - PluginDispatch http.HandlerFunc + PluginArtifacts http.HandlerFunc + PluginArtifact SegmentHandlerFunc + PluginSources http.HandlerFunc + PluginBuilds http.HandlerFunc + PluginBuild SegmentHandlerFunc + PluginGC http.HandlerFunc + PluginsList http.HandlerFunc + PluginItem SegmentHandlerFunc + PluginAction SegmentHandlerFunc + PluginConfig SegmentHandlerFunc + PluginSecrets SegmentHandlerFunc + PluginRollback SegmentHandlerFunc + PluginDraining SegmentHandlerFunc + PluginDispatch http.HandlerFunc + PluginGovernance SegmentHandlerFunc + PluginAdvisories http.HandlerFunc } func NewAPIHandler(prefix string, handlers APIHandlers) http.HandlerFunc { @@ -100,8 +102,14 @@ func NewAPIHandler(prefix string, handlers APIHandlers) http.HandlerFunc { callHandler(w, r, handlers.PluginsList) case path == "/plugins/dispatch-plan" && r.Method == http.MethodGet: callHandler(w, r, handlers.PluginDispatch) + case path == "/plugin-advisories" && (r.Method == http.MethodGet || r.Method == http.MethodPost): + callHandler(w, r, handlers.PluginAdvisories) case strings.HasPrefix(path, "/plugins/"): pluginPath := strings.TrimPrefix(path, "/plugins/") + if strings.Contains(pluginPath, "/governance/") || strings.HasSuffix(pluginPath, "/governance") { + callSegmentHandler(w, r, handlers.PluginGovernance, pluginPath) + return + } if strings.HasSuffix(pluginPath, "/draining/force-close") { callSegmentHandler(w, r, handlers.PluginDraining, strings.TrimSuffix(pluginPath, "/draining/force-close")) return diff --git a/internal/adminhttp/api_test.go b/internal/adminhttp/api_test.go index 7bface1..4c6706a 100644 --- a/internal/adminhttp/api_test.go +++ b/internal/adminhttp/api_test.go @@ -44,8 +44,11 @@ func TestNewAPIHandlerRoutesRequests(t *testing.T) { {name: "plugin config dry-run", method: http.MethodPost, path: "/admin/api/plugins/upstream-rewrite/config/dry-run", wantCall: "plugin_config", wantSegment: "upstream-rewrite/config/dry-run"}, {name: "plugin secrets", method: http.MethodGet, path: "/admin/api/plugins/upstream-rewrite/secrets", wantCall: "plugin_secrets", wantSegment: "upstream-rewrite/secrets"}, {name: "plugin rollback artifact", method: http.MethodPost, path: "/admin/api/plugins/upstream-rewrite/rollback/artifact", wantCall: "plugin_rollback", wantSegment: "upstream-rewrite/rollback/artifact"}, + {name: "plugin governance", method: http.MethodGet, path: "/admin/api/plugins/upstream-rewrite/governance", wantCall: "plugin_governance", wantSegment: "upstream-rewrite/governance"}, + {name: "plugin governance review", method: http.MethodPost, path: "/admin/api/plugins/upstream-rewrite/governance/review", wantCall: "plugin_governance", wantSegment: "upstream-rewrite/governance/review"}, {name: "plugin draining force close", method: http.MethodPost, path: "/admin/api/plugins/mc-auth-proxy/draining/force-close", wantCall: "plugin_draining", wantSegment: "mc-auth-proxy"}, {name: "plugin dispatch", method: http.MethodGet, path: "/admin/api/plugins/dispatch-plan", wantCall: "plugin_dispatch"}, + {name: "plugin advisories", method: http.MethodGet, path: "/admin/api/plugin-advisories", wantCall: "plugin_advisories"}, } for _, tt := range tests { @@ -73,20 +76,22 @@ func TestNewAPIHandlerRoutesRequests(t *testing.T) { AuditLogs: recordCall(&gotCall, "audit_logs"), - PluginArtifacts: recordCall(&gotCall, "plugin_artifacts"), - PluginArtifact: recordSegmentCall(&gotCall, &gotSegment, "plugin_artifact"), - PluginSources: recordCall(&gotCall, "plugin_sources"), - PluginBuilds: recordCall(&gotCall, "plugin_builds"), - PluginBuild: recordSegmentCall(&gotCall, &gotSegment, "plugin_build"), - PluginGC: recordCall(&gotCall, "plugin_gc"), - PluginsList: recordCall(&gotCall, "plugins_list"), - PluginItem: recordSegmentCall(&gotCall, &gotSegment, "plugin_item"), - PluginAction: recordSegmentCall(&gotCall, &gotSegment, "plugin_action"), - PluginConfig: recordSegmentCall(&gotCall, &gotSegment, "plugin_config"), - PluginSecrets: recordSegmentCall(&gotCall, &gotSegment, "plugin_secrets"), - PluginRollback: recordSegmentCall(&gotCall, &gotSegment, "plugin_rollback"), - PluginDraining: recordSegmentCall(&gotCall, &gotSegment, "plugin_draining"), - PluginDispatch: recordCall(&gotCall, "plugin_dispatch"), + PluginArtifacts: recordCall(&gotCall, "plugin_artifacts"), + PluginArtifact: recordSegmentCall(&gotCall, &gotSegment, "plugin_artifact"), + PluginSources: recordCall(&gotCall, "plugin_sources"), + PluginBuilds: recordCall(&gotCall, "plugin_builds"), + PluginBuild: recordSegmentCall(&gotCall, &gotSegment, "plugin_build"), + PluginGC: recordCall(&gotCall, "plugin_gc"), + PluginsList: recordCall(&gotCall, "plugins_list"), + PluginItem: recordSegmentCall(&gotCall, &gotSegment, "plugin_item"), + PluginAction: recordSegmentCall(&gotCall, &gotSegment, "plugin_action"), + PluginConfig: recordSegmentCall(&gotCall, &gotSegment, "plugin_config"), + PluginSecrets: recordSegmentCall(&gotCall, &gotSegment, "plugin_secrets"), + PluginRollback: recordSegmentCall(&gotCall, &gotSegment, "plugin_rollback"), + PluginDraining: recordSegmentCall(&gotCall, &gotSegment, "plugin_draining"), + PluginDispatch: recordCall(&gotCall, "plugin_dispatch"), + PluginGovernance: recordSegmentCall(&gotCall, &gotSegment, "plugin_governance"), + PluginAdvisories: recordCall(&gotCall, "plugin_advisories"), }) resp := httptest.NewRecorder() diff --git a/internal/pluginmanager/governance.go b/internal/pluginmanager/governance.go new file mode 100644 index 0000000..309de1e --- /dev/null +++ b/internal/pluginmanager/governance.go @@ -0,0 +1,1284 @@ +package pluginmanager + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "sort" + "strconv" + "strings" + "time" + + "github.com/tursom/mc-gateway/plugin/api" +) + +const ( + defaultWarningOverrideTTL = 24 * time.Hour +) + +type PreflightAdapter interface { + RunPreflight(ctx context.Context, artifact ArtifactRecord, pluginRecord PluginRecord, profile, action string) (api.PreflightResult, error) +} + +type SelfTestAdapter interface { + RunSelfTest(ctx context.Context, artifact ArtifactRecord, pluginRecord PluginRecord, profile string) (api.SelfTestResult, error) +} + +func (m *Manager) EvaluateGovernance(ctx context.Context, pluginID, artifactID, action, profile, configJSON string) (GovernanceDecision, error) { + decision, _, err := m.evaluateGovernance(ctx, pluginID, artifactID, action, profile, configJSON, false) + return decision, err +} + +func (m *Manager) SetPolicyProfile(profile string) error { + switch profile { + case "": + m.policyProfile = PolicyProfileProd + case PolicyProfileDev, PolicyProfileStaging, PolicyProfileProd: + m.policyProfile = profile + default: + return fmt.Errorf("invalid policy profile %q", profile) + } + return nil +} + +func (m *Manager) PolicyProfile() string { + return m.currentPolicyProfile() +} + +func (m *Manager) currentPolicyProfile() string { + if m.policyProfile == "" { + return PolicyProfileProd + } + return m.policyProfile +} + +func (m *Manager) GovernanceStatus(ctx context.Context, pluginID, artifactID, profile string) (GovernanceStatus, error) { + plugin, err := m.repo.Plugin(ctx, pluginID) + if err != nil { + return GovernanceStatus{}, err + } + if artifactID == "" { + artifactID = plugin.DesiredArtifactID + } + decision, conflict, err := m.evaluateGovernance(ctx, pluginID, artifactID, GovernanceActionEnable, profile, plugin.ConfigJSON, true) + if err != nil { + return GovernanceStatus{}, err + } + reviews, err := m.repo.ListReviews(ctx, pluginID) + if err != nil { + return GovernanceStatus{}, err + } + overrides, err := m.repo.ListWarningOverrides(ctx, pluginID) + if err != nil { + return GovernanceStatus{}, err + } + preflights, err := m.repo.ListPreflights(ctx, pluginID) + if err != nil { + return GovernanceStatus{}, err + } + benchmarks, err := m.repo.ListBenchmarks(ctx, pluginID) + if err != nil { + return GovernanceStatus{}, err + } + advisories, err := m.repo.ListAdvisories(ctx, pluginID) + if err != nil { + return GovernanceStatus{}, err + } + return GovernanceStatus{ + Decision: decision, + Policy: policyForProfile(profile, m.repo.now()), + Reviews: reviews, + WarningOverrides: overrides, + Preflights: preflights, + Benchmarks: benchmarks, + Advisories: advisories, + Conflicts: conflict, + }, nil +} + +func (m *Manager) CreateReview(ctx context.Context, actor, pluginID string, req GovernanceReviewRequest) (ReviewRecord, error) { + plugin, err := m.repo.Plugin(ctx, pluginID) + if err != nil { + return ReviewRecord{}, err + } + if req.ArtifactID == "" { + req.ArtifactID = plugin.DesiredArtifactID + } + if req.Profile == "" { + req.Profile = m.currentPolicyProfile() + } + if req.Decision == "" { + req.Decision = ReviewDecisionApproved + } + switch req.Decision { + case ReviewDecisionApproved, ReviewDecisionRejected: + default: + return ReviewRecord{}, fmt.Errorf("invalid review decision %q", req.Decision) + } + artifact, manifest, err := m.artifactManifest(ctx, pluginID, req.ArtifactID) + if err != nil { + return ReviewRecord{}, err + } + fingerprint := governanceFingerprint(plugin, artifact, manifest, policyHash(policyForProfile(req.Profile, m.repo.now()))) + review := ReviewRecord{ + PluginID: pluginID, + ArtifactID: req.ArtifactID, + Profile: normalizeProfile(req.Profile), + RiskLevel: riskLevel(manifest, artifact), + ConfigHash: fingerprint.ConfigHash, + ScopeHash: fingerprint.ScopeHash, + RolloutHash: fingerprint.RolloutHash, + RuntimeLimitsHash: fingerprint.RuntimeLimitsHash, + FeaturesHash: fingerprint.FeaturesHash, + PolicyHash: fingerprint.PolicyHash, + Decision: req.Decision, + Notes: req.Notes, + ReviewedBy: actor, + } + review, err = m.repo.SaveReview(ctx, review) + if err != nil { + return ReviewRecord{}, err + } + _ = m.repo.RecordOperation(ctx, pluginID, req.ArtifactID, "governance_review", "succeeded", actor, "plugin governance review recorded", map[string]any{ + "profile": review.Profile, + "risk_level": review.RiskLevel, + "decision": review.Decision, + "policy_hash": review.PolicyHash, + }) + return review, nil +} + +func (m *Manager) CreateWarningOverride(ctx context.Context, actor, pluginID string, req WarningOverrideRequest) (WarningOverrideRecord, error) { + plugin, err := m.repo.Plugin(ctx, pluginID) + if err != nil { + return WarningOverrideRecord{}, err + } + if req.ArtifactID == "" { + req.ArtifactID = plugin.DesiredArtifactID + } + if req.Profile == "" { + req.Profile = m.currentPolicyProfile() + } + if req.Action == "" { + req.Action = GovernanceActionEnable + } + if strings.TrimSpace(req.Reason) == "" { + return WarningOverrideRecord{}, errors.New("reason is required") + } + policy := policyForProfile(req.Profile, m.repo.now()) + if req.TTLSeconds <= 0 || req.TTLSeconds > policy.WarningOverrideTTLSeconds { + req.TTLSeconds = policy.WarningOverrideTTLSeconds + } + decision, err := m.EvaluateGovernance(ctx, pluginID, req.ArtifactID, req.Action, req.Profile, plugin.ConfigJSON) + if err != nil { + return WarningOverrideRecord{}, err + } + if hasBlockingIssue(decision.Issues) { + return WarningOverrideRecord{}, errors.New("blocking governance issues cannot be overridden") + } + if !hasWarningIssue(decision.Issues) { + return WarningOverrideRecord{}, errors.New("no warning governance issues require override") + } + now := m.repo.now().Unix() + override := WarningOverrideRecord{ + PluginID: pluginID, + ArtifactID: req.ArtifactID, + Profile: normalizeProfile(req.Profile), + Action: req.Action, + PolicyHash: decision.PolicyHash, + Reason: req.Reason, + CreatedBy: actor, + ExpiresAt: now + req.TTLSeconds, + CreatedAt: now, + } + override, err = m.repo.SaveWarningOverride(ctx, override) + if err != nil { + return WarningOverrideRecord{}, err + } + _ = m.repo.RecordOperation(ctx, pluginID, req.ArtifactID, "governance_warning_override", "succeeded", actor, "governance warning override recorded", map[string]any{ + "profile": override.Profile, + "action": override.Action, + "policy_hash": override.PolicyHash, + "expires_at": override.ExpiresAt, + }) + return override, nil +} + +func (m *Manager) RunPreflight(ctx context.Context, actor, pluginID string, req PreflightRequest) (PreflightResult, error) { + plugin, err := m.repo.Plugin(ctx, pluginID) + if err != nil { + return PreflightResult{}, err + } + if req.ArtifactID == "" { + req.ArtifactID = plugin.DesiredArtifactID + } + if req.ConfigJSON == "" { + req.ConfigJSON = plugin.ConfigJSON + } + if req.Profile == "" { + req.Profile = m.currentPolicyProfile() + } + if req.Action == "" { + req.Action = GovernanceActionEnable + } + artifact, manifest, err := m.artifactManifest(ctx, pluginID, req.ArtifactID) + if err != nil { + return PreflightResult{}, err + } + result := m.preflightChecks(ctx, plugin, artifact, manifest, req.Profile, req.Action, req.ConfigJSON) + if adapter, ok := m.adapter.(PreflightAdapter); ok { + pluginResult, err := adapter.RunPreflight(ctx, artifact, pluginRecordWithConfig(plugin, artifact.ID, req.ConfigJSON), req.Profile, req.Action) + result.Checks = append(result.Checks, apiPreflightChecks(pluginResult.Checks)...) + if err != nil { + result.Checks = append(result.Checks, PreflightCheck{ + Code: "plugin_preflight_error", + Severity: GateSeverityBlocking, + Message: err.Error(), + }) + } + } + result.OK = !preflightHasBlocking(result.Checks) + result.CreatedAt = m.repo.now().Unix() + data, _ := json.Marshal(result) + status := "succeeded" + if !result.OK { + status = "failed" + } + _, err = m.repo.SavePreflight(ctx, PreflightRecord{ + PluginID: pluginID, + ArtifactID: req.ArtifactID, + Profile: normalizeProfile(req.Profile), + Status: status, + ResultJSON: string(data), + CreatedBy: actor, + }) + if err != nil { + return PreflightResult{}, err + } + _ = m.repo.RecordOperation(ctx, pluginID, req.ArtifactID, "governance_preflight", status, actor, "plugin preflight completed", map[string]any{ + "profile": req.Profile, + "ok": result.OK, + }) + return result, nil +} + +func (m *Manager) RunSelfTest(ctx context.Context, actor, pluginID string, req SelfTestRequest) (PreflightResult, error) { + plugin, err := m.repo.Plugin(ctx, pluginID) + if err != nil { + return PreflightResult{}, err + } + if req.ArtifactID == "" { + req.ArtifactID = plugin.DesiredArtifactID + } + if req.Profile == "" { + req.Profile = m.currentPolicyProfile() + } + artifact, _, err := m.artifactManifest(ctx, pluginID, req.ArtifactID) + if err != nil { + return PreflightResult{}, err + } + result := PreflightResult{Profile: normalizeProfile(req.Profile)} + if adapter, ok := m.adapter.(SelfTestAdapter); ok { + pluginResult, err := adapter.RunSelfTest(ctx, artifact, plugin, req.Profile) + result.Checks = append(result.Checks, apiPreflightChecks(pluginResult.Checks)...) + if err != nil { + result.Checks = append(result.Checks, PreflightCheck{Code: "plugin_self_test_error", Severity: GateSeverityBlocking, Message: err.Error()}) + } + } else { + result.Checks = append(result.Checks, PreflightCheck{Code: "self_test_not_implemented", Severity: GateSeverityWarning, Message: "plugin does not implement SelfTester"}) + } + result.OK = !preflightHasBlocking(result.Checks) + result.CreatedAt = m.repo.now().Unix() + data, _ := json.Marshal(result) + status := "succeeded" + if !result.OK { + status = "failed" + } + _, err = m.repo.SavePreflight(ctx, PreflightRecord{ + PluginID: pluginID, + ArtifactID: req.ArtifactID, + Profile: normalizeProfile(req.Profile), + Status: status, + ResultJSON: string(data), + CreatedBy: actor, + }) + if err != nil { + return PreflightResult{}, err + } + _ = m.repo.RecordOperation(ctx, pluginID, req.ArtifactID, "governance_self_test", status, actor, "plugin self-test completed", map[string]any{ + "profile": req.Profile, + "ok": result.OK, + }) + return result, nil +} + +func (m *Manager) SaveBenchmark(ctx context.Context, actor string, req BenchmarkRequest) (BenchmarkRecord, error) { + record, err := m.repo.SaveBenchmark(ctx, actor, req) + if err != nil { + return BenchmarkRecord{}, err + } + _ = m.repo.RecordOperation(ctx, record.PluginID, record.ArtifactID, "governance_benchmark", "succeeded", actor, "plugin benchmark recorded", map[string]any{ + "profile": record.Profile, + "benchmark_profile": record.BenchmarkProfile, + "p95_ms": record.P95MS, + "p99_ms": record.P99MS, + "error_rate": record.ErrorRate, + "active_proxy_limit": record.ActiveProxyCapacity, + "baseline_diff": record.BaselineDiff, + }) + return record, nil +} + +func (m *Manager) UpsertAdvisory(ctx context.Context, actor string, req AdvisoryRequest) (AdvisoryRecord, error) { + record, err := m.repo.UpsertAdvisory(ctx, actor, req) + if err != nil { + return AdvisoryRecord{}, err + } + if record.Action == AdvisoryActionQuarantine || record.Action == AdvisoryActionRevoke { + m.quarantineAffected(ctx, record) + } + targetPlugin := record.PluginID + if targetPlugin == "" && record.ArtifactSHA256 != "" { + if artifacts, listErr := m.repo.ListArtifacts(ctx, ""); listErr == nil { + for _, artifact := range artifacts { + if artifact.SHA256 == record.ArtifactSHA256 { + targetPlugin = artifact.PluginID + break + } + } + } + } + _ = m.repo.RecordOperation(ctx, targetPlugin, "", "governance_advisory", "succeeded", actor, "plugin advisory upserted", map[string]any{ + "advisory_id": record.AdvisoryID, + "status": record.Status, + "action": record.Action, + "artifact_sha256": record.ArtifactSHA256, + "plugin_id": record.PluginID, + "version_range": record.VersionRange, + "dependency_name": record.DependencyName, + "dependency_range": record.DependencyRange, + "recommended_action": record.RecommendedAction, + "fixed_version": record.FixedVersion, + "mitigation": record.Mitigation, + }) + return record, nil +} + +func (m *Manager) ListReviews(ctx context.Context, pluginID string) ([]ReviewRecord, error) { + return m.repo.ListReviews(ctx, pluginID) +} + +func (m *Manager) ListWarningOverrides(ctx context.Context, pluginID string) ([]WarningOverrideRecord, error) { + return m.repo.ListWarningOverrides(ctx, pluginID) +} + +func (m *Manager) ListAdvisories(ctx context.Context, pluginID string) ([]AdvisoryRecord, error) { + return m.repo.ListAdvisories(ctx, pluginID) +} + +func (m *Manager) ListPreflights(ctx context.Context, pluginID string) ([]PreflightRecord, error) { + return m.repo.ListPreflights(ctx, pluginID) +} + +func (m *Manager) ListBenchmarks(ctx context.Context, pluginID string) ([]BenchmarkRecord, error) { + return m.repo.ListBenchmarks(ctx, pluginID) +} + +func (m *Manager) evaluateGovernance(ctx context.Context, pluginID, artifactID, action, profile, configJSON string, preview bool) (GovernanceDecision, ConflictAnalysis, error) { + if action == "" { + action = GovernanceActionEnable + } + profile = normalizeProfile(profile) + policy := policyForProfile(profile, m.repo.now()) + policyHash := policyHash(policy) + plugin, err := m.repo.Plugin(ctx, pluginID) + if err != nil { + return GovernanceDecision{}, ConflictAnalysis{}, err + } + if artifactID == "" { + artifactID = plugin.DesiredArtifactID + } + if configJSON == "" { + configJSON = plugin.ConfigJSON + } + artifact, manifest, err := m.artifactManifest(ctx, pluginID, artifactID) + if err != nil { + return GovernanceDecision{}, ConflictAnalysis{}, err + } + now := m.repo.now().Unix() + decision := GovernanceDecision{ + OK: false, + Action: action, + Profile: profile, + RiskLevel: riskLevel(manifest, artifact), + PolicyHash: policyHash, + CreatedAt: now, + } + var issues []GovernanceIssue + if err := m.validateArtifactGate(artifact); err != nil { + issues = append(issues, issue("artifact_not_loadable", GateSeverityBlocking, err.Error(), pluginID, artifactID, nil)) + } + preflight := m.preflightChecks(ctx, plugin, artifact, manifest, profile, action, configJSON) + for _, check := range preflight.Checks { + issues = append(issues, issue(check.Code, check.Severity, check.Message, pluginID, artifactID, check.Details)) + decision.Checks = append(decision.Checks, issue(check.Code, check.Severity, check.Message, pluginID, artifactID, check.Details)) + } + advisoryIssues, err := m.advisoryIssues(ctx, artifact, manifest) + if err != nil { + return GovernanceDecision{}, ConflictAnalysis{}, err + } + issues = append(issues, advisoryIssues...) + benchmarkIssues, err := m.benchmarkIssues(ctx, artifact, manifest, policy) + if err != nil { + return GovernanceDecision{}, ConflictAnalysis{}, err + } + issues = append(issues, benchmarkIssues...) + conflict, err := m.conflictAnalysis(ctx, plugin, artifact, manifest) + if err != nil { + return GovernanceDecision{}, ConflictAnalysis{}, err + } + issues = append(issues, conflict.Issues...) + if reviewRequired(profile, decision.RiskLevel, policy) { + decision.ReviewRequired = true + fingerprint := governanceFingerprint(pluginRecordWithConfig(plugin, artifactID, configJSON), artifact, manifest, policyHash) + reviewOK, err := m.hasMatchingReview(ctx, pluginID, artifactID, profile, fingerprint) + if err != nil { + return GovernanceDecision{}, ConflictAnalysis{}, err + } + if !reviewOK { + issues = append(issues, issue("review_required", GateSeverityBlocking, "high risk plugin requires approved review for this policy snapshot", pluginID, artifactID, map[string]any{ + "profile": profile, + "risk_level": decision.RiskLevel, + "policy_hash": policyHash, + })) + } + } + decision.Issues = sortedIssues(issues) + if hasBlockingIssue(decision.Issues) { + decision.OK = false + return decision, conflict, nil + } + if hasWarningIssue(decision.Issues) && !preview { + if _, ok, err := m.repo.ActiveWarningOverride(ctx, pluginID, artifactID, profile, action, policyHash); err != nil { + return GovernanceDecision{}, ConflictAnalysis{}, err + } else if !ok { + decision.OK = false + return decision, conflict, nil + } + decision.WarningOverrideUsed = true + } + decision.OK = true + return decision, conflict, nil +} + +func (m *Manager) preflightChecks(ctx context.Context, plugin PluginRecord, artifact ArtifactRecord, manifest Manifest, profile, action, configJSON string) PreflightResult { + result := PreflightResult{Profile: normalizeProfile(profile), CreatedAt: m.repo.now().Unix()} + if configJSON == "" { + configJSON = "{}" + } + if !json.Valid([]byte(configJSON)) { + result.Checks = append(result.Checks, PreflightCheck{Code: "config_invalid", Severity: GateSeverityBlocking, Message: "config_json must be valid JSON"}) + return result + } + if err := validateConfigSchema(manifest.ConfigSchema, configJSON); err != nil { + result.Checks = append(result.Checks, PreflightCheck{Code: "config_schema_failed", Severity: GateSeverityBlocking, Message: err.Error()}) + } + if err := m.validateSecretRefs(ctx, manifest, configJSON); err != nil { + result.Checks = append(result.Checks, PreflightCheck{Code: "secret_missing", Severity: GateSeverityBlocking, Message: err.Error()}) + } + if missing := requiredFeatures(manifest); len(missing) > 0 { + result.Checks = append(result.Checks, PreflightCheck{ + Code: "feature_missing", + Severity: GateSeverityBlocking, + Message: "required feature declaration is not supported by gateway", + Details: map[string]any{"features": missing}, + }) + } + if manifest.RuntimeLimits.HandlerTimeoutMS > int(DefaultHandlerTimeout.Milliseconds()) { + result.Checks = append(result.Checks, PreflightCheck{ + Code: "runtime_limits_warning", + Severity: GateSeverityWarning, + Message: "handler timeout is unset or exceeds default runtime limit", + Details: map[string]any{ + "handler_timeout_ms": manifest.RuntimeLimits.HandlerTimeoutMS, + "default_ms": DefaultHandlerTimeout.Milliseconds(), + }, + }) + } + scope := manifestScope(manifest) + if upstreamModeFromArtifact(artifact) == UpstreamModeProtocolProxy && len(scope.Values) == 0 { + result.Checks = append(result.Checks, PreflightCheck{Code: "scope_global", Severity: GateSeverityWarning, Message: "plugin scope defaults to global"}) + } + rollout := manifestRollout(manifest) + if upstreamModeFromArtifact(artifact) == UpstreamModeProtocolProxy && rollout.Mode == "all" && normalizeProfile(profile) == PolicyProfileProd { + result.Checks = append(result.Checks, PreflightCheck{Code: "rollout_all_prod", Severity: GateSeverityWarning, Message: "prod rollout applies to all traffic"}) + } + if upstreamModeFromArtifact(artifact) == UpstreamModeProtocolProxy { + var summary CapabilitySummary + _ = json.Unmarshal([]byte(artifact.CapabilitiesSummaryJSON), &summary) + if summary.Minecraft == nil { + result.Checks = append(result.Checks, PreflightCheck{Code: "minecraft_capability_missing", Severity: GateSeverityBlocking, Message: "protocol-proxy plugins must declare minecraft capability"}) + } else { + if summary.Minecraft.ProtocolVersions.Min == 0 && summary.Minecraft.ProtocolVersions.Max == 0 && len(summary.Minecraft.ProtocolVersions.Tested) == 0 { + result.Checks = append(result.Checks, PreflightCheck{Code: "minecraft_protocol_untested", Severity: GateSeverityWarning, Message: "minecraft capability does not declare tested protocol versions"}) + } + if len(summary.Minecraft.Forwarding.Supported) == 0 { + result.Checks = append(result.Checks, PreflightCheck{Code: "backend_forwarding_warning", Severity: GateSeverityWarning, Message: "backend forwarding behavior is not declared"}) + } + if summary.Minecraft.Forwarding.RequiresSecret { + hasSecret := false + for _, spec := range manifest.Secrets { + if spec.Required { + hasSecret = true + break + } + } + if !hasSecret { + result.Checks = append(result.Checks, PreflightCheck{Code: "backend_forwarding_secret_missing", Severity: GateSeverityBlocking, Message: "minecraft forwarding requires a declared required secret"}) + } + } + } + } + if external := externalDependencies(manifest); len(external) > 0 { + result.Checks = append(result.Checks, PreflightCheck{Code: "external_dependencies_declared", Severity: GateSeverityInfo, Message: "plugin declares external dependencies", Details: map[string]any{"dependencies": external}}) + } + result.OK = !preflightHasBlocking(result.Checks) + return result +} + +func (m *Manager) conflictAnalysis(ctx context.Context, target PluginRecord, artifact ArtifactRecord, manifest Manifest) (ConflictAnalysis, error) { + analysis := ConflictAnalysis{CreatedAt: m.repo.now().Unix(), Plan: m.DispatchPlan(ctx)} + if upstreamModeFromArtifact(artifact) != UpstreamModeProtocolProxy { + analysis.OK = true + return analysis, nil + } + targetScope := manifestScope(manifest) + plugins, err := m.repo.ListPlugins(ctx) + if err != nil { + return ConflictAnalysis{}, err + } + for _, plugin := range plugins { + if plugin.ID == target.ID || plugin.RuntimeState != RuntimeEnabled || plugin.ActiveArtifactID == "" { + continue + } + otherArtifact, err := m.repo.Artifact(ctx, plugin.ActiveArtifactID) + if err != nil { + continue + } + if upstreamModeFromArtifact(otherArtifact) != UpstreamModeProtocolProxy { + continue + } + var otherManifest Manifest + if json.Unmarshal([]byte(otherArtifact.MetadataJSON), &otherManifest) != nil { + continue + } + otherScope := manifestScope(otherManifest) + if scopesOverlap(targetScope, otherScope) { + analysis.Issues = append(analysis.Issues, issue("scope_overlap", GateSeverityBlocking, "protocol-proxy scope overlaps enabled plugin", target.ID, artifact.ID, map[string]any{ + "other_plugin_id": plugin.ID, + "other_artifact_id": otherArtifact.ID, + "scope": targetScope.Values, + })) + analysis.Issues = append(analysis.Issues, issue("protocol_proxy_singleton", GateSeverityBlocking, "only one protocol-proxy plugin can own an overlapping scope", target.ID, artifact.ID, map[string]any{ + "other_plugin_id": plugin.ID, + })) + } else if target.Priority >= plugin.Priority { + analysis.Issues = append(analysis.Issues, issue("shadowed_handler", GateSeverityWarning, "protocol-proxy handler may be shadowed by a higher priority plugin", target.ID, artifact.ID, map[string]any{ + "other_plugin_id": plugin.ID, + "other_priority": plugin.Priority, + "priority": target.Priority, + })) + } + } + providers := providerSingletons(manifest) + if len(providers) > 0 { + for _, plugin := range plugins { + if plugin.ID == target.ID || plugin.RuntimeState != RuntimeEnabled || plugin.ActiveArtifactID == "" { + continue + } + otherArtifact, err := m.repo.Artifact(ctx, plugin.ActiveArtifactID) + if err != nil { + continue + } + var otherManifest Manifest + if json.Unmarshal([]byte(otherArtifact.MetadataJSON), &otherManifest) != nil { + continue + } + for _, provider := range intersectStrings(providers, providerSingletons(otherManifest)) { + analysis.Issues = append(analysis.Issues, issue("provider_singleton", GateSeverityBlocking, "provider singleton is already owned by enabled plugin", target.ID, artifact.ID, map[string]any{ + "provider": provider, + "other_plugin_id": plugin.ID, + })) + } + } + } + if middlewareCycle(manifest) { + analysis.Issues = append(analysis.Issues, issue("middleware_ordering_cycle", GateSeverityBlocking, "middleware ordering declaration contains a cycle", target.ID, artifact.ID, nil)) + } + analysis.Issues = sortedIssues(analysis.Issues) + analysis.OK = !hasBlockingIssue(analysis.Issues) + return analysis, nil +} + +func (m *Manager) advisoryIssues(ctx context.Context, artifact ArtifactRecord, manifest Manifest) ([]GovernanceIssue, error) { + advisories, err := m.repo.ListAdvisories(ctx, artifact.PluginID) + if err != nil { + return nil, err + } + var issues []GovernanceIssue + for _, advisory := range advisories { + if advisory.Status == AdvisoryStatusAcked || advisory.Status == "" && advisory.Action == AdvisoryActionMitigate { + continue + } + if !advisoryMatches(advisory, artifact, manifest) { + continue + } + severity := GateSeverityWarning + switch advisory.Action { + case AdvisoryActionDenylist, AdvisoryActionQuarantine, AdvisoryActionRevoke: + severity = GateSeverityBlocking + } + code := "advisory_" + advisory.Action + if advisory.Status == AdvisoryStatusRevoked { + code = "advisory_revoke" + severity = GateSeverityBlocking + } + issues = append(issues, issue(code, severity, "artifact matches local security advisory", artifact.PluginID, artifact.ID, map[string]any{ + "advisory_id": advisory.AdvisoryID, + "recommended_action": advisory.RecommendedAction, + "fixed_version": advisory.FixedVersion, + "mitigation": advisory.Mitigation, + })) + } + return issues, nil +} + +func (m *Manager) benchmarkIssues(ctx context.Context, artifact ArtifactRecord, manifest Manifest, policy PolicySnapshot) ([]GovernanceIssue, error) { + benchmarks, err := m.repo.ListBenchmarks(ctx, artifact.PluginID) + if err != nil { + return nil, err + } + var latest *BenchmarkRecord + for idx := range benchmarks { + benchmark := benchmarks[idx] + if benchmark.ArtifactID == artifact.ID && benchmark.Profile == policy.Profile { + latest = &benchmark + break + } + } + if latest == nil { + return nil, nil + } + var issues []GovernanceIssue + if latest.BaselineDiff >= policy.BlockBenchmarkRegression { + issues = append(issues, issue("benchmark_regression_blocking", GateSeverityBlocking, "benchmark regression exceeds blocking threshold", artifact.PluginID, artifact.ID, map[string]any{"baseline_diff": latest.BaselineDiff})) + } else if latest.BaselineDiff >= policy.WarnBenchmarkRegression { + issues = append(issues, issue("benchmark_regression_warning", GateSeverityWarning, "benchmark regression exceeds warning threshold", artifact.PluginID, artifact.ID, map[string]any{"baseline_diff": latest.BaselineDiff})) + } + if manifest.RuntimeLimits.HandlerTimeoutMS > 0 && latest.P99MS > float64(manifest.RuntimeLimits.HandlerTimeoutMS) { + issues = append(issues, issue("benchmark_runtime_limit_exceeded", GateSeverityBlocking, "P99 exceeds handler runtime limit", artifact.PluginID, artifact.ID, map[string]any{ + "p99_ms": latest.P99MS, + "limit": manifest.RuntimeLimits.HandlerTimeoutMS, + })) + } + if latest.ActiveProxyCapacity > 0 { + active := m.activeProxyCountLocked(artifact.PluginID) + if int64(active) > latest.ActiveProxyCapacity { + issues = append(issues, issue("active_proxy_capacity_exceeded", GateSeverityBlocking, "active proxy connections exceed benchmarked capacity", artifact.PluginID, artifact.ID, map[string]any{ + "active": active, + "capacity": latest.ActiveProxyCapacity, + })) + } + } + if latest.ErrorRate >= 0.05 { + issues = append(issues, issue("benchmark_error_rate_blocking", GateSeverityBlocking, "benchmark error rate exceeds blocking threshold", artifact.PluginID, artifact.ID, map[string]any{"error_rate": latest.ErrorRate})) + } else if latest.ErrorRate >= 0.01 { + issues = append(issues, issue("benchmark_error_rate_warning", GateSeverityWarning, "benchmark error rate exceeds warning threshold", artifact.PluginID, artifact.ID, map[string]any{"error_rate": latest.ErrorRate})) + } + return issues, nil +} + +func (m *Manager) hasMatchingReview(ctx context.Context, pluginID, artifactID, profile string, fingerprint governanceFingerprintValue) (bool, error) { + reviews, err := m.repo.ListReviews(ctx, pluginID) + if err != nil { + return false, err + } + for _, review := range reviews { + if review.PluginID != pluginID || review.ArtifactID != artifactID || review.Profile != profile || review.Decision != ReviewDecisionApproved { + continue + } + if review.ConfigHash == fingerprint.ConfigHash && + review.ScopeHash == fingerprint.ScopeHash && + review.RolloutHash == fingerprint.RolloutHash && + review.RuntimeLimitsHash == fingerprint.RuntimeLimitsHash && + review.FeaturesHash == fingerprint.FeaturesHash && + review.PolicyHash == fingerprint.PolicyHash { + return true, nil + } + } + return false, nil +} + +func (m *Manager) artifactManifest(ctx context.Context, pluginID, artifactID string) (ArtifactRecord, Manifest, error) { + artifact, err := m.repo.Artifact(ctx, artifactID) + if err != nil { + return ArtifactRecord{}, Manifest{}, err + } + if artifact.PluginID != pluginID { + return ArtifactRecord{}, Manifest{}, errors.New("artifact plugin_id does not match") + } + var manifest Manifest + if err := json.Unmarshal([]byte(artifact.MetadataJSON), &manifest); err != nil { + return ArtifactRecord{}, Manifest{}, err + } + return artifact, manifest, nil +} + +type governanceFingerprintValue struct { + ConfigHash string + ScopeHash string + RolloutHash string + RuntimeLimitsHash string + FeaturesHash string + PolicyHash string +} + +func governanceFingerprint(plugin PluginRecord, artifact ArtifactRecord, manifest Manifest, policyHash string) governanceFingerprintValue { + _ = artifact + return governanceFingerprintValue{ + ConfigHash: stableHashJSONRaw(defaultJSONObject(plugin.ConfigJSON)), + ScopeHash: stableHash(manifestScope(manifest).Values), + RolloutHash: stableHash(manifestRollout(manifest)), + RuntimeLimitsHash: stableHash(manifest.RuntimeLimits), + FeaturesHash: stableHash(requiredFeatures(manifest)), + PolicyHash: policyHash, + } +} + +func policyForProfile(profile string, now time.Time) PolicySnapshot { + profile = normalizeProfile(profile) + policy := PolicySnapshot{ + Profile: profile, + WarningOverrideTTLSeconds: int64(defaultWarningOverrideTTL.Seconds()), + ReviewRequiredRisk: RiskHigh, + WarnBenchmarkRegression: 0.20, + BlockBenchmarkRegression: 0.50, + CreatedAt: now.Unix(), + } + switch profile { + case PolicyProfileDev: + policy.ReviewRequiredRisk = "" + policy.WarningOverrideTTLSeconds = int64((7 * 24 * time.Hour).Seconds()) + case PolicyProfileStaging: + policy.WarningOverrideTTLSeconds = int64((72 * time.Hour).Seconds()) + case PolicyProfileProd: + policy.WarningOverrideTTLSeconds = int64((24 * time.Hour).Seconds()) + } + return policy +} + +func policyHash(policy PolicySnapshot) string { + policy.CreatedAt = 0 + return stableHash(policy) +} + +func normalizeProfile(profile string) string { + switch profile { + case PolicyProfileDev, PolicyProfileStaging, PolicyProfileProd: + return profile + case "": + return PolicyProfileProd + default: + return PolicyProfileProd + } +} + +func riskLevel(manifest Manifest, artifact ArtifactRecord) string { + var caps map[string]any + _ = json.Unmarshal(manifest.Capabilities, &caps) + if governance, ok := caps["governance"].(map[string]any); ok { + if risk, _ := governance["risk_level"].(string); risk != "" { + switch risk { + case RiskLow, RiskMedium, RiskHigh: + return risk + } + } + } + if upstreamModeFromArtifact(artifact) == UpstreamModeProtocolProxy { + return RiskHigh + } + if len(manifest.Secrets) > 0 || len(externalDependencies(manifest)) > 0 { + return RiskMedium + } + return RiskLow +} + +func reviewRequired(profile, risk string, policy PolicySnapshot) bool { + if profile != PolicyProfileProd || policy.ReviewRequiredRisk == "" { + return false + } + return riskRank(risk) >= riskRank(policy.ReviewRequiredRisk) +} + +func riskRank(risk string) int { + switch risk { + case RiskHigh: + return 3 + case RiskMedium: + return 2 + case RiskLow: + return 1 + default: + return 0 + } +} + +type scopeSpec struct { + Type string + Values []string +} + +type rolloutSpec struct { + Mode string `json:"mode"` +} + +func manifestScope(manifest Manifest) scopeSpec { + var caps map[string]any + _ = json.Unmarshal(manifest.Capabilities, &caps) + raw, _ := caps["scope"].(map[string]any) + spec := scopeSpec{Type: "global"} + if typ, _ := raw["type"].(string); typ != "" { + spec.Type = typ + } + for _, key := range []string{"values", "hosts", "routes", "listeners"} { + spec.Values = append(spec.Values, stringSlice(raw[key])...) + } + if value, _ := raw["value"].(string); value != "" { + spec.Values = append(spec.Values, value) + } + spec.Values = uniqueSortedStrings(spec.Values) + return spec +} + +func manifestRollout(manifest Manifest) rolloutSpec { + var caps map[string]any + _ = json.Unmarshal(manifest.Capabilities, &caps) + raw, _ := caps["rollout"].(map[string]any) + mode, _ := raw["mode"].(string) + if mode == "" { + mode = "all" + } + return rolloutSpec{Mode: mode} +} + +func scopesOverlap(a, b scopeSpec) bool { + if len(a.Values) == 0 || len(b.Values) == 0 || a.Type == "global" || b.Type == "global" { + return true + } + for _, left := range a.Values { + for _, right := range b.Values { + if left == right || left == "*" || right == "*" { + return true + } + } + } + return false +} + +func requiredFeatures(manifest Manifest) []string { + var caps map[string]any + _ = json.Unmarshal(manifest.Capabilities, &caps) + var features []string + for _, rawKey := range []string{"required_features", "features"} { + for _, feature := range stringSlice(caps[rawKey]) { + if !supportedFeature(feature) { + features = append(features, feature) + } + } + } + return uniqueSortedStrings(features) +} + +func supportedFeature(feature string) bool { + switch feature { + case "", ExtensionUpstreamConnect, "upstream.connect", "minecraft", "config", "secret", "preflight", "self-test": + return true + default: + return false + } +} + +func externalDependencies(manifest Manifest) []string { + var caps map[string]any + _ = json.Unmarshal(manifest.Capabilities, &caps) + var deps []string + for _, key := range []string{"external_dependencies", "external_deps", "dependencies"} { + deps = append(deps, stringSlice(caps[key])...) + } + return uniqueSortedStrings(deps) +} + +func providerSingletons(manifest Manifest) []string { + var caps map[string]any + _ = json.Unmarshal(manifest.Capabilities, &caps) + var providers []string + for _, key := range []string{"provider_singletons", "providers"} { + providers = append(providers, stringSlice(caps[key])...) + } + return uniqueSortedStrings(providers) +} + +func middlewareCycle(manifest Manifest) bool { + var caps map[string]any + _ = json.Unmarshal(manifest.Capabilities, &caps) + raw, ok := caps["middleware_order"].([]any) + if !ok || len(raw) == 0 { + return false + } + graph := map[string][]string{} + for _, item := range raw { + edge, _ := item.(map[string]any) + before, _ := edge["before"].(string) + after, _ := edge["after"].(string) + if before != "" && after != "" { + graph[before] = append(graph[before], after) + } + } + visiting := map[string]bool{} + visited := map[string]bool{} + var visit func(string) bool + visit = func(node string) bool { + if visiting[node] { + return true + } + if visited[node] { + return false + } + visiting[node] = true + for _, next := range graph[node] { + if visit(next) { + return true + } + } + visiting[node] = false + visited[node] = true + return false + } + for node := range graph { + if visit(node) { + return true + } + } + return false +} + +func advisoryMatches(advisory AdvisoryRecord, artifact ArtifactRecord, manifest Manifest) bool { + if advisory.ArtifactSHA256 != "" && advisory.ArtifactSHA256 == artifact.SHA256 { + return true + } + if advisory.PluginID != "" && advisory.PluginID == artifact.PluginID { + return versionInRange(artifact.Version, advisory.VersionRange) + } + if advisory.DependencyName != "" { + for _, dep := range sbomDependencies(manifest) { + if dep.Name == advisory.DependencyName && versionInRange(dep.Version, advisory.DependencyRange) { + return true + } + } + } + return false +} + +type dependencySpec struct { + Name string + Version string +} + +func sbomDependencies(manifest Manifest) []dependencySpec { + var raw map[string]any + if len(manifest.SupplyChain) == 0 || json.Unmarshal(manifest.SupplyChain, &raw) != nil { + return nil + } + var deps []dependencySpec + for _, key := range []string{"dependencies", "sbom_dependencies", "modules"} { + items, _ := raw[key].([]any) + for _, item := range items { + obj, _ := item.(map[string]any) + name, _ := obj["name"].(string) + if name == "" { + name, _ = obj["path"].(string) + } + version, _ := obj["version"].(string) + if name != "" { + deps = append(deps, dependencySpec{Name: name, Version: version}) + } + } + } + return deps +} + +func versionInRange(version, expr string) bool { + expr = strings.TrimSpace(expr) + if expr == "" || expr == "*" { + return true + } + for _, part := range strings.Split(expr, ",") { + part = strings.TrimSpace(part) + switch { + case strings.HasPrefix(part, "<="): + if compareVersion(version, strings.TrimSpace(strings.TrimPrefix(part, "<="))) > 0 { + return false + } + case strings.HasPrefix(part, ">="): + if compareVersion(version, strings.TrimSpace(strings.TrimPrefix(part, ">="))) < 0 { + return false + } + case strings.HasPrefix(part, "<"): + if compareVersion(version, strings.TrimSpace(strings.TrimPrefix(part, "<"))) >= 0 { + return false + } + case strings.HasPrefix(part, ">"): + if compareVersion(version, strings.TrimSpace(strings.TrimPrefix(part, ">"))) <= 0 { + return false + } + default: + if version != part { + return false + } + } + } + return true +} + +func compareVersion(a, b string) int { + as := versionParts(a) + bs := versionParts(b) + for i := 0; i < len(as) || i < len(bs); i++ { + var av, bv int + if i < len(as) { + av = as[i] + } + if i < len(bs) { + bv = bs[i] + } + if av < bv { + return -1 + } + if av > bv { + return 1 + } + } + return 0 +} + +func versionParts(version string) []int { + version = strings.TrimPrefix(strings.TrimSpace(version), "v") + fields := strings.FieldsFunc(version, func(r rune) bool { + return r == '.' || r == '-' || r == '+' + }) + var parts []int + for _, field := range fields { + n, err := strconv.Atoi(field) + if err != nil { + break + } + parts = append(parts, n) + } + return parts +} + +func apiPreflightChecks(checks []api.PreflightCheck) []PreflightCheck { + result := make([]PreflightCheck, 0, len(checks)) + for _, check := range checks { + severity := check.Severity + if severity == "" { + severity = GateSeverityWarning + } + result = append(result, PreflightCheck{ + Code: check.Code, + Severity: severity, + Message: check.Message, + }) + } + return result +} + +func pluginRecordWithConfig(plugin PluginRecord, artifactID, configJSON string) PluginRecord { + plugin.DesiredArtifactID = artifactID + plugin.ConfigJSON = configJSON + return plugin +} + +func issue(code, severity, message, pluginID, artifactID string, details map[string]any) GovernanceIssue { + return GovernanceIssue{Code: code, Severity: severity, Message: message, PluginID: pluginID, ArtifactID: artifactID, Details: details} +} + +func sortedIssues(issues []GovernanceIssue) []GovernanceIssue { + sort.SliceStable(issues, func(i, j int) bool { + if issues[i].Severity != issues[j].Severity { + return severityRank(issues[i].Severity) > severityRank(issues[j].Severity) + } + if issueRank(issues[i].Code) != issueRank(issues[j].Code) { + return issueRank(issues[i].Code) > issueRank(issues[j].Code) + } + if issues[i].Code != issues[j].Code { + return issues[i].Code < issues[j].Code + } + return issues[i].PluginID < issues[j].PluginID + }) + return issues +} + +func issueRank(code string) int { + switch code { + case "scope_overlap": + return 10 + case "review_required": + return 9 + case "feature_missing", "secret_missing": + return 8 + case "advisory_revoke": + return 7 + default: + return 0 + } +} + +func severityRank(severity string) int { + switch severity { + case GateSeverityBlocking: + return 3 + case GateSeverityWarning: + return 2 + case GateSeverityInfo: + return 1 + default: + return 0 + } +} + +func hasBlockingIssue(issues []GovernanceIssue) bool { + for _, issue := range issues { + if issue.Severity == GateSeverityBlocking { + return true + } + } + return false +} + +func hasWarningIssue(issues []GovernanceIssue) bool { + for _, issue := range issues { + if issue.Severity == GateSeverityWarning { + return true + } + } + return false +} + +func preflightHasBlocking(checks []PreflightCheck) bool { + for _, check := range checks { + if check.Severity == GateSeverityBlocking { + return true + } + } + return false +} + +func stableHash(value any) string { + data, _ := json.Marshal(value) + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]) +} + +func stableHashJSONRaw(raw string) string { + var value any + if json.Unmarshal([]byte(defaultJSONObject(raw)), &value) != nil { + return stableHash(raw) + } + return stableHash(value) +} + +func stringSlice(value any) []string { + switch typed := value.(type) { + case []string: + return typed + case []any: + result := make([]string, 0, len(typed)) + for _, item := range typed { + if text, ok := item.(string); ok && text != "" { + result = append(result, text) + } + } + return result + case string: + if typed != "" { + return []string{typed} + } + } + return nil +} + +func manifestCapabilitiesRaw(artifact ArtifactRecord) string { + var manifest Manifest + if json.Unmarshal([]byte(artifact.MetadataJSON), &manifest) != nil { + return "{}" + } + if len(manifest.Capabilities) == 0 { + return "{}" + } + return string(manifest.Capabilities) +} + +func jsonObjectFromRaw(raw, key string) any { + var value map[string]any + if json.Unmarshal([]byte(defaultJSONObject(raw)), &value) != nil { + return nil + } + return value[key] +} + +func uniqueSortedStrings(values []string) []string { + seen := make(map[string]bool, len(values)) + var result []string + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" || seen[value] { + continue + } + seen[value] = true + result = append(result, value) + } + sort.Strings(result) + return result +} + +func intersectStrings(left, right []string) []string { + set := make(map[string]bool, len(left)) + for _, value := range left { + set[value] = true + } + var result []string + for _, value := range right { + if set[value] { + result = append(result, value) + } + } + return uniqueSortedStrings(result) +} + +func governanceBlockedError(decision GovernanceDecision) error { + for _, issue := range decision.Issues { + if issue.Severity == GateSeverityBlocking { + return fmt.Errorf("governance gate blocked: %s: %s", issue.Code, issue.Message) + } + } + for _, issue := range decision.Issues { + if issue.Severity == GateSeverityWarning { + return fmt.Errorf("governance warning requires override: %s: %s", issue.Code, issue.Message) + } + } + return errors.New("governance gate blocked") +} diff --git a/internal/pluginmanager/governance_test.go b/internal/pluginmanager/governance_test.go new file mode 100644 index 0000000..092ba9d --- /dev/null +++ b/internal/pluginmanager/governance_test.go @@ -0,0 +1,174 @@ +package pluginmanager + +import ( + "context" + "encoding/json" + "strings" + "testing" + "time" +) + +func TestGovernanceHighRiskProtocolProxyRequiresReview(t *testing.T) { + manager := newManagerForTest(t, &fakeAdapter{}) + artifact := uploadTestArtifactWithCapabilities(t, manager, "proxy-review", testProtocolProxyCapabilities()) + if _, err := manager.SetDesired(context.Background(), "admin", "proxy-review", artifact.ID, DesiredEnabled, `{}`, 10); err != nil { + t.Fatalf("SetDesired() error = %v", err) + } + _, err := manager.Enable(context.Background(), "admin", "proxy-review") + if err == nil || !strings.Contains(err.Error(), "review_required") { + t.Fatalf("Enable() error = %v, want review_required", err) + } + if _, err := manager.CreateReview(context.Background(), "admin", "proxy-review", GovernanceReviewRequest{ + ArtifactID: artifact.ID, + Profile: PolicyProfileProd, + Decision: ReviewDecisionApproved, + }); err != nil { + t.Fatalf("CreateReview() error = %v", err) + } + if _, err := manager.Enable(context.Background(), "admin", "proxy-review"); err != nil { + t.Fatalf("Enable(after review) error = %v", err) + } +} + +func TestGovernanceBlocksProtocolProxyScopeOverlap(t *testing.T) { + manager := newManagerForTest(t, &fakeAdapter{}) + first := enableProtocolProxyTestPlugin(t, manager, "proxy-a") + if first.ID == "" { + t.Fatal("first protocol proxy artifact id is empty") + } + second := uploadTestArtifactWithCapabilities(t, manager, "proxy-b", testProtocolProxyCapabilities()) + if _, err := manager.SetDesired(context.Background(), "admin", "proxy-b", second.ID, DesiredEnabled, `{}`, 20); err != nil { + t.Fatalf("SetDesired(second) error = %v", err) + } + approveGovernanceForTest(t, manager, "proxy-b", second.ID) + _, err := manager.Enable(context.Background(), "admin", "proxy-b") + if err == nil || !strings.Contains(err.Error(), "scope_overlap") { + t.Fatalf("Enable(second) error = %v, want scope_overlap", err) + } +} + +func TestGovernanceBlocksMissingFeatureAndSecret(t *testing.T) { + manager := newManagerForTest(t, &fakeAdapter{}) + artifact := uploadTestArtifactWithManifest(t, manager, "feature-secret", func(manifest *Manifest) { + manifest.Capabilities = json.RawMessage(`{"required_features":["wasm-sandbox"]}`) + manifest.Secrets = []SecretSpec{{Name: "api_token", Required: true}} + }) + if _, err := manager.SetDesired(context.Background(), "admin", "feature-secret", artifact.ID, DesiredEnabled, `{}`, 10); err == nil { + t.Fatal("SetDesired() error = nil, want missing secret from dry-run") + } + if _, err := manager.UpsertSecret(context.Background(), "admin", "feature-secret", artifact.ID, "api_token", "secret", true, false); err != nil { + t.Fatalf("UpsertSecret() error = %v", err) + } + if _, err := manager.SetDesired(context.Background(), "admin", "feature-secret", artifact.ID, DesiredEnabled, `{}`, 10); err != nil { + t.Fatalf("SetDesired(after secret) error = %v", err) + } + _, err := manager.Enable(context.Background(), "admin", "feature-secret") + if err == nil || !strings.Contains(err.Error(), "feature_missing") { + t.Fatalf("Enable() error = %v, want feature_missing", err) + } +} + +func TestGovernanceAdvisoryRevokeBlocksRollback(t *testing.T) { + manager := newManagerForTest(t, &fakeAdapter{}) + oldArtifact := uploadTestArtifact(t, manager, "revoke-plugin") + if _, err := manager.SetDesired(context.Background(), "admin", "revoke-plugin", oldArtifact.ID, DesiredEnabled, `{}`, 10); err != nil { + t.Fatalf("SetDesired(old) error = %v", err) + } + if _, err := manager.Enable(context.Background(), "admin", "revoke-plugin"); err != nil { + t.Fatalf("Enable(old) error = %v", err) + } + newArtifact := uploadTestArtifactWithManifest(t, manager, "revoke-plugin", func(manifest *Manifest) { + manifest.Version = "0.2.0" + }) + if _, err := manager.SetDesired(context.Background(), "admin", "revoke-plugin", newArtifact.ID, DesiredEnabled, `{}`, 10); err != nil { + t.Fatalf("SetDesired(new) error = %v", err) + } + if _, err := manager.Enable(context.Background(), "admin", "revoke-plugin"); err != nil { + t.Fatalf("Enable(new) error = %v", err) + } + if _, err := manager.UpsertAdvisory(context.Background(), "admin", AdvisoryRequest{ + AdvisoryID: "MCG-2026-0001", + Status: AdvisoryStatusRevoked, + Action: AdvisoryActionRevoke, + ArtifactSHA256: oldArtifact.SHA256, + }); err != nil { + t.Fatalf("UpsertAdvisory() error = %v", err) + } + _, err := manager.RollbackArtifact(context.Background(), "admin", "revoke-plugin", oldArtifact.ID) + if err == nil || !strings.Contains(err.Error(), "advisory_revoke") { + t.Fatalf("RollbackArtifact() error = %v, want advisory_revoke", err) + } +} + +func TestGovernanceWarningOverrideTTL(t *testing.T) { + now := time.Unix(1000, 0) + db := openPluginManagerTestDB(t) + manager := New(Options{ + DB: db, + ArtifactRoot: t.TempDir(), + Adapter: &fakeAdapter{}, + }) + manager.repo.now = func() time.Time { return now } + artifact := uploadTestArtifact(t, manager, "bench-plugin") + if _, err := manager.SetDesired(context.Background(), "admin", "bench-plugin", artifact.ID, DesiredEnabled, `{}`, 10); err != nil { + t.Fatalf("SetDesired() error = %v", err) + } + if _, err := manager.SaveBenchmark(context.Background(), "admin", BenchmarkRequest{ + ArtifactID: artifact.ID, + Profile: PolicyProfileProd, + BenchmarkProfile: "release", + P95MS: 10, + P99MS: 20, + BaselineDiff: 0.30, + }); err != nil { + t.Fatalf("SaveBenchmark() error = %v", err) + } + _, err := manager.Enable(context.Background(), "admin", "bench-plugin") + if err == nil || !strings.Contains(err.Error(), "benchmark_regression_warning") { + t.Fatalf("Enable() error = %v, want benchmark warning", err) + } + if _, err := manager.CreateWarningOverride(context.Background(), "admin", "bench-plugin", WarningOverrideRequest{ + ArtifactID: artifact.ID, + Profile: PolicyProfileProd, + Action: GovernanceActionEnable, + Reason: "accepted for canary", + TTLSeconds: 60, + }); err != nil { + t.Fatalf("CreateWarningOverride() error = %v", err) + } + if _, err := manager.Enable(context.Background(), "admin", "bench-plugin"); err != nil { + t.Fatalf("Enable(with override) error = %v", err) + } + if _, err := manager.Disable(context.Background(), "admin", "bench-plugin"); err != nil { + t.Fatalf("Disable() error = %v", err) + } + now = now.Add(2 * time.Minute) + _, err = manager.Enable(context.Background(), "admin", "bench-plugin") + if err == nil || !strings.Contains(err.Error(), "benchmark_regression_warning") { + t.Fatalf("Enable(after override expiry) error = %v, want benchmark warning", err) + } +} + +func TestGovernanceBenchmarkBlocking(t *testing.T) { + manager := newManagerForTest(t, &fakeAdapter{}) + artifact := uploadTestArtifactWithManifest(t, manager, "bench-block", func(manifest *Manifest) { + manifest.RuntimeLimits.HandlerTimeoutMS = 100 + }) + if _, err := manager.SetDesired(context.Background(), "admin", "bench-block", artifact.ID, DesiredEnabled, `{}`, 10); err != nil { + t.Fatalf("SetDesired() error = %v", err) + } + if _, err := manager.SaveBenchmark(context.Background(), "admin", BenchmarkRequest{ + ArtifactID: artifact.ID, + Profile: PolicyProfileProd, + BenchmarkProfile: "release", + P95MS: 80, + P99MS: 200, + BaselineDiff: 0.60, + }); err != nil { + t.Fatalf("SaveBenchmark() error = %v", err) + } + _, err := manager.Enable(context.Background(), "admin", "bench-block") + if err == nil || !strings.Contains(err.Error(), "benchmark_regression_blocking") { + t.Fatalf("Enable() error = %v, want benchmark_regression_blocking", err) + } +} diff --git a/internal/pluginmanager/manager.go b/internal/pluginmanager/manager.go index c983b09..293a620 100644 --- a/internal/pluginmanager/manager.go +++ b/internal/pluginmanager/manager.go @@ -31,6 +31,52 @@ type ConfigDryRunAdapter interface { type GoPluginAdapter struct{} func (a GoPluginAdapter) Load(ctx context.Context, artifact ArtifactRecord, pluginRecord PluginRecord, gateway *Gateway) (api.Plugin, error) { + return a.instantiate(ctx, artifact, pluginRecord, gateway, true) +} + +func (a GoPluginAdapter) DryRunConfig(ctx context.Context, artifact ArtifactRecord, pluginRecord PluginRecord) error { + _ = ctx + _, err := a.instantiate(ctx, artifact, pluginRecord, nil, false) + return err +} + +func (a GoPluginAdapter) RunPreflight(ctx context.Context, artifact ArtifactRecord, pluginRecord PluginRecord, profile, action string) (api.PreflightResult, error) { + instance, err := a.instantiate(ctx, artifact, pluginRecord, nil, false) + if err != nil { + return api.PreflightResult{}, err + } + checker, ok := instance.(api.PreflightChecker) + if !ok { + return api.PreflightResult{}, nil + } + var config map[string]any + _ = json.Unmarshal([]byte(defaultJSONObject(pluginRecord.ConfigJSON)), &config) + return checker.Preflight(api.PreflightContext{ + PluginID: pluginRecord.ID, + ArtifactID: artifact.ID, + Profile: profile, + Action: action, + Config: config, + Scope: jsonObjectFromRaw(manifestCapabilitiesRaw(artifact), "scope"), + Rollout: jsonObjectFromRaw(manifestCapabilitiesRaw(artifact), "rollout"), + RuntimeLimits: jsonObjectFromRaw(artifact.MetadataJSON, "runtime_limits"), + Features: stringSlice(jsonObjectFromRaw(manifestCapabilitiesRaw(artifact), "required_features")), + }) +} + +func (a GoPluginAdapter) RunSelfTest(ctx context.Context, artifact ArtifactRecord, pluginRecord PluginRecord, profile string) (api.SelfTestResult, error) { + instance, err := a.instantiate(ctx, artifact, pluginRecord, nil, false) + if err != nil { + return api.SelfTestResult{}, err + } + tester, ok := instance.(api.SelfTester) + if !ok { + return api.SelfTestResult{}, nil + } + return tester.SelfTest(api.SelfTestProfile{Name: profile}) +} + +func (a GoPluginAdapter) instantiate(ctx context.Context, artifact ArtifactRecord, pluginRecord PluginRecord, gateway *Gateway, init bool) (api.Plugin, error) { _ = ctx opened, err := stdplugin.Open(artifact.FilePath) if err != nil { @@ -59,42 +105,12 @@ func (a GoPluginAdapter) Load(ctx context.Context, artifact ArtifactRecord, plug if err := instance.ReloadConfig(cfg); err != nil { return nil, err } - if err := instance.Init(gateway); err != nil { - return nil, err - } - return instance, nil -} - -func (a GoPluginAdapter) DryRunConfig(ctx context.Context, artifact ArtifactRecord, pluginRecord PluginRecord) error { - _ = ctx - opened, err := stdplugin.Open(artifact.FilePath) - if err != nil { - return err - } - symbolName := "Plugin" - var manifest Manifest - if err := json.Unmarshal([]byte(artifact.MetadataJSON), &manifest); err == nil && manifest.Runtime.EntrySymbol != "" { - symbolName = manifest.Runtime.EntrySymbol - } - symbol, err := opened.Lookup(symbolName) - if err != nil { - return err - } - factory, ok := symbol.(func() api.Plugin) - if !ok { - return fmt.Errorf("plugin symbol %q has invalid signature", symbolName) - } - instance := factory() - cfg := instance.NewConfigObj() - if cfg != nil && pluginRecord.ConfigJSON != "" && canUnmarshalInto(cfg) { - if err := json.Unmarshal([]byte(pluginRecord.ConfigJSON), cfg); err != nil { - return fmt.Errorf("decode plugin config: %w", err) + if init { + if err := instance.Init(gateway); err != nil { + return nil, err } } - if err := instance.ReloadConfig(cfg); err != nil { - return err - } - return nil + return instance, nil } func canUnmarshalInto(value any) bool { @@ -106,12 +122,13 @@ func canUnmarshalInto(value any) bool { } type Manager struct { - repo Repository - store ArtifactStore - adapter RuntimeAdapter - builders map[string]SourceBuilder - handleConn func(net.Conn) - wg *sync.WaitGroup + repo Repository + store ArtifactStore + adapter RuntimeAdapter + builders map[string]SourceBuilder + handleConn func(net.Conn) + wg *sync.WaitGroup + policyProfile string mu sync.Mutex loaded map[string]*loadedPlugin @@ -181,12 +198,13 @@ type ProxyConnectionStats struct { } type Options struct { - DB *sql.DB - ArtifactRoot string - HandleConn func(net.Conn) - WaitGroup *sync.WaitGroup - Adapter RuntimeAdapter - Builders map[string]SourceBuilder + DB *sql.DB + ArtifactRoot string + HandleConn func(net.Conn) + WaitGroup *sync.WaitGroup + Adapter RuntimeAdapter + Builders map[string]SourceBuilder + PolicyProfile string } func New(options Options) *Manager { @@ -195,15 +213,16 @@ func New(options Options) *Manager { adapter = GoPluginAdapter{} } manager := &Manager{ - repo: NewRepository(options.DB), - store: NewArtifactStore(options.ArtifactRoot), - adapter: adapter, - builders: options.Builders, - handleConn: options.HandleConn, - wg: options.WaitGroup, - loaded: make(map[string]*loadedPlugin), - proxyConns: make(map[uint64]*proxyConnection), - drainingIDs: make(map[string]bool), + repo: NewRepository(options.DB), + store: NewArtifactStore(options.ArtifactRoot), + adapter: adapter, + builders: options.Builders, + handleConn: options.HandleConn, + wg: options.WaitGroup, + policyProfile: options.PolicyProfile, + loaded: make(map[string]*loadedPlugin), + proxyConns: make(map[uint64]*proxyConnection), + drainingIDs: make(map[string]bool), } if manager.builders == nil { manager.builders = map[string]SourceBuilder{ @@ -539,6 +558,17 @@ func (m *Manager) RollbackArtifact(ctx context.Context, actor, pluginID, artifac if err != nil { return PluginRecord{}, err } + decision, err := m.EvaluateGovernance(ctx, pluginID, artifactID, GovernanceActionRollback, m.currentPolicyProfile(), current.ConfigJSON) + if err == nil && !decision.OK { + err = governanceBlockedError(decision) + } + if err != nil { + _ = m.repo.RecordOperation(ctx, pluginID, artifactID, "artifact_rollback_gate", "failed", actor, err.Error(), map[string]any{ + "active_changed": false, + "governance_decision": decision, + }) + return PluginRecord{}, err + } if _, err := m.DryRunConfig(ctx, pluginID, artifactID, current.ConfigJSON); err != nil { _ = m.repo.RecordOperation(ctx, pluginID, artifactID, "artifact_rollback", "failed", actor, err.Error(), map[string]any{ "active_changed": false, @@ -553,8 +583,9 @@ func (m *Manager) RollbackArtifact(ctx context.Context, actor, pluginID, artifac return PluginRecord{}, err } _ = m.repo.RecordOperation(ctx, pluginID, artifactID, "artifact_rollback", "succeeded", actor, "artifact rollback desired state updated", map[string]any{ - "desired_generation": plugin.DesiredGeneration, - "active_changed": false, + "desired_generation": plugin.DesiredGeneration, + "active_changed": false, + "governance_decision": decision, }) return plugin, nil } @@ -576,6 +607,19 @@ func (m *Manager) RollbackConfigSnapshot(ctx context.Context, actor string, snap desiredState = snapshot.DesiredState priority = snapshot.Priority } + decision, err := m.EvaluateGovernance(ctx, snapshot.PluginID, artifactID, GovernanceActionRollback, m.currentPolicyProfile(), snapshot.ConfigJSON) + if err == nil && !decision.OK { + err = governanceBlockedError(decision) + } + if err != nil { + _ = m.repo.RecordOperation(ctx, snapshot.PluginID, artifactID, "config_rollback_gate", "failed", actor, err.Error(), map[string]any{ + "snapshot_id": snapshot.ID, + "full_desired": fullDesired, + "active_changed": false, + "governance_decision": decision, + }) + return PluginRecord{}, err + } if _, err := m.DryRunConfig(ctx, snapshot.PluginID, artifactID, snapshot.ConfigJSON); err != nil { _ = m.repo.RecordOperation(ctx, snapshot.PluginID, artifactID, "config_rollback", "failed", actor, err.Error(), map[string]any{ "snapshot_id": snapshot.ID, @@ -594,10 +638,11 @@ func (m *Manager) RollbackConfigSnapshot(ctx context.Context, actor string, snap return PluginRecord{}, err } _ = m.repo.RecordOperation(ctx, snapshot.PluginID, artifactID, "config_rollback", "succeeded", actor, "config snapshot rollback desired state updated", map[string]any{ - "snapshot_id": snapshot.ID, - "full_desired": fullDesired, - "desired_generation": next.DesiredGeneration, - "active_changed": false, + "snapshot_id": snapshot.ID, + "full_desired": fullDesired, + "desired_generation": next.DesiredGeneration, + "active_changed": false, + "governance_decision": decision, }) return next, nil } @@ -630,6 +675,17 @@ func (m *Manager) Enable(ctx context.Context, actor, pluginID string) (PluginRec return PluginRecord{}, err } } + decision, err := m.EvaluateGovernance(ctx, pluginID, pluginRecord.DesiredArtifactID, GovernanceActionEnable, m.currentPolicyProfile(), pluginRecord.ConfigJSON) + if err == nil && !decision.OK { + err = governanceBlockedError(decision) + } + if err != nil { + _ = m.repo.MarkRuntime(ctx, pluginID, RuntimeFailed, "", "", pluginRecord.AppliedGeneration, err.Error(), map[string]any{"governance": decision}, nil) + _ = m.repo.RecordOperation(ctx, pluginID, pluginRecord.DesiredArtifactID, "enable_gate", "failed", actor, err.Error(), map[string]any{ + "decision": decision, + }) + return PluginRecord{}, err + } m.mu.Lock() defer m.mu.Unlock() @@ -656,8 +712,9 @@ func (m *Manager) Enable(ctx context.Context, actor, pluginID string) (PluginRec m.publish(next) _ = m.repo.UpdateArtifactStatus(ctx, loaded.artifact.ID, ArtifactStatusLoaded, "") _ = m.repo.RecordOperation(ctx, pluginID, loaded.artifact.ID, "enable", "succeeded", actor, "plugin enabled", map[string]any{ - "desired_generation": loaded.record.DesiredGeneration, - "handler_count": len(loaded.handlers), + "desired_generation": loaded.record.DesiredGeneration, + "handler_count": len(loaded.handlers), + "governance_decision": decision, }) return m.repo.Plugin(ctx, pluginID) } @@ -728,6 +785,15 @@ func (m *Manager) Reconcile(ctx context.Context) error { } nextByPlugin := make(map[string][]*upstreamHandler) for _, pluginRecord := range desired { + decision, err := m.EvaluateGovernance(ctx, pluginRecord.ID, pluginRecord.DesiredArtifactID, GovernanceActionEnable, m.currentPolicyProfile(), pluginRecord.ConfigJSON) + if err == nil && !decision.OK { + err = governanceBlockedError(decision) + } + if err != nil { + _ = m.repo.MarkRuntime(ctx, pluginRecord.ID, RuntimeFailed, "", "", pluginRecord.AppliedGeneration, err.Error(), map[string]any{"governance": decision}, nil) + _ = m.repo.RecordOperation(ctx, pluginRecord.ID, pluginRecord.DesiredArtifactID, "reconcile_gate", "failed", "system", err.Error(), map[string]any{"decision": decision}) + continue + } loaded, err := m.loadLocked(ctx, pluginRecord) if err != nil { _ = m.repo.MarkRuntime(ctx, pluginRecord.ID, RuntimeFailed, "", "", pluginRecord.AppliedGeneration, err.Error(), map[string]any{"error": err.Error()}, nil) @@ -901,6 +967,38 @@ func (m *Manager) ListSecrets(ctx context.Context, pluginID string) ([]SecretRec return m.repo.ListSecrets(ctx, pluginID) } +func (m *Manager) quarantineAffected(ctx context.Context, advisory AdvisoryRecord) { + plugins, err := m.repo.ListPlugins(ctx) + if err != nil { + return + } + var manifests = make(map[string]Manifest) + m.mu.Lock() + defer m.mu.Unlock() + for _, plugin := range plugins { + if plugin.RuntimeState != RuntimeEnabled || plugin.ActiveArtifactID == "" { + continue + } + artifact, err := m.repo.Artifact(ctx, plugin.ActiveArtifactID) + if err != nil { + continue + } + manifest := manifests[artifact.ID] + if manifest.ID == "" { + _ = json.Unmarshal([]byte(artifact.MetadataJSON), &manifest) + manifests[artifact.ID] = manifest + } + if advisoryMatches(advisory, artifact, manifest) { + m.removeFromDispatchLocked(plugin.ID) + m.markDrainingLocked(plugin.ID) + _ = m.repo.MarkRuntime(ctx, plugin.ID, RuntimeDraining, artifact.ID, artifact.ID, plugin.AppliedGeneration, "plugin quarantined by advisory "+advisory.AdvisoryID, map[string]any{ + "quarantine": true, + "advisory_id": advisory.AdvisoryID, + }, nil) + } + } +} + func (m *Manager) UpsertSecret(ctx context.Context, actor, pluginID, artifactID, name, value string, reloadRequired, hotReload bool) (SecretRecord, error) { if artifactID == "" { plugin, err := m.repo.Plugin(ctx, pluginID) diff --git a/internal/pluginmanager/manager_test.go b/internal/pluginmanager/manager_test.go index f0c4e3e..4178782 100644 --- a/internal/pluginmanager/manager_test.go +++ b/internal/pluginmanager/manager_test.go @@ -510,10 +510,11 @@ func TestProtocolProxyTrackDrainAndForceClose(t *testing.T) { }, } manager := newManagerForTest(t, adapter) - artifact := uploadTestArtifactWithCapabilities(t, manager, "plugin-a", json.RawMessage(`{"upstream_connect":{"mode":"protocol-proxy"}}`)) + artifact := uploadTestArtifactWithCapabilities(t, manager, "plugin-a", testProtocolProxyCapabilities()) if _, err := manager.SetDesired(context.Background(), "admin", "plugin-a", artifact.ID, DesiredEnabled, `{}`, 10); err != nil { t.Fatalf("SetDesired() error = %v", err) } + approveGovernanceForTest(t, manager, "plugin-a", artifact.ID) if _, err := manager.Enable(context.Background(), "admin", "plugin-a"); err != nil { t.Fatalf("Enable() error = %v", err) } @@ -616,10 +617,11 @@ func TestProtocolProxyInitialWriteTimeoutClosesUnreadableConn(t *testing.T) { }, } manager := newManagerForTest(t, adapter) - artifact := uploadTestArtifactWithCapabilities(t, manager, "plugin-a", json.RawMessage(`{"upstream_connect":{"mode":"protocol-proxy"}}`)) + artifact := uploadTestArtifactWithCapabilities(t, manager, "plugin-a", testProtocolProxyCapabilities()) if _, err := manager.SetDesired(context.Background(), "admin", "plugin-a", artifact.ID, DesiredEnabled, `{"unused":true}`, 10); err != nil { t.Fatalf("SetDesired() error = %v", err) } + approveGovernanceForTest(t, manager, "plugin-a", artifact.ID) if _, err := manager.Enable(context.Background(), "admin", "plugin-a"); err != nil { t.Fatalf("Enable() error = %v", err) } @@ -648,21 +650,46 @@ func TestProtocolProxyInitialWriteTimeoutClosesUnreadableConn(t *testing.T) { func enableProtocolProxyTestPlugin(t *testing.T, manager *Manager, pluginID string) ArtifactRecord { t.Helper() - artifact := uploadTestArtifactWithCapabilities(t, manager, pluginID, json.RawMessage(`{"upstream_connect":{"mode":"protocol-proxy"}}`)) + artifact := uploadTestArtifactWithCapabilities(t, manager, pluginID, testProtocolProxyCapabilities()) if _, err := manager.SetDesired(context.Background(), "admin", pluginID, artifact.ID, DesiredEnabled, `{}`, 10); err != nil { t.Fatalf("SetDesired() error = %v", err) } + approveGovernanceForTest(t, manager, pluginID, artifact.ID) if _, err := manager.Enable(context.Background(), "admin", pluginID); err != nil { t.Fatalf("Enable() error = %v", err) } return artifact } +func approveGovernanceForTest(t *testing.T, manager *Manager, pluginID, artifactID string) { + t.Helper() + if _, err := manager.CreateReview(context.Background(), "admin", pluginID, GovernanceReviewRequest{ + ArtifactID: artifactID, + Profile: PolicyProfileProd, + Decision: ReviewDecisionApproved, + Notes: "test approval", + }); err != nil { + t.Fatalf("CreateReview(%s) error = %v", pluginID, err) + } +} + func newManagerForTest(t *testing.T, adapter RuntimeAdapter) *Manager { t.Helper() return newManagerForTestWithBuilders(t, adapter, nil) } +func testProtocolProxyCapabilities() json.RawMessage { + return json.RawMessage(`{ + "upstream_connect":{"mode":"protocol-proxy"}, + "scope":{"type":"host","values":["play.example"]}, + "rollout":{"mode":"canary"}, + "minecraft":{ + "protocol_versions":{"tested":[767]}, + "forwarding":{"supported":["none"],"default":"none"} + } + }`) +} + func newManagerForTestWithBuilders(t *testing.T, adapter RuntimeAdapter, builders map[string]SourceBuilder) *Manager { t.Helper() db := openPluginManagerTestDB(t) diff --git a/internal/pluginmanager/repository.go b/internal/pluginmanager/repository.go index 4581124..47ab4c7 100644 --- a/internal/pluginmanager/repository.go +++ b/internal/pluginmanager/repository.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "strings" "time" ) @@ -599,6 +600,359 @@ FROM plugin_secrets` return secrets, rows.Err() } +func (r Repository) SaveReview(ctx context.Context, review ReviewRecord) (ReviewRecord, error) { + now := r.now().Unix() + if review.CreatedAt == 0 { + review.CreatedAt = now + } + if review.Profile == "" { + review.Profile = PolicyProfileDev + } + if review.RiskLevel == "" { + review.RiskLevel = RiskLow + } + if review.Decision == "" { + review.Decision = ReviewDecisionApproved + } + _, err := r.db.ExecContext(ctx, ` +INSERT INTO plugin_reviews( + plugin_id, artifact_id, profile, risk_level, config_hash, scope_hash, rollout_hash, + runtime_limits_hash, features_hash, policy_hash, decision, notes, reviewed_by, created_at +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + review.PluginID, review.ArtifactID, review.Profile, review.RiskLevel, review.ConfigHash, review.ScopeHash, review.RolloutHash, + review.RuntimeLimitsHash, review.FeaturesHash, review.PolicyHash, review.Decision, review.Notes, review.ReviewedBy, review.CreatedAt) + if err != nil { + return ReviewRecord{}, err + } + id, err := lastInsertID(ctx, r.db) + if err != nil { + return ReviewRecord{}, err + } + review.ID = id + return review, nil +} + +func (r Repository) ListReviews(ctx context.Context, pluginID string) ([]ReviewRecord, error) { + query := ` +SELECT id, plugin_id, artifact_id, profile, risk_level, config_hash, scope_hash, rollout_hash, + runtime_limits_hash, features_hash, policy_hash, decision, notes, reviewed_by, created_at +FROM plugin_reviews` + var args []any + if pluginID != "" { + query += ` WHERE plugin_id = ?` + args = append(args, pluginID) + } + query += ` ORDER BY created_at DESC, id DESC` + rows, err := r.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var reviews []ReviewRecord + for rows.Next() { + review, err := scanReview(rows) + if err != nil { + return nil, err + } + reviews = append(reviews, review) + } + return reviews, rows.Err() +} + +func (r Repository) ApprovedReview(ctx context.Context, pluginID, artifactID, profile, policyHash, configHash, scopeHash, rolloutHash, runtimeHash, featuresHash string) (ReviewRecord, error) { + row := r.db.QueryRowContext(ctx, ` +SELECT id, plugin_id, artifact_id, profile, risk_level, config_hash, scope_hash, rollout_hash, + runtime_limits_hash, features_hash, policy_hash, decision, notes, reviewed_by, created_at +FROM plugin_reviews +WHERE plugin_id = ? AND artifact_id = ? AND profile = ? AND policy_hash = ? + AND config_hash = ? AND scope_hash = ? AND rollout_hash = ? AND runtime_limits_hash = ? AND features_hash = ? + AND decision = ? +ORDER BY created_at DESC, id DESC +LIMIT 1`, pluginID, artifactID, profile, policyHash, configHash, scopeHash, rolloutHash, runtimeHash, featuresHash, ReviewDecisionApproved) + review, err := scanReview(row) + if errors.Is(err, sql.ErrNoRows) { + return ReviewRecord{}, nil + } + return review, err +} + +func (r Repository) SaveWarningOverride(ctx context.Context, override WarningOverrideRecord) (WarningOverrideRecord, error) { + now := r.now().Unix() + if override.CreatedAt == 0 { + override.CreatedAt = now + } + if override.Profile == "" { + override.Profile = PolicyProfileDev + } + _, err := r.db.ExecContext(ctx, ` +INSERT INTO plugin_warning_overrides(plugin_id, artifact_id, profile, action, policy_hash, reason, created_by, expires_at, created_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + override.PluginID, override.ArtifactID, override.Profile, override.Action, override.PolicyHash, override.Reason, override.CreatedBy, override.ExpiresAt, override.CreatedAt) + if err != nil { + return WarningOverrideRecord{}, err + } + id, err := lastInsertID(ctx, r.db) + if err != nil { + return WarningOverrideRecord{}, err + } + override.ID = id + return override, nil +} + +func (r Repository) ActiveWarningOverride(ctx context.Context, pluginID, artifactID, profile, action, policyHash string) (WarningOverrideRecord, bool, error) { + row := r.db.QueryRowContext(ctx, ` +SELECT id, plugin_id, artifact_id, profile, action, policy_hash, reason, created_by, expires_at, created_at +FROM plugin_warning_overrides +WHERE plugin_id = ? AND artifact_id = ? AND profile = ? AND action = ? AND policy_hash = ? AND expires_at > ? +ORDER BY expires_at DESC, id DESC +LIMIT 1`, pluginID, artifactID, profile, action, policyHash, r.now().Unix()) + override, err := scanWarningOverride(row) + if errors.Is(err, sql.ErrNoRows) { + return WarningOverrideRecord{}, false, nil + } + if err != nil { + return WarningOverrideRecord{}, false, err + } + return override, true, nil +} + +func (r Repository) ListWarningOverrides(ctx context.Context, pluginID string) ([]WarningOverrideRecord, error) { + query := ` +SELECT id, plugin_id, artifact_id, profile, action, policy_hash, reason, created_by, expires_at, created_at +FROM plugin_warning_overrides` + var args []any + if pluginID != "" { + query += ` WHERE plugin_id = ?` + args = append(args, pluginID) + } + query += ` ORDER BY created_at DESC, id DESC` + rows, err := r.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var overrides []WarningOverrideRecord + for rows.Next() { + override, err := scanWarningOverride(rows) + if err != nil { + return nil, err + } + overrides = append(overrides, override) + } + return overrides, rows.Err() +} + +func (r Repository) UpsertAdvisory(ctx context.Context, actor string, req AdvisoryRequest) (AdvisoryRecord, error) { + now := r.now().Unix() + if req.Status == "" { + req.Status = AdvisoryStatusActive + } + if req.Action == "" { + req.Action = AdvisoryActionDenylist + } + if strings.TrimSpace(req.AdvisoryID) == "" { + return AdvisoryRecord{}, errors.New("advisory_id is required") + } + res, err := r.db.ExecContext(ctx, ` +UPDATE plugin_advisories +SET status = ?, action = ?, artifact_sha256 = ?, plugin_id = ?, version_range = ?, + dependency_name = ?, dependency_range = ?, recommended_action = ?, fixed_version = ?, + mitigation = ?, created_by = ?, updated_at = ? +WHERE advisory_id = ?`, + req.Status, req.Action, req.ArtifactSHA256, req.PluginID, req.VersionRange, + req.DependencyName, req.DependencyRange, req.RecommendedAction, req.FixedVersion, + req.Mitigation, actor, now, req.AdvisoryID) + if err != nil { + return AdvisoryRecord{}, err + } + if rows, _ := res.RowsAffected(); rows == 0 { + _, err = r.db.ExecContext(ctx, ` +INSERT INTO plugin_advisories( + advisory_id, status, action, artifact_sha256, plugin_id, version_range, dependency_name, + dependency_range, recommended_action, fixed_version, mitigation, created_by, created_at, updated_at +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + req.AdvisoryID, req.Status, req.Action, req.ArtifactSHA256, req.PluginID, req.VersionRange, req.DependencyName, + req.DependencyRange, req.RecommendedAction, req.FixedVersion, req.Mitigation, actor, now, now) + if err != nil { + return AdvisoryRecord{}, err + } + } + return r.AdvisoryByID(ctx, req.AdvisoryID) +} + +func (r Repository) AdvisoryByID(ctx context.Context, advisoryID string) (AdvisoryRecord, error) { + row := r.db.QueryRowContext(ctx, ` +SELECT id, advisory_id, status, action, artifact_sha256, plugin_id, version_range, dependency_name, + dependency_range, recommended_action, fixed_version, mitigation, created_by, created_at, updated_at +FROM plugin_advisories +WHERE advisory_id = ? +ORDER BY id DESC +LIMIT 1`, advisoryID) + return scanAdvisory(row) +} + +func (r Repository) ListAdvisories(ctx context.Context, pluginID string) ([]AdvisoryRecord, error) { + query := ` +SELECT id, advisory_id, status, action, artifact_sha256, plugin_id, version_range, dependency_name, + dependency_range, recommended_action, fixed_version, mitigation, created_by, created_at, updated_at +FROM plugin_advisories` + var args []any + if pluginID != "" { + query += ` WHERE plugin_id = ? OR plugin_id = ''` + args = append(args, pluginID) + } + query += ` ORDER BY updated_at DESC, id DESC` + rows, err := r.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var advisories []AdvisoryRecord + for rows.Next() { + advisory, err := scanAdvisory(rows) + if err != nil { + return nil, err + } + advisories = append(advisories, advisory) + } + return advisories, rows.Err() +} + +func (r Repository) SavePreflight(ctx context.Context, record PreflightRecord) (PreflightRecord, error) { + now := r.now().Unix() + if record.CreatedAt == 0 { + record.CreatedAt = now + } + if record.Profile == "" { + record.Profile = PolicyProfileDev + } + if record.ResultJSON == "" || !json.Valid([]byte(record.ResultJSON)) { + record.ResultJSON = "{}" + } + _, err := r.db.ExecContext(ctx, ` +INSERT INTO plugin_preflight_results(plugin_id, artifact_id, profile, status, result_json, created_by, created_at) +VALUES (?, ?, ?, ?, ?, ?, ?)`, + record.PluginID, record.ArtifactID, record.Profile, record.Status, record.ResultJSON, record.CreatedBy, record.CreatedAt) + if err != nil { + return PreflightRecord{}, err + } + id, err := lastInsertID(ctx, r.db) + if err != nil { + return PreflightRecord{}, err + } + record.ID = id + return record, nil +} + +func (r Repository) ListPreflights(ctx context.Context, pluginID string) ([]PreflightRecord, error) { + query := ` +SELECT id, plugin_id, artifact_id, profile, status, result_json, created_by, created_at +FROM plugin_preflight_results` + var args []any + if pluginID != "" { + query += ` WHERE plugin_id = ?` + args = append(args, pluginID) + } + query += ` ORDER BY created_at DESC, id DESC` + rows, err := r.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var records []PreflightRecord + for rows.Next() { + record, err := scanPreflight(rows) + if err != nil { + return nil, err + } + records = append(records, record) + } + return records, rows.Err() +} + +func (r Repository) SaveBenchmark(ctx context.Context, actor string, req BenchmarkRequest) (BenchmarkRecord, error) { + now := r.now().Unix() + if req.Profile == "" { + req.Profile = PolicyProfileDev + } + record := BenchmarkRecord{ + PluginID: "", + ArtifactID: req.ArtifactID, + Profile: req.Profile, + BenchmarkProfile: req.BenchmarkProfile, + P95MS: req.P95MS, + P99MS: req.P99MS, + ErrorRate: req.ErrorRate, + ActiveProxyCapacity: req.ActiveProxyCapacity, + BaselineDiff: req.BaselineDiff, + CreatedBy: actor, + CreatedAt: now, + } + artifact, err := r.Artifact(ctx, req.ArtifactID) + if err != nil { + return BenchmarkRecord{}, err + } + record.PluginID = artifact.PluginID + _, err = r.db.ExecContext(ctx, ` +INSERT INTO plugin_benchmarks( + plugin_id, artifact_id, profile, benchmark_profile, p95_ms, p99_ms, error_rate, + active_proxy_capacity, baseline_diff, created_by, created_at +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + record.PluginID, record.ArtifactID, record.Profile, record.BenchmarkProfile, record.P95MS, record.P99MS, record.ErrorRate, + record.ActiveProxyCapacity, record.BaselineDiff, record.CreatedBy, record.CreatedAt) + if err != nil { + return BenchmarkRecord{}, err + } + id, err := lastInsertID(ctx, r.db) + if err != nil { + return BenchmarkRecord{}, err + } + record.ID = id + return record, nil +} + +func (r Repository) ListBenchmarks(ctx context.Context, pluginID string) ([]BenchmarkRecord, error) { + query := ` +SELECT id, plugin_id, artifact_id, profile, benchmark_profile, p95_ms, p99_ms, error_rate, + active_proxy_capacity, baseline_diff, created_by, created_at +FROM plugin_benchmarks` + var args []any + if pluginID != "" { + query += ` WHERE plugin_id = ?` + args = append(args, pluginID) + } + query += ` ORDER BY created_at DESC, id DESC` + rows, err := r.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var records []BenchmarkRecord + for rows.Next() { + record, err := scanBenchmark(rows) + if err != nil { + return nil, err + } + records = append(records, record) + } + return records, rows.Err() +} + +func (r Repository) LatestBenchmark(ctx context.Context, pluginID, artifactID, profile string) (BenchmarkRecord, error) { + row := r.db.QueryRowContext(ctx, ` +SELECT id, plugin_id, artifact_id, profile, benchmark_profile, p95_ms, p99_ms, error_rate, + active_proxy_capacity, baseline_diff, created_by, created_at +FROM plugin_benchmarks +WHERE plugin_id = ? AND artifact_id = ? AND profile = ? +ORDER BY created_at DESC, id DESC +LIMIT 1`, pluginID, artifactID, profile) + benchmark, err := scanBenchmark(row) + if errors.Is(err, sql.ErrNoRows) { + return BenchmarkRecord{}, nil + } + return benchmark, err +} + func (r Repository) RecordOperation(ctx context.Context, pluginID, artifactID, operation, status, actor, message string, metadata any) error { metadataJSON, err := marshalDefaultObject(metadata) if err != nil { @@ -691,6 +1045,55 @@ func scanBuild(row rowScanner) (BuildRecord, error) { return build, err } +func scanReview(row rowScanner) (ReviewRecord, error) { + var review ReviewRecord + err := row.Scan( + &review.ID, &review.PluginID, &review.ArtifactID, &review.Profile, &review.RiskLevel, + &review.ConfigHash, &review.ScopeHash, &review.RolloutHash, &review.RuntimeLimitsHash, + &review.FeaturesHash, &review.PolicyHash, &review.Decision, &review.Notes, &review.ReviewedBy, &review.CreatedAt, + ) + return review, err +} + +func scanWarningOverride(row rowScanner) (WarningOverrideRecord, error) { + var override WarningOverrideRecord + err := row.Scan( + &override.ID, &override.PluginID, &override.ArtifactID, &override.Profile, &override.Action, + &override.PolicyHash, &override.Reason, &override.CreatedBy, &override.ExpiresAt, &override.CreatedAt, + ) + return override, err +} + +func scanAdvisory(row rowScanner) (AdvisoryRecord, error) { + var advisory AdvisoryRecord + err := row.Scan( + &advisory.ID, &advisory.AdvisoryID, &advisory.Status, &advisory.Action, &advisory.ArtifactSHA256, + &advisory.PluginID, &advisory.VersionRange, &advisory.DependencyName, &advisory.DependencyRange, + &advisory.RecommendedAction, &advisory.FixedVersion, &advisory.Mitigation, &advisory.CreatedBy, + &advisory.CreatedAt, &advisory.UpdatedAt, + ) + return advisory, err +} + +func scanPreflight(row rowScanner) (PreflightRecord, error) { + var record PreflightRecord + err := row.Scan( + &record.ID, &record.PluginID, &record.ArtifactID, &record.Profile, &record.Status, + &record.ResultJSON, &record.CreatedBy, &record.CreatedAt, + ) + return record, err +} + +func scanBenchmark(row rowScanner) (BenchmarkRecord, error) { + var record BenchmarkRecord + err := row.Scan( + &record.ID, &record.PluginID, &record.ArtifactID, &record.Profile, &record.BenchmarkProfile, + &record.P95MS, &record.P99MS, &record.ErrorRate, &record.ActiveProxyCapacity, + &record.BaselineDiff, &record.CreatedBy, &record.CreatedAt, + ) + return record, err +} + func marshalDefaultObject(value any) (string, error) { if value == nil { return "{}", nil diff --git a/internal/pluginmanager/types.go b/internal/pluginmanager/types.go index 5daf257..c18d770 100644 --- a/internal/pluginmanager/types.go +++ b/internal/pluginmanager/types.go @@ -53,6 +53,34 @@ const ( RuntimeDisabled = "disabled" RuntimeDraining = "draining" + PolicyProfileDev = "dev" + PolicyProfileStaging = "staging" + PolicyProfileProd = "prod" + + RiskLow = "low" + RiskMedium = "medium" + RiskHigh = "high" + + GateSeverityWarning = "warning" + GateSeverityBlocking = "blocking" + GateSeverityInfo = "info" + + GovernanceActionEnable = "enable" + GovernanceActionRollback = "rollback" + GovernanceActionPromotion = "promotion_apply" + + AdvisoryActionDenylist = "denylist" + AdvisoryActionQuarantine = "quarantine" + AdvisoryActionRevoke = "revoke" + AdvisoryActionMitigate = "mitigate" + + ReviewDecisionApproved = "approved" + ReviewDecisionRejected = "rejected" + + AdvisoryStatusActive = "active" + AdvisoryStatusRevoked = "revoked" + AdvisoryStatusAcked = "acknowledged" + DefaultPriority = 100 DefaultHandlerTimeout = 3 * time.Second DefaultManifestMaxBytes = 256 * 1024 @@ -258,6 +286,196 @@ type SecretRecord struct { UpdatedAt int64 `json:"updated_at"` } +type PolicySnapshot struct { + Profile string `json:"profile"` + WarningOverrideTTLSeconds int64 `json:"warning_override_ttl_seconds"` + ReviewRequiredRisk string `json:"review_required_risk"` + WarnBenchmarkRegression float64 `json:"warn_benchmark_regression"` + BlockBenchmarkRegression float64 `json:"block_benchmark_regression"` + CreatedAt int64 `json:"created_at"` +} + +type GovernanceIssue struct { + Code string `json:"code"` + Severity string `json:"severity"` + Message string `json:"message"` + PluginID string `json:"plugin_id,omitempty"` + ArtifactID string `json:"artifact_id,omitempty"` + Details map[string]any `json:"details,omitempty"` +} + +type GovernanceDecision struct { + OK bool `json:"ok"` + Action string `json:"action"` + Profile string `json:"profile"` + RiskLevel string `json:"risk_level"` + PolicyHash string `json:"policy_hash"` + ReviewRequired bool `json:"review_required"` + WarningOverrideUsed bool `json:"warning_override_used"` + Issues []GovernanceIssue `json:"issues"` + Checks []GovernanceIssue `json:"checks"` + CreatedAt int64 `json:"created_at"` +} + +type ConflictAnalysis struct { + OK bool `json:"ok"` + Issues []GovernanceIssue `json:"issues"` + Plan DispatchPlan `json:"plan"` + CreatedAt int64 `json:"created_at"` +} + +type PreflightCheck struct { + Code string `json:"code"` + Severity string `json:"severity"` + Message string `json:"message"` + Details map[string]any `json:"details,omitempty"` +} + +type PreflightResult struct { + OK bool `json:"ok"` + Profile string `json:"profile"` + Checks []PreflightCheck `json:"checks"` + CreatedAt int64 `json:"created_at"` +} + +type GovernanceStatus struct { + Decision GovernanceDecision `json:"decision"` + Policy PolicySnapshot `json:"policy"` + Reviews []ReviewRecord `json:"reviews"` + WarningOverrides []WarningOverrideRecord `json:"warning_overrides"` + Preflights []PreflightRecord `json:"preflights"` + Benchmarks []BenchmarkRecord `json:"benchmarks"` + Advisories []AdvisoryRecord `json:"advisories"` + Conflicts ConflictAnalysis `json:"conflicts"` +} + +type ReviewRecord struct { + ID int64 `json:"id"` + PluginID string `json:"plugin_id"` + ArtifactID string `json:"artifact_id"` + Profile string `json:"profile"` + RiskLevel string `json:"risk_level"` + ConfigHash string `json:"config_hash"` + ScopeHash string `json:"scope_hash"` + RolloutHash string `json:"rollout_hash"` + RuntimeLimitsHash string `json:"runtime_limits_hash"` + FeaturesHash string `json:"features_hash"` + PolicyHash string `json:"policy_hash"` + Decision string `json:"decision"` + Notes string `json:"notes"` + ReviewedBy string `json:"reviewed_by"` + CreatedAt int64 `json:"created_at"` +} + +type WarningOverrideRecord struct { + ID int64 `json:"id"` + PluginID string `json:"plugin_id"` + ArtifactID string `json:"artifact_id"` + Profile string `json:"profile"` + Action string `json:"action"` + PolicyHash string `json:"policy_hash"` + Reason string `json:"reason"` + CreatedBy string `json:"created_by"` + ExpiresAt int64 `json:"expires_at"` + CreatedAt int64 `json:"created_at"` +} + +type AdvisoryRecord struct { + ID int64 `json:"id"` + AdvisoryID string `json:"advisory_id"` + Status string `json:"status"` + Action string `json:"action"` + ArtifactSHA256 string `json:"artifact_sha256"` + PluginID string `json:"plugin_id"` + VersionRange string `json:"version_range"` + DependencyName string `json:"dependency_name"` + DependencyRange string `json:"dependency_range"` + RecommendedAction string `json:"recommended_action"` + FixedVersion string `json:"fixed_version"` + Mitigation string `json:"mitigation"` + CreatedBy string `json:"created_by"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + +type PreflightRecord struct { + ID int64 `json:"id"` + PluginID string `json:"plugin_id"` + ArtifactID string `json:"artifact_id"` + Profile string `json:"profile"` + Status string `json:"status"` + ResultJSON string `json:"result_json"` + CreatedBy string `json:"created_by"` + CreatedAt int64 `json:"created_at"` +} + +type BenchmarkRecord struct { + ID int64 `json:"id"` + PluginID string `json:"plugin_id"` + ArtifactID string `json:"artifact_id"` + Profile string `json:"profile"` + BenchmarkProfile string `json:"benchmark_profile"` + P95MS float64 `json:"p95_ms"` + P99MS float64 `json:"p99_ms"` + ErrorRate float64 `json:"error_rate"` + ActiveProxyCapacity int64 `json:"active_proxy_capacity"` + BaselineDiff float64 `json:"baseline_diff"` + CreatedBy string `json:"created_by"` + CreatedAt int64 `json:"created_at"` +} + +type GovernanceReviewRequest struct { + ArtifactID string `json:"artifact_id"` + Profile string `json:"profile"` + Decision string `json:"decision"` + Notes string `json:"notes"` +} + +type WarningOverrideRequest struct { + ArtifactID string `json:"artifact_id"` + Profile string `json:"profile"` + Action string `json:"action"` + Reason string `json:"reason"` + TTLSeconds int64 `json:"ttl_seconds"` +} + +type PreflightRequest struct { + ArtifactID string `json:"artifact_id"` + Profile string `json:"profile"` + Action string `json:"action"` + ConfigJSON string `json:"config_json"` +} + +type SelfTestRequest struct { + ArtifactID string `json:"artifact_id"` + Profile string `json:"profile"` +} + +type AdvisoryRequest struct { + AdvisoryID string `json:"advisory_id"` + Status string `json:"status"` + Action string `json:"action"` + ArtifactSHA256 string `json:"artifact_sha256"` + PluginID string `json:"plugin_id"` + VersionRange string `json:"version_range"` + DependencyName string `json:"dependency_name"` + DependencyRange string `json:"dependency_range"` + RecommendedAction string `json:"recommended_action"` + FixedVersion string `json:"fixed_version"` + Mitigation string `json:"mitigation"` +} + +type BenchmarkRequest struct { + ArtifactID string `json:"artifact_id"` + Profile string `json:"profile"` + BenchmarkProfile string `json:"benchmark_profile"` + P95MS float64 `json:"p95_ms"` + P99MS float64 `json:"p99_ms"` + ErrorRate float64 `json:"error_rate"` + ActiveProxyCapacity int64 `json:"active_proxy_capacity"` + BaselineDiff float64 `json:"baseline_diff"` +} + type ProxyConnectionSummary struct { ID uint64 `json:"id"` PluginID string `json:"plugin_id"` diff --git a/plugin/api/api.go b/plugin/api/api.go index 11dbdaf..f5f5aae 100644 --- a/plugin/api/api.go +++ b/plugin/api/api.go @@ -23,6 +23,44 @@ type ( ReloadConfig(config any) error } + PreflightCheck struct { + Code string `json:"code"` + Severity string `json:"severity"` + Message string `json:"message"` + } + + PreflightContext struct { + PluginID string `json:"plugin_id"` + ArtifactID string `json:"artifact_id"` + Profile string `json:"profile"` + Action string `json:"action"` + Config map[string]any `json:"config,omitempty"` + Scope any `json:"scope,omitempty"` + Rollout any `json:"rollout,omitempty"` + RuntimeLimits any `json:"runtime_limits,omitempty"` + Features []string `json:"features,omitempty"` + } + + PreflightResult struct { + Checks []PreflightCheck `json:"checks"` + } + + SelfTestProfile struct { + Name string `json:"name"` + } + + SelfTestResult struct { + Checks []PreflightCheck `json:"checks"` + } + + PreflightChecker interface { + Preflight(context any) (PreflightResult, error) + } + + SelfTester interface { + SelfTest(profile SelfTestProfile) (SelfTestResult, error) + } + Gateway interface { HandleConn(conn net.Conn)