diff --git a/.gitignore b/.gitignore index 9bbcd5fb..d8071d08 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,4 @@ CLAUDE.md .claude/ .handoffs/ .worktrees/ +.superpowers/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 1474c465..eff54e39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,38 @@ Requires formae >= 0.89.0. a defect worth recording, or AWS-managed flooding without a filterable signal. +- Polymorphic `auth` on the target config: `DefaultChainAuth` (the existing + default credential provider chain — env vars, shared config, IMDS/IRSA — + optionally pinned to a shared-config `profile`) or `OidcAuth` (federated + identity: an OIDC identity token from the paired oidc-credential broker, + exchanged for credentials by assuming a role you name in the target + account). `auth` and the legacy flat `profile` field are mutually + exclusive; setting both is rejected at eval. + + `OidcAuth` requires a formae agent with oidc-credential broker support + (`minFormaeVersion = "0.89.0"` in `formae-plugin.pkl`). Paired with an + older agent, or an agent with no broker paired, credential resolution + fails closed with an explicit error rather than ever falling back to + ambient credentials. + + **Known limitation: the STS exchange uses a default-configured client.** + The `AssumeRoleWithWebIdentity` call that turns the identity token into + credentials is made with a region-only STS client, so + `AWS_USE_FIPS_ENDPOINT`, `AWS_ENDPOINT_URL_STS` and a custom CA bundle are + not honoured on that one exchange. Proxy settings are honoured, because + they come from the HTTP transport rather than from SDK configuration. + Every other AWS call the plugin makes uses the fully configured client and + is unaffected. + + **One-time bookkeeping, not drift.** Existing targets carry no `auth` + block, and adding the field to the schema is a change formae records + against every target's stored metadata regardless of whether the target's + declared configuration actually changed. Expect a single, resource-inert + target-metadata update on the first reconcile after upgrading to this + version — no cloud resource is read, created, updated or destroyed by it. + If a stack shows exactly one such update per target immediately after the + upgrade, this is why; it is not drift and does not recur. + - `AWS::RDS::Database` and `AWS::RDS::DatabaseRole` support. A PostgreSQL database inside an Aurora cluster, and its owning login role, are now first-class declared resources. CloudControl models a cluster and its @@ -338,6 +370,27 @@ Requires formae >= 0.89.0. declaring the new five-resource forma. A later release will accept the older two-part identifier so this is unnecessary. +### Deprecated + +- The flat `profile` field on the target config is deprecated in favor of + `auth = new DefaultChainAuth { profile = ... }`. It continues to work + unchanged — a target set this way still authenticates via the default + credential chain pinned to that profile — and logs one deprecation warning + per plugin process rather than on every call. There is no removal in this + release; flat `profile` will be removed at a future major version, posted + ahead of time. + + **Migrating is a normal target update.** Both `profile` and `auth` are + declared mutable, so rewriting a target from the flat `profile` to an + `auth` block changes the target's configuration in place and touches no + cloud resource. + + This holds only on an agent at or above the release that carries these + hints. An **older** agent does not see them, classifies the dropped + top-level `Profile` key as an immutable change, and plans a target + replace, which destroys and recreates every resource on that target. + Upgrade the agent first, then migrate. + ### Fixed - An `AWS::CertificateManager::Certificate` with `subjectAlternativeNames` no diff --git a/aws.go b/aws.go index e184f6db..2fc0003d 100644 --- a/aws.go +++ b/aws.go @@ -29,11 +29,30 @@ import ( // The SDK automatically provides identity methods (Name, Version, Namespace) // and schema methods (SupportedResources, SchemaForResourceType) by reading // formae-plugin.pkl and schema/pkl/ at startup. -type Plugin struct{} +type Plugin struct { + // oidc carries the token source the SDK installs via SetOidcTokenSource, + // plus the plugin-lifetime credentials cache it backs. Nil until the SDK + // calls SetOidcTokenSource (or on an agent too old to pair a broker), in + // which case every target config threads nil deps and Oidc auth fails + // closed rather than falling back to ambient credentials. + oidc *config.OidcDeps +} // Compile-time check: Plugin must satisfy ResourcePlugin interface. var _ plugin.ResourcePlugin = &Plugin{} +// Compile-time check: Plugin must satisfy OidcAware, so the SDK hands it an +// OidcTokenSource at startup. +var _ plugin.OidcAware = &Plugin{} + +// SetOidcTokenSource receives the token source the SDK mints OIDC identity +// tokens through. Called once at startup; every FromTargetConfig call below +// threads the resulting deps onto the parsed Config so Oidc auth blocks can +// exchange a token for AWS credentials. +func (p *Plugin) SetOidcTokenSource(src plugin.OidcTokenSource) { + p.oidc = config.NewOidcDeps(src) +} + // EKSAutomodeResourceTypes lists AWS CloudFormation resource types that EKS Automode manages. // These resources are tagged with "kubernetes.io/cluster/" = "owned". var EKSAutomodeResourceTypes = []string{ @@ -157,7 +176,7 @@ func (p *Plugin) LabelConfig() pkgmodel.LabelConfig { } func (p *Plugin) Create(ctx context.Context, request *resource.CreateRequest) (*resource.CreateResult, error) { - targetConfig := config.FromTargetConfig(request.TargetConfig) + targetConfig := config.FromTargetConfig(request.TargetConfig).WithOidcDeps(p.oidc) if registry.HasProvisioner(request.ResourceType, resource.OperationCreate) { provisioner := registry.Get(request.ResourceType, resource.OperationCreate, targetConfig) return provisioner.Create(ctx, request) @@ -172,12 +191,13 @@ func (p *Plugin) Create(ctx context.Context, request *resource.CreateRequest) (* } func (p *Plugin) Update(ctx context.Context, request *resource.UpdateRequest) (*resource.UpdateResult, error) { + targetConfig := config.FromTargetConfig(request.TargetConfig).WithOidcDeps(p.oidc) if registry.HasProvisioner(request.ResourceType, resource.OperationUpdate) { - provisioner := registry.Get(request.ResourceType, resource.OperationUpdate, config.FromTargetConfig(request.TargetConfig)) + provisioner := registry.Get(request.ResourceType, resource.OperationUpdate, targetConfig) return provisioner.Update(ctx, request) } - client, err := ccx.NewClient(config.FromTargetConfig(request.TargetConfig)) + client, err := ccx.NewClient(targetConfig) if err != nil { return nil, err } @@ -186,14 +206,15 @@ func (p *Plugin) Update(ctx context.Context, request *resource.UpdateRequest) (* } func (p *Plugin) Status(ctx context.Context, request *resource.StatusRequest) (*resource.StatusResult, error) { + targetConfig := config.FromTargetConfig(request.TargetConfig).WithOidcDeps(p.oidc) if request.ResourceType != "" { if registry.HasProvisioner(request.ResourceType, resource.OperationCheckStatus) { - provisioner := registry.Get(request.ResourceType, resource.OperationCheckStatus, config.FromTargetConfig(request.TargetConfig)) + provisioner := registry.Get(request.ResourceType, resource.OperationCheckStatus, targetConfig) return provisioner.Status(ctx, request) } } - client, err := ccx.NewClient(config.FromTargetConfig(request.TargetConfig)) + client, err := ccx.NewClient(targetConfig) if err != nil { return nil, err } @@ -202,12 +223,13 @@ func (p *Plugin) Status(ctx context.Context, request *resource.StatusRequest) (* } func (p *Plugin) Delete(ctx context.Context, request *resource.DeleteRequest) (*resource.DeleteResult, error) { + targetConfig := config.FromTargetConfig(request.TargetConfig).WithOidcDeps(p.oidc) if registry.HasProvisioner(request.ResourceType, resource.OperationDelete) { - provisioner := registry.Get(request.ResourceType, resource.OperationDelete, config.FromTargetConfig(request.TargetConfig)) + provisioner := registry.Get(request.ResourceType, resource.OperationDelete, targetConfig) return provisioner.Delete(ctx, request) } - client, err := ccx.NewClient(config.FromTargetConfig(request.TargetConfig)) + client, err := ccx.NewClient(targetConfig) if err != nil { return nil, err } @@ -216,12 +238,13 @@ func (p *Plugin) Delete(ctx context.Context, request *resource.DeleteRequest) (* } func (p *Plugin) Read(ctx context.Context, request *resource.ReadRequest) (*resource.ReadResult, error) { + targetConfig := config.FromTargetConfig(request.TargetConfig).WithOidcDeps(p.oidc) if registry.HasProvisioner(request.ResourceType, resource.OperationRead) { - provisioner := registry.Get(request.ResourceType, resource.OperationRead, config.FromTargetConfig(request.TargetConfig)) + provisioner := registry.Get(request.ResourceType, resource.OperationRead, targetConfig) return provisioner.Read(ctx, request) } - client, err := ccx.NewClient(config.FromTargetConfig(request.TargetConfig)) + client, err := ccx.NewClient(targetConfig) if err != nil { return nil, err } @@ -230,12 +253,13 @@ func (p *Plugin) Read(ctx context.Context, request *resource.ReadRequest) (*reso } func (p *Plugin) List(ctx context.Context, request *resource.ListRequest) (*resource.ListResult, error) { + targetConfig := config.FromTargetConfig(request.TargetConfig).WithOidcDeps(p.oidc) if registry.HasProvisioner(request.ResourceType, resource.OperationList) { - provisioner := registry.Get(request.ResourceType, resource.OperationList, config.FromTargetConfig(request.TargetConfig)) + provisioner := registry.Get(request.ResourceType, resource.OperationList, targetConfig) return provisioner.List(ctx, request) } - client, err := ccx.NewClient(config.FromTargetConfig(request.TargetConfig)) + client, err := ccx.NewClient(targetConfig) if err != nil { return nil, err } diff --git a/aws_test.go b/aws_test.go index 2edfb673..526ca05d 100644 --- a/aws_test.go +++ b/aws_test.go @@ -7,11 +7,82 @@ package main import ( + "context" + "encoding/json" + "errors" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/platform-engineering-labs/formae/pkg/plugin" + "github.com/platform-engineering-labs/formae/pkg/plugin/resource" ) +// Compile-time check: Plugin must satisfy OidcAware, so the SDK can hand it +// an OidcTokenSource at startup. +var _ plugin.OidcAware = (*Plugin)(nil) + +// oidcTokenSourceFunc adapts a function to plugin.OidcTokenSource so a test +// can script what the source answers, and observe the ctx it was called +// with. +type oidcTokenSourceFunc func(ctx context.Context, audience string) (string, error) + +func (f oidcTokenSourceFunc) IdentityToken(ctx context.Context, audience string) (string, error) { + return f(ctx, audience) +} + +func TestSetOidcTokenSource_PopulatesDeps(t *testing.T) { + p := &Plugin{} + assert.Nil(t, p.oidc) + + src := oidcTokenSourceFunc(func(context.Context, string) (string, error) { + return "stub-token", nil + }) + p.SetOidcTokenSource(src) + + require.NotNil(t, p.oidc) + assert.NotNil(t, p.oidc.Source) +} + +// operationCtxMarkerKey is the context.WithValue marker used to prove a ctx +// travelled, unmodified in its values, from the operation call down into the +// OidcTokenSource. +type operationCtxMarkerKey struct{} + +// TestRead_OidcRoutesTheOperationCtxToTheTokenSource drives Plugin.Read (with +// a resource type carrying no registered provisioner, so it falls through to +// the CloudControl client) against a target config with an Oidc auth block. +// The stub token source returns an error, which fails the AWS SDK's +// credential resolution before it signs or sends any request, so the +// assertion never depends on network access or a real STS exchange; it only +// exercises Config->ToAwsConfig->CredentialsCache->Retrieve->IdentityToken +// far enough to observe the ctx that reached the token source. +func TestRead_OidcRoutesTheOperationCtxToTheTokenSource(t *testing.T) { + var gotCtx context.Context + src := oidcTokenSourceFunc(func(ctx context.Context, audience string) (string, error) { + gotCtx = ctx + assert.Equal(t, "sts.amazonaws.com", audience) + return "", errors.New("stub source: deliberately unminted, for ctx-routing assertions only") + }) + + p := &Plugin{} + p.SetOidcTokenSource(src) + + ctx := context.WithValue(context.Background(), operationCtxMarkerKey{}, "operation-ctx") + request := &resource.ReadRequest{ + ResourceType: "AWS::Formae::NoSuchProvisioneredType", + NativeID: "irrelevant-for-this-test", + TargetConfig: json.RawMessage(`{"Region":"us-east-1","Auth":{"Type":"Oidc","RoleArn":"arn:aws:iam::123456789012:role/formae-agent"}}`), + } + + _, err := p.Read(ctx, request) + + require.Error(t, err) + require.NotNil(t, gotCtx) + assert.Equal(t, "operation-ctx", gotCtx.Value(operationCtxMarkerKey{})) +} + func TestMatchesFilter(t *testing.T) { t.Run("matches when all filter properties are present and equal", func(t *testing.T) { properties := `{"VpcId":"vpc-123","SubnetId":"subnet-456","CidrBlock":"10.0.0.0/24"}` diff --git a/formae-plugin.pkl b/formae-plugin.pkl index d61d2241..82e7d714 100644 --- a/formae-plugin.pkl +++ b/formae-plugin.pkl @@ -11,6 +11,10 @@ summary = "AWS resource plugin (CloudControl-based)" description = "AWS CloudControl resource plugin for Formae" category = "cloud" license = "FSL-1.1-ALv2" +// The dev line at planning time is 0.89.0-dev.2 (last stable 0.88.1), so +// 0.89.0 is the release expected to carry the oidc-credential broker this +// plugin's OidcAuth depends on. Confirm the real release number before +// publishing and correct this if that work lands in a later release instead. minFormaeVersion = "0.89.0" output { diff --git a/go.mod b/go.mod index 0ce1d626..5bd92949 100644 --- a/go.mod +++ b/go.mod @@ -31,7 +31,8 @@ require ( github.com/evanphx/json-patch/v5 v5.9.11 github.com/google/uuid v1.6.0 github.com/platform-engineering-labs/formae/pkg/model v0.1.26 - github.com/platform-engineering-labs/formae/pkg/plugin v0.4.1 + // Re-pin to the next official pkg/plugin tag when one is cut. + github.com/platform-engineering-labs/formae/pkg/plugin v0.4.2-0.20260821224650-dc5149d5a102 github.com/platform-engineering-labs/formae/pkg/plugin-conformance-tests v0.2.7-0.20260811042554-70c525251630 github.com/stretchr/testify v1.11.1 ) @@ -42,7 +43,7 @@ require ( github.com/apple/pkl-go v0.13.2 // indirect github.com/asdine/storm v2.1.2+incompatible // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.19.15 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.15 github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22 // indirect github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.1.10 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.36 // indirect @@ -73,6 +74,8 @@ require ( github.com/miekg/dns v1.1.72 // indirect github.com/naegelejd/go-acl v0.0.0-20260323030528-42e4d61407df // indirect github.com/platform-engineering-labs/formae/pkg/api/model v0.1.1 // indirect + // Re-pin to the next official pkg/credential tag when one is cut. + github.com/platform-engineering-labs/formae/pkg/credential v0.0.0-20260821213704-ba68bacf6dd6 // indirect github.com/platform-engineering-labs/orbital v0.1.36 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect @@ -117,6 +120,9 @@ require ( resty.dev/v3 v3.0.0-beta.6.0.20260127085140-f531c9de7027 // indirect ) -replace ergo.services/ergo => github.com/JeroenSoeters/ergo v1.999.320-pel.2 +// Matches the ergo fork revision required by the pinned pkg/plugin and +// pkg/credential (both need -pel.6). A replace directive is not transitive, +// so it has to be repeated here. +replace ergo.services/ergo => github.com/JeroenSoeters/ergo v1.999.320-pel.6 replace ergo.services/actor/statemachine => github.com/JeroenSoeters/actor/statemachine v0.0.0-20260205190926-8b1b2eaf30f4 diff --git a/go.sum b/go.sum index a1494074..6b72a8ba 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ github.com/DataDog/zstd v1.5.7 h1:ybO8RBeh29qrxIhCA9E8gKY6xfONU9T6G6aP9DTKfLE= github.com/DataDog/zstd v1.5.7/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw= github.com/JeroenSoeters/actor/statemachine v0.0.0-20260205190926-8b1b2eaf30f4 h1:JdoPbqQSv9iDCfkBFH5wDZCNpYHgJJVsrD7Xg0Jp57o= github.com/JeroenSoeters/actor/statemachine v0.0.0-20260205190926-8b1b2eaf30f4/go.mod h1:XbiEudzYCbg5YvbIMqylWXH2iWnUeKagDTfPR7uOaqg= -github.com/JeroenSoeters/ergo v1.999.320-pel.2 h1:UL0ROEToN9mmkXobs4qBHKiWodHJNkbZdx9bXRd5b8c= -github.com/JeroenSoeters/ergo v1.999.320-pel.2/go.mod h1:bLQ6PoO6Mz/8gVuzvPv3xfMfo1P9w6rZV1WnMXMeMdg= +github.com/JeroenSoeters/ergo v1.999.320-pel.6 h1:aq/v/tYTn+/QJZuMV6GDDnln2EUI+Wy3cVFhs7TB1lU= +github.com/JeroenSoeters/ergo v1.999.320-pel.6/go.mod h1:bLQ6PoO6Mz/8gVuzvPv3xfMfo1P9w6rZV1WnMXMeMdg= github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= github.com/Sereal/Sereal/Go/sereal v0.0.0-20250307140414-035be09f1bc8 h1:6+qQvLkethJZQLb6r+Jxh0qxa7TfeFPBHlCzF7Ml8CM= @@ -144,12 +144,12 @@ github.com/naegelejd/go-acl v0.0.0-20260323030528-42e4d61407df h1:1xWk/De6cs3h2r github.com/naegelejd/go-acl v0.0.0-20260323030528-42e4d61407df/go.mod h1:sTJHuiEPB0WNdPYPOP9M6FQU4YTHemXgDLIQqkbBdDI= github.com/platform-engineering-labs/formae/pkg/api/model v0.1.1 h1:ZMTgKwSomy2cVcl/+NivSqopbWeHbmYeQ+BxoYq8bVY= github.com/platform-engineering-labs/formae/pkg/api/model v0.1.1/go.mod h1:0ncHFCsGA6b0w1kBm6m+QwJ823qAY2vL47GvoR0BTyU= +github.com/platform-engineering-labs/formae/pkg/credential v0.0.0-20260821213704-ba68bacf6dd6 h1:3F9pGvQDm4HHvPfVhohzjeCJZHvrVy3KCvPb1px7tLU= +github.com/platform-engineering-labs/formae/pkg/credential v0.0.0-20260821213704-ba68bacf6dd6/go.mod h1:VS9hO24cYUcGYao9nfPb41HBfE2hF4WMrIS2fZwRwzM= github.com/platform-engineering-labs/formae/pkg/model v0.1.26 h1:80p843bmz9sLTtUFveMGWYeGcpRjvceyVxUgPvlApqs= github.com/platform-engineering-labs/formae/pkg/model v0.1.26/go.mod h1:1dmsFwoaJZkHevBsAIZr068CWzG7de1VNz7PFWvM3Z8= -github.com/platform-engineering-labs/formae/pkg/plugin v0.4.1 h1:dg8TBQVJR8DVF28bvmzAG4Ms1Id0yIr6Kd8c3UTO3iw= -github.com/platform-engineering-labs/formae/pkg/plugin v0.4.1/go.mod h1:ZFXMfeZljHVDWQTSeJ4dj2EG3iTjdrxQjm7DpvOYUXk= -github.com/platform-engineering-labs/formae/pkg/plugin-conformance-tests v0.2.7-0.20260810030146-5a3389594c8e h1:EvXbhuvwOq3CIyx+pin9uyenol6IvBJFhkO/rdIAKm8= -github.com/platform-engineering-labs/formae/pkg/plugin-conformance-tests v0.2.7-0.20260810030146-5a3389594c8e/go.mod h1:gq591ZeRZ4jAHpdSvCyrOvi8qyALxcXQQhd0B6GRMAo= +github.com/platform-engineering-labs/formae/pkg/plugin v0.4.2-0.20260821224650-dc5149d5a102 h1:/heBYUEoIef2G50AmXZtXbDaDENoblspX2FFdy59zeo= +github.com/platform-engineering-labs/formae/pkg/plugin v0.4.2-0.20260821224650-dc5149d5a102/go.mod h1:VXdXrX5hBF3xXK0GIUowIhTBUaog0BmjU8TOXF2yUfA= github.com/platform-engineering-labs/formae/pkg/plugin-conformance-tests v0.2.7-0.20260811042554-70c525251630 h1:ZyGSai5xPIi+vxAS04JlVr04Fecw3K9Zj2bsR4OBkdE= github.com/platform-engineering-labs/formae/pkg/plugin-conformance-tests v0.2.7-0.20260811042554-70c525251630/go.mod h1:gq591ZeRZ4jAHpdSvCyrOvi8qyALxcXQQhd0B6GRMAo= github.com/platform-engineering-labs/orbital v0.1.36 h1:nPMLxDbwDrjlhJoMQXAE86HtVhQHC2A6BJlWYmNM75c= diff --git a/pkg/config/config.go b/pkg/config/config.go index c3b083a5..163dadb0 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -7,22 +7,220 @@ package config import ( "context" "encoding/json" + "errors" + "fmt" + "log/slog" + "strings" + "sync" "github.com/aws/aws-sdk-go-v2/aws" awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials/stscreds" + "github.com/aws/aws-sdk-go-v2/service/sts" + + "github.com/platform-engineering-labs/formae/pkg/plugin" +) + +// Auth discriminator values, matching the Type field the Pkl schema renders +// on the nested Auth object. +const ( + AuthTypeDefaultChain = "DefaultChain" + AuthTypeOidc = "Oidc" ) +// deprecatedFlatProfileWarning is logged, once, whenever a target's flat +// Profile field is what produces a DefaultChain auth (as opposed to an +// explicit `auth = DefaultChainAuth { ... }` block). It is the only +// deprecation surface available for this: the plugin does not implement +// ObservablePlugin, so plugin.LoggerFromContext returns an indistinguishable +// no-op, and Pkl's @Deprecated annotation does not print at eval. +const deprecatedFlatProfileWarning = "target config uses the deprecated flat profile; set auth = new DefaultChainAuth" + +// warnFlatFallback fires the deprecation warning at most once for Config +// values that carry no OidcDeps (e.g. a plugin instance the agent hasn't +// wired a token source into), so flat-profile users still see it once per +// process rather than not at all. +var warnFlatFallback sync.Once + +// OidcDeps is owned by the Plugin instance, never process-global: a global +// would outlive individual plugin instances and make tests order-dependent. +// A Config with nil deps behaves flat-only: Oidc auth fails closed, and the +// deprecation warning falls back to warnFlatFallback. +// +// Build one with NewOidcDeps, never a bare &OidcDeps{...} literal outside +// this package: NewOidcDeps wires the production STS factory. A literal +// leaves stsFactory nil and oidcCredentials falls back to the same factory, +// so it still works, but the seam is then invisible at the construction site. +type OidcDeps struct { + // Source mints the OIDC identity tokens exchanged for AWS credentials. + Source plugin.OidcTokenSource + + // caches holds one *aws.CredentialsCache per distinct Oidc auth block, + // built lazily by credentialsCacheFor. + caches sync.Map + + // stsFactory builds the STS client used for AssumeRoleWithWebIdentity. + // A seam for tests; production wiring is sts.NewFromConfig. + stsFactory func(aws.Config) stscreds.AssumeRoleWithWebIdentityAPIClient + + // warnFlat ensures the flat-profile deprecation warning logs at most + // once per plugin instance. + warnFlat sync.Once +} + +// defaultSTSFactory is the production STS client factory: the client needs no +// credentials of its own, because AssumeRoleWithWebIdentity is unsigned. +func defaultSTSFactory(cfg aws.Config) stscreds.AssumeRoleWithWebIdentityAPIClient { + return sts.NewFromConfig(cfg) +} + +// NewOidcDeps builds the OidcDeps a Plugin instance owns, wired to mint AWS +// credentials against real STS. +func NewOidcDeps(src plugin.OidcTokenSource) *OidcDeps { + return &OidcDeps{ + Source: src, + stsFactory: defaultSTSFactory, + } +} + type Config struct { - Region string `json:"Region"` - Profile string `json:"Profile"` + Region string `json:"Region"` + Profile string `json:"Profile"` + Auth json.RawMessage `json:"Auth,omitempty"` + + // deps carries what the plugin instance owns: the token source, the + // per-plugin credentials-cache registry, the STS client factory seam, + // and the warn-once state. Never serialized; nil deps means flat-only + // behavior. + deps *OidcDeps } -func (c *Config) ToAwsConfig(ctx context.Context) (aws.Config, error) { - var opts []func(*awsconfig.LoadOptions) error +// WithOidcDeps threads the plugin instance's OidcDeps onto Config without +// changing FromTargetConfig's signature or call sites. +func (c *Config) WithOidcDeps(d *OidcDeps) *Config { + c.deps = d + return c +} + +// authDiscriminator is the shape every Auth block variant shares. +type authDiscriminator struct { + Type string `json:"Type"` +} + +// isAuthAbsent reports whether raw carries no explicit Auth block: this is +// true for a nil/empty field, the JSON literal null, and whitespace-only +// content. +func isAuthAbsent(raw json.RawMessage) bool { + trimmed := strings.TrimSpace(string(raw)) + return trimmed == "" || trimmed == "null" +} + +// synthesizeDefaultChain builds the Auth block an absent Auth field implies: +// a DefaultChain carrying the flat Profile spelling. +func synthesizeDefaultChain(profile string) json.RawMessage { + raw, _ := json.Marshal(struct { + Type string `json:"Type"` + Profile string `json:"Profile"` + }{Type: AuthTypeDefaultChain, Profile: profile}) + return raw +} + +// effectiveAuth resolves the auth block that governs this Config: the +// explicit Auth block if the forma set one, or a synthesised DefaultChain +// carrying the flat Profile spelling otherwise. Setting both is rejected, +// mirroring the Pkl-level `this == null || profile == null` constraint so +// callers that bypass the schema (tests, an older formae binary) still get +// the rule enforced in Go. +func (c *Config) effectiveAuth() (string, json.RawMessage, error) { + if isAuthAbsent(c.Auth) { + return AuthTypeDefaultChain, synthesizeDefaultChain(c.Profile), nil + } - opts = append(opts, awsconfig.WithRegion(c.Region)) if c.Profile != "" { - opts = append(opts, awsconfig.WithSharedConfigProfile(c.Profile)) + return "", nil, errors.New("config: Auth and Profile are mutually exclusive; set one") + } + + var disc authDiscriminator + if err := json.Unmarshal(c.Auth, &disc); err != nil { + return "", nil, fmt.Errorf("config: malformed Auth block: %w", err) + } + if disc.Type == "" { + return "", nil, errors.New("config: Auth block is missing its Type discriminator") + } + + return disc.Type, c.Auth, nil +} + +// awsConfigOptions resolves this Config's effective auth into the +// awsconfig.LoadOptions functions ToAwsConfig hands to LoadDefaultConfig. +// Split out from ToAwsConfig so tests can inspect the resolved options (e.g. +// the profile threaded through) without exercising real credential/config- +// file resolution. +func (c *Config) awsConfigOptions() ([]func(*awsconfig.LoadOptions) error, error) { + synthesized := isAuthAbsent(c.Auth) + + authType, rawAuth, err := c.effectiveAuth() + if err != nil { + return nil, err + } + + opts := []func(*awsconfig.LoadOptions) error{awsconfig.WithRegion(c.Region)} + + switch authType { + case AuthTypeDefaultChain: + var chain struct { + Profile string `json:"Profile"` + } + if err := json.Unmarshal(rawAuth, &chain); err != nil { + return nil, fmt.Errorf("config: malformed DefaultChain auth block: %w", err) + } + if chain.Profile != "" { + opts = append(opts, awsconfig.WithSharedConfigProfile(chain.Profile)) + } + if synthesized && chain.Profile != "" { + c.warnDeprecatedFlatProfile() + } + + case AuthTypeOidc: + var oidc struct { + RoleArn string `json:"RoleArn"` + } + if err := json.Unmarshal(rawAuth, &oidc); err != nil { + return nil, fmt.Errorf("config: malformed Oidc auth block: %w", err) + } + if oidc.RoleArn == "" { + return nil, errors.New("config: Oidc auth requires RoleArn") + } + if c.deps == nil || c.deps.Source == nil { + return nil, errors.New("config: Oidc auth requires an OIDC token source, but this plugin instance has none wired (failing closed rather than falling back to ambient credentials)") + } + + opts = append(opts, awsconfig.WithCredentialsProvider( + c.deps.oidcCredentials(c.Region, oidc.RoleArn, rawAuth), + )) + + default: + return nil, fmt.Errorf("config: unknown Auth type %q", authType) + } + + return opts, nil +} + +// warnDeprecatedFlatProfile logs deprecatedFlatProfileWarning once per +// plugin instance (via OidcDeps.warnFlat), or once per process when this +// Config carries no OidcDeps at all. +func (c *Config) warnDeprecatedFlatProfile() { + if c.deps != nil { + c.deps.warnFlat.Do(func() { slog.Warn(deprecatedFlatProfileWarning) }) + return + } + warnFlatFallback.Do(func() { slog.Warn(deprecatedFlatProfileWarning) }) +} + +func (c *Config) ToAwsConfig(ctx context.Context) (aws.Config, error) { + opts, err := c.awsConfigOptions() + if err != nil { + return aws.Config{}, err } return awsconfig.LoadDefaultConfig(ctx, opts...) @@ -38,4 +236,3 @@ func FromTargetConfig(targetConfig json.RawMessage) *Config { return config } - diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go new file mode 100644 index 00000000..cce4566a --- /dev/null +++ b/pkg/config/config_test.go @@ -0,0 +1,311 @@ +// © 2025 Platform Engineering Labs Inc. +// +// SPDX-License-Identifier: FSL-1.1-ALv2 + +//go:build unit + +package config + +import ( + "context" + "encoding/json" + "log/slog" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// countingHandler is a slog.Handler that counts the records routed to it, so +// a test can assert a warning fired a specific number of times without +// inspecting message text. +type countingHandler struct{ count *int } + +func (h countingHandler) Enabled(context.Context, slog.Level) bool { return true } +func (h countingHandler) Handle(context.Context, slog.Record) error { *h.count++; return nil } +func (h countingHandler) WithAttrs([]slog.Attr) slog.Handler { return h } +func (h countingHandler) WithGroup(string) slog.Handler { return h } + +// stubTokenSource is a plugin.OidcTokenSource that always mints the same +// token, so a Config can carry non-nil OidcDeps with a non-nil Source and +// exercise the wired path rather than the "nothing wired at all" fail-closed +// path. +type stubTokenSource struct{} + +func (stubTokenSource) IdentityToken(context.Context, string) (string, error) { + return "stub-token", nil +} + +func TestEffectiveAuth(t *testing.T) { + tests := []struct { + name string + config Config + wantType string + wantAuth string // JSON to compare against, empty means "don't check" + wantErr string + }{ + { + name: "flat profile synthesises DefaultChain", + config: Config{Profile: "legacy-profile"}, + wantType: AuthTypeDefaultChain, + wantAuth: `{"Type":"DefaultChain","Profile":"legacy-profile"}`, + }, + { + name: "no profile and no auth synthesises DefaultChain with an empty profile", + config: Config{}, + wantType: AuthTypeDefaultChain, + wantAuth: `{"Type":"DefaultChain","Profile":""}`, + }, + { + name: "explicit DefaultChain passes through unchanged", + config: Config{Auth: json.RawMessage(`{"Type":"DefaultChain","Profile":"chain-profile"}`)}, + wantType: AuthTypeDefaultChain, + wantAuth: `{"Type":"DefaultChain","Profile":"chain-profile"}`, + }, + { + name: "explicit Oidc passes through unchanged", + config: Config{Auth: json.RawMessage(`{"Type":"Oidc","RoleArn":"arn:aws:iam::123456789012:role/formae-agent"}`)}, + wantType: AuthTypeOidc, + wantAuth: `{"Type":"Oidc","RoleArn":"arn:aws:iam::123456789012:role/formae-agent"}`, + }, + { + name: "explicit Auth and flat Profile both set is rejected", + config: Config{ + Profile: "legacy-profile", + Auth: json.RawMessage(`{"Type":"DefaultChain","Profile":"chain-profile"}`), + }, + wantErr: "mutually exclusive", + }, + { + name: "null Auth literal is treated as absent", + config: Config{Profile: "legacy-profile", Auth: json.RawMessage(`null`)}, + wantType: AuthTypeDefaultChain, + wantAuth: `{"Type":"DefaultChain","Profile":"legacy-profile"}`, + }, + { + name: "whitespace-only Auth is treated as absent", + config: Config{Profile: "legacy-profile", Auth: json.RawMessage(" \n\t")}, + wantType: AuthTypeDefaultChain, + wantAuth: `{"Type":"DefaultChain","Profile":"legacy-profile"}`, + }, + { + name: "malformed Auth JSON errors", + config: Config{Auth: json.RawMessage(`{not valid json`)}, + wantErr: "malformed Auth block", + }, + { + name: "Auth object missing its Type discriminator errors", + config: Config{Auth: json.RawMessage(`{"RoleArn":"arn:aws:iam::123456789012:role/formae-agent"}`)}, + wantErr: "missing its Type discriminator", + }, + { + name: "empty Auth object errors", + config: Config{Auth: json.RawMessage(`{}`)}, + wantErr: "missing its Type discriminator", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotType, gotAuth, err := tt.config.effectiveAuth() + + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.wantType, gotType) + assert.JSONEq(t, tt.wantAuth, string(gotAuth)) + }) + } +} + +func TestToAwsConfig_UnknownAuthType(t *testing.T) { + c := &Config{Region: "us-east-1", Auth: json.RawMessage(`{"Type":"Bogus"}`)} + + _, err := c.ToAwsConfig(context.Background()) + + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown Auth type") +} + +func TestToAwsConfig_OidcMissingRoleArn(t *testing.T) { + for _, auth := range []string{ + `{"Type":"Oidc"}`, + `{"Type":"Oidc","RoleArn":""}`, + } { + c := &Config{Region: "us-east-1", Auth: json.RawMessage(auth)} + + _, err := c.ToAwsConfig(context.Background()) + + require.Error(t, err) + assert.Contains(t, err.Error(), "requires RoleArn") + } +} + +func TestToAwsConfig_OidcWithNoDepsFailsClosed(t *testing.T) { + c := &Config{ + Region: "us-east-1", + Auth: json.RawMessage(`{"Type":"Oidc","RoleArn":"arn:aws:iam::123456789012:role/formae-agent"}`), + } + + _, err := c.ToAwsConfig(context.Background()) + + require.Error(t, err) + assert.Contains(t, err.Error(), "OIDC token source") +} + +func TestToAwsConfig_OidcWithDepsButNilSourceFailsClosed(t *testing.T) { + c := (&Config{ + Region: "us-east-1", + Auth: json.RawMessage(`{"Type":"Oidc","RoleArn":"arn:aws:iam::123456789012:role/formae-agent"}`), + }).WithOidcDeps(&OidcDeps{}) + + _, err := c.ToAwsConfig(context.Background()) + + require.Error(t, err) + assert.Contains(t, err.Error(), "OIDC token source") +} + +func TestAwsConfigOptions_OidcAppliesACredentialsCache(t *testing.T) { + c := (&Config{ + Region: "us-east-1", + Auth: json.RawMessage(`{"Type":"Oidc","RoleArn":"arn:aws:iam::123456789012:role/formae-agent"}`), + }).WithOidcDeps(NewOidcDeps(stubTokenSource{})) + + optFns, err := c.awsConfigOptions() + require.NoError(t, err) + + var lo awsconfig.LoadOptions + for _, fn := range optFns { + require.NoError(t, fn(&lo)) + } + + assert.Equal(t, "us-east-1", lo.Region) + assert.IsType(t, &aws.CredentialsCache{}, lo.Credentials) +} + +func TestAwsConfigOptions_FlatProfileAppliesSharedConfigProfile(t *testing.T) { + // Carries its own OidcDeps purely so the deprecation warning this path + // triggers lands on a per-test sync.Once rather than the process-wide + // warnFlatFallback, which TestDeprecationWarning_FallsBackToProcessOnceWhenDepsAreNil + // owns exclusively for the life of the test binary. + c := (&Config{Region: "us-east-1", Profile: "legacy-profile"}).WithOidcDeps(NewOidcDeps(nil)) + + optFns, err := c.awsConfigOptions() + require.NoError(t, err) + + var lo awsconfig.LoadOptions + for _, fn := range optFns { + require.NoError(t, fn(&lo)) + } + + assert.Equal(t, "us-east-1", lo.Region) + assert.Equal(t, "legacy-profile", lo.SharedConfigProfile) +} + +func TestAwsConfigOptions_NoProfileLeavesSharedConfigProfileUnset(t *testing.T) { + c := &Config{Region: "us-east-1"} + + optFns, err := c.awsConfigOptions() + require.NoError(t, err) + + var lo awsconfig.LoadOptions + for _, fn := range optFns { + require.NoError(t, fn(&lo)) + } + + assert.Equal(t, "us-east-1", lo.Region) + assert.Empty(t, lo.SharedConfigProfile) +} + +func TestAwsConfigOptions_ExplicitDefaultChainAppliesItsProfile(t *testing.T) { + c := &Config{ + Region: "us-east-1", + Auth: json.RawMessage(`{"Type":"DefaultChain","Profile":"chain-profile"}`), + } + + optFns, err := c.awsConfigOptions() + require.NoError(t, err) + + var lo awsconfig.LoadOptions + for _, fn := range optFns { + require.NoError(t, fn(&lo)) + } + + assert.Equal(t, "chain-profile", lo.SharedConfigProfile) +} + +// withCountingSlogDefault swaps the package-level slog default for the +// duration of the test, restoring it on cleanup, and returns a pointer to +// the record count the swapped-in handler increments. +func withCountingSlogDefault(t *testing.T) *int { + t.Helper() + + count := 0 + prev := slog.Default() + slog.SetDefault(slog.New(countingHandler{count: &count})) + t.Cleanup(func() { slog.SetDefault(prev) }) + + return &count +} + +func TestDeprecationWarning_FiresOncePerOidcDeps(t *testing.T) { + count := withCountingSlogDefault(t) + + deps := NewOidcDeps(nil) + c1 := (&Config{Region: "us-east-1", Profile: "legacy-profile"}).WithOidcDeps(deps) + c2 := (&Config{Region: "us-east-1", Profile: "legacy-profile"}).WithOidcDeps(deps) + + _, err := c1.awsConfigOptions() + require.NoError(t, err) + _, err = c2.awsConfigOptions() + require.NoError(t, err) + + assert.Equal(t, 1, *count) +} + +func TestDeprecationWarning_DoesNotFireForExplicitDefaultChainProfile(t *testing.T) { + count := withCountingSlogDefault(t) + + deps := NewOidcDeps(nil) + c := (&Config{ + Region: "us-east-1", + Auth: json.RawMessage(`{"Type":"DefaultChain","Profile":"chain-profile"}`), + }).WithOidcDeps(deps) + + _, err := c.awsConfigOptions() + require.NoError(t, err) + + assert.Equal(t, 0, *count) +} + +func TestDeprecationWarning_DoesNotFireWhenNoProfileIsSet(t *testing.T) { + count := withCountingSlogDefault(t) + + deps := NewOidcDeps(nil) + c := (&Config{Region: "us-east-1"}).WithOidcDeps(deps) + + _, err := c.awsConfigOptions() + require.NoError(t, err) + + assert.Equal(t, 0, *count) +} + +func TestDeprecationWarning_FallsBackToProcessOnceWhenDepsAreNil(t *testing.T) { + count := withCountingSlogDefault(t) + + c1 := &Config{Region: "us-east-1", Profile: "legacy-profile"} + c2 := &Config{Region: "us-east-1", Profile: "legacy-profile"} + + _, err := c1.awsConfigOptions() + require.NoError(t, err) + _, err = c2.awsConfigOptions() + require.NoError(t, err) + + assert.Equal(t, 1, *count) +} diff --git a/pkg/config/oidc_credentials.go b/pkg/config/oidc_credentials.go new file mode 100644 index 00000000..88b12560 --- /dev/null +++ b/pkg/config/oidc_credentials.go @@ -0,0 +1,165 @@ +// © 2025 Platform Engineering Labs Inc. +// +// SPDX-License-Identifier: FSL-1.1-ALv2 + +package config + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "strings" + "sync" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/credentials/stscreds" + + "github.com/platform-engineering-labs/formae/pkg/plugin" +) + +// oidcAudience is the audience every identity token destined for AWS is +// minted for; the role's trust policy matches on it. +const oidcAudience = "sts.amazonaws.com" + +// oidcRefreshTimeout bounds one credential refresh end to end: minting an +// identity token plus exchanging it at STS. The stock aws.CredentialsCache +// hands the refresh a context whose cancellation is already suppressed, so +// without a bound of our own a wedged broker would hold the refresh open +// indefinitely. +const oidcRefreshTimeout = 30 * time.Second + +// oidcRoleSessionName names every web-identity session this plugin opens. +// Without it the SDK generates a random name, so CloudTrail shows an +// unattributable assumed-role principal; a fixed name makes the caller +// legible in the customer's audit trail. +const oidcRoleSessionName = "formae-aws-plugin" + +// oneShotRetriever hands the stock, context-free IdentityTokenRetriever a +// token that was already minted for exactly one Retrieve call. Tokens are +// short-lived and single-use by design, so a retriever is never reused. +type oneShotRetriever struct{ token []byte } + +func (r oneShotRetriever) GetIdentityToken() ([]byte, error) { return r.token, nil } + +// oidcCredentialsProvider mints a fresh identity token per refresh and +// exchanges it for AWS credentials by assuming roleArn. It holds no +// credential state: aws.CredentialsCache owns caching and refresh timing. +type oidcCredentialsProvider struct { + source plugin.OidcTokenSource + roleArn string + stsClient stscreds.AssumeRoleWithWebIdentityAPIClient +} + +// Retrieve reads nothing mutable from ctx. aws.CredentialsCache already +// suppresses caller cancellation on the refresh path (values survive, +// cancellation does not), so honouring the incoming deadline here would be an +// illusion; the refresh instead runs under its own bounded context derived +// with context.WithoutCancel, which keeps the request-scoped values the token +// source needs to reach the right broker. +func (p *oidcCredentialsProvider) Retrieve(ctx context.Context) (aws.Credentials, error) { + refreshCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), oidcRefreshTimeout) + defer cancel() + + token, err := p.source.IdentityToken(refreshCtx, oidcAudience) + if err != nil { + return aws.Credentials{}, fmt.Errorf("config: minting an identity token for role %q: %w", p.roleArn, err) + } + + exchange := stscreds.NewWebIdentityRoleProvider( + p.stsClient, p.roleArn, oneShotRetriever{token: []byte(token)}, + func(o *stscreds.WebIdentityRoleOptions) { o.RoleSessionName = oidcRoleSessionName }, + ) + + creds, err := exchange.Retrieve(refreshCtx) + if err != nil { + return aws.Credentials{}, fmt.Errorf( + "config: exchanging the identity token for credentials on role %q: %w", + p.roleArn, redactToken(err, token), + ) + } + + return creds, nil +} + +// redactToken strips any verbatim occurrence of the identity token from an +// error's message. STS echoes the submitted token in some rejection bodies, +// and the token is bearer credential material that must never reach a log or +// an error shown to a user. Redacting flattens the error chain, so it only +// happens when a leak is actually present. +func redactToken(err error, token string) error { + if token == "" { + return err + } + + msg := err.Error() + if !strings.Contains(msg, token) { + return err + } + + return errors.New(strings.ReplaceAll(msg, token, "[redacted identity token]")) +} + +// oidcCacheKey identifies one credentials cache. Two targets share a cache +// only when they assume the same role in the same region under a +// byte-identical auth block, so any auth change (a new session name, a new +// policy) yields a distinct cache rather than silently reusing credentials +// minted under the old settings. The NUL separators keep a boundary shift +// between the role and the region from colliding. +// +// The key deliberately omits the token source's identity: the SDK installs +// exactly one OidcTokenSource per plugin process (via OidcAware at startup), +// so within one OidcDeps every cache entry is already scoped to that single +// broker pairing and there is nothing for the key to distinguish. +func oidcCacheKey(roleArn, region string, rawAuth json.RawMessage) string { + sum := sha256.Sum256(rawAuth) + + return roleArn + "\x00" + region + "\x00" + hex.EncodeToString(sum[:]) +} + +// credentialsCacheHolder carries a lazily built cache plus the once that +// guarantees a concurrent miss constructs it exactly one time. +type credentialsCacheHolder struct { + once sync.Once + cache *aws.CredentialsCache +} + +// credentialsCacheFor returns the cache registered under key, building it on +// first use. There is deliberately no eviction: the map grows with the number +// of distinct auth configurations a plugin instance sees, which is bounded by +// the targets in play, and evicting an entry could not invalidate the +// references already handed out, so it would reintroduce exactly the +// duplicate token exchanges the cache exists to prevent. +func (d *OidcDeps) credentialsCacheFor(key string, build func() *aws.CredentialsCache) *aws.CredentialsCache { + entry, _ := d.caches.LoadOrStore(key, &credentialsCacheHolder{}) + holder := entry.(*credentialsCacheHolder) + holder.once.Do(func() { holder.cache = build() }) + + return holder.cache +} + +// oidcCredentials returns the plugin-lifetime credentials cache that mints +// AWS credentials for roleArn by exchanging brokered identity tokens. The STS +// client is built from a region-only base config: it needs no credentials of +// its own, because AssumeRoleWithWebIdentity is an unsigned call. +func (d *OidcDeps) oidcCredentials(region, roleArn string, rawAuth json.RawMessage) *aws.CredentialsCache { + // Read the factory into a local rather than filling the field in: an + // OidcDeps built as a bare literal leaves it nil, and defaulting here + // keeps that recoverable without a write that would race a concurrent + // operation reading the same struct. + factory := d.stsFactory + if factory == nil { + factory = defaultSTSFactory + } + + return d.credentialsCacheFor(oidcCacheKey(roleArn, region, rawAuth), func() *aws.CredentialsCache { + return aws.NewCredentialsCache(&oidcCredentialsProvider{ + source: d.Source, + roleArn: roleArn, + stsClient: factory(aws.Config{Region: region}), + }) + }) +} diff --git a/pkg/config/oidc_credentials_test.go b/pkg/config/oidc_credentials_test.go new file mode 100644 index 00000000..62717562 --- /dev/null +++ b/pkg/config/oidc_credentials_test.go @@ -0,0 +1,315 @@ +// © 2025 Platform Engineering Labs Inc. +// +// SPDX-License-Identifier: FSL-1.1-ALv2 + +//go:build unit + +package config + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "path/filepath" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/credentials/stscreds" + "github.com/aws/aws-sdk-go-v2/service/sts" + ststypes "github.com/aws/aws-sdk-go-v2/service/sts/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const testRoleArn = "arn:aws:iam::123456789012:role/formae-agent" + +// tokenSourceFunc adapts a function to plugin.OidcTokenSource so a test can +// script what the broker answers per call. +type tokenSourceFunc func(ctx context.Context, audience string) (string, error) + +func (f tokenSourceFunc) IdentityToken(ctx context.Context, audience string) (string, error) { + return f(ctx, audience) +} + +// fakeSTS stands in for the AssumeRoleWithWebIdentity API client, recording +// what each exchange was called with and answering with canned credentials. +type fakeSTS struct { + mu sync.Mutex + + calls int + token string + roleArn string + sessionName string + ctxErr error + hadDeadline bool + remaining time.Duration + + // fail, when set, turns the exchange into the error it returns. It + // receives the submitted token so a test can make the failure echo it. + fail func(token string) error +} + +func (f *fakeSTS) AssumeRoleWithWebIdentity( + ctx context.Context, + in *sts.AssumeRoleWithWebIdentityInput, + _ ...func(*sts.Options), +) (*sts.AssumeRoleWithWebIdentityOutput, error) { + f.mu.Lock() + defer f.mu.Unlock() + + f.calls++ + f.token = aws.ToString(in.WebIdentityToken) + f.roleArn = aws.ToString(in.RoleArn) + f.sessionName = aws.ToString(in.RoleSessionName) + f.ctxErr = ctx.Err() + if deadline, ok := ctx.Deadline(); ok { + f.hadDeadline = true + f.remaining = time.Until(deadline) + } + + if f.fail != nil { + if err := f.fail(f.token); err != nil { + return nil, err + } + } + + expires := time.Now().Add(time.Hour) + + return &sts.AssumeRoleWithWebIdentityOutput{ + Credentials: &ststypes.Credentials{ + AccessKeyId: aws.String("AKIAEXAMPLE"), + SecretAccessKey: aws.String("example-secret"), + SessionToken: aws.String("example-session"), + Expiration: &expires, + }, + AssumedRoleUser: &ststypes.AssumedRoleUser{ + Arn: aws.String("arn:aws:sts::123456789012:assumed-role/formae-agent/session"), + }, + }, nil +} + +func (f *fakeSTS) callCount() int { + f.mu.Lock() + defer f.mu.Unlock() + + return f.calls +} + +// isolateAwsEnv points the SDK's shared-config discovery at empty files in a +// temp dir, so a test that runs LoadDefaultConfig does not pick up whatever +// AWS configuration the developer's machine happens to carry. +func isolateAwsEnv(t *testing.T) { + t.Helper() + + dir := t.TempDir() + t.Setenv("AWS_CONFIG_FILE", filepath.Join(dir, "config")) + t.Setenv("AWS_SHARED_CREDENTIALS_FILE", filepath.Join(dir, "credentials")) + t.Setenv("AWS_PROFILE", "") + t.Setenv("AWS_EC2_METADATA_DISABLED", "true") +} + +func TestRetrieve_ExchangesTheSourceTokenForCredentials(t *testing.T) { + var gotAudience string + source := tokenSourceFunc(func(_ context.Context, audience string) (string, error) { + gotAudience = audience + return "stub-token", nil + }) + fake := &fakeSTS{} + provider := &oidcCredentialsProvider{source: source, roleArn: testRoleArn, stsClient: fake} + + creds, err := provider.Retrieve(context.Background()) + require.NoError(t, err) + + assert.Equal(t, "sts.amazonaws.com", gotAudience) + assert.Equal(t, 1, fake.callCount()) + assert.Equal(t, "stub-token", fake.token) + assert.Equal(t, testRoleArn, fake.roleArn) + assert.Equal(t, "formae-aws-plugin", fake.sessionName) + assert.Equal(t, "AKIAEXAMPLE", creds.AccessKeyID) + assert.Equal(t, "example-secret", creds.SecretAccessKey) + assert.Equal(t, "example-session", creds.SessionToken) + assert.True(t, creds.CanExpire) + assert.WithinDuration(t, time.Now().Add(time.Hour), creds.Expires, time.Minute) +} + +func TestRetrieve_SourceErrorFailsClosed(t *testing.T) { + source := tokenSourceFunc(func(context.Context, string) (string, error) { + return "", errors.New("broker unavailable") + }) + fake := &fakeSTS{} + provider := &oidcCredentialsProvider{source: source, roleArn: testRoleArn, stsClient: fake} + + creds, err := provider.Retrieve(context.Background()) + + require.Error(t, err) + assert.Contains(t, err.Error(), "broker unavailable") + assert.Equal(t, 0, fake.callCount()) + assert.Equal(t, aws.Credentials{}, creds) +} + +type routingKey struct{} + +func TestRetrieve_DerivesABoundedRefreshCtx(t *testing.T) { + // The incoming ctx is cancelled before Retrieve is even called: the + // refresh must still complete, and must still see the ctx's values. + incoming, cancel := context.WithCancel(context.WithValue(context.Background(), routingKey{}, "routed")) + cancel() + + var sourceErr error + var sourceValue any + source := tokenSourceFunc(func(ctx context.Context, _ string) (string, error) { + sourceErr = ctx.Err() + sourceValue = ctx.Value(routingKey{}) + return "stub-token", nil + }) + fake := &fakeSTS{} + provider := &oidcCredentialsProvider{source: source, roleArn: testRoleArn, stsClient: fake} + + creds, err := provider.Retrieve(incoming) + require.NoError(t, err) + assert.Equal(t, "AKIAEXAMPLE", creds.AccessKeyID) + + assert.NoError(t, sourceErr) + assert.Equal(t, "routed", sourceValue) + + assert.NoError(t, fake.ctxErr) + assert.True(t, fake.hadDeadline, "the refresh ctx must carry a deadline") + assert.Greater(t, fake.remaining, 25*time.Second) + assert.LessOrEqual(t, fake.remaining, oidcRefreshTimeout) +} + +func TestRetrieve_NeverLeaksTheTokenInErrors(t *testing.T) { + const secret = "header.super-secret-jwt-payload.signature" + + t.Run("an STS failure that echoes the submitted token", func(t *testing.T) { + source := tokenSourceFunc(func(context.Context, string) (string, error) { + return secret, nil + }) + fake := &fakeSTS{fail: func(token string) error { + return fmt.Errorf("InvalidIdentityToken: the token %s was rejected", token) + }} + provider := &oidcCredentialsProvider{source: source, roleArn: testRoleArn, stsClient: fake} + + _, err := provider.Retrieve(context.Background()) + + require.Error(t, err) + assert.NotContains(t, err.Error(), secret) + assert.Contains(t, err.Error(), "redacted") + }) + + t.Run("a source failure on a later refresh", func(t *testing.T) { + var calls int + source := tokenSourceFunc(func(context.Context, string) (string, error) { + calls++ + if calls == 1 { + return secret, nil + } + return "", errors.New("broker rotated its signing key") + }) + provider := &oidcCredentialsProvider{source: source, roleArn: testRoleArn, stsClient: &fakeSTS{}} + + _, err := provider.Retrieve(context.Background()) + require.NoError(t, err) + + _, err = provider.Retrieve(context.Background()) + + require.Error(t, err) + assert.NotContains(t, err.Error(), secret) + }) +} + +func TestCredentialsCacheFor_GetOrCreateIsSynchronized(t *testing.T) { + deps := NewOidcDeps(stubTokenSource{}) + + var builds int64 + build := func() *aws.CredentialsCache { + atomic.AddInt64(&builds, 1) + return aws.NewCredentialsCache(&oidcCredentialsProvider{ + source: stubTokenSource{}, + roleArn: testRoleArn, + stsClient: &fakeSTS{}, + }) + } + + const goroutines = 8 + got := make([]*aws.CredentialsCache, goroutines) + start := make(chan struct{}) + + var wg sync.WaitGroup + for i := range got { + wg.Add(1) + go func(i int) { + defer wg.Done() + <-start + got[i] = deps.credentialsCacheFor("shared-key", build) + }(i) + } + close(start) + wg.Wait() + + assert.EqualValues(t, 1, atomic.LoadInt64(&builds), "a concurrent miss must construct exactly once") + for _, cache := range got { + assert.Same(t, got[0], cache) + } +} + +func TestCredentialsCacheFor_KeyIncludesAuthHash(t *testing.T) { + deps := NewOidcDeps(stubTokenSource{}) + build := func() *aws.CredentialsCache { + return aws.NewCredentialsCache(&oidcCredentialsProvider{ + source: stubTokenSource{}, + roleArn: testRoleArn, + stsClient: &fakeSTS{}, + }) + } + + first := oidcCacheKey(testRoleArn, "us-east-1", json.RawMessage(`{"Type":"Oidc","RoleArn":"`+testRoleArn+`","SessionName":"a"}`)) + second := oidcCacheKey(testRoleArn, "us-east-1", json.RawMessage(`{"Type":"Oidc","RoleArn":"`+testRoleArn+`","SessionName":"b"}`)) + require.NotEqual(t, first, second) + + assert.Same(t, deps.credentialsCacheFor(first, build), deps.credentialsCacheFor(first, build)) + assert.NotSame(t, deps.credentialsCacheFor(first, build), deps.credentialsCacheFor(second, build)) +} + +func TestCredentialsCacheFor_KeySeparatesRoleAndRegion(t *testing.T) { + raw := json.RawMessage(`{"Type":"Oidc","RoleArn":"` + testRoleArn + `"}`) + + assert.NotEqual(t, + oidcCacheKey(testRoleArn, "us-east-1", raw), + oidcCacheKey(testRoleArn, "eu-west-1", raw), + ) + // The NUL separator keeps a role/region boundary shift from colliding. + assert.NotEqual(t, + oidcCacheKey("role", "a-b", raw), + oidcCacheKey("role-a", "b", raw), + ) +} + +func TestCacheReuse_OneExchangePerLifetime(t *testing.T) { + isolateAwsEnv(t) + + fake := &fakeSTS{} + deps := NewOidcDeps(stubTokenSource{}) + deps.stsFactory = func(aws.Config) stscreds.AssumeRoleWithWebIdentityAPIClient { return fake } + + auth := json.RawMessage(`{"Type":"Oidc","RoleArn":"` + testRoleArn + `"}`) + + for range 2 { + // A fresh Config each time, as every plugin operation builds one; + // only the plugin-lifetime deps are shared. + c := (&Config{Region: "us-east-1", Auth: auth}).WithOidcDeps(deps) + + cfg, err := c.ToAwsConfig(context.Background()) + require.NoError(t, err) + + creds, err := cfg.Credentials.Retrieve(context.Background()) + require.NoError(t, err) + assert.Equal(t, "AKIAEXAMPLE", creds.AccessKeyID) + } + + assert.Equal(t, 1, fake.callCount(), "the cached credentials must survive across ToAwsConfig calls") +} diff --git a/schema/pkl/aws.pkl b/schema/pkl/aws.pkl index 8376dc9d..f7f00a63 100644 --- a/schema/pkl/aws.pkl +++ b/schema/pkl/aws.pkl @@ -85,12 +85,55 @@ typealias AvailabilityZone = String((str) -> ( open class Config { hidden fixed type: String = "AWS" + + /// Deprecated: legacy spelling of the default credential chain. + /// + /// Mutable for the same reason `auth` is: the profile is an auth concern, + /// and how the agent authenticates is not where the resources live. + /// Marking it mutable makes this deprecation's own migration (flat + /// `profile` to an `auth` block) a target update rather than a target + /// replace, which would otherwise destroy and recreate every resource on + /// the target. + @Deprecated { message = "set auth = new DefaultChainAuth { profile = ... } instead" } + @formae.ConfigFieldHint { createOnly = false } hidden profile: String? + hidden region: Region + /// Authentication strategy. Omitting it means the default credential + /// chain, honouring the deprecated `profile` if set. Exactly one spelling + /// may be used: setting both `auth` and `profile` is rejected at eval. + @formae.ConfigFieldHint { createOnly = false } + hidden auth: Auth?(this == null || profile == null) + fixed Type: String = type fixed Profile: String? = profile fixed Region: Region = region + fixed Auth: Auth? = auth +} + +abstract class Auth { + hidden fixed type: String + fixed Type: String = type +} + +/// The AWS default credential provider chain (env vars, shared config, +/// IMDS/IRSA), optionally pinned to a shared-config profile. +class DefaultChainAuth extends Auth { + fixed type = "DefaultChain" + hidden profile: String? + fixed Profile: String? = profile +} + +/// Federated identity: an OIDC identity token from the paired +/// oidc-credential broker, exchanged for credentials by assuming this role. +class OidcAuth extends Auth { + fixed type = "Oidc" + /// The role in the customer account whose trust policy pins the + /// installation's identity. Everything else (issuer, key, token endpoint) + /// belongs to the broker, not the target. + hidden roleArn: String + fixed RoleArn: String = roleArn } class FieldHint extends formae.FieldHint {} diff --git a/schema_auth_test.go b/schema_auth_test.go new file mode 100644 index 00000000..b8a8c65c --- /dev/null +++ b/schema_auth_test.go @@ -0,0 +1,133 @@ +// © 2025 Platform Engineering Labs Inc. +// +// SPDX-License-Identifier: FSL-1.1-ALv2 + +//go:build unit + +package main + +// These tests exercise the polymorphic Auth schema on aws#Config through +// the real formae eval path: a forma file is rendered by a live formae +// binary against the plugin as installed by `make install`, and the +// resulting target Config JSON is asserted against its expected shape. +// +// They only run when FORMAE_BINARY points at a formae binary (they are +// skipped otherwise, e.g. in CI, which has no formae binary available). +// Before running locally: +// +// make install +// FORMAE_BINARY=/path/to/formae go test -tags=unit -run TestAuthSchema -v . + +import ( + "encoding/json" + "os" + "os/exec" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// evalTarget is the slice of a rendered target this suite asserts on: the +// config the plugin will receive, and the config schema the agent classifies +// config changes against. +type evalTarget struct { + Config json.RawMessage `json:"Config"` + ConfigSchema struct { + Hints map[string]struct { + CreateOnly bool `json:"CreateOnly"` + } `json:"Hints"` + } `json:"ConfigSchema"` +} + +type evalResult struct { + Targets []evalTarget `json:"Targets"` +} + +// runEval evaluates a testdata fixture with the formae binary named by +// FORMAE_BINARY and returns the first rendered target. It fails the (sub)test +// if the binary is missing from FORMAE_BINARY, and returns the raw stderr and +// a non-nil error when evaluation itself fails so callers can assert on +// rejection. +func runEval(t *testing.T, binary, fixture string) (evalTarget, []byte, error) { + t.Helper() + + cmd := exec.Command(binary, "eval", //nolint:gosec // test-only, binary path comes from a trusted env var + "--output-consumer", "machine", + "--schema-location", "local", + fixture, + ) + cmd.Env = append(os.Environ(), "FORMAE_TEST_RUN_ID=unit") + + stdout, err := cmd.Output() + if err != nil { + var stderr []byte + if exitErr, ok := err.(*exec.ExitError); ok { + stderr = exitErr.Stderr + } + return evalTarget{}, stderr, err + } + + var result evalResult + require.NoError(t, json.Unmarshal(stdout, &result), "eval output was not valid JSON: %s", stdout) + require.Len(t, result.Targets, 1, "expected exactly one target in eval output") + + return result.Targets[0], nil, nil +} + +func TestAuthSchema(t *testing.T) { + binary := os.Getenv("FORMAE_BINARY") + if binary == "" { + t.Skip("FORMAE_BINARY not set; skipping formae-eval-backed schema tests") + } + + t.Run("flat profile with no auth block renders Profile and omits Auth", func(t *testing.T) { + target, _, err := runEval(t, binary, "testdata/aws-config-auth-flat.pkl") + require.NoError(t, err) + + assert.JSONEq(t, `{"Type":"AWS","Profile":"legacy-profile","Region":"us-east-1"}`, string(target.Config)) + }) + + t.Run("both auth spellings are hinted mutable so migrating between them updates the target", func(t *testing.T) { + target, _, err := runEval(t, binary, "testdata/aws-config-auth-flat.pkl") + require.NoError(t, err) + + profile, ok := target.ConfigSchema.Hints["Profile"] + require.True(t, ok, "Profile must carry a config field hint, or the agent classifies "+ + "dropping it during the auth migration as immutable and replaces the target") + assert.False(t, profile.CreateOnly) + + auth, ok := target.ConfigSchema.Hints["Auth"] + require.True(t, ok, "Auth must carry a config field hint") + assert.False(t, auth.CreateOnly) + }) + + t.Run("auth = DefaultChainAuth renders a nested Auth object", func(t *testing.T) { + target, _, err := runEval(t, binary, "testdata/aws-config-auth-defaultchain.pkl") + require.NoError(t, err) + + assert.JSONEq(t, `{ + "Type":"AWS", + "Region":"us-east-1", + "Auth":{"Type":"DefaultChain","Profile":"chain-profile"} + }`, string(target.Config)) + }) + + t.Run("auth = OidcAuth renders a nested Auth object", func(t *testing.T) { + target, _, err := runEval(t, binary, "testdata/aws-config-auth-oidc.pkl") + require.NoError(t, err) + + assert.JSONEq(t, `{ + "Type":"AWS", + "Region":"us-east-1", + "Auth":{"Type":"Oidc","RoleArn":"arn:aws:iam::123456789012:role/formae-agent"} + }`, string(target.Config)) + }) + + t.Run("setting both profile and auth is rejected at eval", func(t *testing.T) { + _, stderr, err := runEval(t, binary, "testdata/aws-config-auth-both-set-rejected.pkl") + require.Error(t, err) + assert.Contains(t, string(stderr), "Type constraint") + assert.Contains(t, string(stderr), "this == null || profile == null") + }) +} diff --git a/testdata/aws-config-auth-both-set-rejected.pkl b/testdata/aws-config-auth-both-set-rejected.pkl new file mode 100644 index 00000000..5b61ced6 --- /dev/null +++ b/testdata/aws-config-auth-both-set-rejected.pkl @@ -0,0 +1,31 @@ +/* + * © 2025 Platform Engineering Labs Inc. + * + * SPDX-License-Identifier: FSL-1.1-ALv2 + */ + +amends "@formae/forma.pkl" +import "@formae/formae.pkl" + +import "@aws/aws.pkl" + +local testRunID = read("env:FORMAE_TEST_RUN_ID") +local stackName = "plugin-sdk-test-aws-config-auth-both-set-\(testRunID)" + +forma { + new formae.Stack { + label = stackName + description = "Plugin SDK fixture: both profile and auth set, expected to be rejected at eval" + } + + new formae.Target { + label = "aws-target" + config = new aws.Config { + region = "us-east-1" + profile = "legacy-profile" + auth = new aws.DefaultChainAuth { + profile = "chain-profile" + } + } + } +} diff --git a/testdata/aws-config-auth-defaultchain.pkl b/testdata/aws-config-auth-defaultchain.pkl new file mode 100644 index 00000000..30c3fb68 --- /dev/null +++ b/testdata/aws-config-auth-defaultchain.pkl @@ -0,0 +1,30 @@ +/* + * © 2025 Platform Engineering Labs Inc. + * + * SPDX-License-Identifier: FSL-1.1-ALv2 + */ + +amends "@formae/forma.pkl" +import "@formae/formae.pkl" + +import "@aws/aws.pkl" + +local testRunID = read("env:FORMAE_TEST_RUN_ID") +local stackName = "plugin-sdk-test-aws-config-auth-defaultchain-\(testRunID)" + +forma { + new formae.Stack { + label = stackName + description = "Plugin SDK fixture: auth = DefaultChainAuth with a profile" + } + + new formae.Target { + label = "aws-target" + config = new aws.Config { + region = "us-east-1" + auth = new aws.DefaultChainAuth { + profile = "chain-profile" + } + } + } +} diff --git a/testdata/aws-config-auth-flat.pkl b/testdata/aws-config-auth-flat.pkl new file mode 100644 index 00000000..4af2f504 --- /dev/null +++ b/testdata/aws-config-auth-flat.pkl @@ -0,0 +1,28 @@ +/* + * © 2025 Platform Engineering Labs Inc. + * + * SPDX-License-Identifier: FSL-1.1-ALv2 + */ + +amends "@formae/forma.pkl" +import "@formae/formae.pkl" + +import "@aws/aws.pkl" + +local testRunID = read("env:FORMAE_TEST_RUN_ID") +local stackName = "plugin-sdk-test-aws-config-auth-flat-\(testRunID)" + +forma { + new formae.Stack { + label = stackName + description = "Plugin SDK fixture: legacy flat profile, no auth block" + } + + new formae.Target { + label = "aws-target" + config = new aws.Config { + region = "us-east-1" + profile = "legacy-profile" + } + } +} diff --git a/testdata/aws-config-auth-oidc.pkl b/testdata/aws-config-auth-oidc.pkl new file mode 100644 index 00000000..1aa4557d --- /dev/null +++ b/testdata/aws-config-auth-oidc.pkl @@ -0,0 +1,30 @@ +/* + * © 2025 Platform Engineering Labs Inc. + * + * SPDX-License-Identifier: FSL-1.1-ALv2 + */ + +amends "@formae/forma.pkl" +import "@formae/formae.pkl" + +import "@aws/aws.pkl" + +local testRunID = read("env:FORMAE_TEST_RUN_ID") +local stackName = "plugin-sdk-test-aws-config-auth-oidc-\(testRunID)" + +forma { + new formae.Stack { + label = stackName + description = "Plugin SDK fixture: auth = OidcAuth" + } + + new formae.Target { + label = "aws-target" + config = new aws.Config { + region = "us-east-1" + auth = new aws.OidcAuth { + roleArn = "arn:aws:iam::123456789012:role/formae-agent" + } + } + } +}