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
338 changes: 330 additions & 8 deletions tmproto/artifact.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

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.

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,<redacted>}",
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,<redacted>}",
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,<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 +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:"-"`
Expand All @@ -238,20 +531,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 +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
Expand All @@ -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 "":
Expand Down
Loading
Loading