diff --git a/internal/app/authorization.go b/internal/app/authorization.go index ab82907..f6e7acc 100644 --- a/internal/app/authorization.go +++ b/internal/app/authorization.go @@ -787,6 +787,47 @@ func (s *Server) handleUpsertPackage(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/portal/packages?notice=Package+saved", http.StatusSeeOther) } +func (s *Server) handleSetPackageStatus(w http.ResponseWriter, r *http.Request) { + tech, _ := s.currentTechnician(r) + slug := strings.TrimSpace(r.FormValue("slug")) + if !validSlug(slug) { + http.Error(w, "invalid package ID", http.StatusBadRequest) + return + } + enabled := r.FormValue("enabled") == "1" + result, err := s.db.ExecContext( + r.Context(), + `UPDATE packages + SET enabled = ?, + updated_at = CURRENT_TIMESTAMP + WHERE slug = ?`, + enabled, slug, + ) + if err != nil { + http.Error(w, "unable to update package status", http.StatusInternalServerError) + return + } + updated, err := result.RowsAffected() + if err != nil { + http.Error(w, "unable to confirm package status", http.StatusInternalServerError) + return + } + if updated == 0 { + http.Error(w, "package not found", http.StatusNotFound) + return + } + + eventType := "package_disabled" + notice := "Package+distribution+disabled" + if enabled { + eventType = "package_enabled" + notice = "Package+distribution+enabled" + } + _ = s.audit(r.Context(), eventType, tech.Login, nil, "", slug, s.clientIP(r), + fmt.Sprintf("enabled=%t", enabled)) + http.Redirect(w, r, "/portal/packages?notice="+notice, http.StatusSeeOther) +} + func (s *Server) savePackage( r *http.Request, slug string, diff --git a/internal/app/authorization_test.go b/internal/app/authorization_test.go index 4c0710d..4eee08d 100644 --- a/internal/app/authorization_test.go +++ b/internal/app/authorization_test.go @@ -125,6 +125,101 @@ func authorizationUpdateRequest( return response } +func packageStatusRequest( + t *testing.T, + server *Server, + sessionToken string, + slug string, + enabled bool, +) *httptest.ResponseRecorder { + t.Helper() + values := url.Values{ + "csrf_token": {"csrf-token"}, + "slug": {slug}, + } + if enabled { + values.Set("enabled", "1") + } + request := httptest.NewRequest( + http.MethodPost, + "/portal/packages/status", + strings.NewReader(values.Encode()), + ) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + request.AddCookie(&http.Cookie{Name: sessionCookieName, Value: sessionToken}) + response := httptest.NewRecorder() + server.requireTechnician(server.handleSetPackageStatus)(response, request) + return response +} + +func TestSetPackageStatusChangesOnlyDistributionAvailability(t *testing.T) { + server, sessionToken := newAuthorizationTestServer(t) + if _, err := server.db.Exec( + `INSERT INTO packages + (slug, display_name, package_name, package_version, file_name, sha256, enabled) + VALUES ('sentinelone-linux', 'SentinelOne', 'sentinelone-linux', + '26.1.1.31', 'agent.deb', ?, TRUE)`, + strings.Repeat("a", 64), + ); err != nil { + t.Fatal(err) + } + + response := packageStatusRequest(t, server, sessionToken, "sentinelone-linux", false) + if response.Code != http.StatusSeeOther { + t.Fatalf("disable status = %d, want %d: %s", response.Code, http.StatusSeeOther, response.Body.String()) + } + if location := response.Header().Get("Location"); !strings.Contains(location, "disabled") { + t.Fatalf("disable redirect = %q", location) + } + + var version, fileName, sha256 string + var enabled bool + if err := server.db.QueryRow( + `SELECT package_version, file_name, sha256, enabled + FROM packages WHERE slug = 'sentinelone-linux'`, + ).Scan(&version, &fileName, &sha256, &enabled); err != nil { + t.Fatal(err) + } + if enabled || version != "26.1.1.31" || fileName != "agent.deb" || + sha256 != strings.Repeat("a", 64) { + t.Fatalf("disable changed package metadata: enabled=%t version=%q file=%q sha256=%q", enabled, version, fileName, sha256) + } + + response = packageStatusRequest(t, server, sessionToken, "sentinelone-linux", true) + if response.Code != http.StatusSeeOther { + t.Fatalf("enable status = %d, want %d: %s", response.Code, http.StatusSeeOther, response.Body.String()) + } + if err := server.db.QueryRow( + `SELECT enabled FROM packages WHERE slug = 'sentinelone-linux'`, + ).Scan(&enabled); err != nil { + t.Fatal(err) + } + if !enabled { + t.Fatal("package was not re-enabled") + } + + rows, err := server.db.Query( + `SELECT event_type, actor, package_slug, details + FROM audit_events ORDER BY id`, + ) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + for index, expected := range []string{"package_disabled", "package_enabled"} { + if !rows.Next() { + t.Fatalf("missing audit event %d", index) + } + var eventType, actor, slug, details string + if err := rows.Scan(&eventType, &actor, &slug, &details); err != nil { + t.Fatal(err) + } + if eventType != expected || actor != "taiadmin" || slug != "sentinelone-linux" { + t.Fatalf("audit event = %q/%q/%q", eventType, actor, slug) + } + } +} + func TestUpdateAuthorizationExtendsAndReplacesAccess(t *testing.T) { server, sessionToken := newAuthorizationTestServer(t) oldExpiresAt := seedAuthorizationForUpdate(t, server) diff --git a/internal/app/server.go b/internal/app/server.go index 4eb52be..e22cc8f 100644 --- a/internal/app/server.go +++ b/internal/app/server.go @@ -206,6 +206,7 @@ func (s *Server) Routes() http.Handler { mux.HandleFunc("POST /portal/authorizations/{id}/update", s.requireTechnician(s.handleUpdateAuthorization)) mux.HandleFunc("POST /portal/authorizations/{id}/revoke", s.requireTechnician(s.handleRevokeAuthorization)) mux.HandleFunc("POST /portal/packages", s.requireTechnician(s.handleUpsertPackage)) + mux.HandleFunc("POST /portal/packages/status", s.requireTechnician(s.handleSetPackageStatus)) mux.HandleFunc("POST /portal/packages/upload", s.requireTechnician(s.handleUploadPackage)) mux.HandleFunc("POST /api/v1/exchange", s.handleExchange) mux.HandleFunc("POST /api/v1/hosts/register", s.handleFleetRegister) diff --git a/internal/app/server_test.go b/internal/app/server_test.go index 9ba8db2..f1bc4e7 100644 --- a/internal/app/server_test.go +++ b/internal/app/server_test.go @@ -65,9 +65,14 @@ func TestPackageAndAuditTemplatesExecute(t *testing.T) { Enabled: true, }}, } - if err := templates.ExecuteTemplate(io.Discard, "packages.html", data); err != nil { + var packagesOutput bytes.Buffer + if err := templates.ExecuteTemplate(&packagesOutput, "packages.html", data); err != nil { t.Fatal(err) } + if !strings.Contains(packagesOutput.String(), "Save status only") || + !strings.Contains(packagesOutput.String(), `formaction="/portal/packages/status"`) { + t.Fatal("packages page does not include the status-only update control") + } data.CurrentView = "audit" data.AuditFilters.TimeRange = "30d" diff --git a/internal/app/static/app.css b/internal/app/static/app.css index 6dff8cf..8d04eb4 100644 --- a/internal/app/static/app.css +++ b/internal/app/static/app.css @@ -248,6 +248,7 @@ dd { margin: 4px 0 0; overflow-wrap: anywhere; } .package-row div { display: grid; gap: 4px; } .package-row small { color: var(--muted); } .package-row .status { align-items: center; justify-content: center; align-self: center; line-height: 1; text-align: center; } +.package-update-actions { display: flex; flex-wrap: wrap; gap: 12px; } .toggle { display: flex; align-items: center; gap: 9px; } .empty, .fine-print { color: var(--muted); } .package-forms { display: grid; gap: 26px; } diff --git a/internal/app/static/app.js b/internal/app/static/app.js index dcb7dae..40448b4 100644 --- a/internal/app/static/app.js +++ b/internal/app/static/app.js @@ -18,6 +18,7 @@ document.addEventListener("click", async (event) => { document.addEventListener("submit", async (event) => { const form = event.target.closest("[data-upload-form]"); if (!form) return; + if (event.submitter?.matches("[data-package-status]")) return; event.preventDefault(); const button = form.querySelector("button[type=submit]"); @@ -51,3 +52,12 @@ document.addEventListener("submit", async (event) => { button.disabled = false; } }); + +document.querySelectorAll("[data-package-select]").forEach((select) => { + const enabled = select.form.querySelector("[data-package-enabled]"); + const syncPackageStatus = () => { + enabled.checked = select.selectedOptions[0]?.dataset.enabled === "true"; + }; + select.addEventListener("change", syncPackageStatus); + syncPackageStatus(); +}); diff --git a/internal/app/templates/packages.html b/internal/app/templates/packages.html index 63fa8af..0903ab2 100644 --- a/internal/app/templates/packages.html +++ b/internal/app/templates/packages.html @@ -46,17 +46,22 @@
Update a package
- + -The package ID stays the same. Existing authorizations automatically use the replacement, and its prior Gitea version is removed.
- +Replace the installer and version, or save only its distribution status. Disabling takes effect immediately but keeps the package and its authorization selections intact.
+