Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions app/cli/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 28 additions & 4 deletions app/cli/internal/telemetry/telemetry.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ package telemetry

import (
"context"
"fmt"
"runtime"
"time"

Expand All @@ -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
Expand Down Expand Up @@ -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 and every installation, 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 != "" {
Expand Down Expand Up @@ -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"])
}
101 changes: 101 additions & 0 deletions app/cli/internal/telemetry/telemetry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
36 changes: 36 additions & 0 deletions app/cli/internal/token/token.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -29,13 +31,26 @@ 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 (
userAudience = "user-auth.chainloop"
//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:
Expand Down Expand Up @@ -117,6 +132,7 @@ func Parse(token string) (*ParsedToken, error) {
} else {
return nil, nil
}
pToken.CINamespaceID = ciNamespaceID(claims)
default:
return nil, nil
}
Expand All @@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When repository_owner_id or namespace_id is encoded as a non-integral or oversized JSON number, this conversion changes the namespace before telemetry identity construction. Reject non-integer and out-of-range values before converting, or preserve the original numeric representation.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/cli/internal/token/token.go, line 162:

<comment>When `repository_owner_id` or `namespace_id` is encoded as a non-integral or oversized JSON number, this conversion changes the namespace before telemetry identity construction. Reject non-integer and out-of-range values before converting, or preserve the original numeric representation.</comment>

<file context>
@@ -129,3 +145,23 @@ func Parse(token string) (*ParsedToken, error) {
+				return v
+			}
+		case float64:
+			return strconv.FormatInt(int64(v), 10)
+		}
+	}
</file context>

}
}

return ""
}
26 changes: 24 additions & 2 deletions app/cli/internal/token/token_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
},
{
Expand All @@ -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",
Expand Down Expand Up @@ -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)
})
}
}
Loading