From d56ac299b39e789bf0558b85b8a957eefc8c0f0c Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Fri, 11 Sep 2026 07:06:46 +0200 Subject: [PATCH 1/2] feat(cli): scope federated telemetry identity to the CI namespace Every CLI command authenticated with a federated CI token reported to PostHog under the OIDC issuer URL, which is the same value for every run, every repository and every installation, collapsing all federated traffic into a single person. Federated sessions are now identified by the CI namespace that owns the repository, scoped to the control plane installation: the GitHub organization (repository_owner_id) or the GitLab namespace (namespace_id), both read from claims the runner token already carries. API token, user token and unauthenticated sessions are unchanged, and the claim stays out of the attestation auth metadata. Assisted-by: Claude Code Signed-off-by: Javier Rodriguez Chainloop-Trace-Sessions: e1d524cd-4ee3-472d-9800-c5b0569a6689 --- app/cli/cmd/root.go | 5 + app/cli/internal/telemetry/telemetry.go | 32 +++++- app/cli/internal/telemetry/telemetry_test.go | 101 +++++++++++++++++++ app/cli/internal/token/token.go | 36 +++++++ app/cli/internal/token/token_test.go | 26 ++++- 5 files changed, 194 insertions(+), 6 deletions(-) diff --git a/app/cli/cmd/root.go b/app/cli/cmd/root.go index 8587910e7..f1a6c6357 100644 --- a/app/cli/cmd/root.go +++ b/app/cli/cmd/root.go @@ -494,6 +494,11 @@ func recordCommand(executedCmd *cobra.Command, authInfo *token.ParsedToken) erro tags["token_type"] = authInfo.TokenType.String() tags["user_id"] = authInfo.ID tags["org_id"] = authInfo.OrgID + // Only federated tokens carry a CI namespace, and it is what keeps their events + // from collapsing into a single person shared by every CI run on earth. + if authInfo.CINamespaceID != "" { + tags["ci_namespace_id"] = authInfo.CINamespaceID + } } // Add organization name if available diff --git a/app/cli/internal/telemetry/telemetry.go b/app/cli/internal/telemetry/telemetry.go index d0759c4fe..2a61602f2 100644 --- a/app/cli/internal/telemetry/telemetry.go +++ b/app/cli/internal/telemetry/telemetry.go @@ -17,6 +17,7 @@ package telemetry import ( "context" + "fmt" "runtime" "time" @@ -35,10 +36,13 @@ const UnrecognisedUserID = "unrecognised" // read it from here so the two deadlines cannot drift apart. const FlushTimeout = 2 * time.Second -// authTypeUser mirrors v1.Attestation_Auth_AUTH_TYPE_USER.String(). It is duplicated as a -// literal so this package keeps no dependency on the attestation API; a test pins the two -// values together. -const authTypeUser = "AUTH_TYPE_USER" +// authTypeUser and authTypeFederated mirror the matching +// v1.Attestation_Auth_AuthType.String() values. They are duplicated as literals so this +// package keeps no dependency on the attestation API; a test pins the values together. +const ( + authTypeUser = "AUTH_TYPE_USER" + authTypeFederated = "AUTH_TYPE_FEDERATED" +) // Tags represents a collection of event tags. type Tags map[string]string @@ -107,6 +111,13 @@ func determineUserID(tags Tags) string { machineID, _ := machineid.ProtectedID("chainloop") tags["machine_id"] = machineID + // A federated session's user ID is the OIDC provider's issuer URL, which is the same + // value for every run of every repository, so it has to be scoped before it can + // identify anyone. + if id := federatedUserID(tags); id != "" { + return id + } + // Check if user ID is provided in tags. // This won't happen in the unauthenticated case scenario. if userID, ok := tags["user_id"]; ok && userID != "" { @@ -148,3 +159,16 @@ func (tg Tags) WithEnvironmentInfo() Tags { return tg } + +// federatedUserID returns the identity for a federated CI session, scoped to the control +// plane installation and the CI namespace that owns the repository, or an empty string +// when the session is not federated or the token carried no namespace to scope it with. +// The value is deliberately readable rather than hashed: both parts are non-sensitive +// already, and hashing would only make a person harder to trace back to its source. +func federatedUserID(tags Tags) string { + if tags["token_type"] != authTypeFederated || tags["ci_namespace_id"] == "" { + return "" + } + + return fmt.Sprintf("ci:%s@%s", tags["ci_namespace_id"], tags["cp_url_hash"]) +} diff --git a/app/cli/internal/telemetry/telemetry_test.go b/app/cli/internal/telemetry/telemetry_test.go index d01300eca..51557f18d 100644 --- a/app/cli/internal/telemetry/telemetry_test.go +++ b/app/cli/internal/telemetry/telemetry_test.go @@ -156,3 +156,104 @@ func TestCommandTrackerTrackWithCustomTags(t *testing.T) { mockedClient.AssertNumberOfCalls(t, "TrackEvent", 1) } + +func TestCommandTrackerTrackIdentity(t *testing.T) { + const ( + cpURLHash = "1cc62eb758e48e1cc0bce5454d86036cd824638378fc54ad0cde5882af00248a" + issuer = "https://token.actions.githubusercontent.com" + ) + + testCases := []struct { + name string + tags telemetry.Tags + // want is the distinct_id the event is expected to be sent under. An empty + // value means "whatever the machine ID resolves to", which is host dependent. + want string + wantMachineID bool + }{ + { + name: "user token keeps its user id", + tags: telemetry.Tags{ + tagTokenType: v1.Attestation_Auth_AUTH_TYPE_USER.String(), + "user_id": "bc0b2199-ca85-42ab-a857-042e9c109d43", + "cp_url_hash": cpURLHash, + }, + want: "bc0b2199-ca85-42ab-a857-042e9c109d43", + }, + { + name: "api token keeps its token id", + tags: telemetry.Tags{ + tagTokenType: v1.Attestation_Auth_AUTH_TYPE_API_TOKEN.String(), + "user_id": "4b0f0dd4-4538-4269-92a9-ab5b0fce0258", + "cp_url_hash": cpURLHash, + }, + want: "4b0f0dd4-4538-4269-92a9-ab5b0fce0258", + }, + { + name: "federated token is scoped to its CI namespace and installation", + tags: telemetry.Tags{ + tagTokenType: v1.Attestation_Auth_AUTH_TYPE_FEDERATED.String(), + "user_id": issuer, + "ci_namespace_id": "84607409", + "cp_url_hash": cpURLHash, + }, + want: "ci:84607409@" + cpURLHash, + }, + { + // Two different CI namespaces on the same installation must not collapse + // into the shared issuer-URL person. + name: "a second CI namespace gets its own identity", + tags: telemetry.Tags{ + tagTokenType: v1.Attestation_Auth_AUTH_TYPE_FEDERATED.String(), + "user_id": issuer, + "ci_namespace_id": "9919", + "cp_url_hash": cpURLHash, + }, + want: "ci:9919@" + cpURLHash, + }, + { + // A provider that emits no namespace claim falls back to the previous + // behaviour rather than producing a half-formed identity. + name: "federated token without a namespace falls back to the issuer", + tags: telemetry.Tags{ + tagTokenType: v1.Attestation_Auth_AUTH_TYPE_FEDERATED.String(), + "user_id": issuer, + "cp_url_hash": cpURLHash, + }, + want: issuer, + }, + { + name: "unauthenticated falls back to the machine id", + tags: telemetry.Tags{"cp_url_hash": cpURLHash}, + wantMachineID: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + mockedClient := mocks.NewClient(t) + mockedClient. + On("TrackEvent", mock.Anything, "command_executed", mock.Anything, mock.Anything). + Return(func(_ context.Context, _ string, id string, tags telemetry.Tags) error { + if tc.wantMachineID { + assert.Equal(t, tags["machine_id"], id) + return nil + } + + assert.Equal(t, tc.want, id) + return nil + }) + + err := telemetry.NewCommandTracker(mockedClient).Track(context.Background(), "test-command", tc.tags) + assert.NoError(t, err) + mockedClient.AssertNumberOfCalls(t, "TrackEvent", 1) + }) + } +} + +// TestAuthTypeConstants pins the literals the telemetry package duplicates to the +// attestation API values they mirror, so a rename on either side fails here. +func TestAuthTypeConstants(t *testing.T) { + assert.Equal(t, "AUTH_TYPE_USER", v1.Attestation_Auth_AUTH_TYPE_USER.String()) + assert.Equal(t, "AUTH_TYPE_FEDERATED", v1.Attestation_Auth_AUTH_TYPE_FEDERATED.String()) +} diff --git a/app/cli/internal/token/token.go b/app/cli/internal/token/token.go index 9ae27e81e..3922de626 100644 --- a/app/cli/internal/token/token.go +++ b/app/cli/internal/token/token.go @@ -16,6 +16,8 @@ package token import ( + "strconv" + v1 "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1" "github.com/golang-jwt/jwt/v5" ) @@ -29,6 +31,12 @@ type ParsedToken struct { ID string OrgID string TokenType v1.Attestation_Auth_AuthType + // CINamespaceID identifies the CI namespace a federated token was minted for: the + // GitHub organization that owns the repository, or the GitLab namespace that owns + // the project. Empty for every other token type, and for a provider that emits + // neither claim. It exists for telemetry and must stay out of the attestation auth + // metadata, which is keyed on ID. + CINamespaceID string } const ( @@ -36,6 +44,13 @@ const ( //nolint:gosec apiTokenAudience = "api-token-auth.chainloop" federatedTokenAudience = "chainloop" + + // githubOwnerIDClaim is GitHub Actions' ID of the organization owning the repository + // the workflow runs from. + githubOwnerIDClaim = "repository_owner_id" + // gitlabNamespaceIDClaim is GitLab's ID of the namespace owning the project the job + // runs from. + gitlabNamespaceIDClaim = "namespace_id" ) // Parse the token and return the type of token. At the moment in Chainloop we have 3 types of tokens: @@ -117,6 +132,7 @@ func Parse(token string) (*ParsedToken, error) { } else { return nil, nil } + pToken.CINamespaceID = ciNamespaceID(claims) default: return nil, nil } @@ -129,3 +145,23 @@ func Parse(token string) (*ParsedToken, error) { return pToken, nil } + +// ciNamespaceID returns the provider's identifier for the namespace that owns the +// repository a federated token was minted for. Both providers expose it as a numeric id +// that survives a rename, unlike the matching name claims, and both document it as a +// string, so a JSON number is accepted too rather than silently dropping the namespace. +// Returns an empty string when the provider emits neither claim. +func ciNamespaceID(claims jwt.MapClaims) string { + for _, claim := range []string{githubOwnerIDClaim, gitlabNamespaceIDClaim} { + switch v := claims[claim].(type) { + case string: + if v != "" { + return v + } + case float64: + return strconv.FormatInt(int64(v), 10) + } + } + + return "" +} diff --git a/app/cli/internal/token/token_test.go b/app/cli/internal/token/token_test.go index 461339b19..df73a4e0b 100644 --- a/app/cli/internal/token/token_test.go +++ b/app/cli/internal/token/token_test.go @@ -49,8 +49,9 @@ func TestParse(t *testing.T) { name: "federated token", token: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRldi1rZXkifQ.eyJpc3MiOiJodHRwczovL2NoYWlubG9vcC5naXRsYWIuY29tIiwic3ViIjoicHJvamVjdF9wYXRoOmNoYWlubG9vcC9wcm9qZWN0OnJlZl90eXBlOmJyYW5jaDpyZWY6bWFpbiIsImF1ZCI6ImNoYWlubG9vcCIsImV4cCI6MTczMDAwMDAwMCwibmJmIjoxNzI5OTk2NDAwLCJpYXQiOjE3Mjk5OTY0MDAsImp0aSI6ImpvYi05ODc2IiwicmVmIjoibWFpbiIsInJlZl90eXBlIjoiYnJhbmNoIiwicHJvamVjdF9pZCI6IjQyNDIiLCJwcm9qZWN0X3BhdGgiOiJjaGFpbmxvb3AvcHJvamVjdCIsIm5hbWVzcGFjZV9pZCI6IjQyNDMiLCJuYW1lc3BhY2VfcGF0aCI6ImNoYWlubG9vcCIsInVzZXJfbG9naW4iOiJnaXRsYWItY2ktdG9rZW4iLCJ1c2VyX2VtYWlsIjoiY2lAdXNlci5jb20iLCJ1c2VyX2FjY2Vzc19sZXZlbCI6ImRldmVsb3BlciIsInBpcGVsaW5lX2lkIjoiMTAxIiwicGlwZWxpbmVfc291cmNlIjoicHVzaCIsImpvYl9pZCI6IjIwMiIsInJlZl9wcm90ZWN0ZWQiOnRydWUsImVudmlyb25tZW50IjoicHJvZHVjdGlvbiIsImVudmlyb25tZW50X3Byb3RlY3RlZCI6dHJ1ZSwiZGVwbG95bWVudF90aWVyIjoicHJvZHVjdGlvbiJ9.LkNvVGVzdFNpZ25hdHVyZUNoYWluTG9vcA", want: &ParsedToken{ - ID: "https://chainloop.gitlab.com", - TokenType: v1.Attestation_Auth_AUTH_TYPE_FEDERATED, + ID: "https://chainloop.gitlab.com", + TokenType: v1.Attestation_Auth_AUTH_TYPE_FEDERATED, + CINamespaceID: "4243", }, }, { @@ -61,6 +62,26 @@ func TestParse(t *testing.T) { TokenType: v1.Attestation_Auth_AUTH_TYPE_FEDERATED, }, }, + { + name: "federated github token with an owner id", + token: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL3Rva2VuLmFjdGlvbnMuZ2l0aHVidXNlcmNvbnRlbnQuY29tIiwiYXVkIjoiY2hhaW5sb29wIiwicmVwb3NpdG9yeSI6ImNoYWlubG9vcC1kZXYvY2hhaW5sb29wIiwicmVwb3NpdG9yeV9vd25lciI6ImNoYWlubG9vcC1kZXYiLCJyZXBvc2l0b3J5X293bmVyX2lkIjoiODQ2MDc0MDkiLCJzdWIiOiJyZXBvOmNoYWlubG9vcC1kZXYvY2hhaW5sb29wOnJlZjpyZWZzL2hlYWRzL21haW4ifQ.c2lnbmF0dXJl", + want: &ParsedToken{ + ID: "https://token.actions.githubusercontent.com", + TokenType: v1.Attestation_Auth_AUTH_TYPE_FEDERATED, + CINamespaceID: "84607409", + }, + }, + { + // Both providers document the id as a string, but a JSON number is accepted + // so a provider encoding it unquoted does not silently lose the namespace. + name: "federated github token with a numeric owner id", + token: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL3Rva2VuLmFjdGlvbnMuZ2l0aHVidXNlcmNvbnRlbnQuY29tIiwiYXVkIjoiY2hhaW5sb29wIiwicmVwb3NpdG9yeV9vd25lcl9pZCI6ODQ2MDc0MDl9.c2lnbmF0dXJl", + want: &ParsedToken{ + ID: "https://token.actions.githubusercontent.com", + TokenType: v1.Attestation_Auth_AUTH_TYPE_FEDERATED, + CINamespaceID: "84607409", + }, + }, { name: "federated token without issuer", token: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJjaGFpbmxvb3AifQ.c2lnbmF0dXJl", @@ -90,6 +111,7 @@ func TestParse(t *testing.T) { assert.Equal(t, tt.want.ID, got.ID) assert.Equal(t, tt.want.TokenType, got.TokenType) assert.Equal(t, tt.want.OrgID, got.OrgID) + assert.Equal(t, tt.want.CINamespaceID, got.CINamespaceID) }) } } From 0f9b689d76087db923115feb5a24ea2a390ced7c Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Fri, 11 Sep 2026 07:09:49 +0200 Subject: [PATCH 2/2] docs(cli): clarify why a federated issuer URL cannot identify a caller Assisted-by: Claude Code Signed-off-by: Javier Rodriguez Chainloop-Trace-Sessions: e1d524cd-4ee3-472d-9800-c5b0569a6689 --- app/cli/internal/telemetry/telemetry.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/cli/internal/telemetry/telemetry.go b/app/cli/internal/telemetry/telemetry.go index 2a61602f2..9786ff98f 100644 --- a/app/cli/internal/telemetry/telemetry.go +++ b/app/cli/internal/telemetry/telemetry.go @@ -112,8 +112,8 @@ func determineUserID(tags Tags) string { tags["machine_id"] = machineID // A federated session's user ID is the OIDC provider's issuer URL, which is the same - // value for every run of every repository, so it has to be scoped before it can - // identify anyone. + // value for every run of every repository and every installation, so it has to be + // scoped before it can identify anyone. if id := federatedUserID(tags); id != "" { return id }