privacy(telemetry): reduce commercial detail

This commit is contained in:
rcourtman 2026-03-28 22:46:32 +00:00
parent 59bf0c9cee
commit be5982dcae
6 changed files with 79 additions and 25 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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
}

View file

@ -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

View file

@ -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
}
}
}