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
191 changes: 183 additions & 8 deletions tmproto/artifact.go
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,151 @@ 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"}.
//
// Sensitive: PrivateKey is a bearer-equivalent secret. String() and
// GoString() redact it; ClientEmail/ProjectID/TokenURI are not secret and
// stay visible for debuggability, same line AssetAccess's own redaction
// draws between the discriminator and the payload.
type GCPServiceAccountCredentials struct {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Medium: Round-trip fidelity regression for real GCP credentials. A production GCP service-account key JSON carries type, private_key_id, client_id, auth_uri, auth_provider_x509_cert_url, client_x509_cert_url, universe_domain — none modeled here. decodeServiceAccountCredentials at L346 does a plain json.Unmarshal into this 4-field struct, so every unmodeled field is silently dropped on decode and gone on re-marshal. The prior map[string]any round-tripped a full SA JSON losslessly; the new typed path does not. Note the asymmetry: unknown providers preserve everything via RawServiceAccountCredentials.Fields, but gcp/aws — the providers you do type — lose data. The four modeled fields are enough to mint a JWT assertion, so auth still works; the loss is type/private_key_id/cert URLs, which some consumers (google.CredentialsFromJSON requires type) need. Recommend an overflow-capture field to preserve unmodeled keys, or document the contract as deliberately lossy. The round-trip tests only feed exactly-modeled fields, so they can't catch this.

ClientEmail string `json:"client_email"`
PrivateKey string `json:"private_key"`
ProjectID string `json:"project_id,omitempty"`
TokenURI string `json:"token_uri,omitempty"`
}

// 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{ClientEmail:%s,ProjectID:%s,TokenURI:%s,<redacted>}",
c.ClientEmail, c.ProjectID, c.TokenURI)
}

// 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"`
}

// 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,<redacted>}",
c.AccessKeyID, c.Region)
}

// 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,<redacted>}", 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
Expand All @@ -229,7 +374,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:"-"`
Expand All @@ -238,20 +384,43 @@ 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.
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
Expand Down Expand Up @@ -287,12 +456,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
Expand All @@ -301,7 +472,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 "":
Expand Down
101 changes: 100 additions & 1 deletion tmproto/artifact_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -136,10 +138,107 @@ 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_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
Expand Down
Loading
Loading