This commit is contained in:
David Schroeder
2026-07-25 15:14:23 -05:00
parent e19b23de81
commit 6594aece09
8 changed files with 248 additions and 18 deletions
+4
View File
@@ -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.
+7 -3
View File
@@ -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.
+72
View File
@@ -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)
}
}
+105 -7
View File
@@ -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((
+5 -5
View File
@@ -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(
+12
View File
@@ -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
+11
View File
@@ -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; }
}
+32 -3
View File
@@ -196,7 +196,7 @@
<label>New version <input name="package_version" placeholder="26.2.0.10" required></label>
<label class="toggle"><input type="checkbox" name="enabled" value="1" checked> Enable after upload</label>
<label>Replacement installer <input name="package_file" type="file" required></label>
<p class="form-help">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.</p>
<p class="form-help">The package ID stays the same. Existing authorizations automatically use the replacement, and its prior Gitea version is removed.</p>
<button class="button primary" type="submit">Replace current package</button>
<p class="upload-status" data-upload-status aria-live="polite"></p>
</form>
@@ -236,9 +236,38 @@
<div class="panel-heading">
<div>
<p class="eyebrow">Audit trail</p>
<h2>Latest security events</h2>
<h2>Security events</h2>
</div>
</div>
<form action="/portal" method="get" class="audit-filters">
<label>Time range
<select name="audit_range">
<option value="24h" {{if eq .AuditFilters.TimeRange "24h"}}selected{{end}}>Past 24 hours</option>
<option value="7d" {{if eq .AuditFilters.TimeRange "7d"}}selected{{end}}>Past 7 days</option>
<option value="30d" {{if eq .AuditFilters.TimeRange "30d"}}selected{{end}}>Past 30 days</option>
<option value="90d" {{if eq .AuditFilters.TimeRange "90d"}}selected{{end}}>Past 90 days</option>
<option value="all" {{if eq .AuditFilters.TimeRange "all"}}selected{{end}}>All retained events</option>
</select>
</label>
<label>Event type
<select name="audit_event">
<option value="">All event types</option>
{{range .AuditEventTypes}}
<option value="{{.}}" {{if eq $.AuditFilters.EventType .}}selected{{end}}>{{.}}</option>
{{end}}
</select>
</label>
<label>Technician / actor <input name="audit_actor" value="{{.AuditFilters.Actor}}" placeholder="taiadmin"></label>
<label>Hostname <input name="audit_hostname" value="{{.AuditFilters.Hostname}}" placeholder="pve01"></label>
<label>Package ID <input name="audit_package" value="{{.AuditFilters.PackageSlug}}" placeholder="sentinelone-linux"></label>
<label>Source IP <input name="audit_ip" value="{{.AuditFilters.SourceIP}}" placeholder="10.10.1.25"></label>
<label>Details contain <input name="audit_details" value="{{.AuditFilters.Details}}" placeholder="customer or action"></label>
<div class="audit-filter-actions">
<button class="button secondary" type="submit">Apply filters</button>
<a class="text-button" href="/portal?audit_range=30d">Clear filters</a>
</div>
<p class="form-help audit-filter-note">Audit records are retained indefinitely. Up to the newest 250 matching events are shown.</p>
</form>
<div class="audit-table">
{{range .AuditEvents}}
<div class="audit-row">
@@ -257,7 +286,7 @@
</div>
</div>
{{else}}
<p class="empty audit-empty">No security events have been recorded.</p>
<p class="empty audit-empty">No security events match these filters.</p>
{{end}}
</div>
</section>