diff --git a/docs/deployment.md b/docs/deployment.md index b2075ab..83ba85a 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -165,8 +165,9 @@ technician's Gitea credentials for package operations. - 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. + database retention policy. The Codes view previews the newest 15 records; the + Audit view 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 index 8dcf33f..8ec6754 100644 --- a/internal/app/audit_test.go +++ b/internal/app/audit_test.go @@ -37,6 +37,16 @@ func TestAuditFiltersDefaultToThirtyDays(t *testing.T) { } } +func TestHasAuditQuery(t *testing.T) { + t.Parallel() + if !hasAuditQuery(httptest.NewRequest("GET", "/portal?audit_hostname=pve01", nil)) { + t.Fatal("expected audit query to be detected") + } + if hasAuditQuery(httptest.NewRequest("GET", "/portal?notice=updated", nil)) { + t.Fatal("non-audit query must stay on the Codes view") + } +} + func TestAuditQueryUsesPlaceholders(t *testing.T) { t.Parallel() filters := auditFilters{ @@ -48,14 +58,14 @@ func TestAuditQueryUsesPlaceholders(t *testing.T) { SourceIP: "10.10.1.25", Details: "install-rmm", } - query, arguments := auditQuery(filters) + query, arguments := auditQuery(filters, 250) 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") { + if !strings.HasSuffix(query, "ORDER BY created_at DESC LIMIT ?") { t.Fatalf("query has unexpected limit: %s", query) } expectedArguments := []any{ @@ -65,6 +75,7 @@ func TestAuditQueryUsesPlaceholders(t *testing.T) { "sentinelone-linux", "10.10.1.25", "install-rmm", + 250, } 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 7505eea..6775799 100644 --- a/internal/app/authorization.go +++ b/internal/app/authorization.go @@ -11,6 +11,10 @@ import ( ) func (s *Server) handlePortal(w http.ResponseWriter, r *http.Request) { + if hasAuditQuery(r) { + http.Redirect(w, r, "/portal/audit?"+r.URL.RawQuery, http.StatusSeeOther) + return + } tech, err := s.currentTechnician(r) if err != nil { http.Redirect(w, r, "/", http.StatusSeeOther) @@ -31,13 +35,7 @@ func (s *Server) handlePortal(w http.ResponseWriter, r *http.Request) { http.Error(w, "unable to list installer actions", http.StatusInternalServerError) return } - 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) + auditEvents, err := s.listAuditEvents(r, auditFilters{TimeRange: "all"}, 15) if err != nil { http.Error(w, "unable to list audit events", http.StatusInternalServerError) return @@ -48,14 +46,80 @@ 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"), DefaultHostLimit: s.cfg.DefaultHostLimit, DefaultDuration: strconv.Itoa(int(s.cfg.DefaultDuration.Hours())), Notice: r.URL.Query().Get("notice"), + CurrentView: "codes", + }) +} + +func hasAuditQuery(r *http.Request) bool { + query := r.URL.Query() + for _, field := range []string{ + "audit_range", + "audit_event", + "audit_actor", + "audit_hostname", + "audit_package", + "audit_ip", + "audit_details", + } { + if query.Has(field) { + return true + } + } + return false +} + +func (s *Server) handlePackagesPortal(w http.ResponseWriter, r *http.Request) { + tech, err := s.currentTechnician(r) + if err != nil { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + packages, err := s.listPackages(r) + if err != nil { + http.Error(w, "unable to list packages", http.StatusInternalServerError) + return + } + s.render(w, "packages.html", pageData{ + Title: "Protected Packages", + Technician: tech, + CSRFToken: tech.CSRFToken, + Packages: packages, + Notice: r.URL.Query().Get("notice"), + CurrentView: "packages", + }) +} + +func (s *Server) handleAuditPortal(w http.ResponseWriter, r *http.Request) { + tech, err := s.currentTechnician(r) + if err != nil { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + 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, 250) + if err != nil { + http.Error(w, "unable to list audit events", http.StatusInternalServerError) + return + } + s.render(w, "audit.html", pageData{ + Title: "Audit Trail", + Technician: tech, + CSRFToken: tech.CSRFToken, + AuditEvents: auditEvents, + AuditFilters: auditFilter, + AuditEventTypes: auditEventTypes, + CurrentView: "audit", }) } @@ -133,7 +197,7 @@ func (s *Server) listAuditEventTypes(r *http.Request) ([]string, error) { return eventTypes, rows.Err() } -func auditQuery(filters auditFilters) (string, []any) { +func auditQuery(filters auditFilters, limit int) (string, []any) { query := `SELECT event_type, actor, hostname, package_slug, source_ip, details, created_at FROM audit_events WHERE 1 = 1` @@ -168,12 +232,13 @@ func auditQuery(filters auditFilters) (string, []any) { query += " AND LOCATE(?, " + filter.column + ") > 0" arguments = append(arguments, filter.value) } - query += " ORDER BY created_at DESC LIMIT 250" + query += " ORDER BY created_at DESC LIMIT ?" + arguments = append(arguments, limit) return query, arguments } -func (s *Server) listAuditEvents(r *http.Request, filters auditFilters) ([]auditRecord, error) { - query, arguments := auditQuery(filters) +func (s *Server) listAuditEvents(r *http.Request, filters auditFilters, limit int) ([]auditRecord, error) { + query, arguments := auditQuery(filters, limit) rows, err := s.db.QueryContext( r.Context(), query, @@ -469,7 +534,7 @@ func (s *Server) handleUpsertPackage(w http.ResponseWriter, r *http.Request) { } _ = s.audit(r.Context(), "package_saved", tech.Login, nil, "", values["slug"], s.clientIP(r), fmt.Sprintf("version=%s enabled=%t", values["package_version"], enabled)) - http.Redirect(w, r, "/portal?notice=Package+saved", http.StatusSeeOther) + http.Redirect(w, r, "/portal/packages?notice=Package+saved", http.StatusSeeOther) } func (s *Server) savePackage( diff --git a/internal/app/server.go b/internal/app/server.go index 6569c78..7caf902 100644 --- a/internal/app/server.go +++ b/internal/app/server.go @@ -104,6 +104,7 @@ type pageData struct { Error string Notice string GiteaLoginURL string + CurrentView string } func New(cfg Config) (*Server, error) { @@ -182,6 +183,8 @@ func (s *Server) Routes() http.Handler { mux.HandleFunc("GET /auth/callback", s.handleCallback) mux.HandleFunc("POST /auth/logout", s.handleLogout) mux.HandleFunc("GET /portal", s.requireTechnician(s.handlePortal)) + mux.HandleFunc("GET /portal/packages", s.requireTechnician(s.handlePackagesPortal)) + mux.HandleFunc("GET /portal/audit", s.requireTechnician(s.handleAuditPortal)) mux.HandleFunc("POST /portal/authorizations", s.requireTechnician(s.handleCreateAuthorization)) mux.HandleFunc("POST /portal/authorizations/{id}/revoke", s.requireTechnician(s.handleRevokeAuthorization)) mux.HandleFunc("POST /portal/packages", s.requireTechnician(s.handleUpsertPackage)) diff --git a/internal/app/server_test.go b/internal/app/server_test.go index 30713d5..ea3d6e6 100644 --- a/internal/app/server_test.go +++ b/internal/app/server_test.go @@ -39,3 +39,39 @@ func TestPortalTemplateExecutes(t *testing.T) { t.Fatal(err) } } + +func TestPackageAndAuditTemplatesExecute(t *testing.T) { + t.Parallel() + templates, err := parseTemplates() + if err != nil { + t.Fatal(err) + } + data := pageData{ + Title: "Test", + Technician: &technician{DisplayName: "Technician"}, + CSRFToken: "csrf", + CurrentView: "packages", + Packages: []packageRecord{{ + ID: 1, + Slug: "sentinelone-linux", + DisplayName: "SentinelOne Linux Agent", + PackageVersion: "26.1.1.31", + Enabled: true, + }}, + } + if err := templates.ExecuteTemplate(io.Discard, "packages.html", data); err != nil { + t.Fatal(err) + } + + data.CurrentView = "audit" + data.AuditFilters.TimeRange = "30d" + data.AuditEventTypes = []string{"package_downloaded"} + data.AuditEvents = []auditRecord{{ + EventType: "package_downloaded", + Hostname: "pve01", + CreatedAt: time.Now(), + }} + if err := templates.ExecuteTemplate(io.Discard, "audit.html", data); err != nil { + t.Fatal(err) + } +} diff --git a/internal/app/static/app.css b/internal/app/static/app.css index 1a1393e..cd2594d 100644 --- a/internal/app/static/app.css +++ b/internal/app/static/app.css @@ -49,6 +49,20 @@ button:disabled { cursor: wait; opacity: .58; } } .product-name, .operator { color: var(--muted); } +.brand { white-space: nowrap; } +.portal-nav { display: flex; align-self: stretch; gap: 6px; } +.portal-nav a { + display: flex; + align-items: center; + padding: 0 16px; + color: var(--muted); + border-bottom: 2px solid transparent; + font-size: .82rem; + font-weight: 800; + text-decoration: none; +} +.portal-nav a:hover { color: var(--ink); } +.portal-nav a.active { color: var(--accent); border-bottom-color: var(--accent); } .operator { display: flex; align-items: center; gap: 18px; } .operator form { margin: 0; } @@ -70,6 +84,9 @@ h1, h2, p { margin-top: 0; } h1 { max-width: 760px; margin-bottom: 14px; font-size: clamp(2.5rem, 6vw, 5.7rem); line-height: .94; letter-spacing: -.055em; } h2 { margin-bottom: 0; font-size: 1.4rem; letter-spacing: -.02em; } .hero p:not(.eyebrow) { max-width: 650px; color: var(--muted); font-size: 1.1rem; line-height: 1.65; } +.view-heading { margin-bottom: 38px; } +.view-heading h1 { font-size: clamp(2.4rem, 5vw, 4.8rem); } +.view-heading p:not(.eyebrow) { max-width: 760px; margin-bottom: 0; color: var(--muted); font-size: 1.05rem; line-height: 1.65; } .eyebrow { margin-bottom: 10px; @@ -106,6 +123,7 @@ h2 { margin-bottom: 0; font-size: 1.4rem; letter-spacing: -.02em; } } .panel-heading { padding: 26px 28px 20px; border-bottom: 1px solid var(--line); } +.panel-heading-action { display: flex; align-items: center; justify-content: space-between; gap: 24px; } .stack { display: grid; gap: 18px; padding: 26px 28px 30px; } .stack.compact { padding: 0; } @@ -161,6 +179,7 @@ legend { margin-bottom: 8px; } .text-button.danger { color: var(--danger); } .authorization-list { display: grid; max-height: 690px; overflow: auto; } +.authorization-empty { padding: 22px 26px; } .authorization { padding: 21px 26px; border-bottom: 1px solid var(--line); } .authorization:last-child { border-bottom: 0; } .authorization.closed { opacity: .64; } @@ -210,6 +229,7 @@ dd { margin: 4px 0 0; overflow-wrap: anywhere; } .toggle { display: flex; align-items: center; gap: 9px; } .empty, .fine-print { color: var(--muted); } .package-forms { display: grid; gap: 26px; } +.form-section { padding-top: 26px !important; border-top: 1px solid var(--line); } .form-title { margin: 0; color: var(--ink); font-weight: 850; } .form-help { margin: 0; color: var(--muted); font-size: .82rem; line-height: 1.55; } .upload-status { min-height: 1.2em; margin: 0; color: var(--amber); overflow-wrap: anywhere; } @@ -257,6 +277,8 @@ dd { margin: 4px 0 0; overflow-wrap: anywhere; } .fine-print { margin: 22px 0 0; font-size: .76rem; text-align: center; } @media (max-width: 900px) { + .topbar { padding: 0 3vw; } + .product-name { display: none; } .hero, .content-grid, .package-layout { grid-template-columns: 1fr; } .audit-filters { grid-template-columns: 1fr 1fr; } .audit-row { grid-template-columns: 1fr 1fr; } @@ -266,10 +288,15 @@ dd { margin: 4px 0 0; overflow-wrap: anywhere; } } @media (max-width: 580px) { - .topbar { padding: 0 4vw; } - .product-name, .operator > span { display: none; } + .topbar { min-height: 62px; padding: 0 3vw; } + .wordmark { margin-right: 0; } + .operator > span { display: none; } + .operator { gap: 0; } + .portal-nav { gap: 0; } + .portal-nav a { padding: 0 9px; font-size: .76rem; } .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; } + .panel-heading-action { align-items: flex-start; flex-direction: column; } } diff --git a/internal/app/static/app.js b/internal/app/static/app.js index b149e36..dcb7dae 100644 --- a/internal/app/static/app.js +++ b/internal/app/static/app.js @@ -43,7 +43,7 @@ document.addEventListener("submit", async (event) => { } else { status.textContent = `Uploaded ${result.filename}; SHA-256 ${result.sha256}`; window.setTimeout(() => { - window.location.assign("/portal?notice=Package+uploaded+and+registered"); + window.location.assign("/portal/packages?notice=Package+uploaded+and+registered"); }, 900); } } catch (error) { diff --git a/internal/app/templates/audit.html b/internal/app/templates/audit.html new file mode 100644 index 0000000..d3846ad --- /dev/null +++ b/internal/app/templates/audit.html @@ -0,0 +1,57 @@ +{{define "audit.html"}} + + + + + + {{.Title}} · TAPM + + + + {{template "topbar" .}} + +
+
+
+

