docs: publish Preview 19 dogfood evidence
Export the reviewed allowlisted snapshot from private source commit 05928cebd01b586cf9e9d4b8c8537a7605a6068c. This records the exact candidate, bounded capacity result, stateful migration scratch requirement, authenticated batch identity proof, and immediate live acceptance evidence. AI-Assisted: OpenAI Codex Signed-off-by: Cole Speelman <crspeelman@gmail.com>
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"gamertan.com/observatory/internal/identity"
|
||||
"gamertan.com/observatory/internal/query"
|
||||
"gamertan.com/observatory/internal/site"
|
||||
"gamertan.com/web/access"
|
||||
"gamertan.com/web/auth"
|
||||
"gamertan.com/web/authhttp"
|
||||
)
|
||||
|
||||
const defaultExploreQuery = "logs | window 1h | limit 50"
|
||||
|
||||
func (s *Server) explorePage(w http.ResponseWriter, r *http.Request) {
|
||||
view, _, ok := s.exploreView(w, r, defaultExploreQuery)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
s.renderHTML(w, r, http.StatusOK, site.Explore(view))
|
||||
}
|
||||
|
||||
func (s *Server) exploreForm(w http.ResponseWriter, r *http.Request) {
|
||||
values, err := readForm(w, r, 20<<10, "csrf_token", "query")
|
||||
queryText := defaultExploreQuery
|
||||
if err == nil {
|
||||
queryText = values.Get("query")
|
||||
}
|
||||
view, token, ok := s.exploreView(w, r, queryText)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
csrfOK := err == nil && authhttp.VerifyCSRF(token, "query:execute", values.Get("csrf_token"))
|
||||
if !tokenBoundFormRequest(r, s.options.PublicOrigin, csrfOK) {
|
||||
view.ErrorMessage = "This query form expired or could not be verified. Please try again."
|
||||
s.renderHTML(w, r, http.StatusForbidden, site.Explore(view))
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
view.ErrorMessage = "The query form was not accepted."
|
||||
s.renderHTML(w, r, http.StatusBadRequest, site.Explore(view))
|
||||
return
|
||||
}
|
||||
ast, err := query.Parse(queryText, s.options.MaxQueryRows)
|
||||
if err != nil {
|
||||
view.ErrorMessage = "The query could not be parsed. Check its stages, values, window, and limit."
|
||||
s.renderHTML(w, r, http.StatusUnprocessableEntity, site.Explore(view))
|
||||
return
|
||||
}
|
||||
principal, _ := auth.PrincipalFromContext(r.Context())
|
||||
scope := access.Scope{OrganizationID: view.Organization.ID}
|
||||
sensitive, err := s.identity.Access.Authorize(r.Context(), principal.User.ID, scope, identity.PermissionTelemetryReadSensitive)
|
||||
if err != nil {
|
||||
view.ErrorMessage = "Authorization is temporarily unavailable."
|
||||
s.renderHTML(w, r, http.StatusServiceUnavailable, site.Explore(view))
|
||||
return
|
||||
}
|
||||
result, err := s.store.Query(r.Context(), ast, query.Scope{OrganizationID: view.Organization.ID, Sensitive: sensitive.Allowed}, s.options.QueryBudget, s.now())
|
||||
switch {
|
||||
case errors.Is(err, query.ErrSensitivePermissionRequired):
|
||||
view.ErrorMessage = "This query requires permission to read sensitive fields."
|
||||
s.renderHTML(w, r, http.StatusForbidden, site.Explore(view))
|
||||
return
|
||||
case errors.Is(err, query.ErrBudgetExceeded):
|
||||
view.ErrorMessage = "This query exceeded its execution budget. Narrow the time window, fields, or result limit."
|
||||
s.renderHTML(w, r, http.StatusUnprocessableEntity, site.Explore(view))
|
||||
return
|
||||
case errors.Is(err, query.ErrTypeMismatch):
|
||||
view.ErrorMessage = "A query value did not match the selected field type."
|
||||
s.renderHTML(w, r, http.StatusUnprocessableEntity, site.Explore(view))
|
||||
return
|
||||
case err != nil:
|
||||
view.ErrorMessage = "The bounded query is temporarily unavailable. Your query text remains here to retry."
|
||||
s.renderHTML(w, r, http.StatusServiceUnavailable, site.Explore(view))
|
||||
return
|
||||
}
|
||||
view.Executed = true
|
||||
view.Table = resultTable("Authorized query results", result)
|
||||
view.Table.Empty = "No observations matched this query."
|
||||
view.Stats = site.QueryStatsView{
|
||||
ScannedRows: result.Stats.ScannedRows, MatchedRows: result.Stats.MatchedRows,
|
||||
ScannedBytes: formatQueryBytes(result.Stats.ScannedBytes),
|
||||
Duration: formatQueryDuration(result.Stats.DurationNS),
|
||||
Truncated: result.Stats.Truncated, Approximate: result.Stats.Approximate,
|
||||
}
|
||||
s.renderHTML(w, r, http.StatusOK, site.Explore(view))
|
||||
}
|
||||
|
||||
func (s *Server) exploreView(w http.ResponseWriter, r *http.Request, queryText string) (site.ExploreView, string, bool) {
|
||||
principal, ok := auth.PrincipalFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Redirect(w, r, "/login/", http.StatusSeeOther)
|
||||
return site.ExploreView{}, "", false
|
||||
}
|
||||
values := r.URL.Query()
|
||||
organizationID := values.Get("organization")
|
||||
if len(values) != 1 || len(values["organization"]) != 1 || organizationID == "" {
|
||||
writeProblem(w, http.StatusBadRequest, "organization is required")
|
||||
return site.ExploreView{}, "", false
|
||||
}
|
||||
organizations, err := s.identity.OrganizationsForUser(r.Context(), principal.User.ID)
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusServiceUnavailable, "organization list unavailable")
|
||||
return site.ExploreView{}, "", false
|
||||
}
|
||||
organizationName := ""
|
||||
for _, organization := range organizations {
|
||||
if organization.ID == organizationID {
|
||||
organizationName = organization.Name
|
||||
break
|
||||
}
|
||||
}
|
||||
if organizationName == "" {
|
||||
writeProblem(w, http.StatusForbidden, "organization access denied")
|
||||
return site.ExploreView{}, "", false
|
||||
}
|
||||
scope := access.Scope{OrganizationID: organizationID}
|
||||
decision, err := s.identity.Access.Authorize(r.Context(), principal.User.ID, scope, identity.PermissionTelemetryQuery)
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusServiceUnavailable, "authorization unavailable")
|
||||
return site.ExploreView{}, "", false
|
||||
}
|
||||
if !decision.Allowed {
|
||||
writeProblem(w, http.StatusForbidden, "telemetry query access denied")
|
||||
return site.ExploreView{}, "", false
|
||||
}
|
||||
if err = s.identity.ValidateResourceScope(r.Context(), scope); err != nil {
|
||||
writeProblem(w, http.StatusBadRequest, "invalid organization")
|
||||
return site.ExploreView{}, "", false
|
||||
}
|
||||
token, ok := authhttp.SessionToken(r, s.cookie)
|
||||
if !ok {
|
||||
writeProblem(w, http.StatusUnauthorized, "authentication required")
|
||||
return site.ExploreView{}, "", false
|
||||
}
|
||||
csrf, err := authhttp.CSRFToken(token, "query:execute")
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusServiceUnavailable, "session unavailable")
|
||||
return site.ExploreView{}, "", false
|
||||
}
|
||||
view := site.ExploreView{
|
||||
Head: s.head("Explore — Gamertan Observatory", "Run an authorized, bounded query against organization evidence.", "/app/explore/"),
|
||||
DisplayName: principal.User.DisplayName,
|
||||
Organization: site.OrganizationOption{ID: organizationID, Name: organizationName, Selected: true},
|
||||
Query: queryText, CSRFToken: csrf,
|
||||
EventsURL: "/app/events?organization=" + url.QueryEscape(organizationID),
|
||||
}
|
||||
return view, token, true
|
||||
}
|
||||
|
||||
func formatQueryBytes(value int64) string {
|
||||
if value < 1024 {
|
||||
return fmt.Sprintf("%d B", value)
|
||||
}
|
||||
units := []string{"KiB", "MiB", "GiB", "TiB"}
|
||||
amount := float64(value)
|
||||
for _, unit := range units {
|
||||
amount /= 1024
|
||||
if amount < 1024 || unit == units[len(units)-1] {
|
||||
return fmt.Sprintf("%.1f %s", amount, unit)
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%d B", value)
|
||||
}
|
||||
|
||||
func formatQueryDuration(nanoseconds int64) string {
|
||||
duration := time.Duration(nanoseconds)
|
||||
if duration < time.Microsecond {
|
||||
return duration.String()
|
||||
}
|
||||
if duration < time.Millisecond {
|
||||
return duration.Round(time.Microsecond).String()
|
||||
}
|
||||
return duration.Round(time.Millisecond).String()
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gamertan.com/observatory/internal/identity"
|
||||
"gamertan.com/observatory/internal/site"
|
||||
"gamertan.com/observatory/internal/storage"
|
||||
"gamertan.com/web/access"
|
||||
"gamertan.com/web/auth"
|
||||
"gamertan.com/web/authhttp"
|
||||
)
|
||||
|
||||
// EvaluateAlerts performs one bounded due-rule pass. It publishes only a
|
||||
// generic organization invalidation signal when an incident changed;
|
||||
// telemetry and incident details never enter the SSE stream.
|
||||
func (s *Server) EvaluateAlerts(ctx context.Context) (int, error) {
|
||||
evaluations, err := s.store.EvaluateDueAlertRules(ctx, s.options.QueryBudget, s.now())
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
organizations := map[string]struct{}{}
|
||||
for _, evaluation := range evaluations {
|
||||
if evaluation.IncidentChanged {
|
||||
organizations[evaluation.OrganizationID] = struct{}{}
|
||||
}
|
||||
if evaluation.IncidentChanged && evaluation.IncidentState == "firing" && s.options.PushDispatcher != nil {
|
||||
s.options.PushDispatcher.Enqueue(evaluation.OrganizationID)
|
||||
}
|
||||
}
|
||||
for organizationID := range organizations {
|
||||
s.refresh.publish(organizationID)
|
||||
}
|
||||
return len(evaluations), nil
|
||||
}
|
||||
|
||||
func (s *Server) incidentInbox(w http.ResponseWriter, r *http.Request) {
|
||||
principal, organizationID, organizationName, ok := s.authorizeIncidentRead(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
scope := access.Scope{OrganizationID: organizationID}
|
||||
incidents, err := s.store.Incidents(r.Context(), organizationID, true, 100)
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusServiceUnavailable, "incidents unavailable")
|
||||
return
|
||||
}
|
||||
rules, err := s.store.AlertRules(r.Context(), organizationID)
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusServiceUnavailable, "alert rules unavailable")
|
||||
return
|
||||
}
|
||||
saved, err := s.store.SavedQueries(r.Context(), organizationID)
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusServiceUnavailable, "saved queries unavailable")
|
||||
return
|
||||
}
|
||||
view := site.IncidentInboxView{
|
||||
Head: s.head("Incident inbox — Gamertan Observatory", "Authorized incident response and bounded alert rules.", "/app/incidents/"),
|
||||
DisplayName: principal.User.DisplayName,
|
||||
Organization: site.OrganizationOption{ID: organizationID, Name: organizationName, Selected: true},
|
||||
EventsURL: "/app/events?organization=" + url.QueryEscape(organizationID),
|
||||
OfflineURL: "/app/incidents/offline/?organization=" + url.QueryEscape(organizationID),
|
||||
CacheKey: "/app/incidents/?organization=" + url.QueryEscape(organizationID),
|
||||
}
|
||||
if s.options.PushDispatcher != nil {
|
||||
token, sessionOK := authhttp.SessionToken(r, s.cookie)
|
||||
if !sessionOK {
|
||||
writeProblem(w, http.StatusUnauthorized, "authentication required")
|
||||
return
|
||||
}
|
||||
view.PushPublicKey = s.options.PushPublicKey
|
||||
view.PushCSRF, err = authhttp.CSRFToken(token, "push:manage")
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusServiceUnavailable, "session unavailable")
|
||||
return
|
||||
}
|
||||
}
|
||||
for _, incident := range incidents {
|
||||
item := site.IncidentSummary{ID: incident.ID, Title: incident.Title, State: incident.State, Severity: incident.Severity, StartedAt: incident.StartedAt.Format("2006-01-02 15:04:05 UTC"), UpdatedAt: incident.UpdatedAt.Format("2006-01-02 15:04:05 UTC")}
|
||||
if incident.SilencedUntil != nil {
|
||||
item.SilencedUntil = incident.SilencedUntil.Format("2006-01-02 15:04:05 UTC")
|
||||
}
|
||||
view.Incidents = append(view.Incidents, item)
|
||||
if incident.State != "resolved" {
|
||||
view.OpenCount++
|
||||
}
|
||||
}
|
||||
for _, rule := range rules {
|
||||
item := site.AlertRuleSummary{Name: rule.Name, Description: rule.Description, Severity: rule.Severity, Enabled: rule.Enabled, Interval: rule.EvaluationInterval.String(), LastError: rule.LastError}
|
||||
if rule.LastEvaluatedAt != nil {
|
||||
item.LastEvaluatedAt = rule.LastEvaluatedAt.Format("2006-01-02 15:04:05 UTC")
|
||||
}
|
||||
view.Rules = append(view.Rules, item)
|
||||
}
|
||||
for _, savedQuery := range saved {
|
||||
view.SavedQueries = append(view.SavedQueries, site.SavedQuerySummary{ID: savedQuery.ID, Name: savedQuery.Name, Description: savedQuery.Description, Query: savedQuery.Query})
|
||||
}
|
||||
manage, err := s.identity.Access.Authorize(r.Context(), principal.User.ID, scope, identity.PermissionIncidentsManage)
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusServiceUnavailable, "authorization unavailable")
|
||||
return
|
||||
}
|
||||
view.CanManage = manage.Allowed
|
||||
if view.CanManage {
|
||||
token, sessionOK := authhttp.SessionToken(r, s.cookie)
|
||||
if !sessionOK {
|
||||
writeProblem(w, http.StatusUnauthorized, "authentication required")
|
||||
return
|
||||
}
|
||||
view.ManageCSRF, err = authhttp.CSRFToken(token, "incidents:manage")
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusServiceUnavailable, "session unavailable")
|
||||
return
|
||||
}
|
||||
}
|
||||
s.renderHTML(w, r, http.StatusOK, site.IncidentInbox(view))
|
||||
}
|
||||
|
||||
func (s *Server) offlineIncidentInbox(w http.ResponseWriter, r *http.Request) {
|
||||
_, organizationID, organizationName, ok := s.authorizeIncidentRead(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
incidents, err := s.store.Incidents(r.Context(), organizationID, false, 100)
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusServiceUnavailable, "incidents unavailable")
|
||||
return
|
||||
}
|
||||
view := site.OfflineIncidentView{
|
||||
Head: s.head("Saved incident inbox — Gamertan Observatory", "A deliberately saved read-only incident snapshot.", "/app/incidents/"),
|
||||
Organization: site.OrganizationOption{ID: organizationID, Name: organizationName, Selected: true},
|
||||
CapturedAt: s.now().Format("2006-01-02 15:04:05 UTC"),
|
||||
}
|
||||
for _, incident := range incidents {
|
||||
item := site.IncidentSummary{Title: incident.Title, State: incident.State, Severity: incident.Severity, StartedAt: incident.StartedAt.Format("2006-01-02 15:04:05 UTC"), UpdatedAt: incident.UpdatedAt.Format("2006-01-02 15:04:05 UTC")}
|
||||
if incident.SilencedUntil != nil {
|
||||
item.SilencedUntil = incident.SilencedUntil.Format("2006-01-02 15:04:05 UTC")
|
||||
}
|
||||
view.Incidents = append(view.Incidents, item)
|
||||
}
|
||||
s.renderHTML(w, r, http.StatusOK, site.OfflineIncidentInbox(view))
|
||||
}
|
||||
|
||||
func (s *Server) authorizeIncidentRead(w http.ResponseWriter, r *http.Request) (auth.Principal, string, string, bool) {
|
||||
principal, ok := auth.PrincipalFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Redirect(w, r, "/login/", http.StatusSeeOther)
|
||||
return auth.Principal{}, "", "", false
|
||||
}
|
||||
values := r.URL.Query()
|
||||
organizationValues, exists := values["organization"]
|
||||
if !exists || len(values) != 1 || len(organizationValues) != 1 || organizationValues[0] == "" {
|
||||
writeProblem(w, http.StatusBadRequest, "incident organization is required")
|
||||
return auth.Principal{}, "", "", false
|
||||
}
|
||||
organizationID := organizationValues[0]
|
||||
scope := access.Scope{OrganizationID: organizationID}
|
||||
decision, err := s.identity.Access.Authorize(r.Context(), principal.User.ID, scope, identity.PermissionIncidentsRead)
|
||||
if err != nil || !decision.Allowed {
|
||||
writeProblem(w, http.StatusForbidden, "incident access denied")
|
||||
return auth.Principal{}, "", "", false
|
||||
}
|
||||
if err = s.identity.ValidateResourceScope(r.Context(), scope); err != nil {
|
||||
writeProblem(w, http.StatusBadRequest, "invalid organization")
|
||||
return auth.Principal{}, "", "", false
|
||||
}
|
||||
organizations, err := s.identity.OrganizationsForUser(r.Context(), principal.User.ID)
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusServiceUnavailable, "organization unavailable")
|
||||
return auth.Principal{}, "", "", false
|
||||
}
|
||||
for _, organization := range organizations {
|
||||
if organization.ID == organizationID {
|
||||
return principal, organizationID, organization.Name, true
|
||||
}
|
||||
}
|
||||
writeProblem(w, http.StatusForbidden, "incident access denied")
|
||||
return auth.Principal{}, "", "", false
|
||||
}
|
||||
|
||||
func (s *Server) createAlertRule(w http.ResponseWriter, r *http.Request) {
|
||||
values, principal, ok := s.authorizeIncidentForm(w, r, []string{"organization_id", "csrf_token", "name", "description", "saved_query_id", "severity", "minimum_matches", "required_consecutive", "evaluation_interval"}, nil)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
minimumMatches, err := strconv.Atoi(values.Get("minimum_matches"))
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "alert rule rejected")
|
||||
return
|
||||
}
|
||||
requiredConsecutive, err := strconv.Atoi(values.Get("required_consecutive"))
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "alert rule rejected")
|
||||
return
|
||||
}
|
||||
intervals := map[string]time.Duration{"15s": 15 * time.Second, "30s": 30 * time.Second, "1m": time.Minute, "5m": 5 * time.Minute, "15m": 15 * time.Minute}
|
||||
interval, exists := intervals[values.Get("evaluation_interval")]
|
||||
if !exists {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "alert rule rejected")
|
||||
return
|
||||
}
|
||||
_, err = s.store.SaveAlertRule(r.Context(), storage.AlertRuleInput{
|
||||
OrganizationID: values.Get("organization_id"), Name: values.Get("name"), Description: values.Get("description"),
|
||||
SavedQueryID: values.Get("saved_query_id"), Severity: values.Get("severity"), MinimumMatches: minimumMatches,
|
||||
RequiredConsecutive: requiredConsecutive, EvaluationInterval: interval, Enabled: true, ActorUserID: principal.User.ID,
|
||||
}, s.now())
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "alert rule rejected")
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/app/incidents/?organization="+url.QueryEscape(values.Get("organization_id")), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) transitionIncident(w http.ResponseWriter, r *http.Request) {
|
||||
values, principal, ok := s.authorizeIncidentForm(w, r, []string{"organization_id", "csrf_token", "action"}, []string{"silence_duration"})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var silenceUntil *time.Time
|
||||
if values.Get("action") == "silence" {
|
||||
durations := map[string]time.Duration{"15m": 15 * time.Minute, "1h": time.Hour, "6h": 6 * time.Hour, "24h": 24 * time.Hour, "168h": 7 * 24 * time.Hour}
|
||||
duration, exists := durations[values.Get("silence_duration")]
|
||||
if !exists {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "incident transition rejected")
|
||||
return
|
||||
}
|
||||
until := s.now().Add(duration)
|
||||
silenceUntil = &until
|
||||
} else if values.Get("silence_duration") != "" {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "incident transition rejected")
|
||||
return
|
||||
}
|
||||
_, err := s.store.TransitionIncident(r.Context(), values.Get("organization_id"), r.PathValue("id"), values.Get("action"), principal.User.ID, silenceUntil, s.now())
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "incident transition rejected")
|
||||
return
|
||||
}
|
||||
s.refresh.publish(values.Get("organization_id"))
|
||||
http.Redirect(w, r, "/app/incidents/?organization="+url.QueryEscape(values.Get("organization_id")), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) authorizeIncidentForm(w http.ResponseWriter, r *http.Request, required, optional []string) (url.Values, auth.Principal, bool) {
|
||||
principal, ok := auth.PrincipalFromContext(r.Context())
|
||||
values, err := readFormFields(w, r, 24<<10, required, optional)
|
||||
token, sessionOK := authhttp.SessionToken(r, s.cookie)
|
||||
csrfOK := err == nil && sessionOK && authhttp.VerifyCSRF(token, "incidents:manage", values.Get("csrf_token"))
|
||||
if !tokenBoundFormRequest(r, s.options.PublicOrigin, csrfOK) {
|
||||
writeProblem(w, http.StatusForbidden, "valid incident form required")
|
||||
return nil, auth.Principal{}, false
|
||||
}
|
||||
if !ok {
|
||||
writeProblem(w, http.StatusUnauthorized, "authentication required")
|
||||
return nil, auth.Principal{}, false
|
||||
}
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusBadRequest, "invalid incident management request")
|
||||
return nil, auth.Principal{}, false
|
||||
}
|
||||
scope := access.Scope{OrganizationID: values.Get("organization_id")}
|
||||
decision, err := s.identity.Access.Authorize(r.Context(), principal.User.ID, scope, identity.PermissionIncidentsManage)
|
||||
if err != nil || !decision.Allowed {
|
||||
writeProblem(w, http.StatusForbidden, "incident management denied")
|
||||
return nil, auth.Principal{}, false
|
||||
}
|
||||
if err = s.identity.ValidateResourceScope(r.Context(), scope); err != nil {
|
||||
writeProblem(w, http.StatusBadRequest, "invalid organization")
|
||||
return nil, auth.Principal{}, false
|
||||
}
|
||||
if !csrfOK {
|
||||
writeProblem(w, http.StatusForbidden, "valid incident CSRF token required")
|
||||
return nil, auth.Principal{}, false
|
||||
}
|
||||
return values, principal, true
|
||||
}
|
||||
@@ -0,0 +1,591 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"gamertan.com/observatory/internal/identity"
|
||||
"gamertan.com/observatory/internal/query"
|
||||
"gamertan.com/observatory/internal/site"
|
||||
"gamertan.com/observatory/internal/storage"
|
||||
"gamertan.com/web/access"
|
||||
"gamertan.com/web/auth"
|
||||
"gamertan.com/web/authhttp"
|
||||
)
|
||||
|
||||
func (s *Server) createSavedQuery(w http.ResponseWriter, r *http.Request) {
|
||||
values, principal, ok := s.authorizeManagementForm(w, r, "organization_id", "csrf_token", "name", "description", "query")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
s.saveQuery(w, r, values, principal, values.Get("query"))
|
||||
}
|
||||
|
||||
func (s *Server) createBuiltQuery(w http.ResponseWriter, r *http.Request) {
|
||||
required := []string{"organization_id", "csrf_token", "name", "description", "signal", "filter_operator", "window", "aggregate", "limit"}
|
||||
optional := []string{"filter_field", "filter_value", "aggregate_field", "group_by", "bucket"}
|
||||
values, principal, ok := s.authorizeManagementFormFields(w, r, required, optional)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
text, err := buildAssistedQuery(values, s.options.MaxQueryRows)
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "assisted query rejected")
|
||||
return
|
||||
}
|
||||
s.saveQuery(w, r, values, principal, text)
|
||||
}
|
||||
|
||||
func (s *Server) saveQuery(w http.ResponseWriter, r *http.Request, values url.Values, principal auth.Principal, text string) {
|
||||
_, err := s.store.SaveQuery(r.Context(), storage.SavedQueryInput{
|
||||
OrganizationID: values.Get("organization_id"), Name: values.Get("name"),
|
||||
Description: values.Get("description"), Query: text,
|
||||
ActorUserID: principal.User.ID, MaxRows: s.options.MaxQueryRows,
|
||||
}, s.now())
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "saved query rejected")
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/app/?organization="+url.QueryEscape(values.Get("organization_id"))+"#saved-work", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func buildAssistedQuery(values url.Values, maxRows int) (string, error) {
|
||||
allowed := func(value string, candidates ...string) bool {
|
||||
for _, candidate := range candidates {
|
||||
if value == candidate {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
signal := values.Get("signal")
|
||||
if !allowed(signal, "logs", "metrics", "traces", "deployments") {
|
||||
return "", fmt.Errorf("unsupported signal")
|
||||
}
|
||||
window := values.Get("window")
|
||||
if !allowed(window, "15m", "1h", "6h", "24h", "168h") {
|
||||
return "", fmt.Errorf("unsupported window")
|
||||
}
|
||||
limit, err := strconv.Atoi(values.Get("limit"))
|
||||
if err != nil || limit < 1 || limit > maxRows || !allowed(values.Get("limit"), "10", "20", "50", "100", "250") {
|
||||
return "", fmt.Errorf("unsupported limit")
|
||||
}
|
||||
stages := []string{signal}
|
||||
filterField := values.Get("filter_field")
|
||||
filterValue := values.Get("filter_value")
|
||||
filterOperator := values.Get("filter_operator")
|
||||
if !allowed(filterOperator, "==", "!=", ">=", "<=", ">", "<") {
|
||||
return "", fmt.Errorf("invalid filter comparison")
|
||||
}
|
||||
if filterField == "" {
|
||||
if filterValue != "" {
|
||||
return "", fmt.Errorf("filter value requires a field")
|
||||
}
|
||||
} else {
|
||||
if !allowed(filterField, "service", "project", "environment", "route", "status", "duration", "name", "severity", "value", "trace_id", "correlation_id") ||
|
||||
filterValue == "" || len(filterValue) > 256 || !utf8.ValidString(filterValue) || strings.IndexByte(filterValue, 0) >= 0 {
|
||||
return "", fmt.Errorf("invalid filter")
|
||||
}
|
||||
quoted := strconv.Quote(filterValue)
|
||||
quoted = strings.ReplaceAll(quoted, "|", `\u007c`)
|
||||
stages = append(stages, "where "+filterField+" "+filterOperator+" "+quoted)
|
||||
}
|
||||
stages = append(stages, "window "+window)
|
||||
|
||||
aggregate := values.Get("aggregate")
|
||||
aggregateField := values.Get("aggregate_field")
|
||||
groupBy := values.Get("group_by")
|
||||
bucket := values.Get("bucket")
|
||||
if aggregate == "none" {
|
||||
if aggregateField != "" || groupBy != "" || bucket != "" {
|
||||
return "", fmt.Errorf("summary options require an aggregate")
|
||||
}
|
||||
} else {
|
||||
if !allowed(aggregate, "count", "min", "max", "sum", "avg", "p50", "p95", "p99") ||
|
||||
!allowed(groupBy, "", "service", "project", "environment", "route", "status", "name", "severity") ||
|
||||
!allowed(bucket, "", "1m", "5m", "15m", "1h") {
|
||||
return "", fmt.Errorf("invalid summary")
|
||||
}
|
||||
expression := "count()"
|
||||
if aggregate == "count" {
|
||||
if aggregateField != "" {
|
||||
return "", fmt.Errorf("count accepts no field")
|
||||
}
|
||||
} else {
|
||||
if !allowed(aggregateField, "value", "duration", "status") {
|
||||
return "", fmt.Errorf("numeric aggregate field required")
|
||||
}
|
||||
expression = aggregate + "(" + aggregateField + ")"
|
||||
}
|
||||
groups := make([]string, 0, 2)
|
||||
if groupBy != "" {
|
||||
groups = append(groups, groupBy)
|
||||
}
|
||||
if bucket != "" {
|
||||
groups = append(groups, "window("+bucket+")")
|
||||
}
|
||||
stage := "summarize " + expression
|
||||
if len(groups) > 0 {
|
||||
stage += " by " + strings.Join(groups, ", ")
|
||||
}
|
||||
stages = append(stages, stage)
|
||||
}
|
||||
stages = append(stages, "limit "+strconv.Itoa(limit))
|
||||
text := strings.Join(stages, " | ")
|
||||
if _, err = query.Parse(text, maxRows); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return text, nil
|
||||
}
|
||||
|
||||
func (s *Server) createDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
values, principal, ok := s.authorizeManagementForm(w, r, "organization_id", "csrf_token", "slug", "name", "description", "panel_title", "saved_query_id", "visualization")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
organizationID := values.Get("organization_id")
|
||||
queryValue, err := s.store.SavedQuery(r.Context(), organizationID, values.Get("saved_query_id"))
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "dashboard query rejected")
|
||||
return
|
||||
}
|
||||
visualization := values.Get("visualization")
|
||||
if !validDashboardPresentation(queryValue, visualization) {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "dashboard presentation does not match query")
|
||||
return
|
||||
}
|
||||
_, err = s.store.SaveDashboard(r.Context(), storage.DashboardInput{
|
||||
OrganizationID: organizationID, Slug: values.Get("slug"), Name: values.Get("name"),
|
||||
Description: values.Get("description"), ActorUserID: principal.User.ID,
|
||||
Panels: []storage.DashboardPanel{{Position: 0, Title: values.Get("panel_title"), Visualization: visualization, SavedQueryID: queryValue.ID}},
|
||||
}, s.now())
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "dashboard rejected")
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/app/dashboards/"+url.PathEscape(values.Get("slug"))+"/?organization="+url.QueryEscape(organizationID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) updateDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
values, principal, ok := s.authorizeManagementForm(w, r, "organization_id", "csrf_token", "dashboard_id", "expected_revision", "slug", "name", "description")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
current, ok := s.dashboardForRevision(w, r, values)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
updated, err := s.store.SaveDashboard(r.Context(), storage.DashboardInput{
|
||||
ID: current.ID, ExpectedRevision: current.Revision, OrganizationID: current.OrganizationID,
|
||||
Slug: values.Get("slug"), Name: values.Get("name"), Description: values.Get("description"),
|
||||
Panels: current.Panels, ActorUserID: principal.User.ID,
|
||||
}, s.now())
|
||||
if !writeDashboardRevisionResult(w, err) {
|
||||
return
|
||||
}
|
||||
redirectDashboard(w, r, updated.Slug, current.OrganizationID)
|
||||
}
|
||||
|
||||
func (s *Server) addDashboardPanel(w http.ResponseWriter, r *http.Request) {
|
||||
values, principal, ok := s.authorizeManagementForm(w, r, "organization_id", "csrf_token", "dashboard_id", "expected_revision", "panel_title", "saved_query_id", "visualization")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
current, ok := s.dashboardForRevision(w, r, values)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
queryValue, err := s.store.SavedQuery(r.Context(), current.OrganizationID, values.Get("saved_query_id"))
|
||||
if err != nil || !validDashboardPresentation(queryValue, values.Get("visualization")) {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "dashboard panel rejected")
|
||||
return
|
||||
}
|
||||
panels := append([]storage.DashboardPanel(nil), current.Panels...)
|
||||
panels = append(panels, storage.DashboardPanel{Position: len(panels), Title: values.Get("panel_title"), Visualization: values.Get("visualization"), SavedQueryID: queryValue.ID})
|
||||
updated, err := s.store.SaveDashboard(r.Context(), storage.DashboardInput{
|
||||
ID: current.ID, ExpectedRevision: current.Revision, OrganizationID: current.OrganizationID,
|
||||
Slug: current.Slug, Name: current.Name, Description: current.Description,
|
||||
Panels: panels, ActorUserID: principal.User.ID,
|
||||
}, s.now())
|
||||
if !writeDashboardRevisionResult(w, err) {
|
||||
return
|
||||
}
|
||||
redirectDashboard(w, r, updated.Slug, current.OrganizationID)
|
||||
}
|
||||
|
||||
func (s *Server) updateDashboardPanel(w http.ResponseWriter, r *http.Request) {
|
||||
values, principal, ok := s.authorizeManagementForm(w, r, "organization_id", "csrf_token", "dashboard_id", "expected_revision", "panel_title", "saved_query_id", "visualization")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
current, ok := s.dashboardForRevision(w, r, values)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
queryValue, err := s.store.SavedQuery(r.Context(), current.OrganizationID, values.Get("saved_query_id"))
|
||||
if err != nil || !validDashboardPresentation(queryValue, values.Get("visualization")) {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "dashboard panel rejected")
|
||||
return
|
||||
}
|
||||
panelID := r.PathValue("panel")
|
||||
panels := append([]storage.DashboardPanel(nil), current.Panels...)
|
||||
found := false
|
||||
for index := range panels {
|
||||
if panels[index].ID != panelID {
|
||||
continue
|
||||
}
|
||||
panels[index].Title = values.Get("panel_title")
|
||||
panels[index].Visualization = values.Get("visualization")
|
||||
panels[index].SavedQueryID = queryValue.ID
|
||||
found = true
|
||||
break
|
||||
}
|
||||
if !found {
|
||||
writeProblem(w, http.StatusNotFound, "dashboard panel not found")
|
||||
return
|
||||
}
|
||||
updated, err := s.store.SaveDashboard(r.Context(), storage.DashboardInput{
|
||||
ID: current.ID, ExpectedRevision: current.Revision, OrganizationID: current.OrganizationID,
|
||||
Slug: current.Slug, Name: current.Name, Description: current.Description,
|
||||
Panels: panels, ActorUserID: principal.User.ID,
|
||||
}, s.now())
|
||||
if !writeDashboardRevisionResult(w, err) {
|
||||
return
|
||||
}
|
||||
redirectDashboard(w, r, updated.Slug, current.OrganizationID)
|
||||
}
|
||||
|
||||
func (s *Server) removeDashboardPanel(w http.ResponseWriter, r *http.Request) {
|
||||
values, principal, ok := s.authorizeManagementForm(w, r, "organization_id", "csrf_token", "dashboard_id", "expected_revision")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
current, ok := s.dashboardForRevision(w, r, values)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
panelID := r.PathValue("panel")
|
||||
panels := make([]storage.DashboardPanel, 0, len(current.Panels))
|
||||
for _, panel := range current.Panels {
|
||||
if panel.ID != panelID {
|
||||
panel.Position = len(panels)
|
||||
panels = append(panels, panel)
|
||||
}
|
||||
}
|
||||
if len(panels) == len(current.Panels) {
|
||||
writeProblem(w, http.StatusNotFound, "dashboard panel not found")
|
||||
return
|
||||
}
|
||||
updated, err := s.store.SaveDashboard(r.Context(), storage.DashboardInput{
|
||||
ID: current.ID, ExpectedRevision: current.Revision, OrganizationID: current.OrganizationID,
|
||||
Slug: current.Slug, Name: current.Name, Description: current.Description,
|
||||
Panels: panels, ActorUserID: principal.User.ID,
|
||||
}, s.now())
|
||||
if !writeDashboardRevisionResult(w, err) {
|
||||
return
|
||||
}
|
||||
redirectDashboard(w, r, updated.Slug, current.OrganizationID)
|
||||
}
|
||||
|
||||
func (s *Server) dashboardForRevision(w http.ResponseWriter, r *http.Request, values url.Values) (storage.Dashboard, bool) {
|
||||
current, err := s.store.Dashboard(r.Context(), values.Get("organization_id"), r.PathValue("slug"))
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusNotFound, "dashboard not found")
|
||||
return storage.Dashboard{}, false
|
||||
}
|
||||
revision, err := strconv.Atoi(values.Get("expected_revision"))
|
||||
if err != nil || revision < 1 || values.Get("dashboard_id") != current.ID {
|
||||
writeProblem(w, http.StatusBadRequest, "dashboard revision is invalid")
|
||||
return storage.Dashboard{}, false
|
||||
}
|
||||
if revision != current.Revision {
|
||||
writeProblem(w, http.StatusConflict, "dashboard changed; reload before editing")
|
||||
return storage.Dashboard{}, false
|
||||
}
|
||||
return current, true
|
||||
}
|
||||
|
||||
func validDashboardPresentation(saved storage.SavedQuery, visualization string) bool {
|
||||
switch visualization {
|
||||
case "table":
|
||||
return true
|
||||
case "stat":
|
||||
return saved.AST.Summary != nil
|
||||
case "timeseries":
|
||||
return saved.AST.Summary != nil && saved.AST.Bucket > 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func writeDashboardRevisionResult(w http.ResponseWriter, err error) bool {
|
||||
if err == nil {
|
||||
return true
|
||||
}
|
||||
if errors.Is(err, storage.ErrDashboardRevisionConflict) {
|
||||
writeProblem(w, http.StatusConflict, "dashboard changed; reload before editing")
|
||||
} else {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "dashboard revision rejected")
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func redirectDashboard(w http.ResponseWriter, r *http.Request, slug, organizationID string) {
|
||||
http.Redirect(w, r, "/app/dashboards/"+url.PathEscape(slug)+"/?organization="+url.QueryEscape(organizationID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) authorizeManagementForm(w http.ResponseWriter, r *http.Request, fields ...string) (url.Values, auth.Principal, bool) {
|
||||
return s.authorizeManagementFormFields(w, r, fields, nil)
|
||||
}
|
||||
|
||||
func (s *Server) authorizeManagementFormFields(w http.ResponseWriter, r *http.Request, required, optional []string) (url.Values, auth.Principal, bool) {
|
||||
principal, ok := auth.PrincipalFromContext(r.Context())
|
||||
values, err := readFormFields(w, r, 24<<10, required, optional)
|
||||
token, sessionOK := authhttp.SessionToken(r, s.cookie)
|
||||
csrfOK := err == nil && sessionOK && authhttp.VerifyCSRF(token, "dashboards:manage", values.Get("csrf_token"))
|
||||
if !tokenBoundFormRequest(r, s.options.PublicOrigin, csrfOK) {
|
||||
writeProblem(w, http.StatusForbidden, "valid dashboard form required")
|
||||
return nil, auth.Principal{}, false
|
||||
}
|
||||
if !ok {
|
||||
writeProblem(w, http.StatusUnauthorized, "authentication required")
|
||||
return nil, auth.Principal{}, false
|
||||
}
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusBadRequest, "invalid dashboard management request")
|
||||
return nil, auth.Principal{}, false
|
||||
}
|
||||
organizationID := values.Get("organization_id")
|
||||
scope := access.Scope{OrganizationID: organizationID}
|
||||
if err = s.identity.ValidateResourceScope(r.Context(), scope); err != nil {
|
||||
writeProblem(w, http.StatusBadRequest, "invalid organization")
|
||||
return nil, auth.Principal{}, false
|
||||
}
|
||||
decision, err := s.identity.Access.Authorize(r.Context(), principal.User.ID, scope, identity.PermissionDashboardsManage)
|
||||
if err != nil || !decision.Allowed {
|
||||
writeProblem(w, http.StatusForbidden, "dashboard management denied")
|
||||
return nil, auth.Principal{}, false
|
||||
}
|
||||
if !csrfOK {
|
||||
writeProblem(w, http.StatusForbidden, "valid dashboard CSRF token required")
|
||||
return nil, auth.Principal{}, false
|
||||
}
|
||||
return values, principal, true
|
||||
}
|
||||
|
||||
func (s *Server) dashboard(w http.ResponseWriter, r *http.Request) {
|
||||
organizationID, principal, ok := s.authorizeDashboardRead(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
dashboard, err := s.store.Dashboard(r.Context(), organizationID, r.PathValue("slug"))
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusNotFound, "dashboard not found")
|
||||
return
|
||||
}
|
||||
organizations, err := s.identity.OrganizationsForUser(r.Context(), principal.User.ID)
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusServiceUnavailable, "organization unavailable")
|
||||
return
|
||||
}
|
||||
organizationName := "Organization"
|
||||
for _, organization := range organizations {
|
||||
if organization.ID == organizationID {
|
||||
organizationName = organization.Name
|
||||
break
|
||||
}
|
||||
}
|
||||
view := site.DashboardView{
|
||||
Head: s.head(dashboard.Name+" — Gamertan Observatory", dashboard.Description, "/app/dashboards/"+url.PathEscape(dashboard.Slug)+"/"),
|
||||
DisplayName: principal.User.DisplayName, Organization: site.OrganizationOption{ID: organizationID, Name: organizationName, Selected: true},
|
||||
ID: dashboard.ID, Slug: dashboard.Slug, Revision: dashboard.Revision,
|
||||
Name: dashboard.Name, Description: dashboard.Description,
|
||||
ExportURL: "/app/dashboards/" + url.PathEscape(dashboard.Slug) + "/export.json?organization=" + url.QueryEscape(organizationID),
|
||||
}
|
||||
manage, err := s.identity.Access.Authorize(r.Context(), principal.User.ID, access.Scope{OrganizationID: organizationID}, identity.PermissionDashboardsManage)
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusServiceUnavailable, "authorization unavailable")
|
||||
return
|
||||
}
|
||||
view.CanManage = manage.Allowed
|
||||
if view.CanManage {
|
||||
token, sessionOK := authhttp.SessionToken(r, s.cookie)
|
||||
if !sessionOK {
|
||||
writeProblem(w, http.StatusUnauthorized, "authentication required")
|
||||
return
|
||||
}
|
||||
view.ManageCSRF, err = authhttp.CSRFToken(token, "dashboards:manage")
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusServiceUnavailable, "session unavailable")
|
||||
return
|
||||
}
|
||||
savedQueries, loadErr := s.store.SavedQueries(r.Context(), organizationID)
|
||||
if loadErr != nil {
|
||||
writeProblem(w, http.StatusServiceUnavailable, "saved queries unavailable")
|
||||
return
|
||||
}
|
||||
for _, saved := range savedQueries {
|
||||
view.SavedQueries = append(view.SavedQueries, site.SavedQuerySummary{ID: saved.ID, Name: saved.Name, Description: saved.Description, Query: saved.Query})
|
||||
}
|
||||
}
|
||||
for _, panel := range dashboard.Panels {
|
||||
view.Panels = append(view.Panels, s.dashboardPanel(r, principal.User.ID, organizationID, panel))
|
||||
}
|
||||
s.renderHTML(w, r, http.StatusOK, site.Dashboard(view))
|
||||
}
|
||||
|
||||
func (s *Server) dashboardPanel(r *http.Request, userID, organizationID string, panel storage.DashboardPanel) site.PanelView {
|
||||
view := site.PanelView{ID: panel.ID, SavedQueryID: panel.SavedQueryID, Title: panel.Title, Visualization: panel.Visualization, Table: site.TableView{Caption: panel.Title, Columns: []site.TableColumn{{Label: "Status"}}, Empty: "Panel data is unavailable."}}
|
||||
saved, err := s.store.SavedQuery(r.Context(), organizationID, panel.SavedQueryID)
|
||||
if err != nil {
|
||||
return view
|
||||
}
|
||||
view.Query = saved.Query
|
||||
scope := access.Scope{OrganizationID: organizationID, ProjectID: saved.Scope.ProjectID, EnvironmentID: saved.Scope.EnvironmentID, ServiceID: saved.Scope.ServiceID}
|
||||
decision, err := s.identity.Access.Authorize(r.Context(), userID, scope, identity.PermissionTelemetryQuery)
|
||||
if err != nil || !decision.Allowed || s.identity.ValidateResourceScope(r.Context(), scope) != nil {
|
||||
return view
|
||||
}
|
||||
sensitive, err := s.identity.Access.Authorize(r.Context(), userID, scope, identity.PermissionTelemetryReadSensitive)
|
||||
if err != nil {
|
||||
return view
|
||||
}
|
||||
result, err := s.store.Query(r.Context(), saved.AST, query.Scope{OrganizationID: organizationID, ProjectID: saved.Scope.ProjectID, EnvironmentID: saved.Scope.EnvironmentID, ServiceID: saved.Scope.ServiceID, Sensitive: sensitive.Allowed}, s.options.QueryBudget, s.now())
|
||||
if err != nil {
|
||||
return view
|
||||
}
|
||||
view.Table = resultTable(panel.Title, result)
|
||||
if panel.Visualization == "timeseries" {
|
||||
view.Chart = resultChart(panel.Title, result)
|
||||
}
|
||||
if panel.Visualization == "stat" {
|
||||
for _, row := range result.Rows {
|
||||
for _, value := range row.Values {
|
||||
if value != nil {
|
||||
view.Stat = boundedCell(*value)
|
||||
return view
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
func resultChart(title string, result query.Result) site.ChartView {
|
||||
if len(result.Columns) < 2 || len(result.Rows) == 0 {
|
||||
return site.ChartView{}
|
||||
}
|
||||
valueIndex := len(result.Columns) - 1
|
||||
valueType := result.Columns[valueIndex].Type
|
||||
if valueType != "integer" && valueType != "float" && valueType != "duration" {
|
||||
return site.ChartView{}
|
||||
}
|
||||
const maxPoints = 48
|
||||
points := make([]struct {
|
||||
label, display string
|
||||
value float64
|
||||
}, 0, min(len(result.Rows), maxPoints))
|
||||
maximum := float64(0)
|
||||
for index, row := range result.Rows {
|
||||
if index >= maxPoints || valueIndex >= len(row.Values) || row.Values[valueIndex] == nil {
|
||||
continue
|
||||
}
|
||||
value, err := strconv.ParseFloat(*row.Values[valueIndex], 64)
|
||||
if err != nil || math.IsNaN(value) || math.IsInf(value, 0) || value < 0 {
|
||||
return site.ChartView{}
|
||||
}
|
||||
labels := make([]string, 0, valueIndex)
|
||||
for column := 0; column < valueIndex && column < len(row.Values); column++ {
|
||||
if row.Values[column] != nil {
|
||||
labels = append(labels, boundedCell(*row.Values[column]))
|
||||
}
|
||||
}
|
||||
label := boundedCell(strings.Join(labels, " · "))
|
||||
if label == "" {
|
||||
label = fmt.Sprintf("Point %d", index+1)
|
||||
}
|
||||
display := boundedCell(*row.Values[valueIndex])
|
||||
if unit := result.Columns[valueIndex].Unit; unit != "" {
|
||||
display += " " + boundedCell(unit)
|
||||
}
|
||||
points = append(points, struct {
|
||||
label, display string
|
||||
value float64
|
||||
}{label: label, display: display, value: value})
|
||||
maximum = math.Max(maximum, value)
|
||||
}
|
||||
if len(points) == 0 {
|
||||
return site.ChartView{}
|
||||
}
|
||||
if maximum == 0 {
|
||||
maximum = 1
|
||||
}
|
||||
view := site.ChartView{Label: title + " visual summary"}
|
||||
for _, point := range points {
|
||||
view.Points = append(view.Points, site.ChartPoint{
|
||||
Label: point.label, Value: strconv.FormatFloat(point.value, 'g', -1, 64),
|
||||
Maximum: strconv.FormatFloat(maximum, 'g', -1, 64), Display: point.display,
|
||||
})
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
func (s *Server) exportDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
organizationID, _, ok := s.authorizeDashboardRead(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
exported, err := s.store.ExportDashboard(r.Context(), organizationID, r.PathValue("slug"))
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusNotFound, "dashboard not found")
|
||||
return
|
||||
}
|
||||
var body bytes.Buffer
|
||||
encoder := json.NewEncoder(&body)
|
||||
encoder.SetIndent("", " ")
|
||||
if err = encoder.Encode(exported); err != nil {
|
||||
writeProblem(w, http.StatusInternalServerError, "dashboard export unavailable")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", "observatory-dashboard-"+r.PathValue("slug")+".json"))
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", body.Len()))
|
||||
if r.Method != http.MethodHead {
|
||||
_, _ = w.Write(body.Bytes())
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) authorizeDashboardRead(w http.ResponseWriter, r *http.Request) (string, auth.Principal, bool) {
|
||||
principal, ok := auth.PrincipalFromContext(r.Context())
|
||||
if !ok {
|
||||
writeProblem(w, http.StatusUnauthorized, "authentication required")
|
||||
return "", auth.Principal{}, false
|
||||
}
|
||||
values := r.URL.Query()
|
||||
organizationID := values.Get("organization")
|
||||
if len(values) != 1 || len(values["organization"]) != 1 || organizationID == "" {
|
||||
writeProblem(w, http.StatusBadRequest, "organization is required")
|
||||
return "", auth.Principal{}, false
|
||||
}
|
||||
scope := access.Scope{OrganizationID: organizationID}
|
||||
if err := s.identity.ValidateResourceScope(r.Context(), scope); err != nil {
|
||||
writeProblem(w, http.StatusBadRequest, "invalid organization")
|
||||
return "", auth.Principal{}, false
|
||||
}
|
||||
decision, err := s.identity.Access.Authorize(r.Context(), principal.User.ID, scope, identity.PermissionDashboardsRead)
|
||||
if err != nil || !decision.Allowed {
|
||||
writeProblem(w, http.StatusForbidden, "dashboard access denied")
|
||||
return "", auth.Principal{}, false
|
||||
}
|
||||
return organizationID, principal, true
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gamertan.com/observatory/internal/identity"
|
||||
"gamertan.com/observatory/internal/model"
|
||||
"gamertan.com/observatory/internal/nativeprotocol"
|
||||
"gamertan.com/observatory/internal/storage"
|
||||
)
|
||||
|
||||
func BenchmarkNativeExactReplay(b *testing.B) {
|
||||
root := filepath.Join(b.TempDir(), "data")
|
||||
if err := os.Mkdir(root, 0o700); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
store, err := storage.Open(root)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
identities, err := identity.Open(root)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
defer identities.Close()
|
||||
token, err := store.CreateSource(context.Background(), "source", model.Scope{OrganizationID: "org", ProjectID: "project", EnvironmentID: "production", ServiceID: "service"})
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
now := time.Date(2026, 8, 18, 20, 0, 0, 0, time.UTC)
|
||||
server, err := New(store, identities, testOptions())
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
server.now = func() time.Time { return now }
|
||||
handler := server.Handler()
|
||||
|
||||
legacy := model.Batch{Version: model.BatchVersion, SourceID: "source", StreamID: "legacy", Sequence: 1, ObservedAt: now, Signal: model.SignalLogs, Records: benchmarkRecords(now)}
|
||||
legacyBody, _ := json.Marshal(legacy)
|
||||
framed := legacy
|
||||
framed.StreamID = "framed"
|
||||
framedBody, _ := json.Marshal(framed)
|
||||
framedEnvelope, _ := framed.Envelope(framedBody)
|
||||
seed := func(path string, body []byte, envelope *model.BatchEnvelope) {
|
||||
request := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(body))
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
if envelope != nil {
|
||||
nativeprotocol.SetHeaders(request.Header, *envelope)
|
||||
}
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusAccepted {
|
||||
b.Fatalf("seed %s: %d %s", path, response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
seed("/api/v1/ingest/native", legacyBody, nil)
|
||||
seed("/api/v2/ingest/native", framedBody, &framedEnvelope)
|
||||
|
||||
for _, benchmark := range []struct {
|
||||
name string
|
||||
path string
|
||||
body []byte
|
||||
envelope *model.BatchEnvelope
|
||||
}{{"legacy-v1", "/api/v1/ingest/native", legacyBody, nil}, {"framed-v2", "/api/v2/ingest/native", framedBody, &framedEnvelope}} {
|
||||
b.Run(benchmark.name, func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
b.SetBytes(int64(len(benchmark.body)))
|
||||
for range b.N {
|
||||
request := httptest.NewRequest(http.MethodPost, benchmark.path, bytes.NewReader(benchmark.body))
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
if benchmark.envelope != nil {
|
||||
nativeprotocol.SetHeaders(request.Header, *benchmark.envelope)
|
||||
}
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusAccepted {
|
||||
b.Fatalf("status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func benchmarkRecords(now time.Time) []model.Observation {
|
||||
records := make([]model.Observation, 500)
|
||||
for index := range records {
|
||||
records[index] = model.Observation{Timestamp: now, Name: "http.request", Attributes: map[string]string{"route": "/items", "status": "200", "method": "GET"}}
|
||||
}
|
||||
return records
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"crypto/ecdh"
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
|
||||
"gamertan.com/observatory/internal/identity"
|
||||
"gamertan.com/observatory/internal/storage"
|
||||
"gamertan.com/observatory/internal/webpush"
|
||||
"gamertan.com/web/access"
|
||||
"gamertan.com/web/auth"
|
||||
"gamertan.com/web/authhttp"
|
||||
"gamertan.com/web/websec"
|
||||
)
|
||||
|
||||
type pushSubscriptionRequest struct {
|
||||
OrganizationID string `json:"organization_id"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Keys struct {
|
||||
P256DH string `json:"p256dh"`
|
||||
Auth string `json:"auth"`
|
||||
} `json:"keys"`
|
||||
}
|
||||
|
||||
func (s *Server) savePushSubscription(w http.ResponseWriter, r *http.Request) {
|
||||
request, principal, ok := s.authorizePushRequest(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
p256dh, p256Err := base64.RawURLEncoding.DecodeString(request.Keys.P256DH)
|
||||
authSecret, authErr := base64.RawURLEncoding.DecodeString(request.Keys.Auth)
|
||||
if p256Err != nil || authErr != nil || len(p256dh) != 65 || len(authSecret) != 16 {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "push subscription rejected")
|
||||
return
|
||||
}
|
||||
if _, err := ecdh.P256().NewPublicKey(p256dh); err != nil {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "push subscription rejected")
|
||||
return
|
||||
}
|
||||
if _, err := webpush.ValidateEndpoint(request.Endpoint); err != nil {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "push subscription rejected")
|
||||
return
|
||||
}
|
||||
subscription, err := s.store.SavePushSubscription(r.Context(), storage.PushSubscriptionInput{OrganizationID: request.OrganizationID, UserID: principal.User.ID, Endpoint: request.Endpoint, P256DH: p256dh, Auth: authSecret}, s.now())
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "push subscription rejected")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, struct {
|
||||
ID string `json:"id"`
|
||||
}{subscription.ID})
|
||||
}
|
||||
|
||||
func (s *Server) deletePushSubscription(w http.ResponseWriter, r *http.Request) {
|
||||
request, principal, ok := s.authorizePushRequest(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, err := webpush.ValidateEndpoint(request.Endpoint); err != nil {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "push subscription rejected")
|
||||
return
|
||||
}
|
||||
remaining, err := s.store.DeletePushSubscription(r.Context(), request.OrganizationID, principal.User.ID, request.Endpoint)
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusNotFound, "push subscription not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, struct {
|
||||
Remaining bool `json:"remaining"`
|
||||
}{remaining})
|
||||
}
|
||||
|
||||
func (s *Server) pushSubscriptionStatus(w http.ResponseWriter, r *http.Request) {
|
||||
request, principal, ok := s.authorizePushRequest(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, err := webpush.ValidateEndpoint(request.Endpoint); err != nil {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "push subscription rejected")
|
||||
return
|
||||
}
|
||||
subscribed, err := s.store.HasPushSubscription(r.Context(), request.OrganizationID, principal.User.ID, request.Endpoint)
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusServiceUnavailable, "push subscription status unavailable")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, struct {
|
||||
Subscribed bool `json:"subscribed"`
|
||||
}{subscribed})
|
||||
}
|
||||
|
||||
func (s *Server) authorizePushRequest(w http.ResponseWriter, r *http.Request) (pushSubscriptionRequest, auth.Principal, bool) {
|
||||
if s.options.PushDispatcher == nil {
|
||||
writeProblem(w, http.StatusNotFound, "Web Push is not configured")
|
||||
return pushSubscriptionRequest{}, auth.Principal{}, false
|
||||
}
|
||||
if !websec.SameOrigin(r, s.options.PublicOrigin) {
|
||||
writeProblem(w, http.StatusForbidden, "same-origin request required")
|
||||
return pushSubscriptionRequest{}, auth.Principal{}, false
|
||||
}
|
||||
principal, ok := auth.PrincipalFromContext(r.Context())
|
||||
if !ok {
|
||||
writeProblem(w, http.StatusUnauthorized, "authentication required")
|
||||
return pushSubscriptionRequest{}, auth.Principal{}, false
|
||||
}
|
||||
body := http.MaxBytesReader(w, r.Body, 8<<10)
|
||||
defer body.Close()
|
||||
var request pushSubscriptionRequest
|
||||
if err := decodeOne(body, &request); err != nil {
|
||||
writeProblem(w, http.StatusBadRequest, "invalid push subscription request")
|
||||
return pushSubscriptionRequest{}, auth.Principal{}, false
|
||||
}
|
||||
scope := access.Scope{OrganizationID: request.OrganizationID}
|
||||
decision, err := s.identity.Access.Authorize(r.Context(), principal.User.ID, scope, identity.PermissionIncidentsRead)
|
||||
if err != nil || !decision.Allowed {
|
||||
writeProblem(w, http.StatusForbidden, "incident access denied")
|
||||
return pushSubscriptionRequest{}, auth.Principal{}, false
|
||||
}
|
||||
if err = s.identity.ValidateResourceScope(r.Context(), scope); err != nil {
|
||||
writeProblem(w, http.StatusBadRequest, "invalid organization")
|
||||
return pushSubscriptionRequest{}, auth.Principal{}, false
|
||||
}
|
||||
token, sessionOK := authhttp.SessionToken(r, s.cookie)
|
||||
if !sessionOK || !authhttp.VerifyCSRF(token, "push:manage", r.Header.Get("X-CSRF-Token")) {
|
||||
writeProblem(w, http.StatusForbidden, "valid push CSRF token required")
|
||||
return pushSubscriptionRequest{}, auth.Principal{}, false
|
||||
}
|
||||
return request, principal, true
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"gamertan.com/observatory/internal/site"
|
||||
)
|
||||
|
||||
func (s *Server) webManifest(w http.ResponseWriter, r *http.Request) {
|
||||
serveFixedBody(w, r, site.WebManifest(), "application/manifest+json")
|
||||
}
|
||||
|
||||
func (s *Server) serviceWorker(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Service-Worker-Allowed", "/")
|
||||
serveFixedBody(w, r, site.ServiceWorker(), "text/javascript; charset=utf-8")
|
||||
}
|
||||
|
||||
func (s *Server) offlineShell(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderHTML(w, r, http.StatusOK, site.Offline(site.OfflineView{Head: s.head("Offline — Gamertan Observatory", "Observatory is temporarily unreachable.", "/offline/")}))
|
||||
}
|
||||
|
||||
func serveFixedBody(w http.ResponseWriter, r *http.Request, body []byte, contentType string) {
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(body)))
|
||||
if r.Method != http.MethodHead {
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var errRefreshCapacity = errors.New("live refresh capacity reached")
|
||||
|
||||
// refreshHub carries only a coalescible invalidation signal. It never carries
|
||||
// telemetry, resource names, incident details, or credentials.
|
||||
type refreshHub struct {
|
||||
mu sync.Mutex
|
||||
next uint64
|
||||
total int
|
||||
maxTotal int
|
||||
maxPerOrganization int
|
||||
subscribers map[string]map[uint64]chan struct{}
|
||||
}
|
||||
|
||||
func newRefreshHub(maxTotal, maxPerOrganization int) *refreshHub {
|
||||
return &refreshHub{maxTotal: maxTotal, maxPerOrganization: maxPerOrganization, subscribers: make(map[string]map[uint64]chan struct{})}
|
||||
}
|
||||
|
||||
func (hub *refreshHub) subscribe(organizationID string) (<-chan struct{}, func(), error) {
|
||||
hub.mu.Lock()
|
||||
defer hub.mu.Unlock()
|
||||
group := hub.subscribers[organizationID]
|
||||
if hub.total >= hub.maxTotal || len(group) >= hub.maxPerOrganization {
|
||||
return nil, nil, errRefreshCapacity
|
||||
}
|
||||
if group == nil {
|
||||
group = make(map[uint64]chan struct{})
|
||||
hub.subscribers[organizationID] = group
|
||||
}
|
||||
hub.next++
|
||||
id := hub.next
|
||||
updates := make(chan struct{}, 1)
|
||||
group[id] = updates
|
||||
hub.total++
|
||||
var once sync.Once
|
||||
remove := func() {
|
||||
once.Do(func() {
|
||||
hub.mu.Lock()
|
||||
defer hub.mu.Unlock()
|
||||
if current := hub.subscribers[organizationID]; current != nil {
|
||||
if _, exists := current[id]; exists {
|
||||
delete(current, id)
|
||||
hub.total--
|
||||
}
|
||||
if len(current) == 0 {
|
||||
delete(hub.subscribers, organizationID)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
return updates, remove, nil
|
||||
}
|
||||
|
||||
func (hub *refreshHub) publish(organizationID string) {
|
||||
hub.mu.Lock()
|
||||
defer hub.mu.Unlock()
|
||||
for _, updates := range hub.subscribers[organizationID] {
|
||||
select {
|
||||
case updates <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRefreshHubIsBoundedCoalescedAndOrganizationScoped(t *testing.T) {
|
||||
hub := newRefreshHub(2, 1)
|
||||
first, removeFirst, err := hub.subscribe("organization-a")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer removeFirst()
|
||||
if _, _, err = hub.subscribe("organization-a"); !errors.Is(err, errRefreshCapacity) {
|
||||
t.Fatalf("same-organization capacity err=%v", err)
|
||||
}
|
||||
second, removeSecond, err := hub.subscribe("organization-b")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer removeSecond()
|
||||
if _, _, err = hub.subscribe("organization-c"); !errors.Is(err, errRefreshCapacity) {
|
||||
t.Fatalf("total capacity err=%v", err)
|
||||
}
|
||||
|
||||
hub.publish("organization-a")
|
||||
hub.publish("organization-a")
|
||||
select {
|
||||
case <-first:
|
||||
default:
|
||||
t.Fatal("organization A did not receive refresh")
|
||||
}
|
||||
select {
|
||||
case <-first:
|
||||
t.Fatal("duplicate refresh was not coalesced")
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case <-second:
|
||||
t.Fatal("organization B received organization A refresh")
|
||||
default:
|
||||
}
|
||||
|
||||
removeFirst()
|
||||
if _, removeReplacement, err := hub.subscribe("organization-a"); err != nil {
|
||||
t.Fatalf("released capacity was not reusable: %v", err)
|
||||
} else {
|
||||
removeReplacement()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,810 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"crypto/ecdh"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gamertan.com/observatory/internal/identity"
|
||||
"gamertan.com/observatory/internal/model"
|
||||
"gamertan.com/observatory/internal/nativeprotocol"
|
||||
"gamertan.com/observatory/internal/otlp"
|
||||
"gamertan.com/observatory/internal/query"
|
||||
"gamertan.com/observatory/internal/storage"
|
||||
"gamertan.com/web/access"
|
||||
"gamertan.com/web/auth"
|
||||
"gamertan.com/web/authhttp"
|
||||
"gamertan.com/web/requestmeta"
|
||||
"gamertan.com/web/websec"
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
PublicOrigin string
|
||||
MaxBodyBytes int64
|
||||
MaxConcurrentIngest int
|
||||
MaxQueryRows int
|
||||
QueryBudget query.Budget
|
||||
SessionLifetime time.Duration
|
||||
PushPublicKey string
|
||||
PushDispatcher PushDispatcher
|
||||
}
|
||||
|
||||
type PushDispatcher interface {
|
||||
Enqueue(organizationID string) bool
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
store *storage.Store
|
||||
identity *identity.Services
|
||||
options Options
|
||||
cookie authhttp.CookieConfig
|
||||
now func() time.Time
|
||||
refresh *refreshHub
|
||||
requests *requestmeta.Resolver
|
||||
ingestSlots chan struct{}
|
||||
}
|
||||
|
||||
func New(store *storage.Store, identities *identity.Services, options Options) (*Server, error) {
|
||||
if store == nil || identities == nil || identities.Auth == nil || identities.Access == nil {
|
||||
return nil, errors.New("server storage and identity services are required")
|
||||
}
|
||||
origin, err := url.Parse(options.PublicOrigin)
|
||||
if err != nil || origin.Scheme != "https" || origin.Host == "" || origin.User != nil || origin.Path != "" && origin.Path != "/" || origin.RawQuery != "" || origin.Fragment != "" {
|
||||
return nil, errors.New("server public origin must be an absolute HTTPS origin")
|
||||
}
|
||||
options.PublicOrigin = strings.TrimSuffix(options.PublicOrigin, "/")
|
||||
if options.MaxConcurrentIngest == 0 {
|
||||
options.MaxConcurrentIngest = 8
|
||||
}
|
||||
if options.MaxBodyBytes < 1024 || options.MaxConcurrentIngest < 1 || options.MaxConcurrentIngest > 64 || options.MaxQueryRows < 1 || options.SessionLifetime < 5*time.Minute || options.SessionLifetime > 30*24*time.Hour {
|
||||
return nil, errors.New("server limits are invalid")
|
||||
}
|
||||
if options.QueryBudget.MaxRows != options.MaxQueryRows || options.QueryBudget.MaxDuration < time.Millisecond || options.QueryBudget.MaxScannedBytes < 1 || options.QueryBudget.MaxMemoryBytes < 1 {
|
||||
return nil, errors.New("server query budget is invalid")
|
||||
}
|
||||
if (options.PushPublicKey == "") != (options.PushDispatcher == nil) {
|
||||
return nil, errors.New("server Web Push key and dispatcher must be configured together")
|
||||
}
|
||||
if options.PushPublicKey != "" {
|
||||
publicKey, decodeErr := base64.RawURLEncoding.DecodeString(options.PushPublicKey)
|
||||
if decodeErr != nil || len(publicKey) != 65 {
|
||||
return nil, errors.New("server Web Push public key is invalid")
|
||||
}
|
||||
if _, decodeErr = ecdh.P256().NewPublicKey(publicKey); decodeErr != nil {
|
||||
return nil, errors.New("server Web Push public key is invalid")
|
||||
}
|
||||
}
|
||||
cookie := authhttp.CookieConfig{Name: "__Host-observatory_session", Lifetime: options.SessionLifetime, SameSite: http.SameSiteStrictMode}
|
||||
if err = cookie.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
requests, err := requestmeta.New(requestmeta.Config{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("server request metadata: %w", err)
|
||||
}
|
||||
return &Server{store: store, identity: identities, options: options, cookie: cookie, now: func() time.Time { return time.Now().UTC() }, refresh: newRefreshHub(256, 8), requests: requests, ingestSlots: make(chan struct{}, options.MaxConcurrentIngest)}, nil
|
||||
}
|
||||
|
||||
func (s *Server) enterIngest(w http.ResponseWriter) (func(), bool) {
|
||||
select {
|
||||
case s.ingestSlots <- struct{}{}:
|
||||
return func() { <-s.ingestSlots }, true
|
||||
default:
|
||||
w.Header().Set("Retry-After", "1")
|
||||
writeProblem(w, http.StatusServiceUnavailable, "ingestion capacity temporarily unavailable")
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /healthz", textOK("ok\n"))
|
||||
mux.HandleFunc("HEAD /healthz", textOK("ok\n"))
|
||||
mux.HandleFunc("GET /readyz", textOK("ready\n"))
|
||||
mux.HandleFunc("HEAD /readyz", textOK("ready\n"))
|
||||
mux.HandleFunc("GET /{$}", s.landing)
|
||||
mux.HandleFunc("HEAD /{$}", s.landing)
|
||||
mux.HandleFunc("GET /manifest.webmanifest", s.webManifest)
|
||||
mux.HandleFunc("HEAD /manifest.webmanifest", s.webManifest)
|
||||
mux.HandleFunc("GET /service-worker.js", s.serviceWorker)
|
||||
mux.HandleFunc("HEAD /service-worker.js", s.serviceWorker)
|
||||
mux.HandleFunc("GET /offline/{$}", s.offlineShell)
|
||||
mux.HandleFunc("HEAD /offline/{$}", s.offlineShell)
|
||||
mux.HandleFunc("GET /login/{$}", s.loginPage)
|
||||
mux.HandleFunc("HEAD /login/{$}", s.loginPage)
|
||||
mux.HandleFunc("POST /login/{$}", s.loginForm)
|
||||
mux.HandleFunc("POST /logout/{$}", s.logoutForm)
|
||||
mux.HandleFunc("GET /account/password/{$}", s.passwordPage)
|
||||
mux.HandleFunc("HEAD /account/password/{$}", s.passwordPage)
|
||||
mux.HandleFunc("POST /account/password/{$}", s.passwordForm)
|
||||
mux.HandleFunc("GET /app/{$}", s.app)
|
||||
mux.HandleFunc("HEAD /app/{$}", s.app)
|
||||
mux.HandleFunc("GET /app/explore/{$}", s.explorePage)
|
||||
mux.HandleFunc("HEAD /app/explore/{$}", s.explorePage)
|
||||
mux.HandleFunc("POST /app/explore/{$}", s.exploreForm)
|
||||
mux.HandleFunc("GET /app/events", s.events)
|
||||
mux.HandleFunc("POST /app/queries/{$}", s.createSavedQuery)
|
||||
mux.HandleFunc("POST /app/queries/builder/{$}", s.createBuiltQuery)
|
||||
mux.HandleFunc("POST /app/dashboards/{$}", s.createDashboard)
|
||||
mux.HandleFunc("GET /app/dashboards/{slug}/{$}", s.dashboard)
|
||||
mux.HandleFunc("HEAD /app/dashboards/{slug}/{$}", s.dashboard)
|
||||
mux.HandleFunc("POST /app/dashboards/{slug}/{$}", s.updateDashboard)
|
||||
mux.HandleFunc("POST /app/dashboards/{slug}/panels/{$}", s.addDashboardPanel)
|
||||
mux.HandleFunc("POST /app/dashboards/{slug}/panels/{panel}/{$}", s.updateDashboardPanel)
|
||||
mux.HandleFunc("POST /app/dashboards/{slug}/panels/{panel}/remove/{$}", s.removeDashboardPanel)
|
||||
mux.HandleFunc("GET /app/dashboards/{slug}/export.json", s.exportDashboard)
|
||||
mux.HandleFunc("HEAD /app/dashboards/{slug}/export.json", s.exportDashboard)
|
||||
mux.HandleFunc("GET /app/incidents/{$}", s.incidentInbox)
|
||||
mux.HandleFunc("HEAD /app/incidents/{$}", s.incidentInbox)
|
||||
mux.HandleFunc("GET /app/incidents/offline/{$}", s.offlineIncidentInbox)
|
||||
mux.HandleFunc("HEAD /app/incidents/offline/{$}", s.offlineIncidentInbox)
|
||||
mux.HandleFunc("POST /app/alert-rules/{$}", s.createAlertRule)
|
||||
mux.HandleFunc("POST /app/incidents/{id}/{$}", s.transitionIncident)
|
||||
mux.HandleFunc("GET /assets/", s.serveAsset)
|
||||
mux.HandleFunc("HEAD /assets/", s.serveAsset)
|
||||
mux.HandleFunc("POST /api/v1/ingest/native", s.ingest)
|
||||
mux.HandleFunc("POST /api/v2/ingest/native", s.ingestFramed)
|
||||
mux.HandleFunc("POST /v1/logs", s.ingestOTLP(otlp.Logs))
|
||||
mux.HandleFunc("POST /v1/metrics", s.ingestOTLP(otlp.Metrics))
|
||||
mux.HandleFunc("POST /v1/traces", s.ingestOTLP(otlp.Traces))
|
||||
mux.HandleFunc("POST /api/v1/agent/enroll", s.enrollAgent)
|
||||
mux.HandleFunc("POST /api/v1/agent/alert-transition", s.recordAgentAlertTransition)
|
||||
mux.HandleFunc("DELETE /api/v1/agent/source", s.revokeAgentSource)
|
||||
mux.HandleFunc("POST /api/v1/session", s.login)
|
||||
mux.HandleFunc("DELETE /api/v1/session", s.logout)
|
||||
mux.HandleFunc("POST /api/v1/account/password", s.changePassword)
|
||||
mux.HandleFunc("POST /api/v1/query/parse", s.parseQuery)
|
||||
mux.HandleFunc("POST /api/v1/query/explain", s.explainQuery)
|
||||
mux.HandleFunc("POST /api/v1/query", s.executeQuery)
|
||||
mux.HandleFunc("POST /api/v1/push/subscription", s.savePushSubscription)
|
||||
mux.HandleFunc("POST /api/v1/push/subscription/status", s.pushSubscriptionStatus)
|
||||
mux.HandleFunc("DELETE /api/v1/push/subscription", s.deletePushSubscription)
|
||||
return securityHeaders(s.requests.Middleware(authhttp.Optional(s.identity.Auth, s.cookie)(s.requirePasswordChange(mux))))
|
||||
}
|
||||
|
||||
func (s *Server) recordAgentAlertTransition(w http.ResponseWriter, r *http.Request) {
|
||||
token, ok := bearer(r.Header.Get("Authorization"))
|
||||
if !ok || !strings.HasPrefix(token, "obs1.") {
|
||||
writeProblem(w, http.StatusUnauthorized, "source authentication required")
|
||||
return
|
||||
}
|
||||
// Authenticate before decoding. RecordSourceAlertTransition authenticates
|
||||
// again while binding scope and raw evidence to the credential.
|
||||
if _, err := s.store.Authenticate(r.Context(), token); err != nil {
|
||||
writeProblem(w, http.StatusUnauthorized, "source authentication required")
|
||||
return
|
||||
}
|
||||
leave, ok := s.enterIngest(w)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
defer leave()
|
||||
body := http.MaxBytesReader(w, r.Body, 64<<10)
|
||||
defer body.Close()
|
||||
var transition model.AlertTransition
|
||||
if err := decodeOne(body, &transition); err != nil {
|
||||
writeProblem(w, http.StatusBadRequest, "invalid source alert transition")
|
||||
return
|
||||
}
|
||||
ack, err := s.store.RecordSourceAlertTransition(r.Context(), token, transition, s.now())
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "source alert transition rejected")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, ack)
|
||||
}
|
||||
|
||||
func (s *Server) requirePasswordChange(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
principal, ok := auth.PrincipalFromContext(r.Context())
|
||||
if !ok || !principal.User.PasswordChangeRequired || passwordChangeAllowed(r) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodGet || r.Method == http.MethodHead {
|
||||
http.Redirect(w, r, "/account/password/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
writeProblem(w, http.StatusForbidden, "password change required")
|
||||
})
|
||||
}
|
||||
|
||||
func passwordChangeAllowed(r *http.Request) bool {
|
||||
switch r.URL.Path {
|
||||
case "/healthz", "/readyz", "/manifest.webmanifest", "/service-worker.js", "/offline/", "/account/password/", "/logout/", "/api/v1/account/password", "/api/v1/session":
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(r.URL.Path, "/assets/")
|
||||
}
|
||||
|
||||
func (s *Server) revokeAgentSource(w http.ResponseWriter, r *http.Request) {
|
||||
token, ok := bearer(r.Header.Get("Authorization"))
|
||||
if !ok || !strings.HasPrefix(token, "obs1.") {
|
||||
writeProblem(w, http.StatusUnauthorized, "source authentication required")
|
||||
return
|
||||
}
|
||||
source, err := s.store.Authenticate(r.Context(), token)
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusUnauthorized, "source authentication required")
|
||||
return
|
||||
}
|
||||
if err = s.store.RevokeSource(r.Context(), source.ID); err != nil {
|
||||
writeProblem(w, http.StatusServiceUnavailable, "source revocation unavailable")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) enrollAgent(w http.ResponseWriter, r *http.Request) {
|
||||
token, ok := bearer(r.Header.Get("Authorization"))
|
||||
if !ok || !strings.HasPrefix(token, "obse1.") {
|
||||
writeProblem(w, http.StatusUnauthorized, "valid enrollment required")
|
||||
return
|
||||
}
|
||||
body := http.MaxBytesReader(w, r.Body, 1)
|
||||
defer body.Close()
|
||||
payload, bodyErr := io.ReadAll(body)
|
||||
if bodyErr != nil || len(payload) != 0 {
|
||||
writeProblem(w, http.StatusBadRequest, "enrollment request body must be empty")
|
||||
return
|
||||
}
|
||||
enrollment, credential, err := s.store.RedeemEnrollment(r.Context(), token, s.now())
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusUnauthorized, "valid enrollment required")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, struct {
|
||||
SourceID string `json:"source_id"`
|
||||
Credential string `json:"credential"`
|
||||
}{enrollment.SourceID, credential})
|
||||
}
|
||||
|
||||
func (s *Server) ingest(w http.ResponseWriter, r *http.Request) {
|
||||
token, ok := bearer(r.Header.Get("Authorization"))
|
||||
if !ok {
|
||||
writeProblem(w, http.StatusUnauthorized, "source authentication required")
|
||||
return
|
||||
}
|
||||
source, err := s.store.Authenticate(r.Context(), token)
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusUnauthorized, "source authentication required")
|
||||
return
|
||||
}
|
||||
leave, ok := s.enterIngest(w)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
defer leave()
|
||||
body := http.MaxBytesReader(w, r.Body, s.options.MaxBodyBytes)
|
||||
defer body.Close()
|
||||
var batch model.Batch
|
||||
if err := decodeOne(body, &batch); err != nil {
|
||||
writeProblem(w, http.StatusBadRequest, "invalid native batch")
|
||||
return
|
||||
}
|
||||
ack, err := s.store.Ingest(r.Context(), token, batch, s.now())
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "batch rejected")
|
||||
return
|
||||
}
|
||||
s.refresh.publish(source.Scope.OrganizationID)
|
||||
writeJSON(w, http.StatusAccepted, ack)
|
||||
}
|
||||
|
||||
func (s *Server) ingestFramed(w http.ResponseWriter, r *http.Request) {
|
||||
token, ok := bearer(r.Header.Get("Authorization"))
|
||||
if !ok {
|
||||
writeProblem(w, http.StatusUnauthorized, "source authentication required")
|
||||
return
|
||||
}
|
||||
source, err := s.store.Authenticate(r.Context(), token)
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusUnauthorized, "source authentication required")
|
||||
return
|
||||
}
|
||||
leave, ok := s.enterIngest(w)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
defer leave()
|
||||
mediaType, parameters, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||
if err != nil || mediaType != "application/json" || len(parameters) != 0 {
|
||||
writeProblem(w, http.StatusUnsupportedMediaType, "native JSON content type required")
|
||||
return
|
||||
}
|
||||
envelope, err := nativeprotocol.ParseHeaders(r.Header, s.options.MaxBodyBytes)
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusBadRequest, "invalid native batch envelope")
|
||||
return
|
||||
}
|
||||
body := http.MaxBytesReader(w, r.Body, s.options.MaxBodyBytes)
|
||||
defer body.Close()
|
||||
if _, exact, checkErr := s.store.CheckNativeReplay(r.Context(), token, envelope); checkErr != nil {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "batch rejected")
|
||||
return
|
||||
} else if exact {
|
||||
if err = verifyNativeReplayBody(body, envelope); err != nil {
|
||||
writeProblem(w, http.StatusBadRequest, "native batch body does not match envelope")
|
||||
return
|
||||
}
|
||||
ack, confirmErr := s.store.ConfirmNativeReplay(r.Context(), token, envelope)
|
||||
if confirmErr != nil {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "batch rejected")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, ack)
|
||||
return
|
||||
}
|
||||
encoded, err := io.ReadAll(body)
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusBadRequest, "invalid native batch")
|
||||
return
|
||||
}
|
||||
var batch model.Batch
|
||||
if err = decodeOne(bytes.NewReader(encoded), &batch); err != nil {
|
||||
writeProblem(w, http.StatusBadRequest, "invalid native batch")
|
||||
return
|
||||
}
|
||||
ack, err := s.store.IngestNative(r.Context(), token, batch, envelope, encoded, s.now())
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "batch rejected")
|
||||
return
|
||||
}
|
||||
if !ack.Duplicate {
|
||||
s.refresh.publish(source.Scope.OrganizationID)
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, ack)
|
||||
}
|
||||
|
||||
func verifyNativeReplayBody(body io.Reader, envelope model.BatchEnvelope) error {
|
||||
digest := sha256.New()
|
||||
written, err := io.Copy(digest, body)
|
||||
if err != nil || written != envelope.EncodedBytes || hex.EncodeToString(digest.Sum(nil)) != envelope.WireDigest {
|
||||
return errors.New("native batch body does not match envelope")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) ingestOTLP(signal otlp.Signal) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
token, ok := bearer(r.Header.Get("Authorization"))
|
||||
if !ok || !strings.HasPrefix(token, "obs1.") {
|
||||
writeProblem(w, http.StatusUnauthorized, "source authentication required")
|
||||
return
|
||||
}
|
||||
// Authenticate before parsing attacker-controlled protobuf. IngestAuto
|
||||
// authenticates again while assigning the next sequence under its source
|
||||
// lock so revocation cannot race into an acknowledged write.
|
||||
source, err := s.store.Authenticate(r.Context(), token)
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusUnauthorized, "source authentication required")
|
||||
return
|
||||
}
|
||||
leave, ok := s.enterIngest(w)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
defer leave()
|
||||
mediaType, parameters, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||
if err != nil || mediaType != "application/x-protobuf" || len(parameters) != 0 {
|
||||
writeProblem(w, http.StatusUnsupportedMediaType, "OTLP protobuf content type required")
|
||||
return
|
||||
}
|
||||
body, status, err := readOTLPBody(w, r, s.options.MaxBodyBytes)
|
||||
if err != nil {
|
||||
title := "invalid OTLP request body"
|
||||
if status == http.StatusRequestEntityTooLarge {
|
||||
title = "OTLP request body exceeds limit"
|
||||
} else if status == http.StatusUnsupportedMediaType {
|
||||
title = "unsupported OTLP content encoding"
|
||||
}
|
||||
writeProblem(w, status, title)
|
||||
return
|
||||
}
|
||||
now := s.now()
|
||||
records, err := otlp.Decode(signal, body, now)
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusBadRequest, "invalid OTLP protobuf payload")
|
||||
return
|
||||
}
|
||||
if _, err = s.store.IngestAuto(r.Context(), token, signal.StreamID(), signal.ModelSignal(), records, now); err != nil {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "OTLP batch rejected")
|
||||
return
|
||||
}
|
||||
s.refresh.publish(source.Scope.OrganizationID)
|
||||
response, err := otlp.SuccessResponse(signal)
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusInternalServerError, "OTLP response unavailable")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/x-protobuf")
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(response)))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if len(response) > 0 {
|
||||
_, _ = w.Write(response)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readOTLPBody(w http.ResponseWriter, r *http.Request, limit int64) ([]byte, int, error) {
|
||||
compressed := http.MaxBytesReader(w, r.Body, limit)
|
||||
defer compressed.Close()
|
||||
encoding := strings.TrimSpace(strings.ToLower(r.Header.Get("Content-Encoding")))
|
||||
var reader io.Reader = compressed
|
||||
var zipped *gzip.Reader
|
||||
switch encoding {
|
||||
case "", "identity":
|
||||
case "gzip":
|
||||
var err error
|
||||
zipped, err = gzip.NewReader(compressed)
|
||||
if err != nil {
|
||||
return nil, http.StatusBadRequest, err
|
||||
}
|
||||
defer zipped.Close()
|
||||
reader = io.LimitReader(zipped, limit+1)
|
||||
default:
|
||||
return nil, http.StatusUnsupportedMediaType, errors.New("unsupported content encoding")
|
||||
}
|
||||
body, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
var tooLarge *http.MaxBytesError
|
||||
if errors.As(err, &tooLarge) {
|
||||
return nil, http.StatusRequestEntityTooLarge, err
|
||||
}
|
||||
return nil, http.StatusBadRequest, err
|
||||
}
|
||||
if int64(len(body)) > limit {
|
||||
return nil, http.StatusRequestEntityTooLarge, errors.New("decoded body exceeds limit")
|
||||
}
|
||||
return body, http.StatusOK, nil
|
||||
}
|
||||
|
||||
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
|
||||
if !websec.SameOrigin(r, s.options.PublicOrigin) {
|
||||
writeProblem(w, http.StatusForbidden, "same-origin request required")
|
||||
return
|
||||
}
|
||||
body := http.MaxBytesReader(w, r.Body, 16<<10)
|
||||
defer body.Close()
|
||||
var input struct {
|
||||
Identifier string `json:"identifier"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := decodeOne(body, &input); err != nil {
|
||||
writeProblem(w, http.StatusBadRequest, "invalid session request")
|
||||
return
|
||||
}
|
||||
token, principal, err := s.identity.Auth.Authenticate(r.Context(), input.Identifier, input.Password, s.options.SessionLifetime)
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusUnauthorized, "invalid credentials")
|
||||
return
|
||||
}
|
||||
if err = authhttp.SetSession(w, s.cookie, token, s.now()); err != nil {
|
||||
_ = s.identity.Auth.RevokeSession(r.Context(), token)
|
||||
writeProblem(w, http.StatusServiceUnavailable, "session unavailable")
|
||||
return
|
||||
}
|
||||
csrf, err := authhttp.CSRFToken(token, "session:delete")
|
||||
if err != nil {
|
||||
_ = s.identity.Auth.RevokeSession(r.Context(), token)
|
||||
writeProblem(w, http.StatusServiceUnavailable, "session unavailable")
|
||||
return
|
||||
}
|
||||
passwordCSRF := ""
|
||||
if principal.User.PasswordChangeRequired {
|
||||
passwordCSRF, err = authhttp.CSRFToken(token, "account:password:change")
|
||||
if err != nil {
|
||||
_ = s.identity.Auth.RevokeSession(r.Context(), token)
|
||||
writeProblem(w, http.StatusServiceUnavailable, "session unavailable")
|
||||
return
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, struct {
|
||||
UserID string `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
CSRFToken string `json:"csrf_token"`
|
||||
PasswordChangeCSRF string `json:"password_change_csrf,omitempty"`
|
||||
PasswordChangeRequired bool `json:"password_change_required"`
|
||||
}{principal.User.ID, principal.User.Username, principal.User.DisplayName, csrf, passwordCSRF, principal.User.PasswordChangeRequired})
|
||||
}
|
||||
|
||||
func (s *Server) changePassword(w http.ResponseWriter, r *http.Request) {
|
||||
if !websec.SameOrigin(r, s.options.PublicOrigin) {
|
||||
writeProblem(w, http.StatusForbidden, "same-origin request required")
|
||||
return
|
||||
}
|
||||
principal, ok := auth.PrincipalFromContext(r.Context())
|
||||
token, tokenOK := authhttp.SessionToken(r, s.cookie)
|
||||
if !ok || !tokenOK || !principal.User.PasswordChangeRequired || !authhttp.VerifyCSRF(token, "account:password:change", r.Header.Get("X-CSRF-Token")) {
|
||||
writeProblem(w, http.StatusForbidden, "password change authorization required")
|
||||
return
|
||||
}
|
||||
body := http.MaxBytesReader(w, r.Body, 8<<10)
|
||||
defer body.Close()
|
||||
var input struct {
|
||||
CurrentPassword string `json:"current_password"`
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
if err := decodeOne(body, &input); err != nil {
|
||||
writeProblem(w, http.StatusBadRequest, "invalid password change request")
|
||||
return
|
||||
}
|
||||
if err := s.identity.Auth.ChangePassword(r.Context(), principal.User.ID, input.CurrentPassword, input.NewPassword); err != nil {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "password change rejected")
|
||||
return
|
||||
}
|
||||
if err := authhttp.ClearSession(w, s.cookie); err != nil {
|
||||
writeProblem(w, http.StatusServiceUnavailable, "session unavailable")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
|
||||
if !websec.SameOrigin(r, s.options.PublicOrigin) {
|
||||
writeProblem(w, http.StatusForbidden, "same-origin request required")
|
||||
return
|
||||
}
|
||||
token, ok := authhttp.SessionToken(r, s.cookie)
|
||||
if !ok || !authhttp.VerifyCSRF(token, "session:delete", r.Header.Get("X-CSRF-Token")) {
|
||||
writeProblem(w, http.StatusForbidden, "valid session CSRF token required")
|
||||
return
|
||||
}
|
||||
if err := s.identity.Auth.RevokeSession(r.Context(), token); err != nil && !errors.Is(err, auth.ErrSessionNotFound) {
|
||||
writeProblem(w, http.StatusServiceUnavailable, "session unavailable")
|
||||
return
|
||||
}
|
||||
if err := authhttp.ClearSession(w, s.cookie); err != nil {
|
||||
writeProblem(w, http.StatusServiceUnavailable, "session unavailable")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
type queryRequest struct {
|
||||
Query string `json:"query,omitempty"`
|
||||
AST *query.AST `json:"ast,omitempty"`
|
||||
OrganizationID string `json:"organization_id,omitempty"`
|
||||
ProjectID string `json:"project_id,omitempty"`
|
||||
EnvironmentID string `json:"environment_id,omitempty"`
|
||||
ServiceID string `json:"service_id,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) parseQuery(w http.ResponseWriter, r *http.Request) {
|
||||
body := http.MaxBytesReader(w, r.Body, 16<<10)
|
||||
defer body.Close()
|
||||
var input queryRequest
|
||||
if err := decodeOne(body, &input); err != nil {
|
||||
writeProblem(w, http.StatusBadRequest, "invalid query request")
|
||||
return
|
||||
}
|
||||
ast, err := parseAST(input, s.options.MaxQueryRows)
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "query rejected")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, ast)
|
||||
}
|
||||
|
||||
func (s *Server) explainQuery(w http.ResponseWriter, r *http.Request) {
|
||||
if !websec.SameOrigin(r, s.options.PublicOrigin) {
|
||||
writeProblem(w, http.StatusForbidden, "same-origin request required")
|
||||
return
|
||||
}
|
||||
principal, ok := auth.PrincipalFromContext(r.Context())
|
||||
if !ok {
|
||||
writeProblem(w, http.StatusUnauthorized, "authentication required")
|
||||
return
|
||||
}
|
||||
body := http.MaxBytesReader(w, r.Body, 16<<10)
|
||||
defer body.Close()
|
||||
var input queryRequest
|
||||
if err := decodeOne(body, &input); err != nil {
|
||||
writeProblem(w, http.StatusBadRequest, "invalid query request")
|
||||
return
|
||||
}
|
||||
ast, err := parseAST(input, s.options.MaxQueryRows)
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "query rejected")
|
||||
return
|
||||
}
|
||||
requested := access.Scope{OrganizationID: input.OrganizationID, ProjectID: input.ProjectID, EnvironmentID: input.EnvironmentID, ServiceID: input.ServiceID}
|
||||
decision, err := s.identity.Access.Authorize(r.Context(), principal.User.ID, requested, identity.PermissionTelemetryQuery)
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusBadRequest, "invalid resource scope")
|
||||
return
|
||||
}
|
||||
if !decision.Allowed {
|
||||
writeProblem(w, http.StatusForbidden, "resource access denied")
|
||||
return
|
||||
}
|
||||
if err := s.identity.ValidateResourceScope(r.Context(), requested); err != nil {
|
||||
writeProblem(w, http.StatusBadRequest, "invalid resource scope")
|
||||
return
|
||||
}
|
||||
sensitive, err := s.identity.Access.Authorize(r.Context(), principal.User.ID, requested, identity.PermissionTelemetryReadSensitive)
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusServiceUnavailable, "authorization unavailable")
|
||||
return
|
||||
}
|
||||
estimated, err := s.store.EstimateOrganizationBytes(input.OrganizationID)
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusServiceUnavailable, "query planning unavailable")
|
||||
return
|
||||
}
|
||||
registry, _, err := s.store.ActiveDescriptors(r.Context(), input.OrganizationID)
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusServiceUnavailable, "query planning unavailable")
|
||||
return
|
||||
}
|
||||
explain, err := query.Plan(ast, query.Scope{
|
||||
OrganizationID: input.OrganizationID, ProjectID: input.ProjectID,
|
||||
EnvironmentID: input.EnvironmentID, ServiceID: input.ServiceID,
|
||||
Sensitive: sensitive.Allowed,
|
||||
}, registry, estimated, s.options.QueryBudget)
|
||||
if errors.Is(err, query.ErrSensitivePermissionRequired) {
|
||||
writeProblem(w, http.StatusForbidden, "sensitive-field permission required")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "query plan rejected")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, explain)
|
||||
}
|
||||
|
||||
func (s *Server) executeQuery(w http.ResponseWriter, r *http.Request) {
|
||||
if !websec.SameOrigin(r, s.options.PublicOrigin) {
|
||||
writeProblem(w, http.StatusForbidden, "same-origin request required")
|
||||
return
|
||||
}
|
||||
principal, ok := auth.PrincipalFromContext(r.Context())
|
||||
if !ok {
|
||||
writeProblem(w, http.StatusUnauthorized, "authentication required")
|
||||
return
|
||||
}
|
||||
body := http.MaxBytesReader(w, r.Body, 16<<10)
|
||||
defer body.Close()
|
||||
var input queryRequest
|
||||
if err := decodeOne(body, &input); err != nil {
|
||||
writeProblem(w, http.StatusBadRequest, "invalid query request")
|
||||
return
|
||||
}
|
||||
ast, err := parseAST(input, s.options.MaxQueryRows)
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "query rejected")
|
||||
return
|
||||
}
|
||||
requested := access.Scope{OrganizationID: input.OrganizationID, ProjectID: input.ProjectID, EnvironmentID: input.EnvironmentID, ServiceID: input.ServiceID}
|
||||
decision, err := s.identity.Access.Authorize(r.Context(), principal.User.ID, requested, identity.PermissionTelemetryQuery)
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusBadRequest, "invalid resource scope")
|
||||
return
|
||||
}
|
||||
if !decision.Allowed {
|
||||
writeProblem(w, http.StatusForbidden, "resource access denied")
|
||||
return
|
||||
}
|
||||
if err = s.identity.ValidateResourceScope(r.Context(), requested); err != nil {
|
||||
writeProblem(w, http.StatusBadRequest, "invalid resource scope")
|
||||
return
|
||||
}
|
||||
sensitive, err := s.identity.Access.Authorize(r.Context(), principal.User.ID, requested, identity.PermissionTelemetryReadSensitive)
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusServiceUnavailable, "authorization unavailable")
|
||||
return
|
||||
}
|
||||
result, err := s.store.Query(r.Context(), ast, query.Scope{
|
||||
OrganizationID: input.OrganizationID, ProjectID: input.ProjectID,
|
||||
EnvironmentID: input.EnvironmentID, ServiceID: input.ServiceID,
|
||||
Sensitive: sensitive.Allowed,
|
||||
}, s.options.QueryBudget, s.now())
|
||||
switch {
|
||||
case errors.Is(err, query.ErrSensitivePermissionRequired):
|
||||
writeProblem(w, http.StatusForbidden, "sensitive-field permission required")
|
||||
return
|
||||
case errors.Is(err, query.ErrBudgetExceeded):
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "query execution budget exceeded")
|
||||
return
|
||||
case errors.Is(err, query.ErrTypeMismatch):
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "query field type mismatch")
|
||||
return
|
||||
case err != nil:
|
||||
writeProblem(w, http.StatusServiceUnavailable, "query execution unavailable")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func parseAST(input queryRequest, maxRows int) (query.AST, error) {
|
||||
if (input.Query == "") == (input.AST == nil) {
|
||||
return query.AST{}, errors.New("provide exactly one query or AST")
|
||||
}
|
||||
if input.AST == nil {
|
||||
return query.Parse(input.Query, maxRows)
|
||||
}
|
||||
ast := *input.AST
|
||||
var err error
|
||||
if ast.WindowText != "" {
|
||||
ast.Window, err = time.ParseDuration(ast.WindowText)
|
||||
}
|
||||
if err == nil && ast.BucketText != "" {
|
||||
ast.Bucket, err = time.ParseDuration(ast.BucketText)
|
||||
}
|
||||
if err == nil {
|
||||
err = query.Validate(ast, maxRows)
|
||||
}
|
||||
return ast, err
|
||||
}
|
||||
|
||||
func decodeOne(reader io.Reader, value any) error {
|
||||
decoder := json.NewDecoder(reader)
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(value); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
|
||||
return errors.New("request must contain exactly one JSON value")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func bearer(value string) (string, bool) {
|
||||
const prefix = "Bearer "
|
||||
if !strings.HasPrefix(value, prefix) || strings.ContainsAny(value[len(prefix):], " \t\r\n") {
|
||||
return "", false
|
||||
}
|
||||
return value[len(prefix):], value[len(prefix):] != ""
|
||||
}
|
||||
|
||||
func textOK(body string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(body)))
|
||||
if r.Method != http.MethodHead {
|
||||
_, _ = io.WriteString(w, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func securityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'none'; base-uri 'none'; connect-src 'self'; form-action 'self'; frame-ancestors 'none'; img-src 'self'; manifest-src 'self'; script-src 'self'; style-src 'self'; worker-src 'self'")
|
||||
w.Header().Set("Cross-Origin-Opener-Policy", "same-origin")
|
||||
w.Header().Set("Permissions-Policy", "camera=(), geolocation=(), microphone=()")
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
|
||||
func writeProblem(w http.ResponseWriter, status int, title string) {
|
||||
w.Header().Set("Content-Type", "application/problem+json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(struct {
|
||||
Title string `json:"title"`
|
||||
Status int `json:"status"`
|
||||
}{Title: title, Status: status})
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/ecdh"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gamertan.com/observatory/internal/identity"
|
||||
"gamertan.com/observatory/internal/model"
|
||||
"gamertan.com/observatory/internal/nativeprotocol"
|
||||
"gamertan.com/observatory/internal/query"
|
||||
"gamertan.com/observatory/internal/schema"
|
||||
"gamertan.com/observatory/internal/storage"
|
||||
commonpb "go.opentelemetry.io/proto/otlp/common/v1"
|
||||
logspb "go.opentelemetry.io/proto/otlp/logs/v1"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
func TestIngestionConcurrencyIsBoundedAndReusable(t *testing.T) {
|
||||
server, store, identities, _ := newUITestServer(t)
|
||||
defer store.Close()
|
||||
defer identities.Close()
|
||||
server.ingestSlots = make(chan struct{}, 1)
|
||||
|
||||
first := httptest.NewRecorder()
|
||||
leave, ok := server.enterIngest(first)
|
||||
if !ok || leave == nil {
|
||||
t.Fatal("first ingestion slot was unavailable")
|
||||
}
|
||||
second := httptest.NewRecorder()
|
||||
if secondLeave, admitted := server.enterIngest(second); admitted || secondLeave != nil || second.Code != http.StatusServiceUnavailable || second.Header().Get("Retry-After") != "1" || !strings.Contains(second.Body.String(), "ingestion capacity temporarily unavailable") {
|
||||
t.Fatalf("second admission admitted=%t status=%d retry=%q body=%s", admitted, second.Code, second.Header().Get("Retry-After"), second.Body.String())
|
||||
}
|
||||
leave()
|
||||
third := httptest.NewRecorder()
|
||||
thirdLeave, admitted := server.enterIngest(third)
|
||||
if !admitted || thirdLeave == nil {
|
||||
t.Fatal("released ingestion capacity was not reusable")
|
||||
}
|
||||
thirdLeave()
|
||||
}
|
||||
|
||||
func TestServerWebPushConfigurationIsAllOrNothing(t *testing.T) {
|
||||
root := filepath.Join(t.TempDir(), "data")
|
||||
if err := os.Mkdir(root, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store, err := storage.Open(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
identities, err := identity.Open(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer identities.Close()
|
||||
dispatcher := &recordingPushDispatcher{}
|
||||
options := testOptions()
|
||||
options.PushDispatcher = dispatcher
|
||||
if _, err = New(store, identities, options); err == nil {
|
||||
t.Fatal("dispatcher without public key accepted")
|
||||
}
|
||||
options.PushPublicKey = "invalid"
|
||||
if _, err = New(store, identities, options); err == nil {
|
||||
t.Fatal("invalid public key accepted")
|
||||
}
|
||||
private, err := ecdh.P256().GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
options.PushPublicKey = base64.RawURLEncoding.EncodeToString(private.PublicKey().Bytes())
|
||||
if _, err = New(store, identities, options); err != nil {
|
||||
t.Fatalf("valid Web Push configuration: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngestDoesNotExposeValuesInErrors(t *testing.T) {
|
||||
root := filepath.Join(t.TempDir(), "data")
|
||||
if err := os.Mkdir(root, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store, err := storage.Open(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
token, err := store.CreateSource(context.Background(), "source", model.Scope{OrganizationID: "org", ProjectID: "p", EnvironmentID: "prod", ServiceID: "s"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
identities, err := identity.Open(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer identities.Close()
|
||||
server, err := New(store, identities, testOptions())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server.now = func() time.Time { return now }
|
||||
batch := model.Batch{Version: 1, SourceID: "source", StreamID: "logs", Sequence: 1, ObservedAt: now, Signal: model.SignalLogs, Records: []model.Observation{{Timestamp: now, Name: "request", Body: "credential=do-not-echo"}}}
|
||||
body, _ := json.Marshal(batch)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/ingest/native", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rec := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusAccepted {
|
||||
t.Fatalf("expected 202, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/ingest/native", bytes.NewBufferString(`{"secret":"do-not-echo"}`))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rec = httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusBadRequest || bytes.Contains(rec.Body.Bytes(), []byte("do-not-echo")) {
|
||||
t.Fatalf("unsafe error response %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFramedNativeIngestAcknowledgesExactReplayAndOverlappingTime(t *testing.T) {
|
||||
root := filepath.Join(t.TempDir(), "data")
|
||||
if err := os.Mkdir(root, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store, err := storage.Open(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
token, err := store.CreateSource(context.Background(), "source", model.Scope{OrganizationID: "org", ProjectID: "p", EnvironmentID: "prod", ServiceID: "s"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
identities, err := identity.Open(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer identities.Close()
|
||||
now := time.Date(2026, 8, 18, 20, 0, 0, 0, time.UTC)
|
||||
server, err := New(store, identities, testOptions())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server.now = func() time.Time { return now }
|
||||
handler := server.Handler()
|
||||
send := func(batch model.Batch, body []byte, envelope model.BatchEnvelope) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
request := httptest.NewRequest(http.MethodPost, "https://observatory.example/api/v2/ingest/native", bytes.NewReader(body))
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
nativeprotocol.SetHeaders(request.Header, envelope)
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
return response
|
||||
}
|
||||
batch := model.Batch{Version: model.BatchVersion, SourceID: "source", StreamID: "logs", Sequence: 1, ObservedAt: now, Signal: model.SignalLogs, Records: []model.Observation{{Timestamp: now, Name: "first"}}}
|
||||
body, _ := json.Marshal(batch)
|
||||
envelope, _ := batch.Envelope(body)
|
||||
first := send(batch, body, envelope)
|
||||
if first.Code != http.StatusAccepted || strings.Contains(first.Body.String(), `"duplicate":true`) {
|
||||
t.Fatalf("first status=%d body=%s", first.Code, first.Body.String())
|
||||
}
|
||||
replay := send(batch, body, envelope)
|
||||
if replay.Code != http.StatusAccepted || !strings.Contains(replay.Body.String(), `"duplicate":true`) {
|
||||
t.Fatalf("replay status=%d body=%s", replay.Code, replay.Body.String())
|
||||
}
|
||||
tampered := append(append([]byte(nil), body...), ' ')
|
||||
bad := send(batch, tampered, envelope)
|
||||
if bad.Code != http.StatusBadRequest {
|
||||
t.Fatalf("tampered status=%d body=%s", bad.Code, bad.Body.String())
|
||||
}
|
||||
batch.Sequence = 2
|
||||
batch.ObservedAt = now.Add(time.Second)
|
||||
// The second batch intentionally overlaps the first batch's observed time.
|
||||
body, _ = json.Marshal(batch)
|
||||
envelope, _ = batch.Envelope(body)
|
||||
overlap := send(batch, body, envelope)
|
||||
if overlap.Code != http.StatusAccepted {
|
||||
t.Fatalf("overlap status=%d body=%s", overlap.Code, overlap.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOTLPHTTPIngestionIsAuthenticatedBoundedAndCompressed(t *testing.T) {
|
||||
root := filepath.Join(t.TempDir(), "data")
|
||||
if err := os.Mkdir(root, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store, err := storage.Open(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
scope := model.Scope{OrganizationID: "organization-a", ProjectID: "project-a", EnvironmentID: "production", ServiceID: "service-a"}
|
||||
token, err := store.CreateSource(context.Background(), "otlp-source", scope)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
identities, err := identity.Open(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer identities.Close()
|
||||
now := time.Date(2026, 8, 17, 3, 0, 0, 0, time.UTC)
|
||||
server, err := New(store, identities, testOptions())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server.now = func() time.Time { return now }
|
||||
handler := server.Handler()
|
||||
payload, err := proto.Marshal(&logspb.LogsData{ResourceLogs: []*logspb.ResourceLogs{{ScopeLogs: []*logspb.ScopeLogs{{LogRecords: []*logspb.LogRecord{{
|
||||
TimeUnixNano: uint64(now.UnixNano()), EventName: "http.request", Body: &commonpb.AnyValue{Value: &commonpb.AnyValue_StringValue{StringValue: "accepted"}},
|
||||
}}}}}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
send := func(body []byte, encoding string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
request := httptest.NewRequest(http.MethodPost, "https://observatory.example/v1/logs", bytes.NewReader(body))
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
request.Header.Set("Content-Type", "application/x-protobuf")
|
||||
if encoding != "" {
|
||||
request.Header.Set("Content-Encoding", encoding)
|
||||
}
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
return response
|
||||
}
|
||||
|
||||
plain := send(payload, "")
|
||||
if plain.Code != http.StatusOK || plain.Header().Get("Content-Type") != "application/x-protobuf" || plain.Header().Get("Content-Length") != "0" || plain.Body.Len() != 0 {
|
||||
t.Fatalf("plain status=%d headers=%v body=%q", plain.Code, plain.Header(), plain.Body.String())
|
||||
}
|
||||
var compressed bytes.Buffer
|
||||
zipper := gzip.NewWriter(&compressed)
|
||||
if _, err = zipper.Write(payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = zipper.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response := send(compressed.Bytes(), "gzip"); response.Code != http.StatusOK {
|
||||
t.Fatalf("gzip status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
if err = store.Recover(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bytes, err := store.EstimateOrganizationBytes(scope.OrganizationID); err != nil || bytes == 0 {
|
||||
t.Fatalf("projection bytes=%d err=%v", bytes, err)
|
||||
}
|
||||
|
||||
unauthorized := httptest.NewRequest(http.MethodPost, "https://observatory.example/v1/logs", bytes.NewReader(payload))
|
||||
unauthorized.Header.Set("Content-Type", "application/x-protobuf")
|
||||
unauthorizedResult := httptest.NewRecorder()
|
||||
handler.ServeHTTP(unauthorizedResult, unauthorized)
|
||||
if unauthorizedResult.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("unauthorized status=%d", unauthorizedResult.Code)
|
||||
}
|
||||
wrongMedia := httptest.NewRequest(http.MethodPost, "https://observatory.example/v1/logs", bytes.NewReader(payload))
|
||||
wrongMedia.Header.Set("Authorization", "Bearer "+token)
|
||||
wrongMedia.Header.Set("Content-Type", "application/json")
|
||||
wrongMediaResult := httptest.NewRecorder()
|
||||
handler.ServeHTTP(wrongMediaResult, wrongMedia)
|
||||
if wrongMediaResult.Code != http.StatusUnsupportedMediaType {
|
||||
t.Fatalf("media status=%d", wrongMediaResult.Code)
|
||||
}
|
||||
|
||||
compressed.Reset()
|
||||
zipper = gzip.NewWriter(&compressed)
|
||||
if _, err = zipper.Write(bytes.Repeat([]byte{'x'}, int(testOptions().MaxBodyBytes)+1)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = zipper.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tooLarge := send(compressed.Bytes(), "gzip")
|
||||
if tooLarge.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("oversized status=%d body=%s", tooLarge.Code, tooLarge.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionAndScopedExplain(t *testing.T) {
|
||||
root := filepath.Join(t.TempDir(), "data")
|
||||
if err := os.Mkdir(root, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store, err := storage.Open(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
identities, err := identity.Open(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer identities.Close()
|
||||
bootstrap, err := identities.Bootstrap(context.Background(), identity.BootstrapInput{Username: "operator", Email: "operator@example.test", DisplayName: "Operator", Password: "correct horse battery staple"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server, err := New(store, identities, testOptions())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
handler := server.Handler()
|
||||
|
||||
login := httptest.NewRequest(http.MethodPost, "https://observatory.example/api/v1/session", strings.NewReader(`{"identifier":"operator","password":"correct horse battery staple"}`))
|
||||
login.Header.Set("Origin", "https://observatory.example")
|
||||
loginResult := httptest.NewRecorder()
|
||||
handler.ServeHTTP(loginResult, login)
|
||||
if loginResult.Code != http.StatusOK || strings.Contains(loginResult.Body.String(), "correct horse") {
|
||||
t.Fatalf("login status=%d body=%s", loginResult.Code, loginResult.Body.String())
|
||||
}
|
||||
var session struct {
|
||||
CSRFToken string `json:"csrf_token"`
|
||||
}
|
||||
if err = json.Unmarshal(loginResult.Body.Bytes(), &session); err != nil || session.CSRFToken == "" {
|
||||
t.Fatalf("session=%+v err=%v", session, err)
|
||||
}
|
||||
cookies := loginResult.Result().Cookies()
|
||||
if len(cookies) != 1 || cookies[0].Name != "__Host-observatory_session" || !cookies[0].Secure || !cookies[0].HttpOnly {
|
||||
t.Fatalf("cookies=%+v", cookies)
|
||||
}
|
||||
|
||||
explainBody := fmt.Sprintf(`{"organization_id":%q,"query":"logs | where status >= 500 | limit 10"}`, bootstrap.Organization.ID)
|
||||
explain := httptest.NewRequest(http.MethodPost, "https://observatory.example/api/v1/query/explain", strings.NewReader(explainBody))
|
||||
explain.Header.Set("Origin", "https://observatory.example")
|
||||
explain.AddCookie(cookies[0])
|
||||
explainResult := httptest.NewRecorder()
|
||||
handler.ServeHTTP(explainResult, explain)
|
||||
if explainResult.Code != http.StatusOK || !strings.Contains(explainResult.Body.String(), `"projected_sources"`) {
|
||||
t.Fatalf("explain status=%d body=%s", explainResult.Code, explainResult.Body.String())
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
sourceToken, err := store.CreateSource(context.Background(), "query-source", model.Scope{OrganizationID: bootstrap.Organization.ID, ProjectID: "project-a", EnvironmentID: "production", ServiceID: "service-a"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
batch := model.Batch{Version: model.BatchVersion, SourceID: "query-source", StreamID: "requests", Sequence: 1, ObservedAt: now, Signal: model.SignalLogs, Records: []model.Observation{{Timestamp: now, Name: "application.http.request", Attributes: map[string]string{"http.status_code": "503", "http.route": "/failed", "workshop.queue_depth": "12"}}}}
|
||||
if _, err = store.Ingest(context.Background(), sourceToken, batch, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = store.Recover(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
execute := httptest.NewRequest(http.MethodPost, "https://observatory.example/api/v1/query", strings.NewReader(explainBody))
|
||||
execute.Header.Set("Origin", "https://observatory.example")
|
||||
execute.AddCookie(cookies[0])
|
||||
executeResult := httptest.NewRecorder()
|
||||
handler.ServeHTTP(executeResult, execute)
|
||||
if executeResult.Code != http.StatusOK || !strings.Contains(executeResult.Body.String(), `"http.status_code"`) || !strings.Contains(executeResult.Body.String(), `"503"`) {
|
||||
t.Fatalf("execute status=%d body=%s", executeResult.Code, executeResult.Body.String())
|
||||
}
|
||||
reviewed := schema.Descriptor{Version: schema.DescriptorVersion, Signal: model.SignalLogs, Field: "workshop.queue_depth", Type: schema.TypeInteger, Meaning: "Reviewed queue depth for one application service.", Sensitivity: schema.SensitivityInternal, Cardinality: schema.CardinalityLow, Index: schema.IndexRange, Retention: schema.RetentionRaw, ProjectionVersion: 1}
|
||||
if _, err = store.ActivateDescriptor(context.Background(), bootstrap.Organization.ID, reviewed, now.Add(time.Second)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
customBody := fmt.Sprintf(`{"organization_id":%q,"query":"logs | where workshop.queue_depth >= 10 | limit 10"}`, bootstrap.Organization.ID)
|
||||
for _, path := range []string{"/api/v1/query/explain", "/api/v1/query"} {
|
||||
request := httptest.NewRequest(http.MethodPost, "https://observatory.example"+path, strings.NewReader(customBody))
|
||||
request.Header.Set("Origin", "https://observatory.example")
|
||||
request.AddCookie(cookies[0])
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"workshop.queue_depth"`) || !strings.Contains(response.Body.String(), `"indexed":true`) {
|
||||
t.Fatalf("path=%s status=%d body=%s", path, response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
for _, path := range []string{"/api/v1/query/explain", "/api/v1/query"} {
|
||||
denied := httptest.NewRequest(http.MethodPost, "https://observatory.example"+path, strings.NewReader(`{"organization_id":"unowned1","query":"logs | limit 10"}`))
|
||||
denied.Header.Set("Origin", "https://observatory.example")
|
||||
denied.AddCookie(cookies[0])
|
||||
deniedResult := httptest.NewRecorder()
|
||||
handler.ServeHTTP(deniedResult, denied)
|
||||
if deniedResult.Code != http.StatusForbidden {
|
||||
t.Fatalf("path=%s cross-organization status=%d body=%s", path, deniedResult.Code, deniedResult.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
logout := httptest.NewRequest(http.MethodDelete, "https://observatory.example/api/v1/session", nil)
|
||||
logout.Header.Set("Origin", "https://observatory.example")
|
||||
logout.Header.Set("X-CSRF-Token", session.CSRFToken)
|
||||
logout.AddCookie(cookies[0])
|
||||
logoutResult := httptest.NewRecorder()
|
||||
handler.ServeHTTP(logoutResult, logout)
|
||||
if logoutResult.Code != http.StatusNoContent {
|
||||
t.Fatalf("logout status=%d body=%s", logoutResult.Code, logoutResult.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossOriginLoginIsRejected(t *testing.T) {
|
||||
root := filepath.Join(t.TempDir(), "data")
|
||||
if err := os.Mkdir(root, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store, err := storage.Open(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
identities, err := identity.Open(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer identities.Close()
|
||||
server, err := New(store, identities, testOptions())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodPost, "https://observatory.example/api/v1/session", strings.NewReader(`{"identifier":"operator","password":"secret"}`))
|
||||
request.Header.Set("Origin", "https://attacker.example")
|
||||
response := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(response, request)
|
||||
if response.Code != http.StatusForbidden {
|
||||
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentEnrollmentIsSingleUseAndCredentialCanSelfRevoke(t *testing.T) {
|
||||
root := filepath.Join(t.TempDir(), "data")
|
||||
if err := os.Mkdir(root, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store, err := storage.Open(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
identities, err := identity.Open(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer identities.Close()
|
||||
now := time.Date(2026, 8, 17, 2, 0, 0, 0, time.UTC)
|
||||
enrollmentToken, _, err := store.CreateEnrollment(context.Background(), "source-a", model.Scope{OrganizationID: "organization-a", ProjectID: "project-a", EnvironmentID: "production", ServiceID: "service-a"}, "operator-a", 15*time.Minute, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server, err := New(store, identities, testOptions())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server.now = func() time.Time { return now.Add(time.Minute) }
|
||||
enroll := httptest.NewRequest(http.MethodPost, "https://observatory.example/api/v1/agent/enroll", nil)
|
||||
enroll.Header.Set("Authorization", "Bearer "+enrollmentToken)
|
||||
response := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(response, enroll)
|
||||
if response.Code != http.StatusCreated || strings.Contains(response.Body.String(), enrollmentToken) {
|
||||
t.Fatalf("enroll status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
var enrolled struct {
|
||||
SourceID string `json:"source_id"`
|
||||
Credential string `json:"credential"`
|
||||
}
|
||||
if err = json.Unmarshal(response.Body.Bytes(), &enrolled); err != nil || enrolled.SourceID != "source-a" || enrolled.Credential == "" {
|
||||
t.Fatalf("enrolled=%+v err=%v", enrolled, err)
|
||||
}
|
||||
replay := httptest.NewRequest(http.MethodPost, "https://observatory.example/api/v1/agent/enroll", nil)
|
||||
replay.Header.Set("Authorization", "Bearer "+enrollmentToken)
|
||||
replayResponse := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(replayResponse, replay)
|
||||
if replayResponse.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("replay status=%d", replayResponse.Code)
|
||||
}
|
||||
revoke := httptest.NewRequest(http.MethodDelete, "https://observatory.example/api/v1/agent/source", nil)
|
||||
revoke.Header.Set("Authorization", "Bearer "+enrolled.Credential)
|
||||
revokeResponse := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(revokeResponse, revoke)
|
||||
if revokeResponse.Code != http.StatusNoContent {
|
||||
t.Fatalf("revoke status=%d body=%s", revokeResponse.Code, revokeResponse.Body.String())
|
||||
}
|
||||
if _, err = store.Authenticate(context.Background(), enrolled.Credential); err == nil {
|
||||
t.Fatal("revoked source credential remained active")
|
||||
}
|
||||
}
|
||||
|
||||
func testOptions() Options {
|
||||
return Options{PublicOrigin: "https://observatory.example", MaxBodyBytes: 1 << 20, MaxQueryRows: 1000, SessionLifetime: time.Hour, QueryBudget: query.Budget{MaxDuration: 2 * time.Second, MaxRows: 1000, MaxScannedBytes: 10 << 20, MaxMemoryBytes: 8 << 20}}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gamertan.com/observatory/internal/identity"
|
||||
"gamertan.com/observatory/internal/model"
|
||||
"gamertan.com/observatory/internal/storage"
|
||||
)
|
||||
|
||||
func TestAgentAlertTransitionIsAuthenticatedBoundedAndEvidenceBacked(t *testing.T) {
|
||||
root := filepath.Join(t.TempDir(), "data")
|
||||
if err := os.Mkdir(root, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store, err := storage.Open(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
identities, err := identity.Open(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer identities.Close()
|
||||
now := time.Date(2026, 8, 18, 23, 45, 0, 0, time.UTC)
|
||||
scope := model.Scope{OrganizationID: "organization-a", ProjectID: "project-a", EnvironmentID: "production", ServiceID: "service-a"}
|
||||
token, err := store.CreateSource(context.Background(), "source-a", scope)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
saved, err := store.SaveQuery(context.Background(), storage.SavedQueryInput{OrganizationID: scope.OrganizationID, ActorUserID: "operator-a", MaxRows: 100, Name: "Logs", Query: "logs | limit 10", Scope: storage.ResourceScope{ProjectID: scope.ProjectID, EnvironmentID: scope.EnvironmentID, ServiceID: scope.ServiceID}}, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rule, err := store.SaveAlertRule(context.Background(), storage.AlertRuleInput{OrganizationID: scope.OrganizationID, ActorUserID: "operator-a", SavedQueryID: saved.ID, Name: "Logs", Severity: "warning", MinimumMatches: 1, RequiredConsecutive: 1, EvaluationInterval: 15 * time.Second, Enabled: true}, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
batch := model.Batch{Version: model.BatchVersion, SourceID: "source-a", StreamID: "requests", Sequence: 1, ObservedAt: now, Signal: model.SignalLogs, Records: []model.Observation{{Timestamp: now, Name: "request"}}}
|
||||
ingested, err := store.Ingest(context.Background(), token, batch, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server, err := New(store, identities, testOptions())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server.now = func() time.Time { return now }
|
||||
handler := server.Handler()
|
||||
transition := model.AlertTransition{Version: model.AlertTransitionVersion, RuleID: rule.ID, RuleRevision: rule.Revision, AgentEpoch: strings.Repeat("a", 32), Sequence: 1, StreamID: batch.StreamID, BatchSequence: batch.Sequence, SegmentDigest: ingested.Digest, WindowStart: now, WindowEnd: now, State: "matched", ObservedAt: now}
|
||||
payload, err := json.Marshal(transition)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodPost, "https://observatory.example/api/v1/agent/alert-transition", bytes.NewReader(payload))
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusAccepted {
|
||||
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
var ack storage.SourceAlertTransitionAck
|
||||
if err = json.Unmarshal(response.Body.Bytes(), &ack); err != nil || ack.SourceID != "source-a" || ack.RuleID != rule.ID || ack.Duplicate {
|
||||
t.Fatalf("ack=%+v err=%v", ack, err)
|
||||
}
|
||||
|
||||
unauthorized := httptest.NewRequest(http.MethodPost, "https://observatory.example/api/v1/agent/alert-transition", bytes.NewReader([]byte(`{"state":"do-not-echo"}`)))
|
||||
unauthorizedResult := httptest.NewRecorder()
|
||||
handler.ServeHTTP(unauthorizedResult, unauthorized)
|
||||
if unauthorizedResult.Code != http.StatusUnauthorized || strings.Contains(unauthorizedResult.Body.String(), "do-not-echo") {
|
||||
t.Fatalf("unauthorized status=%d body=%q", unauthorizedResult.Code, unauthorizedResult.Body.String())
|
||||
}
|
||||
oversized := httptest.NewRequest(http.MethodPost, "https://observatory.example/api/v1/agent/alert-transition", strings.NewReader(strings.Repeat("x", (64<<10)+1)))
|
||||
oversized.Header.Set("Authorization", "Bearer "+token)
|
||||
oversizedResult := httptest.NewRecorder()
|
||||
handler.ServeHTTP(oversizedResult, oversized)
|
||||
if oversizedResult.Code != http.StatusBadRequest {
|
||||
t.Fatalf("oversized status=%d body=%q", oversizedResult.Code, oversizedResult.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,532 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"gamertan.com/observatory/internal/identity"
|
||||
"gamertan.com/observatory/internal/model"
|
||||
"gamertan.com/observatory/internal/query"
|
||||
"gamertan.com/observatory/internal/site"
|
||||
"gamertan.com/sandwich-hime/sando"
|
||||
"gamertan.com/web/access"
|
||||
"gamertan.com/web/auth"
|
||||
"gamertan.com/web/authhttp"
|
||||
"gamertan.com/web/websec"
|
||||
)
|
||||
|
||||
const loginCSRFCookieName = "__Host-observatory_login_csrf"
|
||||
|
||||
const (
|
||||
loginFormFailure = "This sign-in form expired or could not be verified. Please try again."
|
||||
loginCredentialFailure = "The username or password was not accepted."
|
||||
passwordFormFailure = "This password form expired or could not be verified. Please try again."
|
||||
passwordMatchFailure = "The new passwords did not match. Please enter them again."
|
||||
passwordChangeFailure = "The password could not be changed. Check the temporary password and choose a different password of at least 12 characters."
|
||||
)
|
||||
|
||||
func (s *Server) landing(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := auth.PrincipalFromContext(r.Context()); ok {
|
||||
http.Redirect(w, r, "/app/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
view := site.LandingView{Head: s.head("Gamertan Observatory", "A self-hosted observability platform in development for carefully operated Linux systems.", "/")}
|
||||
s.renderHTML(w, r, http.StatusOK, site.Landing(view))
|
||||
}
|
||||
|
||||
func (s *Server) loginPage(w http.ResponseWriter, r *http.Request) {
|
||||
if principal, ok := auth.PrincipalFromContext(r.Context()); ok {
|
||||
if principal.User.PasswordChangeRequired {
|
||||
http.Redirect(w, r, "/account/password/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/app/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
s.renderLogin(w, r, http.StatusOK, "")
|
||||
}
|
||||
|
||||
func (s *Server) loginForm(w http.ResponseWriter, r *http.Request) {
|
||||
values, err := readFormFields(w, r, 16<<10, []string{"identifier", "password"}, []string{"csrf_token"})
|
||||
csrfOK := err == nil && validLoginCSRF(r, values.Get("csrf_token"))
|
||||
if !tokenBoundFormRequest(r, s.options.PublicOrigin, csrfOK) {
|
||||
s.renderLogin(w, r, http.StatusForbidden, loginFormFailure)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
s.renderLogin(w, r, http.StatusBadRequest, loginFormFailure)
|
||||
return
|
||||
}
|
||||
token, principal, err := s.identity.Auth.Authenticate(r.Context(), values.Get("identifier"), values.Get("password"), s.options.SessionLifetime)
|
||||
if err != nil {
|
||||
s.renderLogin(w, r, http.StatusUnauthorized, loginCredentialFailure)
|
||||
return
|
||||
}
|
||||
if err = authhttp.SetSession(w, s.cookie, token, s.now()); err != nil {
|
||||
_ = s.identity.Auth.RevokeSession(r.Context(), token)
|
||||
writeProblem(w, http.StatusServiceUnavailable, "session unavailable")
|
||||
return
|
||||
}
|
||||
if _, cookieErr := r.Cookie(loginCSRFCookieName); cookieErr == nil {
|
||||
clearLoginCSRF(w)
|
||||
}
|
||||
location := "/app/"
|
||||
if principal.User.PasswordChangeRequired {
|
||||
location = "/account/password/"
|
||||
}
|
||||
http.Redirect(w, r, location, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// tokenBoundFormRequest makes the purpose-bound form token the primary CSRF
|
||||
// proof. Browser origin metadata is defense in depth: an explicit cross-site
|
||||
// or contradictory origin still fails closed, while absent or opaque metadata
|
||||
// does not break an otherwise valid ordinary HTML form submission.
|
||||
func tokenBoundFormRequest(r *http.Request, publicOrigin string, validToken bool) bool {
|
||||
if !validToken {
|
||||
return false
|
||||
}
|
||||
fetchSite := strings.ToLower(strings.TrimSpace(r.Header.Get("Sec-Fetch-Site")))
|
||||
if fetchSite != "" && fetchSite != "same-origin" && fetchSite != "none" {
|
||||
return false
|
||||
}
|
||||
origin := strings.TrimSpace(r.Header.Get("Origin"))
|
||||
if origin == "" || origin == "null" {
|
||||
return true
|
||||
}
|
||||
return websec.SameOrigin(r, publicOrigin)
|
||||
}
|
||||
|
||||
func (s *Server) renderLogin(w http.ResponseWriter, r *http.Request, status int, message string) {
|
||||
csrf, err := s.issueLoginCSRF(w)
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusServiceUnavailable, "login unavailable")
|
||||
return
|
||||
}
|
||||
view := site.LoginView{Head: s.head("Sign in — Gamertan Observatory", "Sign in to the local Gamertan Observatory workshop.", "/login/"), CSRFToken: csrf, ErrorMessage: message}
|
||||
s.renderHTML(w, r, status, site.Login(view))
|
||||
}
|
||||
|
||||
func (s *Server) issueLoginCSRF(w http.ResponseWriter) (string, error) {
|
||||
secret := make([]byte, 32)
|
||||
if _, err := io.ReadFull(rand.Reader, secret); err != nil {
|
||||
return "", errors.New("generate login CSRF token")
|
||||
}
|
||||
token := base64.RawURLEncoding.EncodeToString(secret)
|
||||
http.SetCookie(w, &http.Cookie{Name: loginCSRFCookieName, Value: token, Path: "/", Secure: true, HttpOnly: true, SameSite: http.SameSiteStrictMode, Expires: s.now().Add(10 * time.Minute), MaxAge: 600})
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func validLoginCSRF(r *http.Request, candidate string) bool {
|
||||
cookie, err := r.Cookie(loginCSRFCookieName)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
want, wantErr := base64.RawURLEncoding.DecodeString(cookie.Value)
|
||||
got, gotErr := base64.RawURLEncoding.DecodeString(candidate)
|
||||
return wantErr == nil && gotErr == nil && len(want) == 32 && len(got) == len(want) && subtle.ConstantTimeCompare(want, got) == 1
|
||||
}
|
||||
|
||||
func clearLoginCSRF(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{Name: loginCSRFCookieName, Path: "/", Secure: true, HttpOnly: true, SameSite: http.SameSiteStrictMode, MaxAge: -1})
|
||||
}
|
||||
|
||||
func (s *Server) passwordPage(w http.ResponseWriter, r *http.Request) {
|
||||
principal, ok := auth.PrincipalFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Redirect(w, r, "/login/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if !principal.User.PasswordChangeRequired {
|
||||
http.Redirect(w, r, "/app/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
s.renderPassword(w, r, http.StatusOK, "")
|
||||
}
|
||||
|
||||
func (s *Server) passwordForm(w http.ResponseWriter, r *http.Request) {
|
||||
principal, ok := auth.PrincipalFromContext(r.Context())
|
||||
token, tokenOK := authhttp.SessionToken(r, s.cookie)
|
||||
values, err := readForm(w, r, 8<<10, "csrf_token", "current_password", "new_password", "confirm_password")
|
||||
csrfOK := err == nil && tokenOK && authhttp.VerifyCSRF(token, "account:password:change", values.Get("csrf_token"))
|
||||
if !tokenBoundFormRequest(r, s.options.PublicOrigin, csrfOK) {
|
||||
if ok && tokenOK && principal.User.PasswordChangeRequired {
|
||||
s.renderPassword(w, r, http.StatusForbidden, passwordFormFailure)
|
||||
} else {
|
||||
http.Redirect(w, r, "/login/", http.StatusSeeOther)
|
||||
}
|
||||
return
|
||||
}
|
||||
if !ok || !tokenOK || !principal.User.PasswordChangeRequired {
|
||||
writeProblem(w, http.StatusForbidden, "password change authorization required")
|
||||
return
|
||||
}
|
||||
if err != nil || !csrfOK {
|
||||
s.renderPassword(w, r, http.StatusBadRequest, passwordFormFailure)
|
||||
return
|
||||
}
|
||||
if values.Get("new_password") != values.Get("confirm_password") {
|
||||
s.renderPassword(w, r, http.StatusUnprocessableEntity, passwordMatchFailure)
|
||||
return
|
||||
}
|
||||
if err = s.identity.Auth.ChangePassword(r.Context(), principal.User.ID, values.Get("current_password"), values.Get("new_password")); err != nil {
|
||||
s.renderPassword(w, r, http.StatusUnprocessableEntity, passwordChangeFailure)
|
||||
return
|
||||
}
|
||||
if err = authhttp.ClearSession(w, s.cookie); err != nil {
|
||||
writeProblem(w, http.StatusServiceUnavailable, "session unavailable")
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/login/?password=changed", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) renderPassword(w http.ResponseWriter, r *http.Request, status int, message string) {
|
||||
token, ok := authhttp.SessionToken(r, s.cookie)
|
||||
if !ok {
|
||||
writeProblem(w, http.StatusUnauthorized, "authentication required")
|
||||
return
|
||||
}
|
||||
csrf, err := authhttp.CSRFToken(token, "account:password:change")
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusServiceUnavailable, "session unavailable")
|
||||
return
|
||||
}
|
||||
view := site.PasswordView{Head: s.head("Choose your password — Gamertan Observatory", "Replace the one-time Observatory credential before continuing.", "/account/password/"), CSRFToken: csrf, ErrorMessage: message}
|
||||
s.renderHTML(w, r, status, site.Password(view))
|
||||
}
|
||||
|
||||
func (s *Server) logoutForm(w http.ResponseWriter, r *http.Request) {
|
||||
values, err := readForm(w, r, 4<<10, "csrf_token")
|
||||
token, ok := authhttp.SessionToken(r, s.cookie)
|
||||
csrfOK := err == nil && ok && authhttp.VerifyCSRF(token, "session:delete", values.Get("csrf_token"))
|
||||
if !tokenBoundFormRequest(r, s.options.PublicOrigin, csrfOK) {
|
||||
writeProblem(w, http.StatusForbidden, "valid sign-out form required")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusBadRequest, "invalid sign-out request")
|
||||
return
|
||||
}
|
||||
if !csrfOK {
|
||||
writeProblem(w, http.StatusForbidden, "valid session CSRF token required")
|
||||
return
|
||||
}
|
||||
if err = s.identity.Auth.RevokeSession(r.Context(), token); err != nil && !errors.Is(err, auth.ErrSessionNotFound) {
|
||||
writeProblem(w, http.StatusServiceUnavailable, "session unavailable")
|
||||
return
|
||||
}
|
||||
if err = authhttp.ClearSession(w, s.cookie); err != nil {
|
||||
writeProblem(w, http.StatusServiceUnavailable, "session unavailable")
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/login/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) app(w http.ResponseWriter, r *http.Request) {
|
||||
principal, ok := auth.PrincipalFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Redirect(w, r, "/login/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
organizations, err := s.identity.OrganizationsForUser(r.Context(), principal.User.ID)
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusServiceUnavailable, "organization list unavailable")
|
||||
return
|
||||
}
|
||||
if len(organizations) == 0 {
|
||||
writeProblem(w, http.StatusForbidden, "organization access required")
|
||||
return
|
||||
}
|
||||
queryValues := r.URL.Query()
|
||||
requested := queryValues.Get("organization")
|
||||
if len(queryValues) > 0 {
|
||||
selectedValues, exists := queryValues["organization"]
|
||||
if !exists || len(queryValues) != 1 || len(selectedValues) != 1 || selectedValues[0] == "" {
|
||||
writeProblem(w, http.StatusBadRequest, "invalid organization selection")
|
||||
return
|
||||
}
|
||||
}
|
||||
selected := organizations[0]
|
||||
if requested != "" {
|
||||
found := false
|
||||
for _, organization := range organizations {
|
||||
if organization.ID == requested {
|
||||
selected, found = organization, true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
writeProblem(w, http.StatusForbidden, "organization access denied")
|
||||
return
|
||||
}
|
||||
}
|
||||
scope := access.Scope{OrganizationID: selected.ID}
|
||||
decision, err := s.identity.Access.Authorize(r.Context(), principal.User.ID, scope, identity.PermissionDashboardsRead)
|
||||
if err != nil || !decision.Allowed {
|
||||
writeProblem(w, http.StatusForbidden, "dashboard access denied")
|
||||
return
|
||||
}
|
||||
token, ok := authhttp.SessionToken(r, s.cookie)
|
||||
if !ok {
|
||||
writeProblem(w, http.StatusUnauthorized, "authentication required")
|
||||
return
|
||||
}
|
||||
csrf, err := authhttp.CSRFToken(token, "session:delete")
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusServiceUnavailable, "session unavailable")
|
||||
return
|
||||
}
|
||||
view := site.AppView{
|
||||
Head: s.head("Overview — Gamertan Observatory", "Recent authorized telemetry and saved Observatory work.", "/app/"),
|
||||
DisplayName: principal.User.DisplayName, CSRFToken: csrf,
|
||||
EventsURL: "/app/events?organization=" + url.QueryEscape(selected.ID),
|
||||
RefreshedAt: s.now().Format("2006-01-02 15:04:05 UTC"),
|
||||
Organization: site.OrganizationOption{ID: selected.ID, Name: selected.Name, Selected: true},
|
||||
IncidentsURL: "/app/incidents/?organization=" + url.QueryEscape(selected.ID),
|
||||
}
|
||||
projectionStatus, projectionErr := s.store.OrganizationProjectionStatus(r.Context(), selected.ID, s.now())
|
||||
if projectionErr == nil && projectionStatus.PendingSegments > 0 {
|
||||
view.PendingBatches = projectionStatus.PendingSegments
|
||||
view.ProjectionLag = formatProjectionLag(projectionStatus.OldestPendingLag)
|
||||
}
|
||||
incidentDecision, err := s.identity.Access.Authorize(r.Context(), principal.User.ID, scope, identity.PermissionIncidentsRead)
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusServiceUnavailable, "authorization unavailable")
|
||||
return
|
||||
}
|
||||
if incidentDecision.Allowed {
|
||||
incidents, incidentErr := s.store.Incidents(r.Context(), selected.ID, false, 100)
|
||||
if incidentErr != nil {
|
||||
writeProblem(w, http.StatusServiceUnavailable, "incidents unavailable")
|
||||
return
|
||||
}
|
||||
view.OpenIncidents = len(incidents)
|
||||
}
|
||||
manage, err := s.identity.Access.Authorize(r.Context(), principal.User.ID, scope, identity.PermissionDashboardsManage)
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusServiceUnavailable, "authorization unavailable")
|
||||
return
|
||||
}
|
||||
view.CanManage = manage.Allowed
|
||||
if view.CanManage {
|
||||
view.ManageCSRF, err = authhttp.CSRFToken(token, "dashboards:manage")
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusServiceUnavailable, "session unavailable")
|
||||
return
|
||||
}
|
||||
}
|
||||
for _, organization := range organizations {
|
||||
view.Organizations = append(view.Organizations, site.OrganizationOption{ID: organization.ID, Name: organization.Name, Selected: organization.ID == selected.ID})
|
||||
}
|
||||
view.Signals = s.overviewSignals(r, selected.ID)
|
||||
saved, err := s.store.SavedQueries(r.Context(), selected.ID)
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusServiceUnavailable, "saved queries unavailable")
|
||||
return
|
||||
}
|
||||
for _, item := range saved {
|
||||
view.SavedQueries = append(view.SavedQueries, site.SavedQuerySummary{ID: item.ID, Name: item.Name, Description: item.Description, Query: item.Query})
|
||||
}
|
||||
dashboards, err := s.store.Dashboards(r.Context(), selected.ID)
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusServiceUnavailable, "dashboards unavailable")
|
||||
return
|
||||
}
|
||||
for _, item := range dashboards {
|
||||
view.Dashboards = append(view.Dashboards, site.DashboardSummary{Slug: item.Slug, Name: item.Name, Description: item.Description, PanelCount: len(item.Panels)})
|
||||
}
|
||||
s.renderHTML(w, r, http.StatusOK, site.App(view))
|
||||
}
|
||||
|
||||
func formatProjectionLag(lag time.Duration) string {
|
||||
if lag < time.Second {
|
||||
return "less than one second"
|
||||
}
|
||||
return lag.Round(time.Second).String()
|
||||
}
|
||||
|
||||
func (s *Server) overviewSignals(r *http.Request, organizationID string) []site.SignalView {
|
||||
definitions := []struct {
|
||||
signal model.Signal
|
||||
id, name string
|
||||
description string
|
||||
}{
|
||||
{model.SignalLogs, "logs", "logs", "Recent structured events accepted for this organization."},
|
||||
{model.SignalMetrics, "metrics", "metrics", "Recent numeric observations with a table alternative."},
|
||||
{model.SignalTraces, "traces", "traces", "Recent spans and their correlation identities."},
|
||||
{model.SignalDeployments, "deployments", "deployments", "Recent bounded deployment evidence."},
|
||||
}
|
||||
views := make([]site.SignalView, 0, len(definitions))
|
||||
for _, definition := range definitions {
|
||||
text := string(definition.signal) + " | window 1h | limit 20"
|
||||
ast, err := query.Parse(text, s.options.MaxQueryRows)
|
||||
var result query.Result
|
||||
if err == nil {
|
||||
result, err = s.store.Query(r.Context(), ast, query.Scope{OrganizationID: organizationID}, s.options.QueryBudget, s.now())
|
||||
}
|
||||
table := site.TableView{Caption: "Recent " + definition.name, Columns: []site.TableColumn{{Label: "Status"}}, Empty: "No observations are available in the last hour."}
|
||||
if err != nil {
|
||||
table.Empty = "This bounded query is temporarily unavailable."
|
||||
} else {
|
||||
table = resultTable("Recent "+definition.name, result)
|
||||
}
|
||||
views = append(views, site.SignalView{ID: definition.id, Name: definition.name, Description: definition.description, Query: text, Table: table})
|
||||
}
|
||||
return views
|
||||
}
|
||||
|
||||
func resultTable(caption string, result query.Result) site.TableView {
|
||||
table := site.TableView{Caption: caption, Empty: "No observations are available in the last hour."}
|
||||
for _, column := range result.Columns {
|
||||
table.Columns = append(table.Columns, site.TableColumn{Label: column.Field, Unit: column.Unit})
|
||||
}
|
||||
for _, row := range result.Rows {
|
||||
view := site.TableRow{Values: make([]string, len(result.Columns))}
|
||||
for index := range view.Values {
|
||||
view.Values[index] = "—"
|
||||
if index < len(row.Values) && row.Values[index] != nil {
|
||||
view.Values[index] = boundedCell(*row.Values[index])
|
||||
}
|
||||
}
|
||||
table.Rows = append(table.Rows, view)
|
||||
}
|
||||
return table
|
||||
}
|
||||
|
||||
func boundedCell(value string) string {
|
||||
const maxRunes = 256
|
||||
if utf8.RuneCountInString(value) <= maxRunes {
|
||||
return value
|
||||
}
|
||||
runes := []rune(value)
|
||||
return string(runes[:maxRunes]) + "…"
|
||||
}
|
||||
|
||||
func (s *Server) events(w http.ResponseWriter, r *http.Request) {
|
||||
principal, ok := auth.PrincipalFromContext(r.Context())
|
||||
if !ok {
|
||||
writeProblem(w, http.StatusUnauthorized, "authentication required")
|
||||
return
|
||||
}
|
||||
organizationID := r.URL.Query().Get("organization")
|
||||
if len(r.URL.Query()) != 1 || len(r.URL.Query()["organization"]) != 1 {
|
||||
writeProblem(w, http.StatusBadRequest, "organization is required")
|
||||
return
|
||||
}
|
||||
scope := access.Scope{OrganizationID: organizationID}
|
||||
if err := s.identity.ValidateResourceScope(r.Context(), scope); err != nil {
|
||||
writeProblem(w, http.StatusBadRequest, "invalid organization")
|
||||
return
|
||||
}
|
||||
decision, err := s.identity.Access.Authorize(r.Context(), principal.User.ID, scope, identity.PermissionDashboardsRead)
|
||||
if err != nil || !decision.Allowed {
|
||||
writeProblem(w, http.StatusForbidden, "dashboard access denied")
|
||||
return
|
||||
}
|
||||
updates, remove, err := s.refresh.subscribe(organizationID)
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusServiceUnavailable, "live refresh capacity reached")
|
||||
return
|
||||
}
|
||||
defer remove()
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
writeProblem(w, http.StatusNotImplemented, "streaming unavailable")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("X-Accel-Buffering", "no")
|
||||
_, _ = io.WriteString(w, "event: ready\ndata: {}\n\n")
|
||||
flusher.Flush()
|
||||
heartbeat := time.NewTicker(20 * time.Second)
|
||||
defer heartbeat.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case <-updates:
|
||||
_, _ = io.WriteString(w, "event: refresh\ndata: {}\n\n")
|
||||
flusher.Flush()
|
||||
case <-heartbeat.C:
|
||||
_, _ = io.WriteString(w, ": keepalive\n\n")
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) serveAsset(w http.ResponseWriter, r *http.Request) {
|
||||
body, contentType, ok := site.Asset(r.URL.Path)
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(body)))
|
||||
if r.Method != http.MethodHead {
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) head(title, description, path string) site.HeadView {
|
||||
return site.HeadView{Title: title, Description: description, CanonicalURL: s.options.PublicOrigin + path, Assets: site.AssetPaths()}
|
||||
}
|
||||
|
||||
func (s *Server) renderHTML(w http.ResponseWriter, r *http.Request, status int, component sando.Component) {
|
||||
var body bytes.Buffer
|
||||
if err := sando.Render(r.Context(), &body, component); err != nil {
|
||||
writeProblem(w, http.StatusInternalServerError, "interface render unavailable")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", body.Len()))
|
||||
w.WriteHeader(status)
|
||||
if r.Method != http.MethodHead {
|
||||
_, _ = w.Write(body.Bytes())
|
||||
}
|
||||
}
|
||||
|
||||
func readForm(w http.ResponseWriter, r *http.Request, limit int64, fields ...string) (url.Values, error) {
|
||||
return readFormFields(w, r, limit, fields, nil)
|
||||
}
|
||||
|
||||
func readFormFields(w http.ResponseWriter, r *http.Request, limit int64, required, optional []string) (url.Values, error) {
|
||||
mediaType, parameters, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||
if err != nil || mediaType != "application/x-www-form-urlencoded" || len(parameters) > 1 || len(parameters) == 1 && !strings.EqualFold(parameters["charset"], "utf-8") {
|
||||
return nil, errors.New("URL-encoded form required")
|
||||
}
|
||||
body := http.MaxBytesReader(w, r.Body, limit)
|
||||
defer body.Close()
|
||||
encoded, err := io.ReadAll(body)
|
||||
if err != nil || !utf8.Valid(encoded) {
|
||||
return nil, errors.New("invalid form body")
|
||||
}
|
||||
values, err := url.ParseQuery(string(encoded))
|
||||
if err != nil || len(values) != len(required)+len(optional) {
|
||||
return nil, errors.New("invalid form fields")
|
||||
}
|
||||
for _, field := range required {
|
||||
if len(values[field]) != 1 || values.Get(field) == "" {
|
||||
return nil, errors.New("invalid form field")
|
||||
}
|
||||
}
|
||||
for _, field := range optional {
|
||||
if len(values[field]) != 1 {
|
||||
return nil, errors.New("invalid form field")
|
||||
}
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
@@ -0,0 +1,1122 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ecdh"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gamertan.com/observatory/internal/identity"
|
||||
"gamertan.com/observatory/internal/model"
|
||||
"gamertan.com/observatory/internal/query"
|
||||
"gamertan.com/observatory/internal/schema"
|
||||
"gamertan.com/observatory/internal/site"
|
||||
"gamertan.com/observatory/internal/storage"
|
||||
"gamertan.com/web/authhttp"
|
||||
)
|
||||
|
||||
func TestHandlerAssignsFreshBoundedRequestIDs(t *testing.T) {
|
||||
server, store, identities, _ := newUITestServer(t)
|
||||
defer store.Close()
|
||||
defer identities.Close()
|
||||
handler := server.Handler()
|
||||
|
||||
var previous string
|
||||
for _, test := range []struct {
|
||||
method string
|
||||
target string
|
||||
}{
|
||||
{http.MethodGet, "https://observatory.example/healthz"},
|
||||
{http.MethodHead, "https://observatory.example/readyz"},
|
||||
{http.MethodGet, "https://observatory.example/"},
|
||||
{http.MethodGet, "https://observatory.example/not-found"},
|
||||
} {
|
||||
header := http.Header{"X-Request-ID": []string{"attacker-selected"}}
|
||||
response := perform(handler, test.method, test.target, nil, nil, header)
|
||||
requestID := response.Header().Get("X-Request-ID")
|
||||
decoded, err := hex.DecodeString(requestID)
|
||||
if err != nil || len(decoded) != 16 || requestID == "attacker-selected" || requestID == previous {
|
||||
t.Fatalf("%s %s request_id=%q decoded=%d err=%v previous=%q", test.method, test.target, requestID, len(decoded), err, previous)
|
||||
}
|
||||
previous = requestID
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTMLInterfaceAuthenticationAssetsAndOverview(t *testing.T) {
|
||||
server, store, identities, bootstrap := newUITestServer(t)
|
||||
defer store.Close()
|
||||
defer identities.Close()
|
||||
handler := server.Handler()
|
||||
|
||||
landing := perform(handler, http.MethodGet, "https://observatory.example/", nil, nil)
|
||||
if landing.Code != http.StatusOK || landing.Header().Get("Content-Type") != "text/html; charset=utf-8" || !strings.Contains(landing.Body.String(), "Keep the evidence close.") {
|
||||
t.Fatalf("landing status=%d body=%s", landing.Code, landing.Body.String())
|
||||
}
|
||||
csp := landing.Header().Get("Content-Security-Policy")
|
||||
if strings.Contains(csp, "unsafe-inline") || !strings.Contains(csp, "script-src 'self'") || !strings.Contains(csp, "style-src 'self'") || !strings.Contains(csp, "manifest-src 'self'") || !strings.Contains(csp, "worker-src 'self'") || strings.Contains(landing.Body.String(), "<style") {
|
||||
t.Fatalf("CSP=%q body=%s", csp, landing.Body.String())
|
||||
}
|
||||
if !strings.Contains(landing.Body.String(), `<link rel="manifest" href="/manifest.webmanifest">`) {
|
||||
t.Fatalf("landing omitted manifest discovery: %s", landing.Body.String())
|
||||
}
|
||||
head := perform(handler, http.MethodHead, "https://observatory.example/", nil, nil)
|
||||
if head.Code != http.StatusOK || head.Body.Len() != 0 || head.Header().Get("Content-Length") != landing.Header().Get("Content-Length") {
|
||||
t.Fatalf("HEAD status=%d length=%q body=%d", head.Code, head.Header().Get("Content-Length"), head.Body.Len())
|
||||
}
|
||||
for _, path := range []string{site.AssetPaths().StylePath, site.AssetPaths().ScriptPath, site.AssetPaths().IconPath} {
|
||||
asset := perform(handler, http.MethodGet, "https://observatory.example"+path, nil, nil)
|
||||
if asset.Code != http.StatusOK || asset.Body.Len() == 0 || asset.Header().Get("Cache-Control") != "public, max-age=31536000, immutable" {
|
||||
t.Fatalf("asset %s status=%d cache=%q", path, asset.Code, asset.Header().Get("Cache-Control"))
|
||||
}
|
||||
assetHead := perform(handler, http.MethodHead, "https://observatory.example"+path, nil, nil)
|
||||
if assetHead.Code != http.StatusOK || assetHead.Body.Len() != 0 || assetHead.Header().Get("Content-Length") != asset.Header().Get("Content-Length") {
|
||||
t.Fatalf("asset HEAD %s status=%d", path, assetHead.Code)
|
||||
}
|
||||
}
|
||||
manifest := perform(handler, http.MethodGet, "https://observatory.example/manifest.webmanifest", nil, nil)
|
||||
if manifest.Code != http.StatusOK || manifest.Header().Get("Content-Type") != "application/manifest+json" || !strings.Contains(manifest.Body.String(), site.AssetPaths().IconPath) {
|
||||
t.Fatalf("manifest status=%d headers=%v body=%s", manifest.Code, manifest.Header(), manifest.Body.String())
|
||||
}
|
||||
manifestHead := perform(handler, http.MethodHead, "https://observatory.example/manifest.webmanifest", nil, nil)
|
||||
if manifestHead.Code != http.StatusOK || manifestHead.Body.Len() != 0 || manifestHead.Header().Get("Content-Length") != manifest.Header().Get("Content-Length") {
|
||||
t.Fatalf("manifest HEAD status=%d body=%d", manifestHead.Code, manifestHead.Body.Len())
|
||||
}
|
||||
worker := perform(handler, http.MethodGet, "https://observatory.example/service-worker.js", nil, nil)
|
||||
if worker.Code != http.StatusOK || worker.Header().Get("Content-Type") != "text/javascript; charset=utf-8" || worker.Header().Get("Cache-Control") != "no-cache" || worker.Header().Get("Service-Worker-Allowed") != "/" || !strings.Contains(worker.Body.String(), "cache-inbox") {
|
||||
t.Fatalf("worker status=%d headers=%v", worker.Code, worker.Header())
|
||||
}
|
||||
offline := perform(handler, http.MethodGet, "https://observatory.example/offline/", nil, nil)
|
||||
if offline.Code != http.StatusOK || !strings.Contains(offline.Body.String(), "The evidence is still safe.") {
|
||||
t.Fatalf("offline status=%d body=%s", offline.Code, offline.Body.String())
|
||||
}
|
||||
disabledPush := perform(handler, http.MethodPost, "https://observatory.example/api/v1/push/subscription", strings.NewReader(`{}`), nil)
|
||||
if disabledPush.Code != http.StatusNotFound {
|
||||
t.Fatalf("disabled push status=%d body=%s", disabledPush.Code, disabledPush.Body.String())
|
||||
}
|
||||
|
||||
unauthenticated := perform(handler, http.MethodGet, "https://observatory.example/app/", nil, nil)
|
||||
if unauthenticated.Code != http.StatusSeeOther || unauthenticated.Header().Get("Location") != "/login/" {
|
||||
t.Fatalf("unauthenticated status=%d location=%q", unauthenticated.Code, unauthenticated.Header().Get("Location"))
|
||||
}
|
||||
extraQuery := perform(handler, http.MethodGet, "https://observatory.example/app/?unexpected=true", nil, []*http.Cookie{loginHTML(t, handler)})
|
||||
if extraQuery.Code != http.StatusBadRequest {
|
||||
t.Fatalf("unexpected query status=%d", extraQuery.Code)
|
||||
}
|
||||
|
||||
loginCookie := loginHTML(t, handler)
|
||||
now := server.now()
|
||||
token, err := store.CreateSource(context.Background(), "ui-source", model.Scope{OrganizationID: bootstrap.Organization.ID, ProjectID: "project-a", EnvironmentID: "production", ServiceID: "service-a"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
batch := model.Batch{Version: model.BatchVersion, SourceID: "ui-source", StreamID: "requests", Sequence: 1, ObservedAt: now, Signal: model.SignalLogs, Records: []model.Observation{{Timestamp: now, Name: "application.http.request", Attributes: map[string]string{"http.status_code": "200", "http.route": "/"}}}}
|
||||
body, _ := json.Marshal(batch)
|
||||
updates, remove, err := server.refresh.subscribe(bootstrap.Organization.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer remove()
|
||||
ingestHeaders := http.Header{"Authorization": []string{"Bearer " + token}, "Content-Type": []string{"application/json"}}
|
||||
ingest := perform(handler, http.MethodPost, "https://observatory.example/api/v1/ingest/native", bytes.NewReader(body), nil, ingestHeaders)
|
||||
if ingest.Code != http.StatusAccepted {
|
||||
t.Fatalf("ingest status=%d body=%s", ingest.Code, ingest.Body.String())
|
||||
}
|
||||
pending := perform(handler, http.MethodGet, "https://observatory.example/app/", nil, []*http.Cookie{loginCookie})
|
||||
if pending.Code != http.StatusOK || !strings.Contains(pending.Body.String(), "Durable evidence is still being indexed.") || !strings.Contains(pending.Body.String(), "Accepted batches safely stored: 1.") || !strings.Contains(pending.Body.String(), `role="status"`) {
|
||||
t.Fatalf("pending app status=%d body=%s", pending.Code, pending.Body.String())
|
||||
}
|
||||
if err = store.Recover(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case <-updates:
|
||||
default:
|
||||
t.Fatal("successful ingest did not publish organization refresh")
|
||||
}
|
||||
|
||||
app := perform(handler, http.MethodGet, "https://observatory.example/app/", nil, []*http.Cookie{loginCookie})
|
||||
if app.Code != http.StatusOK || !strings.Contains(app.Body.String(), bootstrap.Organization.Name) || !strings.Contains(app.Body.String(), "application.http.request") || !strings.Contains(app.Body.String(), "Recent metrics") || !strings.Contains(app.Body.String(), "Recent traces") || !strings.Contains(app.Body.String(), "Recent deployments") || !strings.Contains(app.Body.String(), `action="/app/queries/builder/"`) || !strings.Contains(app.Body.String(), "Build a query") {
|
||||
t.Fatalf("app status=%d body=%s", app.Code, app.Body.String())
|
||||
}
|
||||
if strings.Contains(app.Body.String(), "Durable evidence is still being indexed.") {
|
||||
t.Fatalf("app retained indexing status after projection completed: %s", app.Body.String())
|
||||
}
|
||||
for _, forbidden := range []string{"Render #", "request number", "position:sticky", "unsafe-inline"} {
|
||||
if strings.Contains(app.Body.String(), forbidden) {
|
||||
t.Fatalf("app exposed forbidden marker %q", forbidden)
|
||||
}
|
||||
}
|
||||
appHead := perform(handler, http.MethodHead, "https://observatory.example/app/", nil, []*http.Cookie{loginCookie})
|
||||
if appHead.Code != http.StatusOK || appHead.Body.Len() != 0 || appHead.Header().Get("Content-Length") != app.Header().Get("Content-Length") {
|
||||
t.Fatalf("app HEAD status=%d body=%d", appHead.Code, appHead.Body.Len())
|
||||
}
|
||||
|
||||
sessionToken := loginCookie.Value
|
||||
csrf, err := authhttp.CSRFToken(sessionToken, "session:delete")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
logout := url.Values{"csrf_token": []string{csrf}}.Encode()
|
||||
logoutHeaders := http.Header{"Origin": []string{"null"}, "Content-Type": []string{"application/x-www-form-urlencoded"}}
|
||||
loggedOut := perform(handler, http.MethodPost, "https://observatory.example/logout/", strings.NewReader(logout), []*http.Cookie{loginCookie}, logoutHeaders)
|
||||
if loggedOut.Code != http.StatusSeeOther || loggedOut.Header().Get("Location") != "/login/" {
|
||||
t.Fatalf("logout status=%d location=%q", loggedOut.Code, loggedOut.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExploreWorkbenchUsesBoundedServerRenderedQueries(t *testing.T) {
|
||||
server, store, identities, bootstrap := newUITestServer(t)
|
||||
defer store.Close()
|
||||
defer identities.Close()
|
||||
handler := server.Handler()
|
||||
|
||||
unauthenticated := perform(handler, http.MethodGet, "https://observatory.example/app/explore/?organization="+url.QueryEscape(bootstrap.Organization.ID), nil, nil)
|
||||
if unauthenticated.Code != http.StatusSeeOther || unauthenticated.Header().Get("Location") != "/login/" {
|
||||
t.Fatalf("unauthenticated status=%d location=%q", unauthenticated.Code, unauthenticated.Header().Get("Location"))
|
||||
}
|
||||
cookie := loginHTML(t, handler)
|
||||
missingOrganization := perform(handler, http.MethodGet, "https://observatory.example/app/explore/", nil, []*http.Cookie{cookie})
|
||||
if missingOrganization.Code != http.StatusBadRequest {
|
||||
t.Fatalf("missing organization status=%d body=%s", missingOrganization.Code, missingOrganization.Body.String())
|
||||
}
|
||||
target := "https://observatory.example/app/explore/?organization=" + url.QueryEscape(bootstrap.Organization.ID)
|
||||
page := perform(handler, http.MethodGet, target, nil, []*http.Cookie{cookie})
|
||||
pageBody := page.Body.String()
|
||||
currentLink := `<a aria-current="page" href="/app/explore/?organization=` + bootstrap.Organization.ID + `">Explore</a>`
|
||||
if page.Code != http.StatusOK || !strings.Contains(pageBody, "Follow the evidence.") || !strings.Contains(pageBody, currentLink) || !strings.Contains(pageBody, defaultExploreQuery) || strings.Contains(pageBody, "Authorized query results") {
|
||||
t.Fatalf("explore status=%d body=%s", page.Code, pageBody)
|
||||
}
|
||||
if !strings.Contains(pageBody, `method="post" action="/app/explore/?organization=`+bootstrap.Organization.ID+`"`) || strings.Contains(pageBody, "?query=") {
|
||||
t.Fatalf("explore form did not keep query in POST body: %s", pageBody)
|
||||
}
|
||||
head := perform(handler, http.MethodHead, target, nil, []*http.Cookie{cookie})
|
||||
if head.Code != http.StatusOK || head.Body.Len() != 0 || head.Header().Get("Content-Length") != page.Header().Get("Content-Length") {
|
||||
t.Fatalf("explore HEAD status=%d length=%q body=%d", head.Code, head.Header().Get("Content-Length"), head.Body.Len())
|
||||
}
|
||||
|
||||
now := server.now()
|
||||
sourceToken, err := store.CreateSource(context.Background(), "explore-source", model.Scope{OrganizationID: bootstrap.Organization.ID, ProjectID: "project-a", EnvironmentID: "production", ServiceID: "service-a"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
batch := model.Batch{Version: model.BatchVersion, SourceID: "explore-source", StreamID: "requests", Sequence: 1, ObservedAt: now, Signal: model.SignalLogs, Records: []model.Observation{{Timestamp: now, Name: "application.http.request", Attributes: map[string]string{"http.status_code": "200", "http.route": "/explore-proof"}}}}
|
||||
if _, err = store.Ingest(context.Background(), sourceToken, batch, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = store.Recover(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
csrf, err := authhttp.CSRFToken(cookie.Value, "query:execute")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
queryText := `logs | where route == "/explore-proof" | window 1h | limit 10`
|
||||
form := url.Values{"csrf_token": []string{csrf}, "query": []string{queryText}}.Encode()
|
||||
headers := http.Header{"Content-Type": []string{"application/x-www-form-urlencoded"}}
|
||||
result := perform(handler, http.MethodPost, target, strings.NewReader(form), []*http.Cookie{cookie}, headers)
|
||||
resultBody := result.Body.String()
|
||||
for _, required := range []string{"Authorized query results", "/explore-proof", "Scanned rows", "Matched rows", "Scanned bytes", "Execution", html.EscapeString(queryText)} {
|
||||
if !strings.Contains(resultBody, required) {
|
||||
t.Fatalf("result omitted %q: status=%d body=%s", required, result.Code, resultBody)
|
||||
}
|
||||
}
|
||||
if result.Code != http.StatusOK || strings.Contains(result.Header().Get("Content-Type"), "application/json") {
|
||||
t.Fatalf("query status=%d type=%q body=%s", result.Code, result.Header().Get("Content-Type"), resultBody)
|
||||
}
|
||||
|
||||
invalidForm := url.Values{"csrf_token": []string{"invalid"}, "query": []string{"logs | limit 10"}}.Encode()
|
||||
invalid := perform(handler, http.MethodPost, target, strings.NewReader(invalidForm), []*http.Cookie{cookie}, headers)
|
||||
if invalid.Code != http.StatusForbidden || !strings.Contains(invalid.Body.String(), "query form expired") || strings.Contains(invalid.Header().Get("Content-Type"), "application/json") {
|
||||
t.Fatalf("invalid CSRF status=%d body=%s", invalid.Code, invalid.Body.String())
|
||||
}
|
||||
|
||||
badQuery := url.Values{"csrf_token": []string{csrf}, "query": []string{"logs | become unbounded"}}.Encode()
|
||||
rejected := perform(handler, http.MethodPost, target, strings.NewReader(badQuery), []*http.Cookie{cookie}, headers)
|
||||
if rejected.Code != http.StatusUnprocessableEntity || !strings.Contains(rejected.Body.String(), "could not be parsed") || !strings.Contains(rejected.Body.String(), "logs | become unbounded") {
|
||||
t.Fatalf("rejected query status=%d body=%s", rejected.Code, rejected.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTMLLoginFailsClosed(t *testing.T) {
|
||||
server, store, identities, _ := newUITestServer(t)
|
||||
defer store.Close()
|
||||
defer identities.Close()
|
||||
handler := server.Handler()
|
||||
|
||||
wrongOrigin := http.Header{"Origin": []string{"https://attacker.example"}, "Content-Type": []string{"application/x-www-form-urlencoded"}}
|
||||
result := perform(handler, http.MethodPost, "https://observatory.example/login/", strings.NewReader("identifier=operator&password=do-not-echo"), nil, wrongOrigin)
|
||||
if result.Code != http.StatusForbidden || !strings.Contains(result.Body.String(), loginFormFailure) || strings.Contains(result.Body.String(), "do-not-echo") || strings.Contains(result.Header().Get("Content-Type"), "application/json") {
|
||||
t.Fatalf("wrong origin status=%d body=%s", result.Code, result.Body.String())
|
||||
}
|
||||
csrfCookie, csrfToken := loginFormCSRF(t, handler)
|
||||
extraField := http.Header{"Origin": []string{"https://observatory.example"}, "Content-Type": []string{"application/x-www-form-urlencoded"}}
|
||||
extraForm := url.Values{"csrf_token": []string{csrfToken}, "identifier": []string{"operator"}, "password": []string{"wrong"}, "next": []string{"https://attacker.example"}}.Encode()
|
||||
result = perform(handler, http.MethodPost, "https://observatory.example/login/", strings.NewReader(extraForm), []*http.Cookie{csrfCookie}, extraField)
|
||||
if result.Code != http.StatusForbidden || !strings.Contains(result.Body.String(), loginFormFailure) || strings.Contains(result.Body.String(), "attacker.example") || result.Header().Get("Location") != "" {
|
||||
t.Fatalf("extra field status=%d location=%q body=%s", result.Code, result.Header().Get("Location"), result.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTMLLoginUsesTokenWhenBrowserOmitsOrObscuresOriginMetadata(t *testing.T) {
|
||||
server, store, identities, _ := newUITestServer(t)
|
||||
defer store.Close()
|
||||
defer identities.Close()
|
||||
handler := server.Handler()
|
||||
|
||||
page := perform(handler, http.MethodGet, "https://observatory.example/login/", nil, nil)
|
||||
if page.Code != http.StatusOK {
|
||||
t.Fatalf("login page status=%d body=%s", page.Code, page.Body.String())
|
||||
}
|
||||
var csrfCookie *http.Cookie
|
||||
for _, cookie := range page.Result().Cookies() {
|
||||
if cookie.Name == loginCSRFCookieName {
|
||||
csrfCookie = cookie
|
||||
}
|
||||
}
|
||||
if csrfCookie == nil || !csrfCookie.Secure || !csrfCookie.HttpOnly || csrfCookie.SameSite != http.SameSiteStrictMode || csrfCookie.MaxAge != 600 {
|
||||
t.Fatalf("login CSRF cookie=%+v", csrfCookie)
|
||||
}
|
||||
const marker = `name="csrf_token" value="`
|
||||
start := strings.Index(page.Body.String(), marker)
|
||||
if start < 0 {
|
||||
t.Fatalf("login page omitted CSRF token: %s", page.Body.String())
|
||||
}
|
||||
start += len(marker)
|
||||
end := strings.IndexByte(page.Body.String()[start:], '"')
|
||||
if end < 0 {
|
||||
t.Fatal("login page CSRF token is unterminated")
|
||||
}
|
||||
token := page.Body.String()[start : start+end]
|
||||
if token == "" || token != csrfCookie.Value {
|
||||
t.Fatal("login form and cookie CSRF tokens differ")
|
||||
}
|
||||
|
||||
form := url.Values{"csrf_token": []string{token}, "identifier": []string{"not-a-user"}, "password": []string{"not-a-password"}}.Encode()
|
||||
contentType := http.Header{"Content-Type": []string{"application/x-www-form-urlencoded"}}
|
||||
omittedMetadata := perform(handler, http.MethodPost, "https://observatory.example/login/", strings.NewReader(form), []*http.Cookie{csrfCookie}, contentType)
|
||||
if omittedMetadata.Code != http.StatusUnauthorized || strings.Contains(omittedMetadata.Body.String(), "not-a-password") {
|
||||
t.Fatalf("origin-metadata fallback status=%d body=%s", omittedMetadata.Code, omittedMetadata.Body.String())
|
||||
}
|
||||
opaqueSameOriginHeaders := contentType.Clone()
|
||||
opaqueSameOriginHeaders.Set("Origin", "null")
|
||||
opaqueSameOriginHeaders.Set("Sec-Fetch-Site", "same-origin")
|
||||
opaqueSameOrigin := perform(handler, http.MethodPost, "https://observatory.example/login/", strings.NewReader(form), []*http.Cookie{csrfCookie}, opaqueSameOriginHeaders)
|
||||
if opaqueSameOrigin.Code != http.StatusUnauthorized || strings.Contains(opaqueSameOrigin.Body.String(), "not-a-password") {
|
||||
t.Fatalf("opaque same-origin fallback status=%d body=%s", opaqueSameOrigin.Code, opaqueSameOrigin.Body.String())
|
||||
}
|
||||
|
||||
withoutToken := perform(handler, http.MethodPost, "https://observatory.example/login/", strings.NewReader("identifier=not-a-user&password=not-a-password"), nil, contentType)
|
||||
if withoutToken.Code != http.StatusForbidden {
|
||||
t.Fatalf("originless tokenless status=%d body=%s", withoutToken.Code, withoutToken.Body.String())
|
||||
}
|
||||
opaqueWithoutToken := perform(handler, http.MethodPost, "https://observatory.example/login/", strings.NewReader("identifier=not-a-user&password=not-a-password"), nil, opaqueSameOriginHeaders)
|
||||
if opaqueWithoutToken.Code != http.StatusForbidden {
|
||||
t.Fatalf("opaque tokenless status=%d body=%s", opaqueWithoutToken.Code, opaqueWithoutToken.Body.String())
|
||||
}
|
||||
opaqueWithoutFetchMetadata := contentType.Clone()
|
||||
opaqueWithoutFetchMetadata.Set("Origin", "null")
|
||||
opaqueTokenOnly := perform(handler, http.MethodPost, "https://observatory.example/login/", strings.NewReader(form), []*http.Cookie{csrfCookie}, opaqueWithoutFetchMetadata)
|
||||
if opaqueTokenOnly.Code != http.StatusUnauthorized || !strings.Contains(opaqueTokenOnly.Body.String(), loginCredentialFailure) {
|
||||
t.Fatalf("opaque origin without same-origin fetch metadata status=%d body=%s", opaqueTokenOnly.Code, opaqueTokenOnly.Body.String())
|
||||
}
|
||||
wrongOrigin := contentType.Clone()
|
||||
wrongOrigin.Set("Origin", "https://attacker.example")
|
||||
crossSite := perform(handler, http.MethodPost, "https://observatory.example/login/", strings.NewReader(form), []*http.Cookie{csrfCookie}, wrongOrigin)
|
||||
if crossSite.Code != http.StatusForbidden {
|
||||
t.Fatalf("cross-site token replay status=%d body=%s", crossSite.Code, crossSite.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemporaryOperatorMustRotatePasswordBeforeUsingApplication(t *testing.T) {
|
||||
server, store, identities, _ := newRotationTestServer(t)
|
||||
defer store.Close()
|
||||
defer identities.Close()
|
||||
handler := server.Handler()
|
||||
const temporary = "temporary correct horse battery staple"
|
||||
const replacement = "permanent correct horse battery staple"
|
||||
|
||||
headers := http.Header{"Origin": []string{"https://observatory.example"}, "Content-Type": []string{"application/x-www-form-urlencoded"}}
|
||||
login := loginHTMLResponse(t, handler, "operator", temporary)
|
||||
if login.Code != http.StatusSeeOther || login.Header().Get("Location") != "/account/password/" || strings.Contains(login.Body.String(), temporary) {
|
||||
t.Fatalf("login status=%d location=%q body=%s", login.Code, login.Header().Get("Location"), login.Body.String())
|
||||
}
|
||||
var cookie *http.Cookie
|
||||
for _, candidate := range login.Result().Cookies() {
|
||||
if candidate.Name == "__Host-observatory_session" {
|
||||
cookie = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
if cookie == nil {
|
||||
t.Fatalf("login cookies=%+v", login.Result().Cookies())
|
||||
}
|
||||
blocked := perform(handler, http.MethodGet, "https://observatory.example/app/", nil, []*http.Cookie{cookie})
|
||||
if blocked.Code != http.StatusSeeOther || blocked.Header().Get("Location") != "/account/password/" {
|
||||
t.Fatalf("blocked app status=%d location=%q", blocked.Code, blocked.Header().Get("Location"))
|
||||
}
|
||||
blockedWrite := perform(handler, http.MethodPost, "https://observatory.example/api/v1/query", strings.NewReader(`{}`), []*http.Cookie{cookie}, headers)
|
||||
if blockedWrite.Code != http.StatusForbidden {
|
||||
t.Fatalf("blocked write status=%d body=%s", blockedWrite.Code, blockedWrite.Body.String())
|
||||
}
|
||||
page := perform(handler, http.MethodGet, "https://observatory.example/account/password/", nil, []*http.Cookie{cookie})
|
||||
if page.Code != http.StatusOK || !strings.Contains(page.Body.String(), "Choose your password") || strings.Contains(page.Body.String(), temporary) {
|
||||
t.Fatalf("password page status=%d body=%s", page.Code, page.Body.String())
|
||||
}
|
||||
head := perform(handler, http.MethodHead, "https://observatory.example/account/password/", nil, []*http.Cookie{cookie})
|
||||
if head.Code != http.StatusOK || head.Body.Len() != 0 || head.Header().Get("Content-Length") != page.Header().Get("Content-Length") {
|
||||
t.Fatalf("password HEAD status=%d length=%q body=%d", head.Code, head.Header().Get("Content-Length"), head.Body.Len())
|
||||
}
|
||||
csrf, err := authhttp.CSRFToken(cookie.Value, "account:password:change")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
crossSiteHeaders := http.Header{"Origin": []string{"https://attacker.example"}, "Sec-Fetch-Site": []string{"cross-site"}, "Content-Type": []string{"application/x-www-form-urlencoded"}}
|
||||
crossSiteForm := url.Values{"csrf_token": []string{csrf}, "current_password": []string{temporary}, "new_password": []string{replacement}, "confirm_password": []string{replacement}}.Encode()
|
||||
crossSite := perform(handler, http.MethodPost, "https://observatory.example/account/password/", strings.NewReader(crossSiteForm), []*http.Cookie{cookie}, crossSiteHeaders)
|
||||
if crossSite.Code != http.StatusForbidden || !strings.Contains(crossSite.Body.String(), passwordFormFailure) || strings.Contains(crossSite.Body.String(), temporary) || strings.Contains(crossSite.Body.String(), replacement) || strings.Contains(crossSite.Header().Get("Content-Type"), "application/json") {
|
||||
t.Fatalf("cross-site password status=%d body=%s", crossSite.Code, crossSite.Body.String())
|
||||
}
|
||||
invalidTokenForm := url.Values{"csrf_token": []string{"invalid"}, "current_password": []string{temporary}, "new_password": []string{replacement}, "confirm_password": []string{replacement}}.Encode()
|
||||
invalidToken := perform(handler, http.MethodPost, "https://observatory.example/account/password/", strings.NewReader(invalidTokenForm), []*http.Cookie{cookie}, headers)
|
||||
if invalidToken.Code != http.StatusForbidden || !strings.Contains(invalidToken.Body.String(), passwordFormFailure) || strings.Contains(invalidToken.Body.String(), temporary) || strings.Contains(invalidToken.Body.String(), replacement) || strings.Contains(invalidToken.Header().Get("Content-Type"), "application/json") {
|
||||
t.Fatalf("invalid-token password status=%d body=%s", invalidToken.Code, invalidToken.Body.String())
|
||||
}
|
||||
mismatch := url.Values{"csrf_token": []string{csrf}, "current_password": []string{temporary}, "new_password": []string{replacement}, "confirm_password": []string{"different password value"}}.Encode()
|
||||
rejected := perform(handler, http.MethodPost, "https://observatory.example/account/password/", strings.NewReader(mismatch), []*http.Cookie{cookie}, headers)
|
||||
if rejected.Code != http.StatusUnprocessableEntity || !strings.Contains(rejected.Body.String(), passwordMatchFailure) || strings.Contains(rejected.Body.String(), temporary) || strings.Contains(rejected.Body.String(), replacement) {
|
||||
t.Fatalf("mismatch status=%d body=%s", rejected.Code, rejected.Body.String())
|
||||
}
|
||||
change := url.Values{"csrf_token": []string{csrf}, "current_password": []string{temporary}, "new_password": []string{replacement}, "confirm_password": []string{replacement}}.Encode()
|
||||
privacyHeaders := http.Header{"Origin": []string{"null"}, "Content-Type": []string{"application/x-www-form-urlencoded"}}
|
||||
changed := perform(handler, http.MethodPost, "https://observatory.example/account/password/", strings.NewReader(change), []*http.Cookie{cookie}, privacyHeaders)
|
||||
if changed.Code != http.StatusSeeOther || changed.Header().Get("Location") != "/login/?password=changed" {
|
||||
t.Fatalf("change status=%d location=%q body=%s", changed.Code, changed.Header().Get("Location"), changed.Body.String())
|
||||
}
|
||||
oldSession := perform(handler, http.MethodGet, "https://observatory.example/app/", nil, []*http.Cookie{cookie})
|
||||
if oldSession.Code != http.StatusSeeOther || oldSession.Header().Get("Location") != "/login/" {
|
||||
t.Fatalf("old session status=%d location=%q", oldSession.Code, oldSession.Header().Get("Location"))
|
||||
}
|
||||
if _, _, err = identities.Auth.Authenticate(t.Context(), "operator", temporary, time.Hour); err == nil {
|
||||
t.Fatal("temporary password remained valid")
|
||||
}
|
||||
_, principal, err := identities.Auth.Authenticate(t.Context(), "operator", replacement, time.Hour)
|
||||
if err != nil || principal.User.PasswordChangeRequired {
|
||||
t.Fatalf("replacement principal=%+v err=%v", principal, err)
|
||||
}
|
||||
|
||||
newLogin := loginHTMLResponse(t, handler, "operator", replacement)
|
||||
if newLogin.Code != http.StatusSeeOther || newLogin.Header().Get("Location") != "/app/" {
|
||||
t.Fatalf("new login status=%d location=%q", newLogin.Code, newLogin.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPITemporaryOperatorReceivesScopedRotationToken(t *testing.T) {
|
||||
server, store, identities, _ := newRotationTestServer(t)
|
||||
defer store.Close()
|
||||
defer identities.Close()
|
||||
handler := server.Handler()
|
||||
loginHeaders := http.Header{"Origin": []string{"https://observatory.example"}, "Content-Type": []string{"application/json"}}
|
||||
login := perform(handler, http.MethodPost, "https://observatory.example/api/v1/session", strings.NewReader(`{"identifier":"operator","password":"temporary correct horse battery staple"}`), nil, loginHeaders)
|
||||
var session struct {
|
||||
PasswordChangeRequired bool `json:"password_change_required"`
|
||||
PasswordChangeCSRF string `json:"password_change_csrf"`
|
||||
}
|
||||
if login.Code != http.StatusOK || json.Unmarshal(login.Body.Bytes(), &session) != nil || !session.PasswordChangeRequired || session.PasswordChangeCSRF == "" {
|
||||
t.Fatalf("login status=%d session=%+v body=%s", login.Code, session, login.Body.String())
|
||||
}
|
||||
cookies := login.Result().Cookies()
|
||||
changeHeaders := http.Header{"Origin": []string{"https://observatory.example"}, "Content-Type": []string{"application/json"}, "X-CSRF-Token": []string{session.PasswordChangeCSRF}}
|
||||
changed := perform(handler, http.MethodPost, "https://observatory.example/api/v1/account/password", strings.NewReader(`{"current_password":"temporary correct horse battery staple","new_password":"API replacement password value"}`), cookies, changeHeaders)
|
||||
if changed.Code != http.StatusNoContent || changed.Body.Len() != 0 {
|
||||
t.Fatalf("change status=%d body=%s", changed.Code, changed.Body.String())
|
||||
}
|
||||
_, principal, err := identities.Auth.Authenticate(t.Context(), "operator", "API replacement password value", time.Hour)
|
||||
if err != nil || principal.User.PasswordChangeRequired {
|
||||
t.Fatalf("principal=%+v err=%v", principal, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLiveRefreshStreamIsAuthorizedAndCarriesNoTelemetry(t *testing.T) {
|
||||
server, store, identities, bootstrap := newUITestServer(t)
|
||||
defer store.Close()
|
||||
defer identities.Close()
|
||||
handler := server.Handler()
|
||||
unauthorized := perform(handler, http.MethodGet, "https://observatory.example/app/events?organization="+url.QueryEscape(bootstrap.Organization.ID), nil, nil)
|
||||
if unauthorized.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("unauthorized stream status=%d", unauthorized.Code)
|
||||
}
|
||||
cookie := loginHTML(t, handler)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
request := httptest.NewRequest(http.MethodGet, "https://observatory.example/app/events?organization="+url.QueryEscape(bootstrap.Organization.ID), nil).WithContext(ctx)
|
||||
request.AddCookie(cookie)
|
||||
stream := newStreamRecorder()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
handler.ServeHTTP(stream, request)
|
||||
close(done)
|
||||
}()
|
||||
waitFlush := func() {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-stream.flushed:
|
||||
case <-ctx.Done():
|
||||
t.Fatal("stream did not flush before timeout")
|
||||
}
|
||||
}
|
||||
waitFlush()
|
||||
if stream.statusCode() != http.StatusOK || stream.Header().Get("Content-Type") != "text/event-stream; charset=utf-8" || stream.Header().Get("X-Accel-Buffering") != "no" || stream.bodyString() != "event: ready\ndata: {}\n\n" {
|
||||
t.Fatalf("stream status=%d headers=%v body=%q", stream.statusCode(), stream.Header(), stream.bodyString())
|
||||
}
|
||||
server.refresh.publish(bootstrap.Organization.ID)
|
||||
waitFlush()
|
||||
streamBody := stream.bodyString()
|
||||
if streamBody != "event: ready\ndata: {}\n\nevent: refresh\ndata: {}\n\n" || strings.Contains(streamBody, bootstrap.Organization.ID) || strings.Contains(streamBody, "service") {
|
||||
t.Fatalf("stream body=%q", streamBody)
|
||||
}
|
||||
cancel()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("stream did not stop after cancellation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardManagementIsScopedCSRFProtectedAndExportable(t *testing.T) {
|
||||
server, store, identities, bootstrap := newUITestServer(t)
|
||||
defer store.Close()
|
||||
defer identities.Close()
|
||||
handler := server.Handler()
|
||||
cookie := loginHTML(t, handler)
|
||||
csrf, err := authhttp.CSRFToken(cookie.Value, "dashboards:manage")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
headers := http.Header{"Origin": []string{"https://observatory.example"}, "Content-Type": []string{"application/x-www-form-urlencoded"}}
|
||||
|
||||
invalid := url.Values{
|
||||
"organization_id": []string{bootstrap.Organization.ID}, "csrf_token": []string{"invalid"},
|
||||
"name": []string{"Recent errors"}, "description": []string{"A bounded recent error view."},
|
||||
"query": []string{"logs | where status >= 500 | window 1h | limit 50"},
|
||||
}.Encode()
|
||||
denied := perform(handler, http.MethodPost, "https://observatory.example/app/queries/", strings.NewReader(invalid), []*http.Cookie{cookie}, headers)
|
||||
if denied.Code != http.StatusForbidden {
|
||||
t.Fatalf("invalid CSRF status=%d", denied.Code)
|
||||
}
|
||||
|
||||
queryForm := url.Values{
|
||||
"organization_id": []string{bootstrap.Organization.ID}, "csrf_token": []string{csrf},
|
||||
"name": []string{"Recent errors"}, "description": []string{"A bounded recent error view."},
|
||||
"query": []string{"logs | where status >= 500 | window 1h | limit 50"},
|
||||
}.Encode()
|
||||
privacyHeaders := http.Header{"Origin": []string{"null"}, "Content-Type": []string{"application/x-www-form-urlencoded"}}
|
||||
createdQuery := perform(handler, http.MethodPost, "https://observatory.example/app/queries/", strings.NewReader(queryForm), []*http.Cookie{cookie}, privacyHeaders)
|
||||
if createdQuery.Code != http.StatusSeeOther || !strings.HasPrefix(createdQuery.Header().Get("Location"), "/app/?organization=") {
|
||||
t.Fatalf("query status=%d location=%q body=%s", createdQuery.Code, createdQuery.Header().Get("Location"), createdQuery.Body.String())
|
||||
}
|
||||
queries, err := store.SavedQueries(context.Background(), bootstrap.Organization.ID)
|
||||
if err != nil || len(queries) != 1 {
|
||||
t.Fatalf("queries=%+v err=%v", queries, err)
|
||||
}
|
||||
mismatchedDashboard := url.Values{
|
||||
"organization_id": []string{bootstrap.Organization.ID}, "csrf_token": []string{csrf},
|
||||
"slug": []string{"invalid-stat"}, "name": []string{"Invalid stat"},
|
||||
"description": []string{"A non-summary query cannot become a statistic."}, "panel_title": []string{"Invalid"},
|
||||
"saved_query_id": []string{queries[0].ID}, "visualization": []string{"stat"},
|
||||
}.Encode()
|
||||
mismatched := perform(handler, http.MethodPost, "https://observatory.example/app/dashboards/", strings.NewReader(mismatchedDashboard), []*http.Cookie{cookie}, headers)
|
||||
if mismatched.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("mismatched presentation status=%d body=%s", mismatched.Code, mismatched.Body.String())
|
||||
}
|
||||
|
||||
dashboardForm := url.Values{
|
||||
"organization_id": []string{bootstrap.Organization.ID}, "csrf_token": []string{csrf},
|
||||
"slug": []string{"recent-errors"}, "name": []string{"Recent errors"},
|
||||
"description": []string{"An accessible bounded error dashboard."}, "panel_title": []string{"Errors"},
|
||||
"saved_query_id": []string{queries[0].ID}, "visualization": []string{"table"},
|
||||
}.Encode()
|
||||
createdDashboard := perform(handler, http.MethodPost, "https://observatory.example/app/dashboards/", strings.NewReader(dashboardForm), []*http.Cookie{cookie}, headers)
|
||||
if createdDashboard.Code != http.StatusSeeOther || !strings.HasPrefix(createdDashboard.Header().Get("Location"), "/app/dashboards/recent-errors/") {
|
||||
t.Fatalf("dashboard status=%d location=%q body=%s", createdDashboard.Code, createdDashboard.Header().Get("Location"), createdDashboard.Body.String())
|
||||
}
|
||||
|
||||
target := "https://observatory.example/app/dashboards/recent-errors/?organization=" + url.QueryEscape(bootstrap.Organization.ID)
|
||||
dashboard := perform(handler, http.MethodGet, target, nil, []*http.Cookie{cookie})
|
||||
if dashboard.Code != http.StatusOK || !strings.Contains(dashboard.Body.String(), "An accessible bounded error dashboard.") || !strings.Contains(dashboard.Body.String(), "Recent errors") || !strings.Contains(dashboard.Body.String(), ">Errors</h2>") {
|
||||
t.Fatalf("dashboard status=%d body=%s", dashboard.Code, dashboard.Body.String())
|
||||
}
|
||||
dashboardHead := perform(handler, http.MethodHead, target, nil, []*http.Cookie{cookie})
|
||||
if dashboardHead.Code != http.StatusOK || dashboardHead.Body.Len() != 0 || dashboardHead.Header().Get("Content-Length") != dashboard.Header().Get("Content-Length") {
|
||||
t.Fatalf("dashboard HEAD status=%d length=%q", dashboardHead.Code, dashboardHead.Header().Get("Content-Length"))
|
||||
}
|
||||
storedDashboard, err := store.Dashboard(context.Background(), bootstrap.Organization.ID, "recent-errors")
|
||||
if err != nil || storedDashboard.Revision != 1 || !strings.Contains(dashboard.Body.String(), "Update dashboard details") || !strings.Contains(dashboard.Body.String(), `name="expected_revision" value="1"`) {
|
||||
t.Fatalf("stored dashboard=%+v err=%v body=%s", storedDashboard, err, dashboard.Body.String())
|
||||
}
|
||||
revisionForm := url.Values{
|
||||
"organization_id": []string{bootstrap.Organization.ID}, "csrf_token": []string{csrf},
|
||||
"dashboard_id": []string{storedDashboard.ID}, "expected_revision": []string{strconv.Itoa(storedDashboard.Revision)},
|
||||
"slug": []string{storedDashboard.Slug}, "name": []string{"Current errors"},
|
||||
"description": []string{"A revision-safe bounded error dashboard."},
|
||||
}.Encode()
|
||||
revised := perform(handler, http.MethodPost, target, strings.NewReader(revisionForm), []*http.Cookie{cookie}, headers)
|
||||
if revised.Code != http.StatusSeeOther {
|
||||
t.Fatalf("revision status=%d body=%s", revised.Code, revised.Body.String())
|
||||
}
|
||||
stale := perform(handler, http.MethodPost, target, strings.NewReader(revisionForm), []*http.Cookie{cookie}, headers)
|
||||
if stale.Code != http.StatusConflict {
|
||||
t.Fatalf("stale revision status=%d body=%s", stale.Code, stale.Body.String())
|
||||
}
|
||||
storedDashboard, err = store.Dashboard(context.Background(), bootstrap.Organization.ID, "recent-errors")
|
||||
if err != nil || storedDashboard.Revision != 2 || storedDashboard.Name != "Current errors" {
|
||||
t.Fatalf("revised dashboard=%+v err=%v", storedDashboard, err)
|
||||
}
|
||||
addPanelForm := url.Values{
|
||||
"organization_id": []string{bootstrap.Organization.ID}, "csrf_token": []string{csrf},
|
||||
"dashboard_id": []string{storedDashboard.ID}, "expected_revision": []string{strconv.Itoa(storedDashboard.Revision)},
|
||||
"panel_title": []string{"Recent failures"}, "saved_query_id": []string{queries[0].ID}, "visualization": []string{"table"},
|
||||
}.Encode()
|
||||
addTarget := "https://observatory.example/app/dashboards/recent-errors/panels/?organization=" + url.QueryEscape(bootstrap.Organization.ID)
|
||||
added := perform(handler, http.MethodPost, addTarget, strings.NewReader(addPanelForm), []*http.Cookie{cookie}, headers)
|
||||
if added.Code != http.StatusSeeOther {
|
||||
t.Fatalf("add panel status=%d body=%s", added.Code, added.Body.String())
|
||||
}
|
||||
storedDashboard, err = store.Dashboard(context.Background(), bootstrap.Organization.ID, "recent-errors")
|
||||
if err != nil || storedDashboard.Revision != 3 || len(storedDashboard.Panels) != 2 {
|
||||
t.Fatalf("dashboard after add=%+v err=%v", storedDashboard, err)
|
||||
}
|
||||
addedPanel := storedDashboard.Panels[1]
|
||||
updatePanelForm := url.Values{
|
||||
"organization_id": []string{bootstrap.Organization.ID}, "csrf_token": []string{csrf},
|
||||
"dashboard_id": []string{storedDashboard.ID}, "expected_revision": []string{strconv.Itoa(storedDashboard.Revision)},
|
||||
"panel_title": []string{"Renamed failures"}, "saved_query_id": []string{queries[0].ID}, "visualization": []string{"table"},
|
||||
}.Encode()
|
||||
updatePanelTarget := "https://observatory.example/app/dashboards/recent-errors/panels/" + url.PathEscape(addedPanel.ID) + "/?organization=" + url.QueryEscape(bootstrap.Organization.ID)
|
||||
updatedPanel := perform(handler, http.MethodPost, updatePanelTarget, strings.NewReader(updatePanelForm), []*http.Cookie{cookie}, headers)
|
||||
if updatedPanel.Code != http.StatusSeeOther {
|
||||
t.Fatalf("update panel status=%d body=%s", updatedPanel.Code, updatedPanel.Body.String())
|
||||
}
|
||||
storedDashboard, err = store.Dashboard(context.Background(), bootstrap.Organization.ID, "recent-errors")
|
||||
if err != nil || storedDashboard.Revision != 4 || storedDashboard.Panels[1].Title != "Renamed failures" {
|
||||
t.Fatalf("dashboard after panel update=%+v err=%v", storedDashboard, err)
|
||||
}
|
||||
mismatchedRevision := url.Values{
|
||||
"organization_id": []string{bootstrap.Organization.ID}, "csrf_token": []string{csrf},
|
||||
"dashboard_id": []string{storedDashboard.ID}, "expected_revision": []string{strconv.Itoa(storedDashboard.Revision)},
|
||||
"panel_title": []string{"Invalid chart"}, "saved_query_id": []string{queries[0].ID}, "visualization": []string{"timeseries"},
|
||||
}.Encode()
|
||||
mismatchedPanel := perform(handler, http.MethodPost, updatePanelTarget, strings.NewReader(mismatchedRevision), []*http.Cookie{cookie}, headers)
|
||||
if mismatchedPanel.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("mismatched panel status=%d body=%s", mismatchedPanel.Code, mismatchedPanel.Body.String())
|
||||
}
|
||||
removePanelForm := url.Values{
|
||||
"organization_id": []string{bootstrap.Organization.ID}, "csrf_token": []string{csrf},
|
||||
"dashboard_id": []string{storedDashboard.ID}, "expected_revision": []string{strconv.Itoa(storedDashboard.Revision)},
|
||||
}.Encode()
|
||||
removeTarget := "https://observatory.example/app/dashboards/recent-errors/panels/" + url.PathEscape(addedPanel.ID) + "/remove/?organization=" + url.QueryEscape(bootstrap.Organization.ID)
|
||||
removed := perform(handler, http.MethodPost, removeTarget, strings.NewReader(removePanelForm), []*http.Cookie{cookie}, headers)
|
||||
if removed.Code != http.StatusSeeOther {
|
||||
t.Fatalf("remove panel status=%d body=%s", removed.Code, removed.Body.String())
|
||||
}
|
||||
storedDashboard, err = store.Dashboard(context.Background(), bootstrap.Organization.ID, "recent-errors")
|
||||
if err != nil || storedDashboard.Revision != 5 || len(storedDashboard.Panels) != 1 {
|
||||
t.Fatalf("dashboard after remove=%+v err=%v", storedDashboard, err)
|
||||
}
|
||||
|
||||
exportTarget := "https://observatory.example/app/dashboards/recent-errors/export.json?organization=" + url.QueryEscape(bootstrap.Organization.ID)
|
||||
exported := perform(handler, http.MethodGet, exportTarget, nil, []*http.Cookie{cookie})
|
||||
if exported.Code != http.StatusOK || exported.Header().Get("Content-Type") != "application/json" || !strings.Contains(exported.Body.String(), `"version": 1`) || strings.Contains(exported.Body.String(), bootstrap.Organization.ID) || strings.Contains(exported.Body.String(), bootstrap.User.ID) {
|
||||
t.Fatalf("export status=%d headers=%v body=%s", exported.Code, exported.Header(), exported.Body.String())
|
||||
}
|
||||
exportedHead := perform(handler, http.MethodHead, exportTarget, nil, []*http.Cookie{cookie})
|
||||
if exportedHead.Code != http.StatusOK || exportedHead.Body.Len() != 0 || exportedHead.Header().Get("Content-Length") != exported.Header().Get("Content-Length") {
|
||||
t.Fatalf("export HEAD status=%d length=%q", exportedHead.Code, exportedHead.Header().Get("Content-Length"))
|
||||
}
|
||||
unauthorized := perform(handler, http.MethodGet, exportTarget, nil, nil)
|
||||
if unauthorized.Code != http.StatusUnauthorized || unauthorized.Body.String() == exported.Body.String() {
|
||||
t.Fatalf("unauthorized export status=%d", unauthorized.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIncidentRulesEvaluationInboxAndResponseAreScoped(t *testing.T) {
|
||||
server, store, identities, bootstrap := newUITestServer(t)
|
||||
defer store.Close()
|
||||
defer identities.Close()
|
||||
pushes := &recordingPushDispatcher{}
|
||||
server.options.PushDispatcher = pushes
|
||||
server.options.PushPublicKey = base64.RawURLEncoding.EncodeToString(append([]byte{4}, make([]byte, 64)...))
|
||||
handler := server.Handler()
|
||||
cookie := loginHTML(t, handler)
|
||||
now := server.now()
|
||||
saved, err := store.SaveQuery(context.Background(), storage.SavedQueryInput{
|
||||
OrganizationID: bootstrap.Organization.ID, ActorUserID: bootstrap.User.ID, MaxRows: 100,
|
||||
Name: "Recent failures", Description: "Recent HTTP failures.", Query: "logs | where status >= 500 | window 1h | limit 50",
|
||||
}, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token, err := store.CreateSource(context.Background(), "incident-source", model.Scope{OrganizationID: bootstrap.Organization.ID, ProjectID: "project-a", EnvironmentID: "production", ServiceID: "service-a"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
batch := model.Batch{Version: model.BatchVersion, SourceID: "incident-source", StreamID: "requests", Sequence: 1, ObservedAt: now, Signal: model.SignalLogs, Records: []model.Observation{{Timestamp: now, Name: "http.request", Attributes: map[string]string{"http.status_code": "503", "http.route": "/failed"}}}}
|
||||
if _, err = store.Ingest(context.Background(), token, batch, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = store.Recover(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
csrf, err := authhttp.CSRFToken(cookie.Value, "incidents:manage")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
headers := http.Header{"Origin": []string{"https://observatory.example"}, "Content-Type": []string{"application/x-www-form-urlencoded"}}
|
||||
form := url.Values{
|
||||
"organization_id": []string{bootstrap.Organization.ID}, "csrf_token": []string{csrf},
|
||||
"name": []string{"HTTP failures"}, "description": []string{"Open when a bounded saved query finds a failure."},
|
||||
"saved_query_id": []string{saved.ID}, "severity": []string{"critical"}, "minimum_matches": []string{"1"},
|
||||
"required_consecutive": []string{"1"}, "evaluation_interval": []string{"15s"},
|
||||
}
|
||||
invalid := cloneValues(form)
|
||||
invalid.Set("csrf_token", "invalid")
|
||||
denied := perform(handler, http.MethodPost, "https://observatory.example/app/alert-rules/", strings.NewReader(invalid.Encode()), []*http.Cookie{cookie}, headers)
|
||||
if denied.Code != http.StatusForbidden {
|
||||
t.Fatalf("invalid CSRF status=%d body=%s", denied.Code, denied.Body.String())
|
||||
}
|
||||
privacyHeaders := http.Header{"Origin": []string{"null"}, "Content-Type": []string{"application/x-www-form-urlencoded"}}
|
||||
created := perform(handler, http.MethodPost, "https://observatory.example/app/alert-rules/", strings.NewReader(form.Encode()), []*http.Cookie{cookie}, privacyHeaders)
|
||||
if created.Code != http.StatusSeeOther || !strings.HasPrefix(created.Header().Get("Location"), "/app/incidents/?organization=") {
|
||||
t.Fatalf("create status=%d location=%q body=%s", created.Code, created.Header().Get("Location"), created.Body.String())
|
||||
}
|
||||
|
||||
updates, remove, err := server.refresh.subscribe(bootstrap.Organization.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer remove()
|
||||
if evaluated, evaluationErr := server.EvaluateAlerts(context.Background()); evaluationErr != nil || evaluated != 1 {
|
||||
t.Fatalf("evaluated=%d err=%v", evaluated, evaluationErr)
|
||||
}
|
||||
if len(pushes.organizations) != 1 || pushes.organizations[0] != bootstrap.Organization.ID {
|
||||
t.Fatalf("push organizations=%v", pushes.organizations)
|
||||
}
|
||||
select {
|
||||
case <-updates:
|
||||
default:
|
||||
t.Fatal("incident change did not publish a generic refresh")
|
||||
}
|
||||
incidents, err := store.Incidents(context.Background(), bootstrap.Organization.ID, false, 10)
|
||||
if err != nil || len(incidents) != 1 || incidents[0].State != "firing" {
|
||||
t.Fatalf("incidents=%+v err=%v", incidents, err)
|
||||
}
|
||||
|
||||
path := "https://observatory.example/app/incidents/?organization=" + url.QueryEscape(bootstrap.Organization.ID)
|
||||
inbox := perform(handler, http.MethodGet, path, nil, []*http.Cookie{cookie})
|
||||
if inbox.Code != http.StatusOK || !strings.Contains(inbox.Body.String(), "What needs attention?") || !strings.Contains(inbox.Body.String(), "HTTP failures") || !strings.Contains(inbox.Body.String(), "critical · firing") || !strings.Contains(inbox.Body.String(), "data-cache-inbox") || !strings.Contains(inbox.Body.String(), "data-push-toggle") || !strings.Contains(inbox.Body.String(), `data-open-incident-count="1"`) || strings.Contains(inbox.Body.String(), "/failed") {
|
||||
t.Fatalf("inbox status=%d body=%s", inbox.Code, inbox.Body.String())
|
||||
}
|
||||
pushCSRF, err := authhttp.CSRFToken(cookie.Value, "push:manage")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clientKey, err := ecdh.P256().GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
authSecret := make([]byte, 16)
|
||||
if _, err = rand.Read(authSecret); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pushBody, _ := json.Marshal(map[string]any{"organization_id": bootstrap.Organization.ID, "endpoint": "https://push.example.test/send/browser", "keys": map[string]string{"p256dh": base64.RawURLEncoding.EncodeToString(clientKey.PublicKey().Bytes()), "auth": base64.RawURLEncoding.EncodeToString(authSecret)}})
|
||||
pushHeaders := make(http.Header)
|
||||
pushHeaders.Set("Origin", "https://observatory.example")
|
||||
pushHeaders.Set("Content-Type", "application/json")
|
||||
pushHeaders.Set("X-CSRF-Token", pushCSRF)
|
||||
invalidPushHeaders := pushHeaders.Clone()
|
||||
invalidPushHeaders.Set("X-CSRF-Token", "invalid")
|
||||
invalidPush := perform(handler, http.MethodPost, "https://observatory.example/api/v1/push/subscription", bytes.NewReader(pushBody), []*http.Cookie{cookie}, invalidPushHeaders)
|
||||
if invalidPush.Code != http.StatusForbidden {
|
||||
t.Fatalf("invalid push CSRF status=%d body=%s", invalidPush.Code, invalidPush.Body.String())
|
||||
}
|
||||
crossOriginPushHeaders := pushHeaders.Clone()
|
||||
crossOriginPushHeaders.Set("Origin", "https://attacker.example")
|
||||
crossOriginPush := perform(handler, http.MethodPost, "https://observatory.example/api/v1/push/subscription", bytes.NewReader(pushBody), []*http.Cookie{cookie}, crossOriginPushHeaders)
|
||||
if crossOriginPush.Code != http.StatusForbidden {
|
||||
t.Fatalf("cross-origin push status=%d body=%s", crossOriginPush.Code, crossOriginPush.Body.String())
|
||||
}
|
||||
registered := perform(handler, http.MethodPost, "https://observatory.example/api/v1/push/subscription", bytes.NewReader(pushBody), []*http.Cookie{cookie}, pushHeaders)
|
||||
if registered.Code != http.StatusCreated || !strings.Contains(registered.Body.String(), `"id":"push_`) {
|
||||
t.Fatalf("push registration status=%d body=%s", registered.Code, registered.Body.String())
|
||||
}
|
||||
if subscriptions, listErr := store.PushSubscriptions(context.Background(), bootstrap.Organization.ID); listErr != nil || len(subscriptions) != 1 || subscriptions[0].UserID != bootstrap.User.ID {
|
||||
t.Fatalf("push subscriptions=%+v err=%v", subscriptions, listErr)
|
||||
}
|
||||
statusBody, _ := json.Marshal(map[string]any{"organization_id": bootstrap.Organization.ID, "endpoint": "https://push.example.test/send/browser", "keys": map[string]string{"p256dh": "", "auth": ""}})
|
||||
pushStatus := perform(handler, http.MethodPost, "https://observatory.example/api/v1/push/subscription/status", bytes.NewReader(statusBody), []*http.Cookie{cookie}, pushHeaders)
|
||||
if pushStatus.Code != http.StatusOK || !strings.Contains(pushStatus.Body.String(), `"subscribed":true`) {
|
||||
t.Fatalf("push status=%d body=%s", pushStatus.Code, pushStatus.Body.String())
|
||||
}
|
||||
privateEndpointBody := bytes.Replace(pushBody, []byte("https://push.example.test/send/browser"), []byte("https://127.0.0.1/send/browser"), 1)
|
||||
rejected := perform(handler, http.MethodPost, "https://observatory.example/api/v1/push/subscription", bytes.NewReader(privateEndpointBody), []*http.Cookie{cookie}, pushHeaders)
|
||||
if rejected.Code != http.StatusUnprocessableEntity || strings.Contains(rejected.Body.String(), "127.0.0.1") {
|
||||
t.Fatalf("private endpoint status=%d body=%s", rejected.Code, rejected.Body.String())
|
||||
}
|
||||
invalidCurveBody, _ := json.Marshal(map[string]any{"organization_id": bootstrap.Organization.ID, "endpoint": "https://push.example.test/send/invalid-curve", "keys": map[string]string{"p256dh": base64.RawURLEncoding.EncodeToString(append([]byte{4}, make([]byte, 64)...)), "auth": base64.RawURLEncoding.EncodeToString(authSecret)}})
|
||||
invalidCurve := perform(handler, http.MethodPost, "https://observatory.example/api/v1/push/subscription", bytes.NewReader(invalidCurveBody), []*http.Cookie{cookie}, pushHeaders)
|
||||
if invalidCurve.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("invalid curve status=%d body=%s", invalidCurve.Code, invalidCurve.Body.String())
|
||||
}
|
||||
deleteBody, _ := json.Marshal(map[string]any{"organization_id": bootstrap.Organization.ID, "endpoint": "https://push.example.test/send/browser", "keys": map[string]string{"p256dh": "", "auth": ""}})
|
||||
deleted := perform(handler, http.MethodDelete, "https://observatory.example/api/v1/push/subscription", bytes.NewReader(deleteBody), []*http.Cookie{cookie}, pushHeaders)
|
||||
if deleted.Code != http.StatusOK || !strings.Contains(deleted.Body.String(), `"remaining":false`) {
|
||||
t.Fatalf("push deletion status=%d body=%s", deleted.Code, deleted.Body.String())
|
||||
}
|
||||
offlineInbox := perform(handler, http.MethodGet, "https://observatory.example/app/incidents/offline/?organization="+url.QueryEscape(bootstrap.Organization.ID), nil, []*http.Cookie{cookie})
|
||||
if offlineInbox.Code != http.StatusOK || !strings.Contains(offlineInbox.Body.String(), "Saved incident inbox") || !strings.Contains(offlineInbox.Body.String(), "HTTP failures") {
|
||||
t.Fatalf("offline inbox status=%d body=%s", offlineInbox.Code, offlineInbox.Body.String())
|
||||
}
|
||||
for _, forbidden := range []string{incidents[0].ID, saved.Query, bootstrap.User.ID, "csrf_token", "/failed", "Acknowledge", "Resolve"} {
|
||||
if strings.Contains(offlineInbox.Body.String(), forbidden) {
|
||||
t.Fatalf("offline inbox exposed %q: %s", forbidden, offlineInbox.Body.String())
|
||||
}
|
||||
}
|
||||
unauthenticatedOffline := perform(handler, http.MethodGet, "https://observatory.example/app/incidents/offline/?organization="+url.QueryEscape(bootstrap.Organization.ID), nil, nil)
|
||||
if unauthenticatedOffline.Code != http.StatusSeeOther || unauthenticatedOffline.Header().Get("Location") != "/login/" {
|
||||
t.Fatalf("unauthenticated offline status=%d", unauthenticatedOffline.Code)
|
||||
}
|
||||
inboxHead := perform(handler, http.MethodHead, path, nil, []*http.Cookie{cookie})
|
||||
if inboxHead.Code != http.StatusOK || inboxHead.Body.Len() != 0 || inboxHead.Header().Get("Content-Length") != inbox.Header().Get("Content-Length") {
|
||||
t.Fatalf("inbox HEAD status=%d length=%q body=%d", inboxHead.Code, inboxHead.Header().Get("Content-Length"), inboxHead.Body.Len())
|
||||
}
|
||||
missingOrganization := perform(handler, http.MethodGet, "https://observatory.example/app/incidents/", nil, []*http.Cookie{cookie})
|
||||
if missingOrganization.Code != http.StatusBadRequest {
|
||||
t.Fatalf("missing organization status=%d", missingOrganization.Code)
|
||||
}
|
||||
|
||||
action := url.Values{"organization_id": []string{bootstrap.Organization.ID}, "csrf_token": []string{csrf}, "action": []string{"acknowledge"}, "silence_duration": []string{""}}.Encode()
|
||||
acknowledged := perform(handler, http.MethodPost, "https://observatory.example/app/incidents/"+incidents[0].ID+"/", strings.NewReader(action), []*http.Cookie{cookie}, headers)
|
||||
if acknowledged.Code != http.StatusSeeOther {
|
||||
t.Fatalf("acknowledge status=%d body=%s", acknowledged.Code, acknowledged.Body.String())
|
||||
}
|
||||
current, err := store.Incidents(context.Background(), bootstrap.Organization.ID, false, 10)
|
||||
if err != nil || len(current) != 1 || current[0].State != "acknowledged" || current[0].AcknowledgedBy != bootstrap.User.ID {
|
||||
t.Fatalf("current=%+v err=%v", current, err)
|
||||
}
|
||||
}
|
||||
|
||||
type recordingPushDispatcher struct{ organizations []string }
|
||||
|
||||
func (dispatcher *recordingPushDispatcher) Enqueue(organizationID string) bool {
|
||||
dispatcher.organizations = append(dispatcher.organizations, organizationID)
|
||||
return true
|
||||
}
|
||||
|
||||
func cloneValues(input url.Values) url.Values {
|
||||
result := make(url.Values, len(input))
|
||||
for key, values := range input {
|
||||
result[key] = append([]string(nil), values...)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func TestAssistedQueryBuilderCreatesTypedTimeSeriesWithTableAlternative(t *testing.T) {
|
||||
server, store, identities, bootstrap := newUITestServer(t)
|
||||
defer store.Close()
|
||||
defer identities.Close()
|
||||
handler := server.Handler()
|
||||
cookie := loginHTML(t, handler)
|
||||
csrf, err := authhttp.CSRFToken(cookie.Value, "dashboards:manage")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := server.now()
|
||||
token, err := store.CreateSource(context.Background(), "builder-source", model.Scope{OrganizationID: bootstrap.Organization.ID, ProjectID: "project-a", EnvironmentID: "production", ServiceID: "service-a"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
batch := model.Batch{Version: model.BatchVersion, SourceID: "builder-source", StreamID: "requests", Sequence: 1, ObservedAt: now, Signal: model.SignalLogs, Records: []model.Observation{
|
||||
{Timestamp: now.Add(-6 * time.Minute), Name: "application.http.request", Attributes: map[string]string{"http.status_code": "503", "http.route": "/failed"}},
|
||||
{Timestamp: now.Add(-1 * time.Minute), Name: "application.http.request", Attributes: map[string]string{"http.status_code": "500", "http.route": "/failed"}},
|
||||
}}
|
||||
if _, err = store.Ingest(context.Background(), token, batch, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = store.Recover(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
headers := http.Header{"Origin": []string{"https://observatory.example"}, "Content-Type": []string{"application/x-www-form-urlencoded"}}
|
||||
builderForm := url.Values{
|
||||
"organization_id": []string{bootstrap.Organization.ID}, "csrf_token": []string{csrf},
|
||||
"name": []string{"Errors over time"}, "description": []string{"Five-minute error counts."},
|
||||
"signal": []string{"logs"}, "filter_field": []string{"status"}, "filter_operator": []string{">="}, "filter_value": []string{"500"},
|
||||
"window": []string{"1h"}, "aggregate": []string{"count"}, "aggregate_field": []string{""}, "group_by": []string{"route"}, "bucket": []string{"5m"}, "limit": []string{"50"},
|
||||
}.Encode()
|
||||
createdQuery := perform(handler, http.MethodPost, "https://observatory.example/app/queries/builder/", strings.NewReader(builderForm), []*http.Cookie{cookie}, headers)
|
||||
if createdQuery.Code != http.StatusSeeOther {
|
||||
t.Fatalf("builder status=%d body=%s", createdQuery.Code, createdQuery.Body.String())
|
||||
}
|
||||
queries, err := store.SavedQueries(context.Background(), bootstrap.Organization.ID)
|
||||
if err != nil || len(queries) != 1 {
|
||||
t.Fatalf("queries=%+v err=%v", queries, err)
|
||||
}
|
||||
expectedText := `logs | where status >= "500" | window 1h | summarize count() by route, window(5m) | limit 50`
|
||||
if queries[0].Query != expectedText || queries[0].AST.Signal != model.SignalLogs || len(queries[0].AST.Filters) != 1 || queries[0].AST.Filters[0].Value != "500" || queries[0].AST.Summary == nil || queries[0].AST.Bucket != 5*time.Minute {
|
||||
t.Fatalf("saved query=%+v", queries[0])
|
||||
}
|
||||
dashboardForm := url.Values{
|
||||
"organization_id": []string{bootstrap.Organization.ID}, "csrf_token": []string{csrf},
|
||||
"slug": []string{"error-rate"}, "name": []string{"Error rate"}, "description": []string{"A bounded error trend."},
|
||||
"panel_title": []string{"Errors by route"}, "saved_query_id": []string{queries[0].ID}, "visualization": []string{"timeseries"},
|
||||
}.Encode()
|
||||
createdDashboard := perform(handler, http.MethodPost, "https://observatory.example/app/dashboards/", strings.NewReader(dashboardForm), []*http.Cookie{cookie}, headers)
|
||||
if createdDashboard.Code != http.StatusSeeOther {
|
||||
t.Fatalf("dashboard status=%d body=%s", createdDashboard.Code, createdDashboard.Body.String())
|
||||
}
|
||||
target := "https://observatory.example/app/dashboards/error-rate/?organization=" + url.QueryEscape(bootstrap.Organization.ID)
|
||||
dashboard := perform(handler, http.MethodGet, target, nil, []*http.Cookie{cookie})
|
||||
body := dashboard.Body.String()
|
||||
if dashboard.Code != http.StatusOK || !strings.Contains(body, "Errors by route visual summary") || strings.Count(body, "<meter ") != 2 || !strings.Contains(body, "<table>") || !strings.Contains(body, "<caption>Errors by route</caption>") {
|
||||
t.Fatalf("dashboard status=%d body=%s", dashboard.Code, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssistedQueryBuilderEscapesStageSeparatorsAndRejectsInvalidCombinations(t *testing.T) {
|
||||
values := url.Values{
|
||||
"signal": []string{"logs"}, "filter_field": []string{"name"}, "filter_operator": []string{"=="}, "filter_value": []string{"worker | limit 250"},
|
||||
"window": []string{"1h"}, "aggregate": []string{"none"}, "aggregate_field": []string{""}, "group_by": []string{""}, "bucket": []string{""}, "limit": []string{"50"},
|
||||
}
|
||||
text, err := buildAssistedQuery(values, 1000)
|
||||
if err != nil || strings.Contains(text, `"worker | limit 250"`) || !strings.Contains(text, `\u007c`) {
|
||||
t.Fatalf("text=%q err=%v", text, err)
|
||||
}
|
||||
ast, err := query.Parse(text, 1000)
|
||||
if err != nil || len(ast.Filters) != 1 || ast.Filters[0].Value != "worker | limit 250" || ast.Limit != 50 {
|
||||
t.Fatalf("AST=%+v err=%v", ast, err)
|
||||
}
|
||||
values.Set("bucket", "5m")
|
||||
if _, err = buildAssistedQuery(values, 1000); err == nil {
|
||||
t.Fatal("time bucket without an aggregate was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResultChartIsBoundedAndFailsClosedForNegativeValues(t *testing.T) {
|
||||
result := query.Result{Columns: []query.Column{{Field: "window_start", Type: schema.TypeTime}, {Field: "count", Type: schema.TypeInteger}}}
|
||||
for index := range 60 {
|
||||
label := fmt.Sprintf("2026-08-17T07:%02d:00Z", index)
|
||||
value := strconv.Itoa(index)
|
||||
result.Rows = append(result.Rows, query.Row{Values: []*string{&label, &value}})
|
||||
}
|
||||
chart := resultChart("Requests", result)
|
||||
if len(chart.Points) != 48 || chart.Points[47].Maximum != "47" || chart.Points[47].Value != "47" {
|
||||
t.Fatalf("chart=%+v", chart)
|
||||
}
|
||||
negative := "-1"
|
||||
result.Rows[0].Values[1] = &negative
|
||||
if chart = resultChart("Requests", result); len(chart.Points) != 0 {
|
||||
t.Fatalf("negative chart=%+v", chart)
|
||||
}
|
||||
}
|
||||
|
||||
type streamRecorder struct {
|
||||
mu sync.Mutex
|
||||
header http.Header
|
||||
status int
|
||||
body bytes.Buffer
|
||||
flushed chan struct{}
|
||||
}
|
||||
|
||||
func newStreamRecorder() *streamRecorder {
|
||||
return &streamRecorder{header: make(http.Header), flushed: make(chan struct{}, 4)}
|
||||
}
|
||||
|
||||
func (recorder *streamRecorder) Header() http.Header { return recorder.header }
|
||||
|
||||
func (recorder *streamRecorder) WriteHeader(status int) {
|
||||
recorder.mu.Lock()
|
||||
defer recorder.mu.Unlock()
|
||||
if recorder.status == 0 {
|
||||
recorder.status = status
|
||||
}
|
||||
}
|
||||
|
||||
func (recorder *streamRecorder) Write(body []byte) (int, error) {
|
||||
recorder.mu.Lock()
|
||||
defer recorder.mu.Unlock()
|
||||
if recorder.status == 0 {
|
||||
recorder.status = http.StatusOK
|
||||
}
|
||||
return recorder.body.Write(body)
|
||||
}
|
||||
|
||||
func (recorder *streamRecorder) Flush() {
|
||||
select {
|
||||
case recorder.flushed <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (recorder *streamRecorder) statusCode() int {
|
||||
recorder.mu.Lock()
|
||||
defer recorder.mu.Unlock()
|
||||
return recorder.status
|
||||
}
|
||||
|
||||
func (recorder *streamRecorder) bodyString() string {
|
||||
recorder.mu.Lock()
|
||||
defer recorder.mu.Unlock()
|
||||
return recorder.body.String()
|
||||
}
|
||||
|
||||
func newUITestServer(t *testing.T) (*Server, *storage.Store, *identity.Services, identity.BootstrapResult) {
|
||||
t.Helper()
|
||||
root := filepath.Join(t.TempDir(), "data")
|
||||
if err := os.Mkdir(root, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store, err := storage.Open(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
identities, err := identity.Open(root)
|
||||
if err != nil {
|
||||
store.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
bootstrap, err := identities.Bootstrap(context.Background(), identity.BootstrapInput{Username: "operator", Email: "operator@example.test", DisplayName: "Operator", Password: "correct horse battery staple"})
|
||||
if err != nil {
|
||||
identities.Close()
|
||||
store.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
server, err := New(store, identities, testOptions())
|
||||
if err != nil {
|
||||
identities.Close()
|
||||
store.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
server.now = func() time.Time { return time.Date(2026, 8, 17, 7, 30, 0, 0, time.UTC) }
|
||||
return server, store, identities, bootstrap
|
||||
}
|
||||
|
||||
func newRotationTestServer(t *testing.T) (*Server, *storage.Store, *identity.Services, identity.BootstrapResult) {
|
||||
t.Helper()
|
||||
root := filepath.Join(t.TempDir(), "data")
|
||||
if err := os.Mkdir(root, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store, err := storage.Open(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
identities, err := identity.Open(root)
|
||||
if err != nil {
|
||||
store.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
bootstrap, err := identities.Bootstrap(t.Context(), identity.BootstrapInput{Username: "operator", Email: "operator@example.test", DisplayName: "Operator", Password: "temporary correct horse battery staple", RequirePasswordChange: true})
|
||||
if err != nil {
|
||||
identities.Close()
|
||||
store.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
server, err := New(store, identities, testOptions())
|
||||
if err != nil {
|
||||
identities.Close()
|
||||
store.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
server.now = func() time.Time { return time.Date(2026, 8, 18, 5, 30, 0, 0, time.UTC) }
|
||||
return server, store, identities, bootstrap
|
||||
}
|
||||
|
||||
func loginHTML(t *testing.T, handler http.Handler) *http.Cookie {
|
||||
t.Helper()
|
||||
result := loginHTMLResponse(t, handler, "operator", "correct horse battery staple")
|
||||
if result.Code != http.StatusSeeOther || result.Header().Get("Location") != "/app/" {
|
||||
t.Fatalf("login status=%d location=%q body=%s", result.Code, result.Header().Get("Location"), result.Body.String())
|
||||
}
|
||||
for _, cookie := range result.Result().Cookies() {
|
||||
if cookie.Name == "__Host-observatory_session" && cookie.Secure && cookie.HttpOnly && cookie.SameSite == http.SameSiteStrictMode {
|
||||
return cookie
|
||||
}
|
||||
}
|
||||
t.Fatalf("login cookies=%+v", result.Result().Cookies())
|
||||
return nil
|
||||
}
|
||||
|
||||
func loginHTMLResponse(t *testing.T, handler http.Handler, identifier, password string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
csrfCookie, csrfToken := loginFormCSRF(t, handler)
|
||||
form := url.Values{"csrf_token": []string{csrfToken}, "identifier": []string{identifier}, "password": []string{password}}.Encode()
|
||||
headers := http.Header{"Origin": []string{"https://observatory.example"}, "Content-Type": []string{"application/x-www-form-urlencoded; charset=utf-8"}}
|
||||
return perform(handler, http.MethodPost, "https://observatory.example/login/", strings.NewReader(form), []*http.Cookie{csrfCookie}, headers)
|
||||
}
|
||||
|
||||
func loginFormCSRF(t *testing.T, handler http.Handler) (*http.Cookie, string) {
|
||||
t.Helper()
|
||||
page := perform(handler, http.MethodGet, "https://observatory.example/login/", nil, nil)
|
||||
if page.Code != http.StatusOK {
|
||||
t.Fatalf("login page status=%d body=%s", page.Code, page.Body.String())
|
||||
}
|
||||
var csrfCookie *http.Cookie
|
||||
for _, cookie := range page.Result().Cookies() {
|
||||
if cookie.Name == loginCSRFCookieName {
|
||||
csrfCookie = cookie
|
||||
break
|
||||
}
|
||||
}
|
||||
if csrfCookie == nil || !csrfCookie.Secure || !csrfCookie.HttpOnly || csrfCookie.SameSite != http.SameSiteStrictMode {
|
||||
t.Fatalf("login CSRF cookie=%+v", csrfCookie)
|
||||
}
|
||||
const marker = `name="csrf_token" value="`
|
||||
start := strings.Index(page.Body.String(), marker)
|
||||
if start < 0 {
|
||||
t.Fatalf("login page omitted CSRF token: %s", page.Body.String())
|
||||
}
|
||||
start += len(marker)
|
||||
end := strings.IndexByte(page.Body.String()[start:], '"')
|
||||
if end < 0 {
|
||||
t.Fatal("login page CSRF token is unterminated")
|
||||
}
|
||||
token := page.Body.String()[start : start+end]
|
||||
if token == "" || token != csrfCookie.Value {
|
||||
t.Fatal("login form and cookie CSRF tokens differ")
|
||||
}
|
||||
return csrfCookie, token
|
||||
}
|
||||
|
||||
func perform(handler http.Handler, method, target string, body io.Reader, cookies []*http.Cookie, headerSets ...http.Header) *httptest.ResponseRecorder {
|
||||
request := httptest.NewRequest(method, target, body)
|
||||
for _, cookie := range cookies {
|
||||
request.AddCookie(cookie)
|
||||
}
|
||||
for _, headers := range headerSets {
|
||||
for name, values := range headers {
|
||||
for _, value := range values {
|
||||
request.Header.Add(name, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
result := httptest.NewRecorder()
|
||||
handler.ServeHTTP(result, request)
|
||||
return result
|
||||
}
|
||||
Reference in New Issue
Block a user