mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-27 05:57:27 +00:00
74504427df
/api/diagnostics is readable with settings:read and lists the whole token inventory: every token's ID, name, timestamps, key hint and which agents use it, across organizations. A token bound to one organization could read the other organizations' credential metadata, which the token management endpoints already refuse to show it. The diagnostics result is computed once per organization scope and cached for every caller, so the filter runs after the cache: an API-token caller that is not instance-wide gets an API token section rebuilt over the tokens callerTokenMayManage allows, with counts, notes and agent usage computed over that view. Instance-wide tokens and browser sessions get the full section, and the cache itself still holds the full result. Tests cover the builder (tokens, counts, usage) and the handler on the cached path, including that a scoped caller served from the cache does not change what the next caller sees. Each filter fails when removed. Contract-Neutral: Behavioural fix that applies the token visibility rule to diagnostics; no new API surface or contract.
150 lines
5.6 KiB
Go
150 lines
5.6 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
|
agentsdocker "github.com/rcourtman/pulse-go-rewrite/pkg/agents/docker"
|
|
)
|
|
|
|
// Diagnostics is readable with settings:read and lists API credentials: IDs,
|
|
// names, timestamps, key hints and which agents use each token. The token
|
|
// inventory is global, so a token caller must see only the tokens it may
|
|
// manage, as on the token endpoints themselves.
|
|
type diagnosticsTokenFixture struct {
|
|
cfg *config.Config
|
|
orgA, def, orgB config.APITokenRecord
|
|
}
|
|
|
|
func newDiagnosticsTokenFixture(t *testing.T) *diagnosticsTokenFixture {
|
|
t.Helper()
|
|
mk := func(raw, name, org string, scopes []string) config.APITokenRecord {
|
|
record, err := config.NewAPITokenRecord(raw, name, scopes)
|
|
if err != nil {
|
|
t.Fatalf("new token record %s: %v", name, err)
|
|
}
|
|
record.OrgID = org
|
|
return *record
|
|
}
|
|
f := &diagnosticsTokenFixture{
|
|
orgA: mk("diag-org-a-wildcard.12345678", "org-a admin", "org-a", []string{config.ScopeWildcard}),
|
|
def: mk("diag-default-wildcard.12345678", "default admin", "default", []string{config.ScopeWildcard}),
|
|
orgB: mk("diag-org-b-scoped.12345678", "org-b reader", "org-b", []string{config.ScopeMonitoringRead}),
|
|
}
|
|
f.cfg = &config.Config{DataPath: t.TempDir(), APITokens: []config.APITokenRecord{f.orgA, f.def, f.orgB}}
|
|
f.cfg.SortAPITokens()
|
|
return f
|
|
}
|
|
|
|
func tokenIDs(diag *APITokenDiagnostic) map[string]bool {
|
|
ids := map[string]bool{}
|
|
for _, token := range diag.Tokens {
|
|
ids[token.ID] = true
|
|
}
|
|
return ids
|
|
}
|
|
|
|
func TestAPITokenDiagnosticShowsOnlyTokensTheCallerMayManage(t *testing.T) {
|
|
f := newDiagnosticsTokenFixture(t)
|
|
monitor := newMonitorForDiagnostics(t, f.cfg)
|
|
now := time.Now().UTC()
|
|
for i, token := range []config.APITokenRecord{f.orgA, f.def, f.orgB} {
|
|
report := agentsdocker.Report{
|
|
Agent: agentsdocker.AgentInfo{ID: "agent-" + token.Name, Version: "1.0.0"},
|
|
Host: agentsdocker.HostInfo{Hostname: "docker-" + token.Name, MachineID: "machine-" + token.Name},
|
|
Timestamp: now.Add(time.Duration(i) * time.Second),
|
|
}
|
|
if _, err := monitor.ApplyDockerReport(report, &config.APITokenRecord{ID: token.ID}); err != nil {
|
|
t.Fatalf("ApplyDockerReport %s: %v", token.Name, err)
|
|
}
|
|
}
|
|
|
|
all := buildAPITokenDiagnostic(f.cfg, monitor, nil)
|
|
if all.TokenCount != 3 || len(all.Usage) != 3 {
|
|
t.Fatalf("an unfiltered view should list all three tokens and usages, got %d tokens, %d usages", all.TokenCount, len(all.Usage))
|
|
}
|
|
|
|
view := buildAPITokenDiagnostic(f.cfg, monitor, &f.orgA)
|
|
ids := tokenIDs(view)
|
|
if len(ids) != 1 || !ids[f.orgA.ID] || view.TokenCount != 1 {
|
|
t.Fatalf("org-a caller should see only its own token, got ids %v count %d", ids, view.TokenCount)
|
|
}
|
|
if len(view.Usage) != 1 || view.Usage[0].TokenID != f.orgA.ID {
|
|
t.Fatalf("usage should be limited to the caller's token, got %+v", view.Usage)
|
|
}
|
|
if view.UnusedTokenCount != 1 {
|
|
t.Fatalf("unused count should be computed over the visible tokens, got %d", view.UnusedTokenCount)
|
|
}
|
|
// The existence of other tokens still shows in the global flag only.
|
|
if !view.Enabled || view.RecommendTokenSetup {
|
|
t.Fatalf("enabled=%v recommend=%v, want enabled and no setup recommendation", view.Enabled, view.RecommendTokenSetup)
|
|
}
|
|
|
|
instanceWide := buildAPITokenDiagnostic(f.cfg, monitor, &f.def)
|
|
if instanceWide.TokenCount != 3 {
|
|
t.Fatalf("an instance-wide caller passed to the builder sees %d tokens, want 3 (callers filter nothing for it)", instanceWide.TokenCount)
|
|
}
|
|
}
|
|
|
|
func TestDiagnosticsFiltersTokenSectionAfterTheSharedCache(t *testing.T) {
|
|
f := newDiagnosticsTokenFixture(t)
|
|
router := &Router{config: f.cfg}
|
|
|
|
// The cache holds the instance-wide result and is shared across callers.
|
|
diagnosticsCacheMu.Lock()
|
|
prevCache := diagnosticsCache
|
|
diagnosticsCache = map[string]cachedDiagnosticsEntry{
|
|
diagnosticsScopeKey(context.Background()): {
|
|
diag: DiagnosticsInfo{Version: "cached", APITokens: buildAPITokenDiagnostic(f.cfg, nil, nil)},
|
|
cachedAt: time.Now(),
|
|
},
|
|
}
|
|
diagnosticsCacheMu.Unlock()
|
|
t.Cleanup(func() {
|
|
diagnosticsCacheMu.Lock()
|
|
diagnosticsCache = prevCache
|
|
diagnosticsCacheMu.Unlock()
|
|
})
|
|
|
|
get := func(caller *config.APITokenRecord) (*httptest.ResponseRecorder, DiagnosticsInfo) {
|
|
req := httptest.NewRequest(http.MethodGet, "/api/diagnostics", nil)
|
|
if caller != nil {
|
|
attachAPITokenRecord(req, caller)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
router.handleDiagnostics(rec, req)
|
|
var payload DiagnosticsInfo
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
|
t.Fatalf("decode diagnostics: %v", err)
|
|
}
|
|
return rec, payload
|
|
}
|
|
|
|
rec, scoped := get(&f.orgA)
|
|
if rec.Code != http.StatusOK || scoped.Version != "cached" {
|
|
t.Fatalf("status %d version %q, want a 200 served from the cache", rec.Code, scoped.Version)
|
|
}
|
|
if ids := tokenIDs(scoped.APITokens); len(ids) != 1 || !ids[f.orgA.ID] {
|
|
t.Fatalf("org-a caller saw tokens %v, want only its own", ids)
|
|
}
|
|
for _, other := range []config.APITokenRecord{f.def, f.orgB} {
|
|
if strings.Contains(rec.Body.String(), other.ID) || strings.Contains(rec.Body.String(), other.Name) {
|
|
t.Fatalf("the response leaked token %q: %s", other.Name, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
// A caller served right after still gets its own view, not the previous one.
|
|
for name, caller := range map[string]*config.APITokenRecord{"instance-wide token": &f.def, "browser session": nil} {
|
|
_, full := get(caller)
|
|
if got := len(tokenIDs(full.APITokens)); got != 3 {
|
|
t.Fatalf("%s saw %d tokens, want all 3 after a scoped caller was served from the same cache", name, got)
|
|
}
|
|
}
|
|
}
|