diff --git a/packages/cmd/agent_proxy.go b/packages/cmd/agent_proxy.go index 7363874e..ad908bce 100644 --- a/packages/cmd/agent_proxy.go +++ b/packages/cmd/agent_proxy.go @@ -14,9 +14,11 @@ import ( "github.com/Infisical/infisical-merge/packages/config" "github.com/Infisical/infisical-merge/packages/models" "github.com/Infisical/infisical-merge/packages/sandbox" + "github.com/Infisical/infisical-merge/packages/telemetry" "github.com/Infisical/infisical-merge/packages/util" "github.com/fatih/color" "github.com/go-resty/resty/v2" + "github.com/posthog/posthog-go" "github.com/rs/zerolog/log" "github.com/spf13/cobra" ) @@ -159,7 +161,16 @@ func runAgentProxyConnect(cmd *cobra.Command, args []string) { util.HandleError(fmt.Errorf("project id is required; pass --projectId, set INFISICAL_PROJECT_ID, or run inside a project with .infisical.json")) } - token := resolveAgentToken(cmd) + token, tokenSource := resolveAgentToken(cmd) + + allowReadableBrokered := util.GetBoolFlagOrEnv(cmd, "allow-readable-brokered-secrets", util.INFISICAL_AGENT_PROXY_ALLOW_READABLE_BROKERED_SECRETS_NAME) + + Telemetry.SetActor(telemetry.IdentityClaimsFromToken(token.Token)) + Telemetry.CaptureEvent("cli-command:agent-proxy connect", posthog.NewProperties(). + Set("version", util.CLI_VERSION). + Set("agent", telemetryAgentName(args)). + Set("credentialSource", tokenSource). + Set("allowReadableBrokeredSecrets", allowReadableBrokered)) httpClient := resty.New().SetAuthToken(token.Token) @@ -176,7 +187,6 @@ func runAgentProxyConnect(cmd *cobra.Command, args []string) { realSecrets := fetchAgentRealSecrets(token, projectID, environment, secretPath) - allowReadableBrokered := util.GetBoolFlagOrEnv(cmd, "allow-readable-brokered-secrets", util.INFISICAL_AGENT_PROXY_ALLOW_READABLE_BROKERED_SECRETS_NAME) if !allowReadableBrokered { // static readability is derived from realSecrets we already fetch; dynamic lease-ability comes from // the server (callerCanLease) since we don't fetch dynamic secrets here. @@ -192,7 +202,23 @@ func runAgentProxyConnect(cmd *cobra.Command, args []string) { } } -func resolveAgentToken(cmd *cobra.Command) *models.TokenDetails { +// Only the executable name: argv past the first word routinely carries credentials. +func telemetryAgentName(args []string) string { + if len(args) == 0 { + return "" + } + return filepath.Base(args[0]) +} + +func universalAuthCredentialSource(cmd *cobra.Command) string { + if cmd.Flags().Changed("client-id") { + return "universal-auth-flag" + } + return "universal-auth-env" +} + +// Returns the token and a label for the branch that produced it. +func resolveAgentToken(cmd *cobra.Command) (*models.TokenDetails, string) { clientID, _ := util.GetCmdFlagOrEnvWithDefaultValue(cmd, "client-id", []string{util.INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_NAME}, "") clientSecret, _ := util.GetCmdFlagOrEnvWithDefaultValue(cmd, "client-secret", []string{util.INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET_NAME}, "") @@ -204,7 +230,7 @@ func resolveAgentToken(cmd *cobra.Command) *models.TokenDetails { return &models.TokenDetails{ Type: util.UNIVERSAL_AUTH_TOKEN_IDENTIFIER, Token: loginResp.AccessToken, - } + }, universalAuthCredentialSource(cmd) } token, err := util.GetInfisicalToken(cmd) @@ -214,7 +240,7 @@ func resolveAgentToken(cmd *cobra.Command) *models.TokenDetails { if token == nil { util.HandleError(fmt.Errorf("authentication required; provide --client-id/--client-secret, env vars, or a token")) } - return token + return token, "token" } // Builds http://:/:@host:port (username=projectId, password="/:", jwt last). diff --git a/packages/cmd/agent_proxy_run.go b/packages/cmd/agent_proxy_run.go index 7b51f923..8c31c76c 100644 --- a/packages/cmd/agent_proxy_run.go +++ b/packages/cmd/agent_proxy_run.go @@ -17,10 +17,12 @@ import ( "github.com/Infisical/infisical-merge/packages/agentproxy" "github.com/Infisical/infisical-merge/packages/api" "github.com/Infisical/infisical-merge/packages/sandbox" + "github.com/Infisical/infisical-merge/packages/telemetry" "github.com/Infisical/infisical-merge/packages/util" "github.com/fatih/color" "github.com/go-resty/resty/v2" "github.com/mattn/go-isatty" + "github.com/posthog/posthog-go" "github.com/rs/zerolog" "github.com/rs/zerolog/log" "github.com/spf13/cobra" @@ -169,6 +171,25 @@ func runAgentProxyRun(cmd *cobra.Command, args []string) { util.PrintWarning(fmt.Sprintf("Unable to isolate the network on this host (%s), the agent will share your network connection. Requests are still routed through the proxy, but a program that ignores the proxy settings can reach the network directly. Credential protections are unchanged.", pre.Reason)) } + // After preflight so a downgraded fence is visible; before the proxy starts so a run that dies still counts. + passEnv, _ := cmd.Flags().GetStringArray("pass-env") + setEnv, _ := cmd.Flags().GetStringArray("set-env") + Telemetry.SetActor(telemetry.IdentityClaimsFromToken(src.token())) + Telemetry.CaptureEvent("cli-command:agent-proxy run", posthog.NewProperties(). + Set("version", util.CLI_VERSION). + Set("agent", telemetryAgentName(args)). + Set("platform", runtime.GOOS). + Set("sandboxEnabled", sandboxEnabled). + Set("sandboxSource", sandboxSource(cmd)). + Set("netDowngraded", pre.FallbackToSharedNet). + Set("unmatchedHost", unmatchedHost). + Set("pollInterval", pollInterval). + Set("allowReadCount", len(extraRead)). + Set("allowWriteCount", len(extraWrite)). + Set("allowHostCount", len(allowHosts)). + Set("passEnvCount", len(passEnv)). + Set("setEnvCount", len(setEnv))) + proxy, err := agentproxy.New(agentproxy.Options{ UnmatchedHost: unmatchedHost, PollInterval: time.Duration(pollInterval) * time.Second, @@ -281,6 +302,18 @@ func shutdownProxy(proxy *agentproxy.Proxy) { _ = proxy.Shutdown(ctx) } +// Mirrors resolveSandboxEnabled's order, so a deliberate opt-out is distinguishable from the default. +func sandboxSource(cmd *cobra.Command) string { + switch { + case cmd.Flags().Changed("sandbox"), cmd.Flags().Changed("no-sandbox"): + return "flag" + case os.Getenv("INFISICAL_AGENT_PROXY_SANDBOX") != "": + return "env" + default: + return "default" + } +} + // resolveSandboxEnabled reads the toggle from flag or env only, never .infisical.json (a committed // file must not be able to silently disable the boundary). func resolveSandboxEnabled(cmd *cobra.Command) bool { diff --git a/packages/cmd/agent_proxy_start.go b/packages/cmd/agent_proxy_start.go index 80d5da2b..9185cb2f 100644 --- a/packages/cmd/agent_proxy_start.go +++ b/packages/cmd/agent_proxy_start.go @@ -6,8 +6,10 @@ import ( "time" "github.com/Infisical/infisical-merge/packages/agentproxy" + "github.com/Infisical/infisical-merge/packages/telemetry" "github.com/Infisical/infisical-merge/packages/util" "github.com/fatih/color" + "github.com/posthog/posthog-go" "github.com/rs/zerolog/log" "github.com/spf13/cobra" ) @@ -45,6 +47,13 @@ func runAgentProxyStart(cmd *cobra.Command, args []string) { util.HandleError(err, "Failed to authenticate the agent proxy machine identity") } + Telemetry.SetActor(telemetry.IdentityClaimsFromToken(loginResp.AccessToken)) + Telemetry.CaptureEvent("cli-command:agent-proxy start", posthog.NewProperties(). + Set("version", util.CLI_VERSION). + Set("unmatchedHost", unmatchedHost). + Set("pollInterval", pollInterval). + Set("credentialSource", universalAuthCredentialSource(cmd))) + log.Info().Msg(color.GreenString("Agent proxy authenticated; starting MITM proxy")) var proxyToken atomic.Value diff --git a/packages/telemetry/telemetry.go b/packages/telemetry/telemetry.go index 51f0d8b6..b568304f 100644 --- a/packages/telemetry/telemetry.go +++ b/packages/telemetry/telemetry.go @@ -18,6 +18,10 @@ var POSTHOG_API_KEY_FOR_CLI string type Telemetry struct { isEnabled bool posthogClient posthog.Client + + // Set by SetActor for commands whose credential is not in the environment. + attachedIdentityId string + attachedOrgId string } type NoOpLogger struct{} @@ -60,16 +64,39 @@ func (t *Telemetry) CaptureEvent(eventName string, properties posthog.Properties // is idempotent and persists its state in the local config file. t.IdentifyUserIfNeeded() - t.posthogClient.Enqueue(posthog.Capture{ + capture := posthog.Capture{ DistinctId: userIdentity, Event: eventName, Properties: properties, - }) + } + + if orgId := t.resolveOrganizationId(); orgId != "" { + capture.Groups = posthog.NewGroups().Set("organization", orgId) + } + + t.posthogClient.Enqueue(capture) defer t.posthogClient.Close() } } +// SetActor records who the command is acting as, for commands that resolve their +// own credential and so have nothing in the environment to read. +func (t *Telemetry) SetActor(identityId, orgId string) { + t.attachedIdentityId = identityId + t.attachedOrgId = orgId +} + +// Once an actor is set, its organization is authoritative even when empty: falling +// back to the environment would group the event under a different actor's org. +func (t *Telemetry) resolveOrganizationId() string { + if t.attachedIdentityId != "" || t.attachedOrgId != "" { + return t.attachedOrgId + } + _, orgId := machineIdentityClaimsFromEnv() + return orgId +} + // IdentifyUserIfNeeded sends a PostHog Identify call to enrich the person // record with the user's email, and aliases the anonymous machine ID to the // email so that pre-login CLI events are merged into the same person. @@ -146,55 +173,21 @@ func (t *Telemetry) IdentifyUserIfNeeded() { } } -// getMachineIdentityIdFromEnv inspects the environment variables that the -// CLI uses to receive machine-identity access tokens (the same set checked -// by util.GetInfisicalToken, minus the `--token` flag which is per-command -// and not visible to the telemetry layer) and, if a machine-identity JWT -// is present, returns the `identityId` claim from its payload. +// IdentityClaimsFromToken reads the identity and organization claims out of an +// Infisical JWT. Nothing from the token itself is kept. Machine identity tokens +// name the organization `orgId`, user session tokens `organizationId`. // -// The function is intentionally best-effort and silent on failure: -// - returns "" if no token is set -// - returns "" for service tokens (`st.` prefix), which carry no JWT -// payload and represent the deprecated service-token auth mode -// - returns "" if the JWT is malformed or missing the `identityId` claim -// -// The token's signature is not verified — the value is only used to derive -// a PostHog distinctId, never for authorization. The same token has already -// been (or is about to be) sent to the Infisical API where its signature is -// verified server-side. -func getMachineIdentityIdFromEnv() string { - // Mirror the env-var precedence in util.GetInfisicalToken so that the - // telemetry distinctId aligns with the credential the API call will - // actually use: - // 1. INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN - // 2. INFISICAL_TOKEN - // 3. TOKEN (legacy gateway env var) - envVars := []string{ - util.INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN_NAME, - util.INFISICAL_TOKEN_NAME, - util.INFISICAL_GATEWAY_TOKEN_NAME_LEGACY, - } - - var token string - for _, name := range envVars { - if v := os.Getenv(name); v != "" { - token = v - break - } - } - - if token == "" { - return "" - } - - // Service tokens are deprecated and not JWTs — no identityId to extract. - if strings.HasPrefix(token, "st.") { - return "" +// Best-effort and silent on failure: returns empty strings for a service token +// (`st.` prefix, not a JWT) or a malformed payload. The signature is not +// verified because the values are only used for telemetry, never authorization. +func IdentityClaimsFromToken(token string) (identityId string, orgId string) { + if token == "" || strings.HasPrefix(token, "st.") { + return "", "" } parts := strings.Split(token, ".") if len(parts) != 3 { - return "" + return "", "" } payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[1]) @@ -202,18 +195,48 @@ func getMachineIdentityIdFromEnv() string { // Some JWT issuers pad the payload with `=`; tolerate that variant. payloadBytes, err = base64.URLEncoding.DecodeString(parts[1]) if err != nil { - return "" + return "", "" } } var claims struct { - IdentityID string `json:"identityId"` + IdentityID string `json:"identityId"` + OrgID string `json:"orgId"` + OrganizationID string `json:"organizationId"` } if err := json.Unmarshal(payloadBytes, &claims); err != nil { - return "" + return "", "" + } + + if claims.OrgID != "" { + return claims.IdentityID, claims.OrgID + } + return claims.IdentityID, claims.OrganizationID +} + +// machineIdentityClaimsFromEnv returns the claims from the first machine-identity +// access token set in the environment (the same set util.GetInfisicalToken checks, +// minus the `--token` flag, which is per-command and not visible here). +func machineIdentityClaimsFromEnv() (identityId string, orgId string) { + // Mirror the env-var precedence in util.GetInfisicalToken so that the + // telemetry distinctId aligns with the credential the API call will + // actually use: + // 1. INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN + // 2. INFISICAL_TOKEN + // 3. TOKEN (legacy gateway env var) + envVars := []string{ + util.INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN_NAME, + util.INFISICAL_TOKEN_NAME, + util.INFISICAL_GATEWAY_TOKEN_NAME_LEGACY, + } + + for _, name := range envVars { + if v := os.Getenv(name); v != "" { + return IdentityClaimsFromToken(v) + } } - return claims.IdentityID + return "", "" } func (t *Telemetry) GetDistinctId() (string, error) { @@ -230,7 +253,9 @@ func (t *Telemetry) GetDistinctId() (string, error) { } // Resolution priority: - // 1. Logged-in user email from the persisted config. A logged-in user + // 1. Actor set by SetActor: the credential the command itself resolved, so it + // outranks anything on the machine. Empty for a user session token. + // 2. Logged-in user email from the persisted config. A logged-in user // takes precedence over any machine-identity token that happens to // be exported in the shell, because some commands never authenticate // against the backend at all (e.g. `infisical user switch`, the @@ -239,17 +264,19 @@ func (t *Telemetry) GetDistinctId() (string, error) { // those events to a stale `identity-` would corrupt person-level // analytics, while attributing them to the logged-in email is always // correct. - // 2. Machine-identity access token from env. This is the dominant case + // 3. Machine-identity access token from env. This is the dominant case // in CI / containers / Kubernetes pods, where there is no logged-in // user and the only credential is `INFISICAL_TOKEN` (or the UA-scoped // env var). Aligns with the `identity-` distinctId the backend // uses for MachineIdentityLogin and other identity-scoped events, // so CLI events flow into the same person record. - // 3. Anonymous fallback keyed by the local machine ID. - if infisicalConfig.LoggedInUserEmail != "" { + // 4. Anonymous fallback keyed by the local machine ID. + if t.attachedIdentityId != "" { + distinctId = "identity-" + t.attachedIdentityId + } else if infisicalConfig.LoggedInUserEmail != "" { distinctId = infisicalConfig.LoggedInUserEmail - } else if identityId := getMachineIdentityIdFromEnv(); identityId != "" { - distinctId = "identity-" + identityId + } else if envIdentityId, _ := machineIdentityClaimsFromEnv(); envIdentityId != "" { + distinctId = "identity-" + envIdentityId } else if machineId != "" { distinctId = "anonymous_cli_" + machineId }