diff --git a/tmproto/artifact.go b/tmproto/artifact.go index f77eb34..1dc6dff 100644 --- a/tmproto/artifact.go +++ b/tmproto/artifact.go @@ -219,6 +219,298 @@ const ( AssetAccessMethodSignedURL AssetAccessMethod = "signed_url" ) +// ServiceAccountCredentials is the typed payload carried by +// AssetAccess.Credentials when Method == service_account. Concrete +// implementations: GCPServiceAccountCredentials and +// AWSServiceAccountCredentials for providers this SDK types, plus +// RawServiceAccountCredentials as a forward-compatibility escape hatch for +// any other provider — the same "type what's known, preserve what isn't" +// split validate_ladder.go and Assets.UnmarshalJSON use elsewhere in this +// package. +// +// This is bearer-equivalent credential material — the one payload in the SDK +// where typing matters most. Every implementation MUST have a redacting +// String()/GoString(), same pattern as AssetAccess itself. +type ServiceAccountCredentials interface { + // ProviderTag returns the provider string this value is for ("gcp", + // "aws", ...), mirroring AssetAccess.Provider. Analogous to Asset's + // AssetTag: the wire discriminator is driven from this method, not a + // separately user-settable field. + ProviderTag() string +} + +// GCPServiceAccountCredentials is the typed credential shape for +// AssetAccess{Method: service_account, Provider: "gcp"}. +// +// The struct covers the fields Google's JWTConfigFromJSON parser requires +// (including "type" and "private_key_id") so that a full service-account +// JSON object round-trips without loss. +// +// Sensitive: PrivateKey is a bearer-equivalent secret. String() and +// GoString() redact it; all other fields are non-secret identifiers and +// stay visible for debuggability. +type GCPServiceAccountCredentials struct { + Type string `json:"type,omitempty"` + ClientEmail string `json:"client_email"` + PrivateKeyID string `json:"private_key_id,omitempty"` + PrivateKey string `json:"private_key"` + ProjectID string `json:"project_id,omitempty"` + TokenURI string `json:"token_uri,omitempty"` + + // Extra preserves any wire fields this struct doesn't model by name — + // e.g. client_id, auth_uri, auth_provider_x509_cert_url, + // client_x509_cert_url, universe_domain — so a full GCP service-account + // JSON object round-trips losslessly even as Google's key shape grows, + // instead of the fixed-struct data loss a plain json.Unmarshal would + // otherwise cause. + Extra map[string]any `json:"-"` +} + +// gcpServiceAccountCredentialsKnownFields lists the wire keys +// GCPServiceAccountCredentials models by name; everything else on the wire +// object is captured in Extra instead of being silently dropped. +var gcpServiceAccountCredentialsKnownFields = map[string]bool{ + "type": true, "client_email": true, "private_key_id": true, + "private_key": true, "project_id": true, "token_uri": true, +} + +// ProviderTag implements ServiceAccountCredentials. +func (GCPServiceAccountCredentials) ProviderTag() string { return "gcp" } + +// String returns a redacted form. PrivateKey is never included so %v/%s +// logging cannot leak it. +func (c GCPServiceAccountCredentials) String() string { return c.redacted() } + +// GoString returns a redacted form. %+v / %#v use this path too. +func (c GCPServiceAccountCredentials) GoString() string { return c.redacted() } + +func (c GCPServiceAccountCredentials) redacted() string { + return fmt.Sprintf("GCPServiceAccountCredentials{Type:%s,ClientEmail:%s,PrivateKeyID:%s,ProjectID:%s,TokenURI:%s,}", + c.Type, c.ClientEmail, c.PrivateKeyID, c.ProjectID, c.TokenURI) +} + +// MarshalJSON emits the modeled fields plus any Extra fields captured on +// decode, so a credential object this SDK didn't fully model still +// round-trips byte-for-byte instead of losing the fields it doesn't know. +func (c GCPServiceAccountCredentials) MarshalJSON() ([]byte, error) { + type alias GCPServiceAccountCredentials + known, err := json.Marshal(alias(c)) + if err != nil { + return nil, err + } + if len(c.Extra) == 0 { + return known, nil + } + var m map[string]any + if err := json.Unmarshal(known, &m); err != nil { + return nil, err + } + for k, v := range c.Extra { + if _, exists := m[k]; !exists { + m[k] = v + } + } + return json.Marshal(m) +} + +// UnmarshalJSON decodes the modeled fields and captures anything else on the +// wire object into Extra, instead of silently dropping it. +func (c *GCPServiceAccountCredentials) UnmarshalJSON(data []byte) error { + type alias GCPServiceAccountCredentials + var a alias + if err := json.Unmarshal(data, &a); err != nil { + return err + } + *c = GCPServiceAccountCredentials(a) + + var m map[string]json.RawMessage + if err := json.Unmarshal(data, &m); err != nil { + return err + } + var extra map[string]any + for k, v := range m { + if gcpServiceAccountCredentialsKnownFields[k] { + continue + } + var val any + if err := json.Unmarshal(v, &val); err != nil { + return err + } + if extra == nil { + extra = make(map[string]any, len(m)) + } + extra[k] = val + } + c.Extra = extra + return nil +} + +// AWSServiceAccountCredentials is the typed credential shape for +// AssetAccess{Method: service_account, Provider: "aws"}. +// +// Sensitive: SecretAccessKey and SessionToken are bearer-equivalent secrets +// (a session token alone is sufficient to act as the principal, same as the +// secret key). String() and GoString() redact both; AccessKeyID/Region are +// not secret and stay visible for debuggability. +type AWSServiceAccountCredentials struct { + AccessKeyID string `json:"access_key_id"` + SecretAccessKey string `json:"secret_access_key"` + SessionToken string `json:"session_token,omitempty"` + Region string `json:"region,omitempty"` + + // Extra preserves any wire fields this struct doesn't model by name — + // e.g. an STS expiration timestamp or a role ARN — so a full credential + // object round-trips losslessly instead of the fixed-struct data loss a + // plain json.Unmarshal would otherwise cause. + Extra map[string]any `json:"-"` +} + +// awsServiceAccountCredentialsKnownFields lists the wire keys +// AWSServiceAccountCredentials models by name; everything else on the wire +// object is captured in Extra instead of being silently dropped. +var awsServiceAccountCredentialsKnownFields = map[string]bool{ + "access_key_id": true, "secret_access_key": true, + "session_token": true, "region": true, +} + +// ProviderTag implements ServiceAccountCredentials. +func (AWSServiceAccountCredentials) ProviderTag() string { return "aws" } + +// String returns a redacted form. SecretAccessKey and SessionToken are never +// included so %v/%s logging cannot leak them. +func (c AWSServiceAccountCredentials) String() string { return c.redacted() } + +// GoString returns a redacted form. %+v / %#v use this path too. +func (c AWSServiceAccountCredentials) GoString() string { return c.redacted() } + +func (c AWSServiceAccountCredentials) redacted() string { + return fmt.Sprintf("AWSServiceAccountCredentials{AccessKeyID:%s,Region:%s,}", + c.AccessKeyID, c.Region) +} + +// MarshalJSON emits the modeled fields plus any Extra fields captured on +// decode, so a credential object this SDK didn't fully model still +// round-trips byte-for-byte instead of losing the fields it doesn't know. +func (c AWSServiceAccountCredentials) MarshalJSON() ([]byte, error) { + type alias AWSServiceAccountCredentials + known, err := json.Marshal(alias(c)) + if err != nil { + return nil, err + } + if len(c.Extra) == 0 { + return known, nil + } + var m map[string]any + if err := json.Unmarshal(known, &m); err != nil { + return nil, err + } + for k, v := range c.Extra { + if _, exists := m[k]; !exists { + m[k] = v + } + } + return json.Marshal(m) +} + +// UnmarshalJSON decodes the modeled fields and captures anything else on the +// wire object into Extra, instead of silently dropping it. +func (c *AWSServiceAccountCredentials) UnmarshalJSON(data []byte) error { + type alias AWSServiceAccountCredentials + var a alias + if err := json.Unmarshal(data, &a); err != nil { + return err + } + *c = AWSServiceAccountCredentials(a) + + var m map[string]json.RawMessage + if err := json.Unmarshal(data, &m); err != nil { + return err + } + var extra map[string]any + for k, v := range m { + if awsServiceAccountCredentialsKnownFields[k] { + continue + } + var val any + if err := json.Unmarshal(v, &val); err != nil { + return err + } + if extra == nil { + extra = make(map[string]any, len(m)) + } + extra[k] = val + } + c.Extra = extra + return nil +} + +// RawServiceAccountCredentials is the escape hatch for service_account +// providers this SDK has no typed credential struct for yet. It preserves +// the wire object losslessly under Fields instead of failing to decode — +// the same forward-compatibility trade Assets.UnmarshalJSON makes with +// UnknownAsset for an unrecognized "type". +// +// Its shape (and whether any of its fields are secret) is unknown to the +// SDK, so String()/GoString() redact the entire map rather than guessing. +type RawServiceAccountCredentials struct { + Provider string + Fields map[string]any +} + +// ProviderTag implements ServiceAccountCredentials. +func (r RawServiceAccountCredentials) ProviderTag() string { return r.Provider } + +// MarshalJSON emits Fields directly as the wire "credentials" object — Raw +// is an unwrapping shim, not a nested envelope. +func (r RawServiceAccountCredentials) MarshalJSON() ([]byte, error) { + if r.Fields == nil { + return []byte("{}"), nil + } + return json.Marshal(r.Fields) +} + +// String returns a redacted form: the SDK doesn't know this provider's +// field shape, so it cannot tell secret fields from non-secret ones and +// redacts the whole payload rather than risk leaking one. +func (r RawServiceAccountCredentials) String() string { return r.redacted() } + +// GoString returns a redacted form. %+v / %#v use this path too. +func (r RawServiceAccountCredentials) GoString() string { return r.redacted() } + +func (r RawServiceAccountCredentials) redacted() string { + return fmt.Sprintf("RawServiceAccountCredentials{Provider:%s,}", r.Provider) +} + +// decodeServiceAccountCredentials dispatches on the wire "provider" value to +// the matching typed credential struct, falling back to +// RawServiceAccountCredentials for providers this SDK doesn't type — same +// dispatch-with-fallback shape as Assets.UnmarshalJSON. +func decodeServiceAccountCredentials(provider string, data json.RawMessage) (ServiceAccountCredentials, error) { + if len(data) == 0 { + return nil, nil + } + switch provider { + case "gcp": + var c GCPServiceAccountCredentials + if err := json.Unmarshal(data, &c); err != nil { + return nil, fmt.Errorf("credentials (gcp): %w", err) + } + return c, nil + case "aws": + var c AWSServiceAccountCredentials + if err := json.Unmarshal(data, &c); err != nil { + return nil, fmt.Errorf("credentials (aws): %w", err) + } + return c, nil + default: + var fields map[string]any + if err := json.Unmarshal(data, &fields); err != nil { + return nil, fmt.Errorf("credentials (%s): %w", provider, err) + } + return RawServiceAccountCredentials{Provider: provider, Fields: fields}, nil + } +} + // AssetAccess carries authentication for accessing secured asset URLs. // // Sensitive: Token and Credentials hold secrets. String() and GoString() are @@ -229,7 +521,8 @@ const ( // Routers MUST strip this field (see Artifact.StripAccess) before fanning out // a ContextMatchRequest to multiple buyer agents, per the AdCP spec. // -// Prefer the NewBearerTokenAccess / NewServiceAccountAccess / NewSignedURLAccess +// Prefer the NewBearerTokenAccess / NewGCPServiceAccountAccess / +// NewAWSServiceAccountAccess / NewServiceAccountAccess / NewSignedURLAccess // constructors over literal struct construction. type AssetAccess struct { Method AssetAccessMethod `json:"-"` @@ -238,9 +531,12 @@ type AssetAccess struct { Token string `json:"-"` // Provider and Credentials are emitted only when Method == service_account. - // Provider is "gcp" or "aws". - Provider string `json:"-"` - Credentials map[string]any `json:"-"` + // Provider is the wire discriminator ("gcp", "aws", or any other value a + // counterparty sends); Credentials is the typed payload matching it — + // GCPServiceAccountCredentials / AWSServiceAccountCredentials for known + // providers, RawServiceAccountCredentials for anything else. + Provider string `json:"-"` + Credentials ServiceAccountCredentials `json:"-"` } // NewBearerTokenAccess constructs an AssetAccess for a bearer token. @@ -248,10 +544,30 @@ func NewBearerTokenAccess(token string) AssetAccess { return AssetAccess{Method: AssetAccessMethodBearerToken, Token: token} } +// NewGCPServiceAccountAccess constructs an AssetAccess for a GCP service +// account, typed per GCPServiceAccountCredentials. +func NewGCPServiceAccountAccess(creds GCPServiceAccountCredentials) AssetAccess { + return AssetAccess{Method: AssetAccessMethodServiceAccount, Provider: "gcp", Credentials: creds} +} + +// NewAWSServiceAccountAccess constructs an AssetAccess for an AWS service +// account, typed per AWSServiceAccountCredentials. +func NewAWSServiceAccountAccess(creds AWSServiceAccountCredentials) AssetAccess { + return AssetAccess{Method: AssetAccessMethodServiceAccount, Provider: "aws", Credentials: creds} +} + // NewServiceAccountAccess constructs an AssetAccess for a cloud service -// account. Provider is "gcp" or "aws"; credentials shape is provider-specific. +// account whose provider this SDK has no typed credential struct for yet. +// Prefer NewGCPServiceAccountAccess / NewAWSServiceAccountAccess when +// provider is "gcp" or "aws" — this constructor wraps credentials in +// RawServiceAccountCredentials, which round-trips losslessly but isn't +// typed per-field. func NewServiceAccountAccess(provider string, credentials map[string]any) AssetAccess { - return AssetAccess{Method: AssetAccessMethodServiceAccount, Provider: provider, Credentials: credentials} + return AssetAccess{ + Method: AssetAccessMethodServiceAccount, + Provider: provider, + Credentials: RawServiceAccountCredentials{Provider: provider, Fields: credentials}, + } } // NewSignedURLAccess constructs an AssetAccess for a signed URL — credentials @@ -287,12 +603,14 @@ func (a AssetAccess) MarshalJSON() ([]byte, error) { // UnmarshalJSON decodes the method and only the fields appropriate for it. // Fields belonging to other variants are ignored even if present on the wire. +// For method=service_account, the "credentials" object is dispatched to a +// typed struct by "provider" (see decodeServiceAccountCredentials). func (a *AssetAccess) UnmarshalJSON(data []byte) error { var raw struct { Method AssetAccessMethod `json:"method"` Token string `json:"token,omitempty"` Provider string `json:"provider,omitempty"` - Credentials map[string]any `json:"credentials,omitempty"` + Credentials json.RawMessage `json:"credentials,omitempty"` } if err := json.Unmarshal(data, &raw); err != nil { return err @@ -301,7 +619,11 @@ func (a *AssetAccess) UnmarshalJSON(data []byte) error { case AssetAccessMethodBearerToken: *a = AssetAccess{Method: raw.Method, Token: raw.Token} case AssetAccessMethodServiceAccount: - *a = AssetAccess{Method: raw.Method, Provider: raw.Provider, Credentials: raw.Credentials} + creds, err := decodeServiceAccountCredentials(raw.Provider, raw.Credentials) + if err != nil { + return fmt.Errorf("asset_access: %w", err) + } + *a = AssetAccess{Method: raw.Method, Provider: raw.Provider, Credentials: creds} case AssetAccessMethodSignedURL: *a = AssetAccess{Method: raw.Method} case "": diff --git a/tmproto/artifact_test.go b/tmproto/artifact_test.go index bb7d4d5..91c2589 100644 --- a/tmproto/artifact_test.go +++ b/tmproto/artifact_test.go @@ -123,7 +123,9 @@ func TestAssetAccess_AllMethods_RoundTrip(t *testing.T) { access AssetAccess }{ {"bearer_token", AssetAccess{Method: AssetAccessMethodBearerToken, Token: "ya29.xxx"}}, - {"service_account_gcp", AssetAccess{Method: AssetAccessMethodServiceAccount, Provider: "gcp", Credentials: map[string]any{"client_email": "sa@project.iam.gserviceaccount.com"}}}, + {"service_account_gcp", NewGCPServiceAccountAccess(GCPServiceAccountCredentials{ClientEmail: "sa@project.iam.gserviceaccount.com"})}, + {"service_account_aws", NewAWSServiceAccountAccess(AWSServiceAccountCredentials{AccessKeyID: "AKIAIOSFODNN7EXAMPLE"})}, // #nosec G101 — fake AWS example key + {"service_account_raw_other_provider", NewServiceAccountAccess("azure", map[string]any{"client_id": "abc-123"})}, {"signed_url", AssetAccess{Method: AssetAccessMethodSignedURL}}, } for _, tc := range cases { @@ -136,10 +138,208 @@ func TestAssetAccess_AllMethods_RoundTrip(t *testing.T) { assert.Equal(t, tc.access.Method, got.Method) assert.Equal(t, tc.access.Token, got.Token) assert.Equal(t, tc.access.Provider, got.Provider) + assert.Equal(t, tc.access.Credentials, got.Credentials) }) } } +// TestAssetAccess_GCPServiceAccount_RoundTrip constructs via the typed +// constructor, marshals to JSON, unmarshals back, and confirms the decoded +// Credentials is the exact same typed GCPServiceAccountCredentials value — +// not a map[string]any — with realistic (fabricated) credential shape. +func TestAssetAccess_GCPServiceAccount_RoundTrip(t *testing.T) { + creds := GCPServiceAccountCredentials{ // #nosec G101 — fake PEM block exercising round-trip, not a real key + ClientEmail: "asset-reader@my-project-123.iam.gserviceaccount.com", + PrivateKey: "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEA\n-----END PRIVATE KEY-----\n", + ProjectID: "my-project-123", + TokenURI: "https://oauth2.googleapis.com/token", + } + access := NewGCPServiceAccountAccess(creds) + assert.Equal(t, AssetAccessMethodServiceAccount, access.Method) + assert.Equal(t, "gcp", access.Provider) + + data, err := json.Marshal(access) + require.NoError(t, err) + assert.JSONEq(t, `{ + "method": "service_account", + "provider": "gcp", + "credentials": { + "client_email": "asset-reader@my-project-123.iam.gserviceaccount.com", + "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEA\n-----END PRIVATE KEY-----\n", + "project_id": "my-project-123", + "token_uri": "https://oauth2.googleapis.com/token" + } + }`, string(data)) + + var got AssetAccess + require.NoError(t, json.Unmarshal(data, &got)) + assert.Equal(t, AssetAccessMethodServiceAccount, got.Method) + assert.Equal(t, "gcp", got.Provider) + + gotCreds, ok := got.Credentials.(GCPServiceAccountCredentials) + require.True(t, ok, "decoded Credentials should be typed GCPServiceAccountCredentials, got %T", got.Credentials) + assert.Equal(t, creds, gotCreds) + assert.Equal(t, "gcp", gotCreds.ProviderTag()) +} + +// TestAssetAccess_GCPServiceAccount_FullCredentialRoundTrip verifies that a +// full GCP service-account JSON object (including "type" and "private_key_id" +// required by google.JWTConfigFromJSON) survives a marshal→unmarshal→marshal +// cycle without field loss, both via the typed constructor and via the +// map-based NewServiceAccountAccess constructor. +func TestAssetAccess_GCPServiceAccount_FullCredentialRoundTrip(t *testing.T) { + fullCreds := GCPServiceAccountCredentials{ // #nosec G101 — fabricated values, not real credentials + Type: "service_account", + ClientEmail: "asset-reader@my-project-123.iam.gserviceaccount.com", + PrivateKeyID: "key-abc123", + PrivateKey: "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEA\n-----END PRIVATE KEY-----\n", + ProjectID: "my-project-123", + TokenURI: "https://oauth2.googleapis.com/token", + } + wantJSON := `{ + "method": "service_account", + "provider": "gcp", + "credentials": { + "type": "service_account", + "client_email": "asset-reader@my-project-123.iam.gserviceaccount.com", + "private_key_id": "key-abc123", + "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEA\n-----END PRIVATE KEY-----\n", + "project_id": "my-project-123", + "token_uri": "https://oauth2.googleapis.com/token" + } + }` + + t.Run("typed_constructor", func(t *testing.T) { + access := NewGCPServiceAccountAccess(fullCreds) + data, err := json.Marshal(access) + require.NoError(t, err) + assert.JSONEq(t, wantJSON, string(data)) + + var got AssetAccess + require.NoError(t, json.Unmarshal(data, &got)) + gotCreds, ok := got.Credentials.(GCPServiceAccountCredentials) + require.True(t, ok, "expected GCPServiceAccountCredentials, got %T", got.Credentials) + assert.Equal(t, fullCreds, gotCreds) + }) + + t.Run("map_constructor", func(t *testing.T) { + // NewServiceAccountAccess("gcp", map) wraps in RawServiceAccountCredentials, + // but after a marshal→unmarshal cycle the decoder dispatches on provider="gcp" + // and produces a GCPServiceAccountCredentials — type and private_key_id must survive. + access := NewServiceAccountAccess("gcp", map[string]any{ + "type": "service_account", + "client_email": "asset-reader@my-project-123.iam.gserviceaccount.com", + "private_key_id": "key-abc123", + "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEA\n-----END PRIVATE KEY-----\n", + "project_id": "my-project-123", + "token_uri": "https://oauth2.googleapis.com/token", + }) + data, err := json.Marshal(access) + require.NoError(t, err) + assert.JSONEq(t, wantJSON, string(data)) + + var got AssetAccess + require.NoError(t, json.Unmarshal(data, &got)) + gotCreds, ok := got.Credentials.(GCPServiceAccountCredentials) + require.True(t, ok, "expected GCPServiceAccountCredentials after map round-trip, got %T", got.Credentials) + assert.Equal(t, fullCreds, gotCreds) + }) +} + +// TestAssetAccess_GCPServiceAccount_UnmodeledFieldsRoundTrip verifies that a +// real GCP service-account key JSON — which also carries client_id, auth_uri, +// auth_provider_x509_cert_url, client_x509_cert_url, and universe_domain, +// none of which GCPServiceAccountCredentials models by name — round-trips +// those fields losslessly via Extra instead of dropping them. +func TestAssetAccess_GCPServiceAccount_UnmodeledFieldsRoundTrip(t *testing.T) { + raw := `{ + "method": "service_account", + "provider": "gcp", + "credentials": { + "type": "service_account", + "project_id": "my-project-123", + "private_key_id": "key-abc123", + "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEA\n-----END PRIVATE KEY-----\n", + "client_email": "asset-reader@my-project-123.iam.gserviceaccount.com", + "client_id": "123456789012345678901", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/asset-reader%40my-project-123.iam.gserviceaccount.com", + "universe_domain": "googleapis.com" + } + }` + + var got AssetAccess + require.NoError(t, json.Unmarshal([]byte(raw), &got)) + + gotCreds, ok := got.Credentials.(GCPServiceAccountCredentials) + require.True(t, ok, "expected GCPServiceAccountCredentials, got %T", got.Credentials) + assert.Equal(t, "123456789012345678901", gotCreds.Extra["client_id"]) + assert.Equal(t, "googleapis.com", gotCreds.Extra["universe_domain"]) + + data, err := json.Marshal(got) + require.NoError(t, err) + assert.JSONEq(t, raw, string(data)) +} + +// TestAssetAccess_AWSServiceAccount_RoundTrip mirrors the GCP round-trip +// test for AWS, including the session-token field (STS-issued temporary +// credentials are a common real-world shape). +func TestAssetAccess_AWSServiceAccount_RoundTrip(t *testing.T) { + creds := AWSServiceAccountCredentials{ + AccessKeyID: "ASIAIOSFODNN7EXAMPLE", + SecretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", // #nosec G101 — fake AWS example key + SessionToken: "FQoGZXIvYXdzEBEXAMPLESESSIONTOKEN==", // #nosec G101 — fake STS token + Region: "us-west-2", + } + access := NewAWSServiceAccountAccess(creds) + assert.Equal(t, AssetAccessMethodServiceAccount, access.Method) + assert.Equal(t, "aws", access.Provider) + + data, err := json.Marshal(access) + require.NoError(t, err) + assert.JSONEq(t, `{ + "method": "service_account", + "provider": "aws", + "credentials": { + "access_key_id": "ASIAIOSFODNN7EXAMPLE", + "secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "session_token": "FQoGZXIvYXdzEBEXAMPLESESSIONTOKEN==", + "region": "us-west-2" + } + }`, string(data)) + + var got AssetAccess + require.NoError(t, json.Unmarshal(data, &got)) + + gotCreds, ok := got.Credentials.(AWSServiceAccountCredentials) + require.True(t, ok, "decoded Credentials should be typed AWSServiceAccountCredentials, got %T", got.Credentials) + assert.Equal(t, creds, gotCreds) + assert.Equal(t, "aws", gotCreds.ProviderTag()) +} + +// TestAssetAccess_ServiceAccount_UnknownProvider_RawFallback confirms a +// provider this SDK has no typed struct for still round-trips losslessly via +// RawServiceAccountCredentials, instead of failing to decode — the same +// forward-compat trade UnknownAsset makes for an unrecognized asset "type". +func TestAssetAccess_ServiceAccount_UnknownProvider_RawFallback(t *testing.T) { + raw := `{"method":"service_account","provider":"azure","credentials":{"client_id":"abc","tenant_id":"def"}}` + var got AssetAccess + require.NoError(t, json.Unmarshal([]byte(raw), &got)) + assert.Equal(t, "azure", got.Provider) + + rawCreds, ok := got.Credentials.(RawServiceAccountCredentials) + require.True(t, ok, "decoded Credentials should be RawServiceAccountCredentials, got %T", got.Credentials) + assert.Equal(t, "azure", rawCreds.Provider) + assert.Equal(t, "abc", rawCreds.Fields["client_id"]) + + // Re-marshal preserves the fields. + data, err := json.Marshal(got) + require.NoError(t, err) + assert.JSONEq(t, raw, string(data)) +} + func TestContextMatchRequest_FullDisclosureLadder(t *testing.T) { // Exercise all three disclosure rungs together — artifact (high), // artifact_refs (public), context_signals (classifier-only). The schema diff --git a/tmproto/robustness_test.go b/tmproto/robustness_test.go index bddda19..afbbc13 100644 --- a/tmproto/robustness_test.go +++ b/tmproto/robustness_test.go @@ -54,17 +54,67 @@ func TestAssetAccess_Redacts_String(t *testing.T) { } func TestAssetAccess_Redacts_GCPServiceAccount(t *testing.T) { - a := NewServiceAccountAccess("gcp", map[string]any{ - "client_email": "sa@example.iam.gserviceaccount.com", - "private_key": "-----BEGIN PRIVATE KEY-----\nAAAABBBBCCCC\n-----END PRIVATE KEY-----", + a := NewGCPServiceAccountAccess(GCPServiceAccountCredentials{ + ClientEmail: "sa@example.iam.gserviceaccount.com", + PrivateKey: "-----BEGIN PRIVATE KEY-----\nAAAABBBBCCCC\n-----END PRIVATE KEY-----", }) for _, format := range []string{"%s", "%v", "%+v", "%#v"} { s := fmt.Sprintf(format, a) assert.NotContains(t, s, "AAAABBBBCCCC", "format %s leaked private_key", format) - assert.NotContains(t, s, "sa@example", "format %s leaked client_email", format) + // AssetAccess's own redaction is blanket (Method only), so the + // client_email doesn't surface through it either — that's fine, + // this test only needs to prove no secret leaks. } } +func TestGCPServiceAccountCredentials_Redacts_PrivateKey_KeepsIdentifiers(t *testing.T) { + c := GCPServiceAccountCredentials{ // #nosec G101 — fake PEM block exercising redaction, not a real key + ClientEmail: "sa@example.iam.gserviceaccount.com", + PrivateKey: "-----BEGIN PRIVATE KEY-----\nAAAABBBBCCCC\n-----END PRIVATE KEY-----", + ProjectID: "my-project-123", + TokenURI: "https://oauth2.googleapis.com/token", + } + for _, format := range []string{"%s", "%v", "%+v", "%#v"} { + s := fmt.Sprintf(format, c) + assert.NotContains(t, s, "AAAABBBBCCCC", "format %s leaked private_key", format) + assert.NotContains(t, s, "BEGIN PRIVATE KEY", "format %s leaked private_key marker", format) + } + // Non-secret identifiers stay visible for debuggability. + s := c.String() + assert.Contains(t, s, "sa@example.iam.gserviceaccount.com") + assert.Contains(t, s, "my-project-123") +} + +func TestAWSServiceAccountCredentials_Redacts_Secrets_KeepsIdentifiers(t *testing.T) { + c := AWSServiceAccountCredentials{ + AccessKeyID: "AKIAIOSFODNN7EXAMPLE", + SecretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", // #nosec G101 — fake AWS example key exercising redaction + SessionToken: "FQoGZXIvYXdzEBEXAMPLESESSIONTOKEN", // #nosec G101 — fake token exercising redaction + Region: "us-east-1", + } + for _, format := range []string{"%s", "%v", "%+v", "%#v"} { + s := fmt.Sprintf(format, c) + assert.NotContains(t, s, "wJalrXUtnFEMI", "format %s leaked secret_access_key", format) + assert.NotContains(t, s, "EXAMPLESESSIONTOKEN", "format %s leaked session_token", format) + } + // Non-secret identifiers stay visible for debuggability. + s := c.String() + assert.Contains(t, s, "AKIAIOSFODNN7EXAMPLE") + assert.Contains(t, s, "us-east-1") +} + +func TestRawServiceAccountCredentials_RedactsWholeMap(t *testing.T) { + r := RawServiceAccountCredentials{ + Provider: "azure", + Fields: map[string]any{"client_secret": "azure-super-secret-value"}, // #nosec G101 — fake secret exercising redaction + } + for _, format := range []string{"%s", "%v", "%+v", "%#v"} { + s := fmt.Sprintf(format, r) + assert.NotContains(t, s, "azure-super-secret-value", "format %s leaked raw credential field", format) + } + assert.Contains(t, r.String(), "Provider:azure") +} + func TestAssetAccess_Redacts_InsideLogging(t *testing.T) { // Belt and suspenders: typical calling site log like `log.Printf("req=%+v", req)` // with an ImageAsset carrying an AssetAccess. Must not leak the token.