feat(plugin): add governance release gates
Some checks failed
Go / build (.exe, 386, windows, windows-386) (push) Has been cancelled
Go / build (.exe, amd64, windows, windows-amd64) (push) Has been cancelled
Go / build (.exe, arm64, windows, windows-arm64) (push) Has been cancelled
Go / build (386, freebsd, freebsd-386) (push) Has been cancelled
Go / build (386, linux, linux-386) (push) Has been cancelled
Go / build (386, netbsd, netbsd-386) (push) Has been cancelled
Go / build (386, openbsd, openbsd-386) (push) Has been cancelled
Go / build (386, plan9, plan9-386) (push) Has been cancelled
Go / build (amd64, darwin, darwin-amd64) (push) Has been cancelled
Go / build (amd64, dragonfly, dragonfly-amd64) (push) Has been cancelled
Go / build (amd64, freebsd, freebsd-amd64) (push) Has been cancelled
Go / build (amd64, illumos, illumos-amd64) (push) Has been cancelled
Go / build (amd64, linux, linux-amd64) (push) Has been cancelled
Go / build (amd64, netbsd, netbsd-amd64) (push) Has been cancelled
Go / build (amd64, openbsd, openbsd-amd64) (push) Has been cancelled
Go / build (amd64, plan9, plan9-amd64) (push) Has been cancelled
Go / build (amd64, solaris, solaris-amd64) (push) Has been cancelled
Go / build (arm, 6, linux, linux-armv6) (push) Has been cancelled
Go / build (arm, 7, linux, linux-armv7) (push) Has been cancelled
Go / build (arm, freebsd, freebsd-arm) (push) Has been cancelled
Go / build (arm, netbsd, netbsd-arm) (push) Has been cancelled
Go / build (arm, openbsd, openbsd-arm) (push) Has been cancelled
Go / build (arm, plan9, plan9-arm) (push) Has been cancelled
Go / build (arm64, darwin, darwin-arm64) (push) Has been cancelled
Go / build (arm64, freebsd, freebsd-arm64) (push) Has been cancelled
Go / build (arm64, linux, linux-arm64) (push) Has been cancelled
Go / build (arm64, netbsd, netbsd-arm64) (push) Has been cancelled
Go / build (arm64, openbsd, openbsd-arm64) (push) Has been cancelled
Go / build (loong64, linux, linux-loong64) (push) Has been cancelled
Go / build (mips, linux, linux-mips) (push) Has been cancelled
Go / build (mips64, linux, linux-mips64) (push) Has been cancelled
Go / build (mips64le, linux, linux-mips64le) (push) Has been cancelled
Go / build (mipsle, linux, linux-mipsle) (push) Has been cancelled
Go / build (ppc64, aix, aix-ppc64) (push) Has been cancelled
Go / build (ppc64, linux, linux-ppc64) (push) Has been cancelled
Go / build (ppc64, openbsd, openbsd-ppc64) (push) Has been cancelled
Go / build (ppc64le, linux, linux-ppc64le) (push) Has been cancelled
Go / build (riscv64, freebsd, freebsd-riscv64) (push) Has been cancelled
Go / build (riscv64, linux, linux-riscv64) (push) Has been cancelled
Go / build (riscv64, openbsd, openbsd-riscv64) (push) Has been cancelled
Go / build (s390x, linux, linux-s390x) (push) Has been cancelled
Go / merge-artifacts (push) Has been cancelled
Docker Image / docker (push) Has been cancelled
Some checks failed
Go / build (.exe, 386, windows, windows-386) (push) Has been cancelled
Go / build (.exe, amd64, windows, windows-amd64) (push) Has been cancelled
Go / build (.exe, arm64, windows, windows-arm64) (push) Has been cancelled
Go / build (386, freebsd, freebsd-386) (push) Has been cancelled
Go / build (386, linux, linux-386) (push) Has been cancelled
Go / build (386, netbsd, netbsd-386) (push) Has been cancelled
Go / build (386, openbsd, openbsd-386) (push) Has been cancelled
Go / build (386, plan9, plan9-386) (push) Has been cancelled
Go / build (amd64, darwin, darwin-amd64) (push) Has been cancelled
Go / build (amd64, dragonfly, dragonfly-amd64) (push) Has been cancelled
Go / build (amd64, freebsd, freebsd-amd64) (push) Has been cancelled
Go / build (amd64, illumos, illumos-amd64) (push) Has been cancelled
Go / build (amd64, linux, linux-amd64) (push) Has been cancelled
Go / build (amd64, netbsd, netbsd-amd64) (push) Has been cancelled
Go / build (amd64, openbsd, openbsd-amd64) (push) Has been cancelled
Go / build (amd64, plan9, plan9-amd64) (push) Has been cancelled
Go / build (amd64, solaris, solaris-amd64) (push) Has been cancelled
Go / build (arm, 6, linux, linux-armv6) (push) Has been cancelled
Go / build (arm, 7, linux, linux-armv7) (push) Has been cancelled
Go / build (arm, freebsd, freebsd-arm) (push) Has been cancelled
Go / build (arm, netbsd, netbsd-arm) (push) Has been cancelled
Go / build (arm, openbsd, openbsd-arm) (push) Has been cancelled
Go / build (arm, plan9, plan9-arm) (push) Has been cancelled
Go / build (arm64, darwin, darwin-arm64) (push) Has been cancelled
Go / build (arm64, freebsd, freebsd-arm64) (push) Has been cancelled
Go / build (arm64, linux, linux-arm64) (push) Has been cancelled
Go / build (arm64, netbsd, netbsd-arm64) (push) Has been cancelled
Go / build (arm64, openbsd, openbsd-arm64) (push) Has been cancelled
Go / build (loong64, linux, linux-loong64) (push) Has been cancelled
Go / build (mips, linux, linux-mips) (push) Has been cancelled
Go / build (mips64, linux, linux-mips64) (push) Has been cancelled
Go / build (mips64le, linux, linux-mips64le) (push) Has been cancelled
Go / build (mipsle, linux, linux-mipsle) (push) Has been cancelled
Go / build (ppc64, aix, aix-ppc64) (push) Has been cancelled
Go / build (ppc64, linux, linux-ppc64) (push) Has been cancelled
Go / build (ppc64, openbsd, openbsd-ppc64) (push) Has been cancelled
Go / build (ppc64le, linux, linux-ppc64le) (push) Has been cancelled
Go / build (riscv64, freebsd, freebsd-riscv64) (push) Has been cancelled
Go / build (riscv64, linux, linux-riscv64) (push) Has been cancelled
Go / build (riscv64, openbsd, openbsd-riscv64) (push) Has been cancelled
Go / build (s390x, linux, linux-s390x) (push) Has been cancelled
Go / merge-artifacts (push) Has been cancelled
Docker Image / docker (push) Has been cancelled
This commit is contained in:
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
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<string, unknown>;
|
||||
reviews?: Record<string, unknown>[];
|
||||
warning_overrides?: Record<string, unknown>[];
|
||||
preflights?: Record<string, unknown>[];
|
||||
benchmarks?: Record<string, unknown>[];
|
||||
advisories?: Record<string, unknown>[];
|
||||
conflicts?: {
|
||||
ok: boolean;
|
||||
issues?: GovernanceIssue[];
|
||||
plan?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PluginView {
|
||||
id: string;
|
||||
name?: string;
|
||||
@@ -151,6 +187,8 @@ export interface PluginView {
|
||||
manifest?: Record<string, unknown>;
|
||||
active_proxy_connections?: number;
|
||||
proxy_connections?: PluginProxyConnection[];
|
||||
governance?: GovernanceStatus;
|
||||
governance_error?: string;
|
||||
updated_at?: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -167,6 +167,10 @@ export function renderPluginDetail(plugin: PluginView | null = selectedPlugin())
|
||||
<dt>Minecraft</dt><dd>${escapeHTML(formatJSON(plugin.minecraft))}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h3>Governance</h3>
|
||||
${governancePanel(plugin, canWrite)}
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h3>Config</h3>
|
||||
<textarea id="pluginConfigEditor" ${canWrite ? "" : "readonly"}>${escapeHTML(prettyJSON(plugin.config_json || "{}"))}</textarea>
|
||||
@@ -262,6 +266,12 @@ function bindPluginDetailEvents(plugin: PluginView): void {
|
||||
document.querySelectorAll<HTMLButtonElement>("[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<void> {
|
||||
@@ -413,6 +423,117 @@ async function showSnapshotDiff(pluginID: string, snapshotID: number): Promise<v
|
||||
}
|
||||
}
|
||||
|
||||
async function createGovernanceReview(plugin: PluginView): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
try {
|
||||
const data = await api<Record<string, unknown>>(`/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<void> {
|
||||
try {
|
||||
const data = await api<Record<string, unknown>>(`/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<void> {
|
||||
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<void> {
|
||||
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 `<div class="alert inline-alert">${escapeHTML(plugin.governance_error)}</div>`;
|
||||
}
|
||||
const governance = plugin.governance;
|
||||
const decision = governance?.decision;
|
||||
const issues = decision?.issues || [];
|
||||
return `
|
||||
<dl class="kv">
|
||||
<dt>Profile</dt><dd>${escapeHTML(decision?.profile || "")}</dd>
|
||||
<dt>Risk</dt><dd>${escapeHTML(decision?.risk_level || "")}</dd>
|
||||
<dt>Policy</dt><dd>${escapeHTML(shortID(decision?.policy_hash || ""))}</dd>
|
||||
<dt>Decision</dt><dd>${badge(decision?.ok ? "allowed" : "blocked", !decision?.ok)}</dd>
|
||||
<dt>Review</dt><dd>${badge(decision?.review_required ? "required" : "not required", Boolean(decision?.review_required))}</dd>
|
||||
<dt>Override</dt><dd>${badge(decision?.warning_override_used ? "used" : "not used", Boolean(decision?.warning_override_used))}</dd>
|
||||
</dl>
|
||||
${issues.length ? `<div class="mini-list">${issues.map((issue) => `
|
||||
<div class="mini-row">
|
||||
<span>${badge(issue.severity, issue.severity !== "info")}</span>
|
||||
<span>${escapeHTML(issue.code)}</span>
|
||||
<span>${escapeHTML(issue.message)}</span>
|
||||
</div>
|
||||
`).join("")}</div>` : `<div class="empty">No governance issues</div>`}
|
||||
${canWrite ? `
|
||||
<div class="row-actions">
|
||||
<button class="secondary" type="button" id="pluginGovernanceReviewBtn">Review</button>
|
||||
<button class="secondary" type="button" id="pluginGovernanceOverrideBtn">Override</button>
|
||||
<button class="secondary" type="button" id="pluginGovernancePreflightBtn">Preflight</button>
|
||||
<button class="secondary" type="button" id="pluginGovernanceSelfTestBtn">Self-test</button>
|
||||
<button class="secondary" type="button" id="pluginGovernanceBenchmarkBtn">Benchmark</button>
|
||||
<button class="danger" type="button" id="pluginGovernanceAdvisoryBtn">Revoke artifact</button>
|
||||
</div>
|
||||
` : ""}
|
||||
<pre class="log-output">${escapeHTML(formatJSON({
|
||||
conflicts: governance?.conflicts,
|
||||
reviews: governance?.reviews || [],
|
||||
warning_overrides: governance?.warning_overrides || [],
|
||||
preflights: governance?.preflights || [],
|
||||
benchmarks: governance?.benchmarks || [],
|
||||
advisories: governance?.advisories || [],
|
||||
}))}</pre>
|
||||
`;
|
||||
}
|
||||
|
||||
function artifactList(artifacts: PluginArtifact[], plugin: PluginView): string {
|
||||
if (artifacts.length === 0) {
|
||||
return `<div class="empty">No artifacts</div>`;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
Reference in New Issue
Block a user