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 The first exchange enrolls that host against the authorization. Reusing the
same code and host fingerprint does not consume another host slot. A different same code and host fingerprint does not consume another host slot. A different
fingerprint consumes one slot until the configured limit is reached. 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 When `requested_action` or `requested_package` is present, the broker validates
that entitlement before enrolling the host. Older clients may omit both fields, that entitlement before enrolling the host. Older clients may omit both fields,
but current clients should always identify what they intend to use. 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 the prior Gitea version, and writes audit events without buffering the installer
on local disk. on local disk.
An authorization records the exact version and checksum selected when its code An authorization grants access to the stable package ID, not a particular
is created. Revoke and reissue any active code that included a package after version. Existing active codes and download sessions resolve the package's
replacing that package, because the superseded registry version is removed. 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 The load balancer or Nginx must allow request bodies up to
`TAPM_MAX_UPLOAD_BYTES` and use a sufficiently long request timeout. The `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 ## 9. Backup and rotation
- Include `tapm_broker` in the existing Galera backup policy. - 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. - Keep `.env` outside Git and readable only by the service administrator.
- Rotate both Gitea package tokens and the OAuth secret if either webserver is - Rotate both Gitea package tokens and the OAuth secret if either webserver is
compromised. 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) http.Error(w, "unable to list installer actions", http.StatusInternalServerError)
return 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 { if err != nil {
http.Error(w, "unable to list audit events", http.StatusInternalServerError) http.Error(w, "unable to list audit events", http.StatusInternalServerError)
return return
@@ -42,6 +48,8 @@ func (s *Server) handlePortal(w http.ResponseWriter, r *http.Request) {
CSRFToken: tech.CSRFToken, CSRFToken: tech.CSRFToken,
Authorizations: authorizations, Authorizations: authorizations,
AuditEvents: auditEvents, AuditEvents: auditEvents,
AuditFilters: auditFilter,
AuditEventTypes: auditEventTypes,
Packages: packages, Packages: packages,
Actions: actions, Actions: actions,
NewCode: r.URL.Query().Get("code"), NewCode: r.URL.Query().Get("code"),
@@ -74,13 +82,102 @@ func (s *Server) listActions(r *http.Request) ([]actionRecord, error) {
return records, rows.Err() 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( rows, err := s.db.QueryContext(
r.Context(), r.Context(),
`SELECT event_type, actor, hostname, package_slug, source_ip, details, created_at `SELECT DISTINCT event_type
FROM audit_events FROM audit_events
ORDER BY created_at DESC ORDER BY event_type`,
LIMIT 100`, )
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 { if err != nil {
return nil, err return nil, err
@@ -147,9 +244,10 @@ func (s *Server) listAuthorizations(r *http.Request) ([]authorizationRecord, err
WHERE h.authorization_id = a.id), WHERE h.authorization_id = a.id),
a.created_at, a.expires_at, a.revoked_at, a.created_at, a.expires_at, a.revoked_at,
COALESCE(( COALESCE((
SELECT GROUP_CONCAT(ap.display_name SELECT GROUP_CONCAT(p.display_name
ORDER BY ap.display_name SEPARATOR ', ') ORDER BY p.display_name SEPARATOR ', ')
FROM authorization_packages ap FROM authorization_packages ap
JOIN packages p ON p.id = ap.package_id
WHERE ap.authorization_id = a.id WHERE ap.authorization_id = a.id
), ''), ), ''),
COALESCE(( COALESCE((
+5 -5
View File
@@ -166,7 +166,7 @@ func (s *Server) exchangeDeploymentCode(
FROM authorization_packages ap FROM authorization_packages ap
JOIN packages p ON p.id = ap.package_id JOIN packages p ON p.id = ap.package_id
WHERE ap.authorization_id = ? WHERE ap.authorization_id = ?
AND ap.package_slug = ? AND p.slug = ?
AND p.enabled = TRUE`, AND p.enabled = TRUE`,
authorizationID, request.RequestedPackage, authorizationID, request.RequestedPackage,
).Scan(&authorized); err != nil { ).Scan(&authorized); err != nil {
@@ -241,7 +241,7 @@ func (s *Server) exchangeDeploymentCode(
rows, err := tx.QueryContext( rows, err := tx.QueryContext(
r.Context(), 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 FROM authorization_packages ap
JOIN packages p ON p.id = ap.package_id JOIN packages p ON p.id = ap.package_id
WHERE ap.authorization_id = ? AND p.enabled = TRUE 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 var hostname string
err := s.db.QueryRowContext( err := s.db.QueryRowContext(
r.Context(), r.Context(),
`SELECT p.id, ap.package_slug, ap.display_name, ap.package_name, `SELECT p.id, p.slug, p.display_name, p.package_name,
ap.package_version, ap.file_name, ap.sha256, p.enabled, p.package_version, p.file_name, p.sha256, p.enabled,
ds.authorization_id, ah.hostname ds.authorization_id, ah.hostname
FROM download_sessions ds FROM download_sessions ds
JOIN authorizations a ON a.id = ds.authorization_id 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 ds.expires_at > UTC_TIMESTAMP(6)
AND a.revoked_at IS NULL AND a.revoked_at IS NULL
AND a.expires_at > UTC_TIMESTAMP(6) AND a.expires_at > UTC_TIMESTAMP(6)
AND ap.package_slug = ? AND p.slug = ?
AND p.enabled = TRUE`, AND p.enabled = TRUE`,
sessionHash[:], slug, sessionHash[:], slug,
).Scan( ).Scan(
+12
View File
@@ -78,12 +78,24 @@ type auditRecord struct {
CreatedAt time.Time CreatedAt time.Time
} }
type auditFilters struct {
TimeRange string
EventType string
Actor string
Hostname string
PackageSlug string
SourceIP string
Details string
}
type pageData struct { type pageData struct {
Title string Title string
Technician *technician Technician *technician
CSRFToken string CSRFToken string
Authorizations []authorizationRecord Authorizations []authorizationRecord
AuditEvents []auditRecord AuditEvents []auditRecord
AuditFilters auditFilters
AuditEventTypes []string
Packages []packageRecord Packages []packageRecord
Actions []actionRecord Actions []actionRecord
NewCode string NewCode string
+11
View File
@@ -218,6 +218,15 @@ dd { margin: 4px 0 0; overflow-wrap: anywhere; }
.manual-metadata[open] summary { margin-bottom: 22px; } .manual-metadata[open] summary { margin-bottom: 22px; }
.audit-panel { margin-top: 24px; } .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-table { display: grid; max-height: 520px; overflow: auto; }
.audit-row { .audit-row {
display: grid; display: grid;
@@ -249,6 +258,7 @@ dd { margin: 4px 0 0; overflow-wrap: anywhere; }
@media (max-width: 900px) { @media (max-width: 900px) {
.hero, .content-grid, .package-layout { grid-template-columns: 1fr; } .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 { grid-template-columns: 1fr 1fr; }
.audit-row > div:last-child { grid-column: 1 / -1; } .audit-row > div:last-child { grid-column: 1 / -1; }
.policy-card { width: 100%; } .policy-card { width: 100%; }
@@ -260,5 +270,6 @@ dd { margin: 4px 0 0; overflow-wrap: anywhere; }
.product-name, .operator > span { display: none; } .product-name, .operator > span { display: none; }
.page { width: 94vw; padding-top: 34px; } .page { width: 94vw; padding-top: 34px; }
.field-row, dl { grid-template-columns: 1fr; } .field-row, dl { grid-template-columns: 1fr; }
.audit-filters { grid-template-columns: 1fr; }
.code-reveal { align-items: stretch; flex-direction: column; } .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>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 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> <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> <button class="button primary" type="submit">Replace current package</button>
<p class="upload-status" data-upload-status aria-live="polite"></p> <p class="upload-status" data-upload-status aria-live="polite"></p>
</form> </form>
@@ -236,9 +236,38 @@
<div class="panel-heading"> <div class="panel-heading">
<div> <div>
<p class="eyebrow">Audit trail</p> <p class="eyebrow">Audit trail</p>
<h2>Latest security events</h2> <h2>Security events</h2>
</div> </div>
</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"> <div class="audit-table">
{{range .AuditEvents}} {{range .AuditEvents}}
<div class="audit-row"> <div class="audit-row">
@@ -257,7 +286,7 @@
</div> </div>
</div> </div>
{{else}} {{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}} {{end}}
</div> </div>
</section> </section>