Audit trail

+

Security events.

+

Review retained broker activity and narrow the results to a deployment, host, package, or technician.

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

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

+
+ {{template "audit-rows" .}} +
+
+ + +{{end}} diff --git a/internal/app/templates/components.html b/internal/app/templates/components.html new file mode 100644 index 0000000..0a960eb --- /dev/null +++ b/internal/app/templates/components.html @@ -0,0 +1,44 @@ +{{define "topbar"}} +
+
+ TAPM + Deployment Access +
+ +
+ {{.Technician.DisplayName}} +
+ + +
+
+
+{{end}} + +{{define "audit-rows"}} +
+ {{range .AuditEvents}} +
+
+ {{.EventType}} + {{formatTime .CreatedAt}} +
+
+ {{if .Actor}}{{.Actor}}{{end}} + {{if .Hostname}}{{.Hostname}}{{end}} + {{if .PackageSlug}}{{.PackageSlug}}{{end}} +
+
+ {{.SourceIP}} + {{if .Details}}{{.Details}}{{end}} +
+
+ {{else}} +

No security events match this view.

+ {{end}} +
+{{end}} diff --git a/internal/app/templates/packages.html b/internal/app/templates/packages.html new file mode 100644 index 0000000..63fa8af --- /dev/null +++ b/internal/app/templates/packages.html @@ -0,0 +1,96 @@ +{{define "packages.html"}} + + + + + + {{.Title}} · TAPM + + + + + {{template "topbar" .}} + +
+ {{if .Notice}}
{{.Notice}}
{{end}} + +
+
+

