update
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
{{define "audit.html"}}
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{.Title}} · TAPM</title>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
</head>
|
||||
<body>
|
||||
{{template "topbar" .}}
|
||||
|
||||
<main class="page">
|
||||
<section class="view-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Audit trail</p>
|
||||
<h1>Security events.</h1>
|
||||
<p>Review retained broker activity and narrow the results to a deployment, host, package, or technician.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<form action="/portal/audit" 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?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>
|
||||
{{template "audit-rows" .}}
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,44 @@
|
||||
{{define "topbar"}}
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<span class="wordmark">TAPM</span>
|
||||
<span class="product-name">Deployment Access</span>
|
||||
</div>
|
||||
<nav class="portal-nav" aria-label="Portal">
|
||||
<a href="/portal" {{if eq .CurrentView "codes"}}class="active" aria-current="page"{{end}}>Codes</a>
|
||||
<a href="/portal/packages" {{if eq .CurrentView "packages"}}class="active" aria-current="page"{{end}}>Packages</a>
|
||||
<a href="/portal/audit" {{if eq .CurrentView "audit"}}class="active" aria-current="page"{{end}}>Audit</a>
|
||||
</nav>
|
||||
<div class="operator">
|
||||
<span>{{.Technician.DisplayName}}</span>
|
||||
<form action="/auth/logout" method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<button class="text-button" type="submit">Sign out</button>
|
||||
</form>
|
||||
</div>
|
||||
</header>
|
||||
{{end}}
|
||||
|
||||
{{define "audit-rows"}}
|
||||
<div class="audit-table">
|
||||
{{range .AuditEvents}}
|
||||
<div class="audit-row">
|
||||
<div>
|
||||
<strong>{{.EventType}}</strong>
|
||||
<small>{{formatTime .CreatedAt}}</small>
|
||||
</div>
|
||||
<div>
|
||||
{{if .Actor}}<span>{{.Actor}}</span>{{end}}
|
||||
{{if .Hostname}}<span>{{.Hostname}}</span>{{end}}
|
||||
{{if .PackageSlug}}<span>{{.PackageSlug}}</span>{{end}}
|
||||
</div>
|
||||
<div>
|
||||
<span>{{.SourceIP}}</span>
|
||||
{{if .Details}}<small>{{.Details}}</small>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="empty audit-empty">No security events match this view.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,96 @@
|
||||
{{define "packages.html"}}
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{.Title}} · TAPM</title>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
<script src="/static/app.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
{{template "topbar" .}}
|
||||
|
||||
<main class="page">
|
||||
{{if .Notice}}<div class="notice">{{.Notice}}</div>{{end}}
|
||||
|
||||
<section class="view-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Registry catalog</p>
|
||||
<h1>Protected packages.</h1>
|
||||
<p>Add installers or replace the current version while keeping a stable package ID.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="package-layout">
|
||||
<div class="package-table">
|
||||
<p class="form-title">Current catalog</p>
|
||||
{{range .Packages}}
|
||||
<div class="package-row">
|
||||
<div>
|
||||
<strong>{{.DisplayName}}</strong>
|
||||
<small>{{.Slug}} · {{.PackageVersion}}</small>
|
||||
</div>
|
||||
<span class="status {{if .Enabled}}enabled{{end}}">{{if .Enabled}}Enabled{{else}}Disabled{{end}}</span>
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="empty">No protected packages have been added.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="package-forms">
|
||||
{{if .Packages}}
|
||||
<form action="/portal/packages/upload" method="post" enctype="multipart/form-data" class="stack compact" data-upload-form>
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<input type="hidden" name="mode" value="update">
|
||||
<p class="form-title">Update a package</p>
|
||||
<label>Package
|
||||
<select name="slug" required>
|
||||
{{range .Packages}}
|
||||
<option value="{{.Slug}}">{{.DisplayName}} ({{.Slug}} · {{.PackageVersion}})</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</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>Replacement installer <input name="package_file" type="file" required></label>
|
||||
<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>
|
||||
{{end}}
|
||||
|
||||
<form action="/portal/packages/upload" method="post" enctype="multipart/form-data" class="stack compact form-section" data-upload-form>
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<input type="hidden" name="mode" value="create">
|
||||
<p class="form-title">Add a protected package</p>
|
||||
<label>Package ID <input name="slug" placeholder="sentinelone-linux" pattern="[a-z0-9_-]+" required></label>
|
||||
<label>Display name <input name="display_name" placeholder="SentinelOne Linux Agent" required></label>
|
||||
<label>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>Installer file <input name="package_file" type="file" required></label>
|
||||
<button class="button secondary" type="submit">Upload new package</button>
|
||||
<p class="upload-status" data-upload-status aria-live="polite"></p>
|
||||
</form>
|
||||
|
||||
<details class="manual-metadata">
|
||||
<summary>Register package metadata manually</summary>
|
||||
<form action="/portal/packages" method="post" class="stack compact">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<label>Package ID <input name="slug" placeholder="sentinelone-linux" pattern="[a-z0-9_-]+" required></label>
|
||||
<label>Display name <input name="display_name" placeholder="SentinelOne Linux Agent" required></label>
|
||||
<label>Version <input name="package_version" placeholder="26.1.1.31" required></label>
|
||||
<label>Filename <input name="file_name" placeholder="SentinelAgent_linux_x86_64.deb" required></label>
|
||||
<label>SHA-256 <input name="sha256" minlength="64" maxlength="64" placeholder="64 hexadecimal characters" required></label>
|
||||
<label class="toggle"><input type="checkbox" name="enabled" value="1"> Enable for new authorizations</label>
|
||||
<button class="button secondary" type="submit">Save package metadata</button>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -9,19 +9,7 @@
|
||||
<script src="/static/app.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<span class="wordmark">TAPM</span>
|
||||
<span class="product-name">Deployment Access</span>
|
||||
</div>
|
||||
<div class="operator">
|
||||
<span>{{.Technician.DisplayName}}</span>
|
||||
<form action="/auth/logout" method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<button class="text-button" type="submit">Sign out</button>
|
||||
</form>
|
||||
</div>
|
||||
</header>
|
||||
{{template "topbar" .}}
|
||||
|
||||
<main class="page">
|
||||
{{if .NewCode}}
|
||||
@@ -114,7 +102,7 @@
|
||||
{{end}}
|
||||
</fieldset>
|
||||
|
||||
<button class="button primary" type="submit">Create 3-hour code</button>
|
||||
<button class="button primary" type="submit">Create deployment code</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -151,144 +139,21 @@
|
||||
{{end}}
|
||||
</article>
|
||||
{{else}}
|
||||
<p class="empty">No deployment authorizations have been created.</p>
|
||||
<p class="empty authorization-empty">No deployment authorizations have been created.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="panel package-admin">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Registry catalog</p>
|
||||
<h2>Protected packages</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="package-layout">
|
||||
<div class="package-table">
|
||||
{{range .Packages}}
|
||||
<div class="package-row">
|
||||
<div>
|
||||
<strong>{{.DisplayName}}</strong>
|
||||
<small>{{.Slug}} · {{.PackageVersion}}</small>
|
||||
</div>
|
||||
<span class="status {{if .Enabled}}enabled{{end}}">{{if .Enabled}}Enabled{{else}}Disabled{{end}}</span>
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="empty">Add the first package after publishing it to Gitea.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="package-forms">
|
||||
{{if .Packages}}
|
||||
<form action="/portal/packages/upload" method="post" enctype="multipart/form-data" class="stack compact" data-upload-form>
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<input type="hidden" name="mode" value="update">
|
||||
<p class="form-title">Update a package</p>
|
||||
<label>Package
|
||||
<select name="slug" required>
|
||||
{{range .Packages}}
|
||||
<option value="{{.Slug}}">{{.DisplayName}} ({{.Slug}} · {{.PackageVersion}})</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</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>Replacement installer <input name="package_file" type="file" required></label>
|
||||
<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>
|
||||
{{end}}
|
||||
|
||||
<form action="/portal/packages/upload" method="post" enctype="multipart/form-data" class="stack compact" data-upload-form>
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<input type="hidden" name="mode" value="create">
|
||||
<p class="form-title">Add a protected package</p>
|
||||
<label>Package ID <input name="slug" placeholder="sentinelone-linux" pattern="[a-z0-9_-]+" required></label>
|
||||
<label>Display name <input name="display_name" placeholder="SentinelOne Linux Agent" required></label>
|
||||
<label>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>Installer file <input name="package_file" type="file" required></label>
|
||||
<button class="button secondary" type="submit">Upload new package</button>
|
||||
<p class="upload-status" data-upload-status aria-live="polite"></p>
|
||||
</form>
|
||||
|
||||
<details class="manual-metadata">
|
||||
<summary>Register package metadata manually</summary>
|
||||
<form action="/portal/packages" method="post" class="stack compact">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<label>Package ID <input name="slug" placeholder="sentinelone-linux" pattern="[a-z0-9_-]+" required></label>
|
||||
<label>Display name <input name="display_name" placeholder="SentinelOne Linux Agent" required></label>
|
||||
<label>Version <input name="package_version" placeholder="26.1.1.31" required></label>
|
||||
<label>Filename <input name="file_name" placeholder="SentinelAgent_linux_x86_64.deb" required></label>
|
||||
<label>SHA-256 <input name="sha256" minlength="64" maxlength="64" placeholder="64 hexadecimal characters" required></label>
|
||||
<label class="toggle"><input type="checkbox" name="enabled" value="1"> Enable for new authorizations</label>
|
||||
<button class="button secondary" type="submit">Save package metadata</button>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel audit-panel">
|
||||
<div class="panel-heading">
|
||||
<div class="panel-heading panel-heading-action">
|
||||
<div>
|
||||
<p class="eyebrow">Audit trail</p>
|
||||
<h2>Security events</h2>
|
||||
<p class="eyebrow">Audit preview</p>
|
||||
<h2>Latest 15 security events</h2>
|
||||
</div>
|
||||
<a class="button secondary" href="/portal/audit">Review full audit trail</a>
|
||||
</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">
|
||||
<div>
|
||||
<strong>{{.EventType}}</strong>
|
||||
<small>{{formatTime .CreatedAt}}</small>
|
||||
</div>
|
||||
<div>
|
||||
{{if .Actor}}<span>{{.Actor}}</span>{{end}}
|
||||
{{if .Hostname}}<span>{{.Hostname}}</span>{{end}}
|
||||
{{if .PackageSlug}}<span>{{.PackageSlug}}</span>{{end}}
|
||||
</div>
|
||||
<div>
|
||||
<span>{{.SourceIP}}</span>
|
||||
{{if .Details}}<small>{{.Details}}</small>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="empty audit-empty">No security events match these filters.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
{{template "audit-rows" .}}
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user