From b6f5c9944560d34748dd79e051bb7cf52a0d2790 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20K=C3=A4sser?= Date: Wed, 21 Oct 2026 16:23:08 +0200 Subject: [PATCH 1/4] feat: add identity.assumeRoleChainRef for a dynamic assume-role chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve the STS assume-role chain at runtime from a status./context./spec. path, alongside regionRef/filtersRef/parametersRef, so one Composition can target a different role per control plane (e.g. from an EnvironmentConfig). The resolved list replaces the static assumeRoleChain; an absent path keeps it. A malformed value is fatal and names the ref and the entry index. - input: Identity.AssumeRoleChainRef (+ generated deepcopy and CRD). - fn.go: resolveAssumeRoleChainRef + toAssumeRoleChain, called next to the other resolve*Ref calls; buildAWSConfig is unchanged. - tests: context-sourced chain overrides static, absent keeps static, malformed values are fatal. - docs: README input reference, dynamic queries, and a note on the shared base identity. Signed-off-by: Tobias Kässer --- README.md | 16 ++-- aws_test.go | 85 +++++++++++++++++++ fn.go | 63 +++++++++++++- input/v1beta1/input.go | 6 ++ input/v1beta1/zz_generated.deepcopy.go | 5 ++ .../input/aws.fn.crossplane.io_inputs.yaml | 6 ++ 6 files changed, 174 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index f6d02b5..5e08b69 100644 --- a/README.md +++ b/README.md @@ -104,14 +104,20 @@ skipQueryWhenTargetHasData: true # optional queryIntervalMinutes: 10 # optional throttle identity: # optional; defaults to Secret source: Secret # Secret | IRSA | WebIdentity | PodIdentity - assumeRoleChain: + assumeRoleChain: # or assumeRoleChainRef: (status./context./spec.) - roleARN: arn:aws:iam::222222222222:role/crossplane-readonly ``` -**Dynamic queries:** `regionRef`, `filtersRef`, and `parametersRef` resolve their -value from a `status.`/`context.`/`spec.` path at runtime (overriding the static -field), so a prior pipeline step or the XR can drive the query — the AWS analog -of azresourcegraph's `queryRef`/`subscriptionsRef`. +**Dynamic queries:** `regionRef`, `filtersRef`, `parametersRef`, and +`identity.assumeRoleChainRef` resolve their value from a +`status.`/`context.`/`spec.` path at runtime (overriding the static field), so a +prior pipeline step or the XR can drive the query — the AWS analog of +azresourcegraph's `queryRef`/`subscriptionsRef`. + +The function pod's base identity is shared by every composition on the control +plane, so prefer a `context.` path fed from platform-controlled config (e.g. an +EnvironmentConfig) for `assumeRoleChainRef`: a `spec.` path lets any XR author +pick the role. The target roles' trust policies remain the real control. ## Authentication diff --git a/aws_test.go b/aws_test.go index 8f134cb..de45b70 100644 --- a/aws_test.go +++ b/aws_test.go @@ -983,6 +983,91 @@ func TestRunFunctionResolvesRefs(t *testing.T) { } } +// TestRunFunctionResolvesAssumeRoleChainRef proves identity.assumeRoleChainRef +// replaces the static chain before the query runs, keeps it when the path is +// absent, and is fatal for a malformed value. +func TestRunFunctionResolvesAssumeRoleChainRef(t *testing.T) { + const envRef = "context.[apiextensions.crossplane.io/environment].roleChain" + static := []v1beta1.AssumeRole{{RoleARN: "arn:aws:iam::111111111111:role/static"}} + run := func(t *testing.T, ref, ctx string) (*fnv1.RunFunctionResponse, *v1beta1.Input) { + t.Helper() + var seen *v1beta1.Input + f := &Function{log: logging.NewNopLogger(), awsQuery: &MockAWSQuery{fn: func(_ context.Context, _ map[string][]byte, in *v1beta1.Input) (any, error) { + seen = in + return map[string]any{}, nil + }}} + req := &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: "test"}, + Input: resource.MustStructJSON(fmt.Sprintf(`{ + "apiVersion":"aws.fn.crossplane.io/v1beta1","kind":"Input", + "queryType":"GetCallerIdentity","target":"status.id", + "identity":{"assumeRoleChain":[{"roleARN":"arn:aws:iam::111111111111:role/static"}], + "assumeRoleChainRef":%q} + }`, ref)), + Observed: &fnv1.State{Composite: &fnv1.Resource{Resource: resource.MustStructJSON(observedXR)}}, + Context: resource.MustStructJSON(ctx), + } + rsp, err := f.RunFunction(context.Background(), req) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + return rsp, seen + } + + t.Run("ContextOverridesStatic", func(t *testing.T) { + _, seen := run(t, envRef, `{"apiextensions.crossplane.io/environment":{"roleChain":[ + {"roleARN":"arn:aws:iam::222222222222:role/hop1","externalID":"ext-1","roleSessionName":"fn-aws-query", + "tags":{"team":"platform"},"transitiveTagKeys":["team"]}, + {"roleARN":"arn:aws:iam::333333333333:role/hop2"}]}}`) + if seen == nil { + t.Fatal("awsQuery was not called") + } + want := []v1beta1.AssumeRole{ + { + RoleARN: "arn:aws:iam::222222222222:role/hop1", + ExternalID: aws.String("ext-1"), + RoleSessionName: aws.String("fn-aws-query"), + Tags: map[string]string{"team": "platform"}, + TransitiveTagKeys: []string{"team"}, + }, + {RoleARN: "arn:aws:iam::333333333333:role/hop2"}, + } + if diff := cmp.Diff(want, seen.Identity.AssumeRoleChain); diff != "" { + t.Errorf("-want +got:\n%s", diff) + } + }) + + t.Run("AbsentKeepsStatic", func(t *testing.T) { + _, seen := run(t, envRef, `{}`) + if seen == nil { + t.Fatal("awsQuery was not called") + } + if diff := cmp.Diff(static, seen.Identity.AssumeRoleChain); diff != "" { + t.Errorf("-want +got:\n%s", diff) + } + }) + + cases := map[string]struct{ ref, chain, wantMsg string }{ + "UnrecognizedPrefix": {ref: "bogus.roleChain", chain: `[]`, wantMsg: "unrecognized reference"}, + "NotAList": {ref: envRef, chain: `{"roleARN":"arn:aws:iam::222222222222:role/x"}`, wantMsg: "expected a list"}, + "NotAnObject": {ref: envRef, chain: `["arn:aws:iam::222222222222:role/x"]`, wantMsg: "role 0 is not an object"}, + "MissingRoleARN": {ref: envRef, chain: `[{"roleARN":"arn:aws:iam::222222222222:role/x"},{"externalID":"e"}]`, wantMsg: "role 1 is missing"}, + "WrongFieldType": {ref: envRef, chain: `[{"roleARN":"arn:aws:iam::222222222222:role/x","tags":"team"}]`, wantMsg: "role 0"}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + rsp, seen := run(t, tc.ref, fmt.Sprintf(`{"apiextensions.crossplane.io/environment":{"roleChain":%s}}`, tc.chain)) + if seen != nil { + t.Error("awsQuery should not be called for a malformed assumeRoleChainRef") + } + msg := rsp.GetResults()[0].GetMessage() + if !hasFatal(rsp) || !strings.Contains(msg, tc.ref) || !strings.Contains(msg, tc.wantMsg) { + t.Errorf("want a fatal result naming %q and %q, got: %v", tc.ref, tc.wantMsg, rsp.GetResults()) + } + }) + } +} + func TestRunFunctionIntervalSkip(t *testing.T) { called := false f := &Function{log: logging.NewNopLogger(), awsQuery: &MockAWSQuery{fn: func(_ context.Context, _ map[string][]byte, _ *v1beta1.Input) (any, error) { diff --git a/fn.go b/fn.go index 64432d5..a7e4b70 100644 --- a/fn.go +++ b/fn.go @@ -2,6 +2,7 @@ package main import ( "context" + "encoding/json" "fmt" "maps" "regexp" @@ -47,8 +48,8 @@ func (f *Function) RunFunction(ctx context.Context, req *fnv1.RunFunctionRequest return rsp, nil //nolint:nilerr // err is surfaced via rsp; the RPC itself must not fail } - // Resolve region, filters, and parameters from references if specified - // (dynamic queries driven by spec/status/context). + // Resolve region, filters, parameters, and the assume-role chain from + // references if specified (dynamic queries driven by spec/status/context). if err := f.resolveRegionRef(req, in, rsp); err != nil { return rsp, nil //nolint:nilerr // err is surfaced via rsp; the RPC itself must not fail } @@ -58,6 +59,9 @@ func (f *Function) RunFunction(ctx context.Context, req *fnv1.RunFunctionRequest if err := f.resolveParametersRef(req, in, rsp); err != nil { return rsp, nil //nolint:nilerr // err is surfaced via rsp; the RPC itself must not fail } + if err := f.resolveAssumeRoleChainRef(req, in, rsp); err != nil { + return rsp, nil //nolint:nilerr // err is surfaced via rsp; the RPC itself must not fail + } if in.QueryType == "" { response.Fatal(rsp, errors.New("queryType is required")) @@ -206,6 +210,32 @@ func (f *Function) resolveParametersRef(req *fnv1.RunFunctionRequest, in *v1beta return nil } +// resolveAssumeRoleChainRef resolves Identity.AssumeRoleChainRef into +// Identity.AssumeRoleChain (overriding any static chain), so the role to assume +// can be chosen at runtime. +func (f *Function) resolveAssumeRoleChainRef(req *fnv1.RunFunctionRequest, in *v1beta1.Input, rsp *fnv1.RunFunctionResponse) error { + if in.Identity == nil || in.Identity.AssumeRoleChainRef == nil || *in.Identity.AssumeRoleChainRef == "" { + return nil + } + root, path, err := f.refRoot(req, *in.Identity.AssumeRoleChainRef) + if err != nil { + response.Fatal(rsp, err) + return err + } + v, ok := GetNestedValue(root, path) + if !ok { + return nil + } + chain, err := toAssumeRoleChain(v) + if err != nil { + err = errors.Wrapf(err, "cannot resolve assumeRoleChainRef %q", *in.Identity.AssumeRoleChainRef) + response.Fatal(rsp, err) + return err + } + in.Identity.AssumeRoleChain = chain + return nil +} + // toFilters converts a resolved reference value into a []v1beta1.Filter. The // value must be a list of objects, each with a "name" string and a "values" list. func toFilters(v any) ([]v1beta1.Filter, error) { @@ -234,6 +264,35 @@ func toFilters(v any) ([]v1beta1.Filter, error) { return out, nil } +// toAssumeRoleChain converts a resolved reference value into a +// []v1beta1.AssumeRole. The value must be a list of objects using the +// AssumeRole field names, each with a "roleARN" string. +func toAssumeRoleChain(v any) ([]v1beta1.AssumeRole, error) { + list, ok := v.([]any) + if !ok { + return nil, errors.New("expected a list of {roleARN, ...} objects") + } + out := make([]v1beta1.AssumeRole, len(list)) + for i, e := range list { + m, ok := e.(map[string]any) + if !ok { + return nil, errors.Errorf("role %d is not an object", i) + } + if arn, _ := m["roleARN"].(string); arn == "" { + return nil, errors.Errorf("role %d is missing a string 'roleARN'", i) + } + // Round-trip through JSON so the AssumeRole field names and types apply. + b, err := json.Marshal(m) + if err != nil { + return nil, errors.Wrapf(err, "role %d", i) + } + if err := json.Unmarshal(b, &out[i]); err != nil { + return nil, errors.Wrapf(err, "role %d", i) + } + } + return out, nil +} + // toParameters converts a resolved reference value into a map[string]string. func toParameters(v any) (map[string]string, error) { m, ok := v.(map[string]any) diff --git a/input/v1beta1/input.go b/input/v1beta1/input.go index e07c7d1..6cfe1d0 100644 --- a/input/v1beta1/input.go +++ b/input/v1beta1/input.go @@ -124,6 +124,12 @@ type Identity struct { // credentials resolved from Source. // +optional AssumeRoleChain []AssumeRole `json:"assumeRoleChain,omitempty"` + + // AssumeRoleChainRef resolves AssumeRoleChain from a status./context./spec. + // field path that holds a list of AssumeRole objects (roleARN required). + // Overrides AssumeRoleChain when set. + // +optional + AssumeRoleChainRef *string `json:"assumeRoleChainRef,omitempty"` } // IdentitySource controls how base credentials are resolved. diff --git a/input/v1beta1/zz_generated.deepcopy.go b/input/v1beta1/zz_generated.deepcopy.go index 1063e72..9c6eb00 100644 --- a/input/v1beta1/zz_generated.deepcopy.go +++ b/input/v1beta1/zz_generated.deepcopy.go @@ -80,6 +80,11 @@ func (in *Identity) DeepCopyInto(out *Identity) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.AssumeRoleChainRef != nil { + in, out := &in.AssumeRoleChainRef, &out.AssumeRoleChainRef + *out = new(string) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Identity. diff --git a/package/input/aws.fn.crossplane.io_inputs.yaml b/package/input/aws.fn.crossplane.io_inputs.yaml index 3d215b3..e6f51ac 100644 --- a/package/input/aws.fn.crossplane.io_inputs.yaml +++ b/package/input/aws.fn.crossplane.io_inputs.yaml @@ -115,6 +115,12 @@ spec: - roleARN type: object type: array + assumeRoleChainRef: + description: |- + AssumeRoleChainRef resolves AssumeRoleChain from a status./context./spec. + field path that holds a list of AssumeRole objects (roleARN required). + Overrides AssumeRoleChain when set. + type: string source: default: Secret description: Source of the base credentials. From f9837cdb998c1cb9c3a3569a16856f6c02fc9d99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20K=C3=A4sser?= Date: Wed, 21 Oct 2026 17:13:14 +0200 Subject: [PATCH 2/4] fix: decode assumeRoleChainRef strictly and reject an empty chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decode each resolved entry with the same strict JSON options the SDK uses for the static Input (exact field names, unknown fields rejected), so a typo like externalId fails instead of being accepted. Reject an empty list, which would otherwise drop the static chain and run the query as the base identity. Cover the spec. and status. sources in the override test. Signed-off-by: Tobias Kässer --- README.md | 3 +- aws_test.go | 66 +++++++++++-------- fn.go | 17 +++-- go.mod | 2 +- input/v1beta1/input.go | 4 +- .../input/aws.fn.crossplane.io_inputs.yaml | 4 +- 6 files changed, 57 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 5e08b69..46ff6aa 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,8 @@ azresourcegraph's `queryRef`/`subscriptionsRef`. The function pod's base identity is shared by every composition on the control plane, so prefer a `context.` path fed from platform-controlled config (e.g. an EnvironmentConfig) for `assumeRoleChainRef`: a `spec.` path lets any XR author -pick the role. The target roles' trust policies remain the real control. +pick the role. An empty or malformed chain is fatal rather than falling back to +the base identity. The target roles' trust policies remain the real control. ## Authentication diff --git a/aws_test.go b/aws_test.go index de45b70..5e25a69 100644 --- a/aws_test.go +++ b/aws_test.go @@ -984,12 +984,12 @@ func TestRunFunctionResolvesRefs(t *testing.T) { } // TestRunFunctionResolvesAssumeRoleChainRef proves identity.assumeRoleChainRef -// replaces the static chain before the query runs, keeps it when the path is -// absent, and is fatal for a malformed value. +// replaces the static chain before the query runs (from context, spec or +// status), keeps it when the path is absent, and is fatal for a malformed value. func TestRunFunctionResolvesAssumeRoleChainRef(t *testing.T) { const envRef = "context.[apiextensions.crossplane.io/environment].roleChain" static := []v1beta1.AssumeRole{{RoleARN: "arn:aws:iam::111111111111:role/static"}} - run := func(t *testing.T, ref, ctx string) (*fnv1.RunFunctionResponse, *v1beta1.Input) { + run := func(t *testing.T, ref, ctx, xr string) (*fnv1.RunFunctionResponse, *v1beta1.Input) { t.Helper() var seen *v1beta1.Input f := &Function{log: logging.NewNopLogger(), awsQuery: &MockAWSQuery{fn: func(_ context.Context, _ map[string][]byte, in *v1beta1.Input) (any, error) { @@ -1004,7 +1004,7 @@ func TestRunFunctionResolvesAssumeRoleChainRef(t *testing.T) { "identity":{"assumeRoleChain":[{"roleARN":"arn:aws:iam::111111111111:role/static"}], "assumeRoleChainRef":%q} }`, ref)), - Observed: &fnv1.State{Composite: &fnv1.Resource{Resource: resource.MustStructJSON(observedXR)}}, + Observed: &fnv1.State{Composite: &fnv1.Resource{Resource: resource.MustStructJSON(xr)}}, Context: resource.MustStructJSON(ctx), } rsp, err := f.RunFunction(context.Background(), req) @@ -1014,31 +1014,39 @@ func TestRunFunctionResolvesAssumeRoleChainRef(t *testing.T) { return rsp, seen } - t.Run("ContextOverridesStatic", func(t *testing.T) { - _, seen := run(t, envRef, `{"apiextensions.crossplane.io/environment":{"roleChain":[ - {"roleARN":"arn:aws:iam::222222222222:role/hop1","externalID":"ext-1","roleSessionName":"fn-aws-query", - "tags":{"team":"platform"},"transitiveTagKeys":["team"]}, - {"roleARN":"arn:aws:iam::333333333333:role/hop2"}]}}`) - if seen == nil { - t.Fatal("awsQuery was not called") - } - want := []v1beta1.AssumeRole{ - { - RoleARN: "arn:aws:iam::222222222222:role/hop1", - ExternalID: aws.String("ext-1"), - RoleSessionName: aws.String("fn-aws-query"), - Tags: map[string]string{"team": "platform"}, - TransitiveTagKeys: []string{"team"}, - }, - {RoleARN: "arn:aws:iam::333333333333:role/hop2"}, - } - if diff := cmp.Diff(want, seen.Identity.AssumeRoleChain); diff != "" { - t.Errorf("-want +got:\n%s", diff) - } - }) + chain := `[ + {"roleARN":"arn:aws:iam::222222222222:role/hop1","externalID":"ext-1","roleSessionName":"fn-aws-query", + "tags":{"team":"platform"},"transitiveTagKeys":["team"]}, + {"roleARN":"arn:aws:iam::333333333333:role/hop2"}]` + want := []v1beta1.AssumeRole{ + { + RoleARN: "arn:aws:iam::222222222222:role/hop1", + ExternalID: aws.String("ext-1"), + RoleSessionName: aws.String("fn-aws-query"), + Tags: map[string]string{"team": "platform"}, + TransitiveTagKeys: []string{"team"}, + }, + {RoleARN: "arn:aws:iam::333333333333:role/hop2"}, + } + sources := map[string]struct{ ref, ctx, xr string }{ + "ContextOverridesStatic": {ref: envRef, ctx: `{"apiextensions.crossplane.io/environment":{"roleChain":` + chain + `}}`, xr: observedXR}, + "SpecOverridesStatic": {ref: "spec.roleChain", ctx: `{}`, xr: `{"apiVersion":"example.org/v1","kind":"XR","metadata":{"name":"test"},"spec":{"roleChain":` + chain + `}}`}, + "StatusOverridesStatic": {ref: "status.roleChain", ctx: `{}`, xr: `{"apiVersion":"example.org/v1","kind":"XR","metadata":{"name":"test"},"status":{"roleChain":` + chain + `}}`}, + } + for name, tc := range sources { + t.Run(name, func(t *testing.T) { + _, seen := run(t, tc.ref, tc.ctx, tc.xr) + if seen == nil { + t.Fatal("awsQuery was not called") + } + if diff := cmp.Diff(want, seen.Identity.AssumeRoleChain); diff != "" { + t.Errorf("-want +got:\n%s", diff) + } + }) + } t.Run("AbsentKeepsStatic", func(t *testing.T) { - _, seen := run(t, envRef, `{}`) + _, seen := run(t, envRef, `{}`, observedXR) if seen == nil { t.Fatal("awsQuery was not called") } @@ -1053,10 +1061,12 @@ func TestRunFunctionResolvesAssumeRoleChainRef(t *testing.T) { "NotAnObject": {ref: envRef, chain: `["arn:aws:iam::222222222222:role/x"]`, wantMsg: "role 0 is not an object"}, "MissingRoleARN": {ref: envRef, chain: `[{"roleARN":"arn:aws:iam::222222222222:role/x"},{"externalID":"e"}]`, wantMsg: "role 1 is missing"}, "WrongFieldType": {ref: envRef, chain: `[{"roleARN":"arn:aws:iam::222222222222:role/x","tags":"team"}]`, wantMsg: "role 0"}, + "UnknownField": {ref: envRef, chain: `[{"roleARN":"arn:aws:iam::222222222222:role/x","externalId":"e"}]`, wantMsg: "role 0"}, + "EmptyList": {ref: envRef, chain: `[]`, wantMsg: "empty list"}, } for name, tc := range cases { t.Run(name, func(t *testing.T) { - rsp, seen := run(t, tc.ref, fmt.Sprintf(`{"apiextensions.crossplane.io/environment":{"roleChain":%s}}`, tc.chain)) + rsp, seen := run(t, tc.ref, fmt.Sprintf(`{"apiextensions.crossplane.io/environment":{"roleChain":%s}}`, tc.chain), observedXR) if seen != nil { t.Error("awsQuery should not be called for a malformed assumeRoleChainRef") } diff --git a/fn.go b/fn.go index a7e4b70..ce7713d 100644 --- a/fn.go +++ b/fn.go @@ -2,13 +2,13 @@ package main import ( "context" - "encoding/json" "fmt" "maps" "regexp" "strings" "time" + "github.com/go-json-experiment/json" "github.com/upbound/function-aws-query/input/v1beta1" "google.golang.org/protobuf/types/known/structpb" @@ -265,13 +265,19 @@ func toFilters(v any) ([]v1beta1.Filter, error) { } // toAssumeRoleChain converts a resolved reference value into a -// []v1beta1.AssumeRole. The value must be a list of objects using the -// AssumeRole field names, each with a "roleARN" string. +// []v1beta1.AssumeRole. The value must be a non-empty list of objects using the +// AssumeRole field names, each with a "roleARN" string. Entries are decoded as +// strictly as the static Input (exact field names, unknown fields rejected). func toAssumeRoleChain(v any) ([]v1beta1.AssumeRole, error) { list, ok := v.([]any) if !ok { return nil, errors.New("expected a list of {roleARN, ...} objects") } + if len(list) == 0 { + // An empty chain would silently drop the static chain and run the + // query as the function's base identity. + return nil, errors.New("expected at least one role, got an empty list") + } out := make([]v1beta1.AssumeRole, len(list)) for i, e := range list { m, ok := e.(map[string]any) @@ -281,12 +287,13 @@ func toAssumeRoleChain(v any) ([]v1beta1.AssumeRole, error) { if arn, _ := m["roleARN"].(string); arn == "" { return nil, errors.Errorf("role %d is missing a string 'roleARN'", i) } - // Round-trip through JSON so the AssumeRole field names and types apply. + // Round-trip through JSON with the decoder options the SDK uses for the + // Input, so the AssumeRole field names and types apply. b, err := json.Marshal(m) if err != nil { return nil, errors.Wrapf(err, "role %d", i) } - if err := json.Unmarshal(b, &out[i]); err != nil { + if err := json.Unmarshal(b, &out[i], json.RejectUnknownMembers(true)); err != nil { return nil, errors.Wrapf(err, "role %d", i) } } diff --git a/go.mod b/go.mod index e3305e1..24b2c54 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/servicequotas v1.35.1 github.com/aws/aws-sdk-go-v2/service/sts v1.42.2 github.com/crossplane/function-sdk-go v0.7.1 + github.com/go-json-experiment/json v0.0.0-20240815175050-ebd3a8989ca1 github.com/google/go-cmp v0.7.0 google.golang.org/protobuf v1.36.11 gopkg.in/ini.v1 v1.67.2 @@ -41,7 +42,6 @@ require ( github.com/evanphx/json-patch/v5 v5.9.11 // indirect github.com/fatih/color v1.18.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect - github.com/go-json-experiment/json v0.0.0-20240815175050-ebd3a8989ca1 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/zapr v1.3.0 // indirect github.com/go-openapi/jsonpointer v0.22.5 // indirect diff --git a/input/v1beta1/input.go b/input/v1beta1/input.go index 6cfe1d0..7fde953 100644 --- a/input/v1beta1/input.go +++ b/input/v1beta1/input.go @@ -126,8 +126,8 @@ type Identity struct { AssumeRoleChain []AssumeRole `json:"assumeRoleChain,omitempty"` // AssumeRoleChainRef resolves AssumeRoleChain from a status./context./spec. - // field path that holds a list of AssumeRole objects (roleARN required). - // Overrides AssumeRoleChain when set. + // field path that holds a non-empty list of AssumeRole objects (roleARN + // required, unknown fields rejected). Overrides AssumeRoleChain when set. // +optional AssumeRoleChainRef *string `json:"assumeRoleChainRef,omitempty"` } diff --git a/package/input/aws.fn.crossplane.io_inputs.yaml b/package/input/aws.fn.crossplane.io_inputs.yaml index e6f51ac..f91c744 100644 --- a/package/input/aws.fn.crossplane.io_inputs.yaml +++ b/package/input/aws.fn.crossplane.io_inputs.yaml @@ -118,8 +118,8 @@ spec: assumeRoleChainRef: description: |- AssumeRoleChainRef resolves AssumeRoleChain from a status./context./spec. - field path that holds a list of AssumeRole objects (roleARN required). - Overrides AssumeRoleChain when set. + field path that holds a non-empty list of AssumeRole objects (roleARN + required, unknown fields rejected). Overrides AssumeRoleChain when set. type: string source: default: Secret From f958926540c5fcbe1cc3d81d7f958b5a772de754 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20K=C3=A4sser?= Date: Thu, 22 Oct 2026 10:37:29 +0200 Subject: [PATCH 3/4] fix: make an unresolved assumeRoleChainRef fatal when there is no static chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, if assumeRoleChainRef's path didn't resolve and no static assumeRoleChain was set, the query silently ran as the function's base identity and reported success. A static chain is still used as the explicit fallback; without one the step is now fatal. Signed-off-by: Tobias Kässer --- aws_test.go | 26 +++++++++++++++---- fn.go | 10 +++++-- input/v1beta1/input.go | 3 ++- .../input/aws.fn.crossplane.io_inputs.yaml | 3 ++- 4 files changed, 33 insertions(+), 9 deletions(-) diff --git a/aws_test.go b/aws_test.go index 5e25a69..1802d80 100644 --- a/aws_test.go +++ b/aws_test.go @@ -985,11 +985,12 @@ func TestRunFunctionResolvesRefs(t *testing.T) { // TestRunFunctionResolvesAssumeRoleChainRef proves identity.assumeRoleChainRef // replaces the static chain before the query runs (from context, spec or -// status), keeps it when the path is absent, and is fatal for a malformed value. +// status), keeps it when the path is absent, and is fatal for a malformed value +// or for an absent path with no static chain to fall back to. func TestRunFunctionResolvesAssumeRoleChainRef(t *testing.T) { const envRef = "context.[apiextensions.crossplane.io/environment].roleChain" static := []v1beta1.AssumeRole{{RoleARN: "arn:aws:iam::111111111111:role/static"}} - run := func(t *testing.T, ref, ctx, xr string) (*fnv1.RunFunctionResponse, *v1beta1.Input) { + runWith := func(t *testing.T, identity, ctx, xr string) (*fnv1.RunFunctionResponse, *v1beta1.Input) { t.Helper() var seen *v1beta1.Input f := &Function{log: logging.NewNopLogger(), awsQuery: &MockAWSQuery{fn: func(_ context.Context, _ map[string][]byte, in *v1beta1.Input) (any, error) { @@ -1001,9 +1002,8 @@ func TestRunFunctionResolvesAssumeRoleChainRef(t *testing.T) { Input: resource.MustStructJSON(fmt.Sprintf(`{ "apiVersion":"aws.fn.crossplane.io/v1beta1","kind":"Input", "queryType":"GetCallerIdentity","target":"status.id", - "identity":{"assumeRoleChain":[{"roleARN":"arn:aws:iam::111111111111:role/static"}], - "assumeRoleChainRef":%q} - }`, ref)), + "identity":%s + }`, identity)), Observed: &fnv1.State{Composite: &fnv1.Resource{Resource: resource.MustStructJSON(xr)}}, Context: resource.MustStructJSON(ctx), } @@ -1013,6 +1013,10 @@ func TestRunFunctionResolvesAssumeRoleChainRef(t *testing.T) { } return rsp, seen } + run := func(t *testing.T, ref, ctx, xr string) (*fnv1.RunFunctionResponse, *v1beta1.Input) { + t.Helper() + return runWith(t, fmt.Sprintf(`{"assumeRoleChain":[{"roleARN":"arn:aws:iam::111111111111:role/static"}],"assumeRoleChainRef":%q}`, ref), ctx, xr) + } chain := `[ {"roleARN":"arn:aws:iam::222222222222:role/hop1","externalID":"ext-1","roleSessionName":"fn-aws-query", @@ -1055,6 +1059,18 @@ func TestRunFunctionResolvesAssumeRoleChainRef(t *testing.T) { } }) + // Without a static chain, an unresolved ref must not silently run the query + // as the function's base identity. + t.Run("AbsentWithoutStaticIsFatal", func(t *testing.T) { + rsp, seen := runWith(t, fmt.Sprintf(`{"assumeRoleChainRef":%q}`, envRef), `{}`, observedXR) + if seen != nil { + t.Error("awsQuery should not be called when assumeRoleChainRef does not resolve and there is no static chain") + } + if !hasFatal(rsp) || !strings.Contains(rsp.GetResults()[0].GetMessage(), envRef) { + t.Errorf("want a fatal result naming %q, got: %v", envRef, rsp.GetResults()) + } + }) + cases := map[string]struct{ ref, chain, wantMsg string }{ "UnrecognizedPrefix": {ref: "bogus.roleChain", chain: `[]`, wantMsg: "unrecognized reference"}, "NotAList": {ref: envRef, chain: `{"roleARN":"arn:aws:iam::222222222222:role/x"}`, wantMsg: "expected a list"}, diff --git a/fn.go b/fn.go index ce7713d..d24b9ec 100644 --- a/fn.go +++ b/fn.go @@ -212,7 +212,8 @@ func (f *Function) resolveParametersRef(req *fnv1.RunFunctionRequest, in *v1beta // resolveAssumeRoleChainRef resolves Identity.AssumeRoleChainRef into // Identity.AssumeRoleChain (overriding any static chain), so the role to assume -// can be chosen at runtime. +// can be chosen at runtime. An unresolved path keeps the static chain; with no +// static chain it is fatal, so the query never silently runs as the base identity. func (f *Function) resolveAssumeRoleChainRef(req *fnv1.RunFunctionRequest, in *v1beta1.Input, rsp *fnv1.RunFunctionResponse) error { if in.Identity == nil || in.Identity.AssumeRoleChainRef == nil || *in.Identity.AssumeRoleChainRef == "" { return nil @@ -224,7 +225,12 @@ func (f *Function) resolveAssumeRoleChainRef(req *fnv1.RunFunctionRequest, in *v } v, ok := GetNestedValue(root, path) if !ok { - return nil + if len(in.Identity.AssumeRoleChain) > 0 { + return nil + } + err := errors.Errorf("assumeRoleChainRef %q did not resolve and no static assumeRoleChain is set; refusing to run as the function's base identity", *in.Identity.AssumeRoleChainRef) + response.Fatal(rsp, err) + return err } chain, err := toAssumeRoleChain(v) if err != nil { diff --git a/input/v1beta1/input.go b/input/v1beta1/input.go index 7fde953..7b39730 100644 --- a/input/v1beta1/input.go +++ b/input/v1beta1/input.go @@ -127,7 +127,8 @@ type Identity struct { // AssumeRoleChainRef resolves AssumeRoleChain from a status./context./spec. // field path that holds a non-empty list of AssumeRole objects (roleARN - // required, unknown fields rejected). Overrides AssumeRoleChain when set. + // required, unknown fields rejected). Overrides AssumeRoleChain when set. If + // the path doesn't resolve, AssumeRoleChain is used; without one that is fatal. // +optional AssumeRoleChainRef *string `json:"assumeRoleChainRef,omitempty"` } diff --git a/package/input/aws.fn.crossplane.io_inputs.yaml b/package/input/aws.fn.crossplane.io_inputs.yaml index f91c744..17f0a57 100644 --- a/package/input/aws.fn.crossplane.io_inputs.yaml +++ b/package/input/aws.fn.crossplane.io_inputs.yaml @@ -119,7 +119,8 @@ spec: description: |- AssumeRoleChainRef resolves AssumeRoleChain from a status./context./spec. field path that holds a non-empty list of AssumeRole objects (roleARN - required, unknown fields rejected). Overrides AssumeRoleChain when set. + required, unknown fields rejected). Overrides AssumeRoleChain when set. If + the path doesn't resolve, AssumeRoleChain is used; without one that is fatal. type: string source: default: Secret From 64d753aa4691929f872e070d8f03326478b3f0c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20K=C3=A4sser?= Date: Thu, 22 Oct 2026 10:37:29 +0200 Subject: [PATCH 4/4] docs: add an assumeRoleChainRef example and render target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds example/composition-assume-role-chain-ref.yaml (chain taken from the environment context, as an EnvironmentConfig would supply it), a `make render-assume-role ROLE_ARN=...` target kept out of `make render` like render-irsa, and docs in example/README.md, following #11 and #12. Signed-off-by: Tobias Kässer --- README.md | 6 ++-- example/Makefile | 23 +++++++++++- example/README.md | 22 ++++++++++++ .../composition-assume-role-chain-ref.yaml | 36 +++++++++++++++++++ 4 files changed, 84 insertions(+), 3 deletions(-) create mode 100644 example/composition-assume-role-chain-ref.yaml diff --git a/README.md b/README.md index 46ff6aa..2618704 100644 --- a/README.md +++ b/README.md @@ -117,8 +117,10 @@ azresourcegraph's `queryRef`/`subscriptionsRef`. The function pod's base identity is shared by every composition on the control plane, so prefer a `context.` path fed from platform-controlled config (e.g. an EnvironmentConfig) for `assumeRoleChainRef`: a `spec.` path lets any XR author -pick the role. An empty or malformed chain is fatal rather than falling back to -the base identity. The target roles' trust policies remain the real control. +pick the role. The ref never falls back to the base identity: an empty or +malformed chain is fatal, and a path that doesn't resolve uses the static +`assumeRoleChain` if one is set and is fatal otherwise. The target roles' trust +policies remain the real control. See `example/composition-assume-role-chain-ref.yaml`. ## Authentication diff --git a/example/Makefile b/example/Makefile index a251c71..267af14 100644 --- a/example/Makefile +++ b/example/Makefile @@ -5,6 +5,8 @@ # make commands # print the copy-pasteable render commands (for manual inspection) # make serve # run the function in the foreground (for manual `crossplane render`) # make render-irsa # render the IRSA example (needs real IRSA/role; excluded from `render`) +# make render-assume-role ROLE_ARN=arn:aws:iam:::role/ +# # render the assumeRoleChainRef example (needs a role your creds can assume) # make clean # # Requires: go, the crossplane CLI, and working AWS credentials in @@ -46,7 +48,7 @@ dynamic-context|xr.yaml|composition-dynamic-context.yaml|--context-values='$(CTX endef export EXAMPLES -.PHONY: render commands serve render-irsa build clean help +.PHONY: render commands serve render-irsa render-assume-role build clean help build: $(BIN) @@ -73,6 +75,9 @@ commands: done <<< "$$EXAMPLES" @echo "# irsa (needs real IRSA/Pod Identity + a valid assumeRoleChain role)" @echo "crossplane render xr.yaml composition-irsa.yaml $(FUNCS) -rc" + @echo + @echo "# assume-role-chain-ref (needs a role your credentials can assume)" + @echo "crossplane render xr.yaml composition-assume-role-chain-ref.yaml $(FUNCS) --function-credentials=$(CREDS) --context-values='$$ROLE_CTX' -rc" render: build @set -uo pipefail @@ -110,6 +115,21 @@ render-irsa: build for i in $$(seq 1 40); do (exec 3<>/dev/tcp/127.0.0.1/$(PORT)) 2>/dev/null && break; sleep 0.25; done crossplane render xr.yaml composition-irsa.yaml $(FUNCS) -rc +# The assumeRoleChainRef example takes its chain from the environment context, +# which a real control plane fills from an EnvironmentConfig. The role must be +# assumable by the credentials in $(CREDS), so it is excluded from `render`. +ROLE_ARN ?= arn:aws:iam::222222222222:role/crossplane-readonly +ROLE_CTX := apiextensions.crossplane.io/environment={"awsQuery":{"roleChain":[{"roleARN":"$(ROLE_ARN)"}]}} +export ROLE_CTX + +render-assume-role: build + @echo ">> assumeRoleChainRef example: assuming $(ROLE_ARN)" + $(BIN) --insecure --address=$(ADDR) >/tmp/fn-render.log 2>&1 & + FN_PID=$$! + trap 'kill $$FN_PID 2>/dev/null || true' EXIT + for i in $$(seq 1 40); do (exec 3<>/dev/tcp/127.0.0.1/$(PORT)) 2>/dev/null && break; sleep 0.25; done + crossplane render xr.yaml composition-assume-role-chain-ref.yaml $(FUNCS) --function-credentials=$(CREDS) --context-values='$(ROLE_CTX)' -rc + clean: rm -f $(BIN) /tmp/render-*.out /tmp/fn-render.log @@ -119,5 +139,6 @@ help: @echo " commands print the copy-pasteable render commands for manual inspection" @echo " serve run the function in the foreground for manual 'crossplane render'" @echo " render-irsa render the IRSA example (needs real IRSA/role)" + @echo " render-assume-role ROLE_ARN=... render the assumeRoleChainRef example" @echo " build build the function binary ($(BIN))" @echo " clean remove the built binary and render logs" diff --git a/example/README.md b/example/README.md index c6c28e5..c8954b9 100644 --- a/example/README.md +++ b/example/README.md @@ -115,6 +115,28 @@ existing AWS provider secret. The `Identity.source` selects the mechanism: An optional `identity.assumeRoleChain` assumes one or more roles on top of the base credentials (e.g. cross-account reads). See `composition-irsa.yaml`. +### Choosing the role at runtime (`assumeRoleChainRef`) + +`identity.assumeRoleChainRef` reads the chain from a `context.`/`status.`/`spec.` +path instead, so one Composition can assume a different role per control plane. +`composition-assume-role-chain-ref.yaml` takes it from the environment context, +which a `function-environment-configs` step fills from a platform-owned +EnvironmentConfig; `render` seeds it with `--context-values`. Use a role your +credentials can assume: + +```shell +make render-assume-role ROLE_ARN=arn:aws:iam::222222222222:role/crossplane-readonly +# or, with `make serve` running: +crossplane render xr.yaml composition-assume-role-chain-ref.yaml functions.yaml \ + --function-credentials=secrets/aws-creds.yaml \ + --context-values='apiextensions.crossplane.io/environment={"awsQuery":{"roleChain":[{"roleARN":"arn:aws:iam::222222222222:role/crossplane-readonly"}]}}' \ + -rc +``` + +`status.callerIdentity.arn` shows the assumed role. Without the context value the +step is fatal: the example has no static `assumeRoleChain` to fall back to, and +the function never falls back to its own base identity. + ### Creating the Secret from `~/.aws/credentials` The function reads the `[default]` profile of an AWS shared-credentials INI, so diff --git a/example/composition-assume-role-chain-ref.yaml b/example/composition-assume-role-chain-ref.yaml new file mode 100644 index 0000000..fcab306 --- /dev/null +++ b/example/composition-assume-role-chain-ref.yaml @@ -0,0 +1,36 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: function-aws-query-assume-role-chain-ref +spec: + compositeTypeRef: + apiVersion: example.io/v1alpha1 + kind: XAccount + mode: Pipeline + pipeline: + # The role to assume is chosen at runtime from the pipeline context, so one + # Composition can target a different AWS account per control plane. In a real + # control plane a function-environment-configs step loads it from a + # platform-owned EnvironmentConfig into the environment context key; `crossplane + # render` seeds that key with --context-values (see `make render-assume-role`). + # Prefer a context path over spec.*: a spec path lets any XR author pick the role. + # + # There is no static assumeRoleChain, so if the path doesn't resolve the step is + # fatal instead of silently querying as the function's base identity. + # GetCallerIdentity makes the assumed role visible in status.callerIdentity.arn. + - step: caller-identity + functionRef: + name: function-aws-query + input: + apiVersion: aws.fn.crossplane.io/v1beta1 + kind: Input + queryType: GetCallerIdentity + target: status.callerIdentity + identity: + assumeRoleChainRef: context.[apiextensions.crossplane.io/environment].awsQuery.roleChain + credentials: + - name: aws-creds + source: Secret + secretRef: + namespace: crossplane-system + name: aws-creds