From 6594aece097e19fb135a4e8b0687752880f6ded4 Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Sat, 25 Jul 2026 15:14:23 -0500 Subject: [PATCH] update --- docs/client-api.md | 4 ++ docs/deployment.md | 10 ++- internal/app/audit_test.go | 72 +++++++++++++++++++ internal/app/authorization.go | 112 +++++++++++++++++++++++++++-- internal/app/download.go | 10 +-- internal/app/server.go | 12 ++++ internal/app/static/app.css | 11 +++ internal/app/templates/portal.html | 35 ++++++++- 8 files changed, 248 insertions(+), 18 deletions(-) create mode 100644 internal/app/audit_test.go diff --git a/docs/client-api.md b/docs/client-api.md index 7a32a14..5c24da7 100644 --- a/docs/client-api.md +++ b/docs/client-api.md @@ -43,6 +43,10 @@ Successful response: The first exchange enrolls that host against the authorization. Reusing the same code and host fingerprint does not consume another host slot. A different fingerprint consumes one slot until the configured limit is reached. +Package authorizations grant access by stable package slug. The version, +filename, and checksum in a response are resolved from the current package +catalog when the code is exchanged; replacing a package does not require a new +authorization code. When `requested_action` or `requested_package` is present, the broker validates that entitlement before enrolling the host. Older clients may omit both fields, but current clients should always identify what they intend to use. diff --git a/docs/deployment.md b/docs/deployment.md index c866acf..b2075ab 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -134,9 +134,10 @@ replacement directly to Gitea, calculates SHA-256, updates the catalog, removes the prior Gitea version, and writes audit events without buffering the installer on local disk. -An authorization records the exact version and checksum selected when its code -is created. Revoke and reissue any active code that included a package after -replacing that package, because the superseded registry version is removed. +An authorization grants access to the stable package ID, not a particular +version. Existing active codes and download sessions resolve the package's +current registry version and checksum, so they automatically use a replacement +after it is registered. The load balancer or Nginx must allow request bodies up to `TAPM_MAX_UPLOAD_BYTES` and use a sufficiently long request timeout. The @@ -163,6 +164,9 @@ technician's Gitea credentials for package operations. ## 9. Backup and rotation - Include `tapm_broker` in the existing Galera backup policy. +- Audit records are retained indefinitely unless an administrator establishes a + database retention policy. The portal can filter the retained history and + displays up to the newest 250 matching events. - Keep `.env` outside Git and readable only by the service administrator. - Rotate both Gitea package tokens and the OAuth secret if either webserver is compromised. diff --git a/internal/app/audit_test.go b/internal/app/audit_test.go new file mode 100644 index 0000000..8dcf33f --- /dev/null +++ b/internal/app/audit_test.go @@ -0,0 +1,72 @@ +package app + +import ( + "net/http/httptest" + "reflect" + "strings" + "testing" +) + +func TestAuditFiltersFromRequest(t *testing.T) { + t.Parallel() + request := httptest.NewRequest( + "GET", + "/portal?audit_range=7d&audit_event=package_downloaded&audit_actor=taiadmin&audit_hostname=pve01&audit_package=sentinelone-linux&audit_ip=10.10.1.25&audit_details=install-rmm", + nil, + ) + filters := auditFiltersFromRequest(request) + expected := auditFilters{ + TimeRange: "7d", + EventType: "package_downloaded", + Actor: "taiadmin", + Hostname: "pve01", + PackageSlug: "sentinelone-linux", + SourceIP: "10.10.1.25", + Details: "install-rmm", + } + if !reflect.DeepEqual(filters, expected) { + t.Fatalf("filters = %#v, want %#v", filters, expected) + } +} + +func TestAuditFiltersDefaultToThirtyDays(t *testing.T) { + t.Parallel() + request := httptest.NewRequest("GET", "/portal?audit_range=invalid", nil) + if filters := auditFiltersFromRequest(request); filters.TimeRange != "30d" { + t.Fatalf("time range = %q, want 30d", filters.TimeRange) + } +} + +func TestAuditQueryUsesPlaceholders(t *testing.T) { + t.Parallel() + filters := auditFilters{ + TimeRange: "all", + EventType: "package_downloaded", + Actor: "taiadmin", + Hostname: "pve01", + PackageSlug: "sentinelone-linux", + SourceIP: "10.10.1.25", + Details: "install-rmm", + } + query, arguments := auditQuery(filters) + if strings.Contains(query, filters.Actor) || strings.Contains(query, filters.Hostname) { + t.Fatal("filter values must not be interpolated into the SQL query") + } + if strings.Contains(query, "INTERVAL") { + t.Fatal("all-time query must not include a time restriction") + } + if !strings.HasSuffix(query, "ORDER BY created_at DESC LIMIT 250") { + t.Fatalf("query has unexpected limit: %s", query) + } + expectedArguments := []any{ + "package_downloaded", + "taiadmin", + "pve01", + "sentinelone-linux", + "10.10.1.25", + "install-rmm", + } + if !reflect.DeepEqual(arguments, expectedArguments) { + t.Fatalf("arguments = %#v, want %#v", arguments, expectedArguments) + } +} diff --git a/internal/app/authorization.go b/internal/app/authorization.go index fc94917..7505eea 100644 --- a/internal/app/authorization.go +++ b/internal/app/authorization.go @@ -31,7 +31,13 @@ func (s *Server) handlePortal(w http.ResponseWriter, r *http.Request) { http.Error(w, "unable to list installer actions", http.StatusInternalServerError) return } - auditEvents, err := s.listAuditEvents(r) + auditFilter := auditFiltersFromRequest(r) + auditEventTypes, err := s.listAuditEventTypes(r) + if err != nil { + http.Error(w, "unable to list audit event types", http.StatusInternalServerError) + return + } + auditEvents, err := s.listAuditEvents(r, auditFilter) if err != nil { http.Error(w, "unable to list audit events", http.StatusInternalServerError) return @@ -42,6 +48,8 @@ func (s *Server) handlePortal(w http.ResponseWriter, r *http.Request) { CSRFToken: tech.CSRFToken, Authorizations: authorizations, AuditEvents: auditEvents, + AuditFilters: auditFilter, + AuditEventTypes: auditEventTypes, Packages: packages, Actions: actions, NewCode: r.URL.Query().Get("code"), @@ -74,13 +82,102 @@ func (s *Server) listActions(r *http.Request) ([]actionRecord, error) { return records, rows.Err() } -func (s *Server) listAuditEvents(r *http.Request) ([]auditRecord, error) { +func auditFiltersFromRequest(r *http.Request) auditFilters { + query := r.URL.Query() + timeRange := strings.TrimSpace(query.Get("audit_range")) + switch timeRange { + case "24h", "7d", "30d", "90d", "all": + default: + timeRange = "30d" + } + return auditFilters{ + TimeRange: timeRange, + EventType: limitedFilter(query.Get("audit_event"), 100), + Actor: limitedFilter(query.Get("audit_actor"), 255), + Hostname: limitedFilter(query.Get("audit_hostname"), 255), + PackageSlug: limitedFilter(query.Get("audit_package"), 100), + SourceIP: limitedFilter(query.Get("audit_ip"), 64), + Details: limitedFilter(query.Get("audit_details"), 255), + } +} + +func limitedFilter(value string, maxLength int) string { + value = strings.TrimSpace(value) + characters := []rune(value) + if len(characters) > maxLength { + return string(characters[:maxLength]) + } + return value +} + +func (s *Server) listAuditEventTypes(r *http.Request) ([]string, error) { rows, err := s.db.QueryContext( r.Context(), - `SELECT event_type, actor, hostname, package_slug, source_ip, details, created_at + `SELECT DISTINCT event_type FROM audit_events - ORDER BY created_at DESC - LIMIT 100`, + ORDER BY event_type`, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var eventTypes []string + for rows.Next() { + var eventType string + if err := rows.Scan(&eventType); err != nil { + return nil, err + } + eventTypes = append(eventTypes, eventType) + } + return eventTypes, rows.Err() +} + +func auditQuery(filters auditFilters) (string, []any) { + query := `SELECT event_type, actor, hostname, package_slug, source_ip, details, created_at + FROM audit_events + WHERE 1 = 1` + var arguments []any + + timeClauses := map[string]string{ + "24h": " AND created_at >= UTC_TIMESTAMP(6) - INTERVAL 1 DAY", + "7d": " AND created_at >= UTC_TIMESTAMP(6) - INTERVAL 7 DAY", + "30d": " AND created_at >= UTC_TIMESTAMP(6) - INTERVAL 30 DAY", + "90d": " AND created_at >= UTC_TIMESTAMP(6) - INTERVAL 90 DAY", + } + query += timeClauses[filters.TimeRange] + + if filters.EventType != "" { + query += " AND event_type = ?" + arguments = append(arguments, filters.EventType) + } + containsFilters := []struct { + column string + value string + }{ + {"actor", filters.Actor}, + {"hostname", filters.Hostname}, + {"package_slug", filters.PackageSlug}, + {"source_ip", filters.SourceIP}, + {"details", filters.Details}, + } + for _, filter := range containsFilters { + if filter.value == "" { + continue + } + query += " AND LOCATE(?, " + filter.column + ") > 0" + arguments = append(arguments, filter.value) + } + query += " ORDER BY created_at DESC LIMIT 250" + return query, arguments +} + +func (s *Server) listAuditEvents(r *http.Request, filters auditFilters) ([]auditRecord, error) { + query, arguments := auditQuery(filters) + rows, err := s.db.QueryContext( + r.Context(), + query, + arguments..., ) if err != nil { return nil, err @@ -147,9 +244,10 @@ func (s *Server) listAuthorizations(r *http.Request) ([]authorizationRecord, err WHERE h.authorization_id = a.id), a.created_at, a.expires_at, a.revoked_at, COALESCE(( - SELECT GROUP_CONCAT(ap.display_name - ORDER BY ap.display_name SEPARATOR ', ') + SELECT GROUP_CONCAT(p.display_name + ORDER BY p.display_name SEPARATOR ', ') FROM authorization_packages ap + JOIN packages p ON p.id = ap.package_id WHERE ap.authorization_id = a.id ), ''), COALESCE(( diff --git a/internal/app/download.go b/internal/app/download.go index 6854158..bdb0b43 100644 --- a/internal/app/download.go +++ b/internal/app/download.go @@ -166,7 +166,7 @@ func (s *Server) exchangeDeploymentCode( FROM authorization_packages ap JOIN packages p ON p.id = ap.package_id WHERE ap.authorization_id = ? - AND ap.package_slug = ? + AND p.slug = ? AND p.enabled = TRUE`, authorizationID, request.RequestedPackage, ).Scan(&authorized); err != nil { @@ -241,7 +241,7 @@ func (s *Server) exchangeDeploymentCode( rows, err := tx.QueryContext( r.Context(), - `SELECT ap.package_slug, ap.display_name, ap.package_version, ap.sha256 + `SELECT p.slug, p.display_name, p.package_version, p.sha256 FROM authorization_packages ap JOIN packages p ON p.id = ap.package_id WHERE ap.authorization_id = ? AND p.enabled = TRUE @@ -330,8 +330,8 @@ func (s *Server) handlePackageDownload(w http.ResponseWriter, r *http.Request) { var hostname string err := s.db.QueryRowContext( r.Context(), - `SELECT p.id, ap.package_slug, ap.display_name, ap.package_name, - ap.package_version, ap.file_name, ap.sha256, p.enabled, + `SELECT p.id, p.slug, p.display_name, p.package_name, + p.package_version, p.file_name, p.sha256, p.enabled, ds.authorization_id, ah.hostname FROM download_sessions ds JOIN authorizations a ON a.id = ds.authorization_id @@ -343,7 +343,7 @@ func (s *Server) handlePackageDownload(w http.ResponseWriter, r *http.Request) { AND ds.expires_at > UTC_TIMESTAMP(6) AND a.revoked_at IS NULL AND a.expires_at > UTC_TIMESTAMP(6) - AND ap.package_slug = ? + AND p.slug = ? AND p.enabled = TRUE`, sessionHash[:], slug, ).Scan( diff --git a/internal/app/server.go b/internal/app/server.go index 5353a11..6569c78 100644 --- a/internal/app/server.go +++ b/internal/app/server.go @@ -78,12 +78,24 @@ type auditRecord struct { CreatedAt time.Time } +type auditFilters struct { + TimeRange string + EventType string + Actor string + Hostname string + PackageSlug string + SourceIP string + Details string +} + type pageData struct { Title string Technician *technician CSRFToken string Authorizations []authorizationRecord AuditEvents []auditRecord + AuditFilters auditFilters + AuditEventTypes []string Packages []packageRecord Actions []actionRecord NewCode string diff --git a/internal/app/static/app.css b/internal/app/static/app.css index 4e54d84..1a1393e 100644 --- a/internal/app/static/app.css +++ b/internal/app/static/app.css @@ -218,6 +218,15 @@ dd { margin: 4px 0 0; overflow-wrap: anywhere; } .manual-metadata[open] summary { margin-bottom: 22px; } .audit-panel { margin-top: 24px; } +.audit-filters { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 16px; + padding: 22px 28px; + border-bottom: 1px solid var(--line); +} +.audit-filter-actions { display: flex; align-items: center; gap: 18px; } +.audit-filter-note { grid-column: 1 / -1; } .audit-table { display: grid; max-height: 520px; overflow: auto; } .audit-row { display: grid; @@ -249,6 +258,7 @@ dd { margin: 4px 0 0; overflow-wrap: anywhere; } @media (max-width: 900px) { .hero, .content-grid, .package-layout { grid-template-columns: 1fr; } + .audit-filters { grid-template-columns: 1fr 1fr; } .audit-row { grid-template-columns: 1fr 1fr; } .audit-row > div:last-child { grid-column: 1 / -1; } .policy-card { width: 100%; } @@ -260,5 +270,6 @@ dd { margin: 4px 0 0; overflow-wrap: anywhere; } .product-name, .operator > span { display: none; } .page { width: 94vw; padding-top: 34px; } .field-row, dl { grid-template-columns: 1fr; } + .audit-filters { grid-template-columns: 1fr; } .code-reveal { align-items: stretch; flex-direction: column; } } diff --git a/internal/app/templates/portal.html b/internal/app/templates/portal.html index 4c04572..6fa5c2b 100644 --- a/internal/app/templates/portal.html +++ b/internal/app/templates/portal.html @@ -196,7 +196,7 @@ -

The package ID stays the same. After the replacement is registered, its prior Gitea version is removed. Codes created for the prior version must be revoked and reissued.

+

The package ID stays the same. Existing authorizations automatically use the replacement, and its prior Gitea version is removed.

@@ -236,9 +236,38 @@

Audit trail

-

Latest security events

+

Security events

+
+ + + + + + + +
+ + Clear filters +
+

Audit records are retained indefinitely. Up to the newest 250 matching events are shown.

+
{{range .AuditEvents}}
@@ -257,7 +286,7 @@
{{else}} -

No security events have been recorded.

+

No security events match these filters.

{{end}}