Registry catalog

+

Protected packages.

+

Add installers or replace the current version while keeping a stable package ID.

+
+
+ +
+
+
+

Current catalog

+ {{range .Packages}} +
+
+ {{.DisplayName}} + {{.Slug}} · {{.PackageVersion}} +
+ {{if .Enabled}}Enabled{{else}}Disabled{{end}} +
+ {{else}} +

No protected packages have been added.

+ {{end}} +
+ +
+ {{if .Packages}} +
+ + +

Update a package

+ + + + +

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

+ +

+
+ {{end}} + +
+ + +

Add a protected package

+ + + + + + +

+
+ + +
+
+
+
+ + +{{end}} diff --git a/internal/app/templates/portal.html b/internal/app/templates/portal.html index 6fa5c2b..cc84eb4 100644 --- a/internal/app/templates/portal.html +++ b/internal/app/templates/portal.html @@ -9,19 +9,7 @@ -
-
- TAPM - Deployment Access -
-
- {{.Technician.DisplayName}} -
- - -
-
-
+ {{template "topbar" .}}
{{if .NewCode}} @@ -114,7 +102,7 @@ {{end}} - + @@ -151,144 +139,21 @@ {{end}} {{else}} -

No deployment authorizations have been created.

+

No deployment authorizations have been created.

{{end}} -
-
-
-

Registry catalog

-

Protected packages

-
-
- -
-
- {{range .Packages}} -
-
- {{.DisplayName}} - {{.Slug}} · {{.PackageVersion}} -
- {{if .Enabled}}Enabled{{else}}Disabled{{end}} -
- {{else}} -

Add the first package after publishing it to Gitea.

- {{end}} -
- -
- {{if .Packages}} -
- - -

Update a package

- - - - -

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

- -

-
- {{end}} - -
- - -

Add a protected package

- - - - - - -

-
- - -
-
-
-
-
+
-

Audit trail

-

Security events

+

Audit preview

+

Latest 15 security events

+ Review full audit trail
-
- - - - - - - -
- - Clear filters -
-

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

-
-
- {{range .AuditEvents}} -
-
- {{.EventType}} - {{formatTime .CreatedAt}} -
-
- {{if .Actor}}{{.Actor}}{{end}} - {{if .Hostname}}{{.Hostname}}{{end}} - {{if .PackageSlug}}{{.PackageSlug}}{{end}} -
-
- {{.SourceIP}} - {{if .Details}}{{.Details}}{{end}} -
-
- {{else}} -

No security events match these filters.

- {{end}} -
+ {{template "audit-rows" .}}