diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index 138128dec..800d898db 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -37,8 +37,8 @@ Every field is listed below — nothing else leaves your server: | Relay enabled | `true`/`false` | Whether remote access is enabled | | SSO enabled | `true`/`false` | Whether OIDC/SSO is configured | | Multi-tenant | `true`/`false` | Whether multi-tenant mode is on | -| License tier | `free`, `pro`, etc. | Current license tier | -| API tokens | `3` | Number of API tokens configured | +| Paid license | `true`/`false` | Whether a paid license is active | +| Has API tokens | `true`/`false` | Whether any API tokens are configured | ### What is NOT sent diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index 6dd59b730..f017c42ba 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -125,6 +125,12 @@ owner for privacy-document URLs, while `frontend-modern/public/docs/PRIVACY.md` is the version-matched asset served by the running build. Privacy disclosures must not drift back to GitHub `main` links that can describe a different revision than the installed runtime. +That same disclosure boundary now also fixes the telemetry payload floor: +commercial and auth-adjacent telemetry may report only coarse posture signals +such as whether a paid license is active or whether any API tokens exist. +Exact license tiers and exact API-token counts are not part of the canonical +anonymous telemetry contract and may not be reintroduced without updating this +trust boundary and the governed privacy disclosure together. That same rule also applies inside shipped security guidance itself: `SECURITY.md` and the synced `frontend-modern/public/docs/SECURITY.md` copy may not bounce the operator back to GitHub `main` for section references that the diff --git a/frontend-modern/public/docs/PRIVACY.md b/frontend-modern/public/docs/PRIVACY.md index 138128dec..800d898db 100644 --- a/frontend-modern/public/docs/PRIVACY.md +++ b/frontend-modern/public/docs/PRIVACY.md @@ -37,8 +37,8 @@ Every field is listed below — nothing else leaves your server: | Relay enabled | `true`/`false` | Whether remote access is enabled | | SSO enabled | `true`/`false` | Whether OIDC/SSO is configured | | Multi-tenant | `true`/`false` | Whether multi-tenant mode is on | -| License tier | `free`, `pro`, etc. | Current license tier | -| API tokens | `3` | Number of API tokens configured | +| Paid license | `true`/`false` | Whether a paid license is active | +| Has API tokens | `true`/`false` | Whether any API tokens are configured | ### What is NOT sent diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go index 4fc21060d..e37f81fc8 100644 --- a/internal/telemetry/telemetry.go +++ b/internal/telemetry/telemetry.go @@ -23,8 +23,8 @@ // - Whether relay/remote access is enabled // - Whether SSO/OIDC is configured // - Whether multi-tenant mode is enabled -// - License tier (free/pro/etc.) -// - Number of API tokens configured +// - Whether a paid license is active +// - Whether any API tokens are configured // // # What is NOT sent // @@ -101,13 +101,13 @@ type Ping struct { KubernetesClusters int `json:"kubernetes_clusters"` // Feature usage (booleans and counts — no content) - AIEnabled bool `json:"ai_enabled"` - ActiveAlerts int `json:"active_alerts"` - RelayEnabled bool `json:"relay_enabled"` - SSOEnabled bool `json:"sso_enabled"` - MultiTenant bool `json:"multi_tenant"` - LicenseTier string `json:"license_tier"` // "free", "pro", "pro_annual", "lifetime", etc. - APITokens int `json:"api_tokens"` + AIEnabled bool `json:"ai_enabled"` + ActiveAlerts int `json:"active_alerts"` + RelayEnabled bool `json:"relay_enabled"` + SSOEnabled bool `json:"sso_enabled"` + MultiTenant bool `json:"multi_tenant"` + PaidLicense bool `json:"paid_license"` + HasAPITokens bool `json:"has_api_tokens"` } // Snapshot holds the dynamic state gathered at ping time. @@ -126,8 +126,8 @@ type Snapshot struct { RelayEnabled bool SSOEnabled bool MultiTenant bool - LicenseTier string - APITokens int + PaidLicense bool + HasAPITokens bool } // SnapshotFunc returns the current state snapshot for telemetry. @@ -287,8 +287,8 @@ func applySnapshot(base Ping, fn SnapshotFunc) Ping { ping.RelayEnabled = s.RelayEnabled ping.SSOEnabled = s.SSOEnabled ping.MultiTenant = s.MultiTenant - ping.LicenseTier = s.LicenseTier - ping.APITokens = s.APITokens + ping.PaidLicense = s.PaidLicense + ping.HasAPITokens = s.HasAPITokens return ping } diff --git a/internal/telemetry/telemetry_test.go b/internal/telemetry/telemetry_test.go index 5c15c14fc..ffa97aeea 100644 --- a/internal/telemetry/telemetry_test.go +++ b/internal/telemetry/telemetry_test.go @@ -92,8 +92,9 @@ func TestApplySnapshot(t *testing.T) { VMs: 10, Containers: 5, AIEnabled: true, - LicenseTier: "pro", ActiveAlerts: 2, + PaidLicense: true, + HasAPITokens: true, } } @@ -111,8 +112,11 @@ func TestApplySnapshot(t *testing.T) { if !ping.AIEnabled { t.Fatal("AIEnabled should be true") } - if ping.LicenseTier != "pro" { - t.Fatalf("LicenseTier = %q, want %q", ping.LicenseTier, "pro") + if !ping.PaidLicense { + t.Fatal("PaidLicense should be true") + } + if !ping.HasAPITokens { + t.Fatal("HasAPITokens should be true") } } @@ -171,6 +175,51 @@ func TestSend_Success(t *testing.T) { } } +func TestSend_UsesReducedCommercialSignals(t *testing.T) { + var rawBody []byte + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rawBody, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusNoContent) + })) + defer ts.Close() + + origEndpoint := pingEndpoint + pingEndpoint = ts.URL + defer func() { pingEndpoint = origEndpoint }() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + send(ctx, Ping{ + InstallID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + Version: "6.0.0", + Event: "heartbeat", + Platform: "binary", + OS: "linux", + Arch: "amd64", + PaidLicense: true, + HasAPITokens: true, + }) + + var payload map[string]any + if err := json.Unmarshal(rawBody, &payload); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if _, ok := payload["license_tier"]; ok { + t.Fatal("legacy license_tier field should not be sent") + } + if _, ok := payload["api_tokens"]; ok { + t.Fatal("legacy api_tokens field should not be sent") + } + if got, ok := payload["paid_license"].(bool); !ok || !got { + t.Fatalf("paid_license = %#v, want true", payload["paid_license"]) + } + if got, ok := payload["has_api_tokens"].(bool); !ok || !got { + t.Fatalf("has_api_tokens = %#v, want true", payload["has_api_tokens"]) + } +} + func TestJitteredHeartbeat_WithinBounds(t *testing.T) { min := heartbeatInterval - maxHeartbeatJitter max := heartbeatInterval + maxHeartbeatJitter diff --git a/pkg/server/server.go b/pkg/server/server.go index 1643ffe0e..f2db8a19b 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -446,9 +446,8 @@ func Run(ctx context.Context, version string) error { } snap := telemetry.Snapshot{ - MultiTenant: currentCfg.MultiTenantEnabled, - APITokens: len(currentCfg.APITokens), - LicenseTier: "free", + MultiTenant: currentCfg.MultiTenantEnabled, + HasAPITokens: currentCfg.HasAPITokens(), } // Resource counts come from the tenant-aware monitor aggregate, not the @@ -476,11 +475,11 @@ func Run(ctx context.Context, version string) error { snap.SSOEnabled = ssoCfg.HasEnabledProviders() } - // License tier. + // Coarse commercial posture only; telemetry does not send exact tiers. if router != nil && router.GetLicenseHandlers() != nil { if svc := router.GetLicenseHandlers().Service(context.Background()); svc != nil { if lic := svc.Current(); lic != nil { - snap.LicenseTier = string(lic.Claims.Tier) + snap.PaidLicense = lic.Claims.Tier != pkglicensing.TierFree } } }