Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apis/core/v1alpha1/conditions.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,11 @@ const (
// to manage the AWSResource. If none are selected, this condition will be removed
// and we'll use the custom role to manage the AWSResource
ConditionTypeIAMRoleSelected ConditionType = "ACK.IAMRoleSelected"
// ConditionTypeCrossNamespaceOptInRequired indicates that the resource uses
// cross-namespace behavior (resource references, secret references, or
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Naming inconsistency: PR description calls this ACK.CrossNamespaceDeprecation, the type added here is ACK.CrossNamespaceOptInRequired. The latter is the better of the two (describes the action operators must take), but please update the PR body so downstream release notes don't spread the wrong name — and be aware that the condition Type string is part of the user-visible API.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Fixed in #248 - PR description updated to use ACK.CrossNamespaceOptInRequired.

// field exports) that will require explicit opt-in via
// --enable-cross-namespace=true in a future release.
ConditionTypeCrossNamespaceOptInRequired ConditionType = "ACK.CrossNamespaceOptInRequired"
)

// Condition is the common struct used by all CRDs managed by ACK service
Expand Down
3 changes: 2 additions & 1 deletion pkg/condition/condition.go
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,8 @@ func WithReferencesResolvedCondition(
if err != nil {
errString := err.Error()
conditionStatus := corev1.ConditionUnknown
if strings.Contains(errString, ackerr.ResourceReferenceTerminal.Error()) {
if strings.Contains(errString, ackerr.ResourceReferenceTerminal.Error()) ||
strings.Contains(errString, ackerr.ResourceReferenceCrossNamespaceNotAllowed.Error()) {
conditionStatus = corev1.ConditionFalse
}
SetReferencesResolved(ko, conditionStatus, &FailedReferenceResolutionMessage, &errString)
Expand Down
9 changes: 9 additions & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import (
)

const (
flagEnableCrossNamespace = "enable-cross-namespace"
flagEnableFieldExportReconciler = "enable-field-export-reconciler"
flagEnableLeaderElection = "enable-leader-election"
flagEnableCARM = "enable-carm"
Expand Down Expand Up @@ -92,6 +93,7 @@ type Config struct {
EnableLeaderElection bool
EnableFieldExportReconciler bool
EnableCARM bool
EnableCrossNamespace bool
LeaderElectionNamespace string
EnableDevelopmentLogging bool
AccountID string
Expand Down Expand Up @@ -158,6 +160,13 @@ func (cfg *Config) BindFlags() {
true,
"Enable Cross Account Resource Management.",
)
flag.BoolVar(
&cfg.EnableCrossNamespace, flagEnableCrossNamespace,
true,
"Enable cross-namespace behavior (resource references, secret references, "+
"field exports). When false, the controller rejects any operation that "+
"crosses namespace boundaries.",
)
flag.StringVar(
// In the context of the controller-runtime library, if the LeaderElectionNamespace parametere is not
// explicitly set, the library will automatically default its value to the content of the file
Expand Down
23 changes: 23 additions & 0 deletions pkg/errors/resource_reference.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,14 @@ var (
ResourceReferenceMissingTargetField = fmt.Errorf(
"the referenced resource is missing the target field",
)
// ResourceReferenceCrossNamespaceNotAllowed indicates that a resource
// reference specifies a Namespace that differs from the namespace of the
// resource containing the reference, and the
// --enable-cross-namespace flag is not enabled.
ResourceReferenceCrossNamespaceNotAllowed = fmt.Errorf(
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

--enable-cross-namespace is a bool flag; Set --enable-cross-namespace=true to allow it. is what operators actually need to type. Worth flipping the message text. Also: while the flag still defaults to true, this terminal error will only ever fire when an operator explicitly opted out — so the wording is fine. When the default flips, consider also referencing the ACK.CrossNamespaceOptInRequired condition so users can audit their fleet before the flip lands.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Fixed in #248 — error message now says Set --enable-cross-namespace=true to allow it

"cross-namespace resource reference is not allowed. " +
"Set '--enable-cross-namespace' flag to allow it",
)
)

// ResourceReferenceOrIDRequiredFor returns a ResourceReferenceOrIDRequired error
Expand Down Expand Up @@ -90,3 +98,18 @@ func ResourceReferenceMissingTargetFieldFor(resource string, namespace string,
", targetField:%s", ResourceReferenceMissingTargetField,
resource, namespace, name, targetField)
}

// ResourceReferenceCrossNamespaceNotAllowedFor returns a
// ResourceReferenceCrossNamespaceNotAllowed error annotated with the
// source namespace, target namespace, and referenced resource name.
func ResourceReferenceCrossNamespaceNotAllowedFor(
sourceNamespace string,
targetNamespace string,
name string,
) error {
return fmt.Errorf(
"%w. sourceNamespace:%s, targetNamespace:%s, name:%s",
ResourceReferenceCrossNamespaceNotAllowed,
sourceNamespace, targetNamespace, name,
)
}
88 changes: 74 additions & 14 deletions pkg/runtime/field_export_reconciler.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,23 @@ func (r *fieldExportReconciler) Sync(
r.patchResourceStatus(ctx, &desired, latest)
}()

// Validate cross-namespace access for the field export target
resolvedNamespace, isCrossNamespace, nsErr := r.validateFieldExportNamespace(&desired)
if nsErr != nil {
return desired, r.onError(ctx, &desired, ackerr.NewTerminalError(nsErr))
}
if isCrossNamespace {
r.log.V(0).Info(
"cross-namespace field export detected; this behavior will be "+
"disabled by default in a future release. Set --enable-cross-namespace "+
"to preserve this behavior.",
"fieldExportNamespace", desired.Namespace,
"targetNamespace", r.getTargetNamespace(&desired),
"targetName", *desired.Spec.To.Name,
)
r.setCrossNsOptInRequiredCondition(&desired)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

*desired.Spec.To.Name is dereferenced unconditionally on the log line above — but validateFieldExportNamespace (line 204) explicitly guards desired.Spec.To.Name != nil before reading the same field. Pre-existing writeToConfigMap/writeToSecret paths already deref unguarded too, so this PR isn't introducing the panic, but the inconsistency within new code is worth resolving: either drop the nil-guard in validateFieldExportNamespace (callers downstream already assume non-nil), or thread the safe targetName through and use it here. Pick one and be consistent.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Fixed in #248 - using nil-safe targetName variable with guard check before deref, consistent with validateFieldExportNamespace.

}

// Get the field from the resource
value, err := r.getSourcePathFromResource(from, *desired.Spec.From.Path)
if err != nil {
Expand All @@ -163,11 +180,11 @@ func (r *fieldExportReconciler) Sync(

switch desired.Spec.To.Kind {
case ackv1alpha1.FieldExportOutputTypeConfigMap:
if err = r.writeToConfigMap(ctx, *value, &desired); err != nil {
if err = r.writeToConfigMap(ctx, *value, &desired, resolvedNamespace); err != nil {
return desired, r.onError(ctx, &desired, err)
}
case ackv1alpha1.FieldExportOutputTypeSecret:
if err = r.writeToSecret(ctx, *value, &desired); err != nil {
if err = r.writeToSecret(ctx, *value, &desired, resolvedNamespace); err != nil {
return desired, r.onError(ctx, &desired, err)
}
}
Expand All @@ -177,6 +194,55 @@ func (r *fieldExportReconciler) Sync(
return desired, r.onSuccess(ctx, &desired)
}

// validateFieldExportNamespace checks whether the field export target namespace
// is allowed given the current cross-namespace flag setting.
func (r *fieldExportReconciler) validateFieldExportNamespace(
desired *ackv1alpha1.FieldExport,
) (string, bool, error) {
targetNamespace := r.getTargetNamespace(desired)
targetName := ""
if desired.Spec.To != nil && desired.Spec.To.Name != nil {
targetName = *desired.Spec.To.Name
}

return ValidateCrossNamespaceReferenceString(
r.cfg.EnableCrossNamespace,
desired.Namespace,
targetNamespace,
targetName,
)
}

// getTargetNamespace returns the target namespace for the field export write.
// If Spec.To.Namespace is set, it returns that value; otherwise it returns
// the FieldExport's own namespace.
func (r *fieldExportReconciler) getTargetNamespace(
desired *ackv1alpha1.FieldExport,
) string {
if desired.Spec.To != nil && desired.Spec.To.Namespace != nil && *desired.Spec.To.Namespace != "" {
return *desired.Spec.To.Namespace
}
return desired.Namespace
}

// setCrossNsOptInRequiredCondition sets the ACK.CrossNamespaceOptInRequired
// condition on the FieldExport resource to notify users that cross-namespace
// behavior will require explicit opt-in in a future release.
func (r *fieldExportReconciler) setCrossNsOptInRequiredCondition(
desired *ackv1alpha1.FieldExport,
) {
message := "Cross-namespace field export detected: FieldExport in namespace \"" +
desired.Namespace + "\" targets namespace \"" + r.getTargetNamespace(desired) +
"\". Cross-namespace behavior will require explicit opt-in in a future release. " +
"Set --enable-cross-namespace=true to preserve this behavior."
condition := &ackv1alpha1.Condition{
Type: ackv1alpha1.ConditionTypeCrossNamespaceOptInRequired,
Status: corev1.ConditionTrue,
Message: &message,
}
desired.Status.Conditions = append(desired.Status.Conditions, condition)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

setCrossNsOptInRequiredCondition appends unconditionally. Safe today because resetConditions runs at the top of Sync (line 151), but it diverges from the lookup-or-create pattern used by patchRecoverableCondition (line 517) and patchTerminalCondition (line 551). If resetConditions is ever moved or skipped, this will accumulate duplicates silently. Prefer the existing pattern (search by type, update if present, append if not) so this code reads the same as its neighbors.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Fixed in #248 — now uses lookup-or-create pattern (search by type, update if present, append if not) matching patchRecoverableCondition/patchTerminalCondition.

}

// cleanup removes the finalizer from FieldExport so that k8s object can
// be deleted.
func (r *fieldExportReconciler) cleanup(
Expand Down Expand Up @@ -293,6 +359,7 @@ func (r *fieldExportReconciler) writeToConfigMap(
ctx context.Context,
sourceValue string,
desired *ackv1alpha1.FieldExport,
resolvedNamespace string,
) error {
// Construct the data key
key := fmt.Sprintf("%s.%s", desired.Namespace, desired.Name)
Expand All @@ -302,12 +369,8 @@ func (r *fieldExportReconciler) writeToConfigMap(

// Get the initial configmap
nsn := types.NamespacedName{
Name: *desired.Spec.To.Name,
}
if desired.Spec.To.Namespace != nil {
nsn.Namespace = *desired.Spec.To.Namespace
} else {
nsn.Namespace = desired.Namespace
Name: *desired.Spec.To.Name,
Namespace: resolvedNamespace,
}

cm := &corev1.ConfigMap{}
Expand Down Expand Up @@ -340,6 +403,7 @@ func (r *fieldExportReconciler) writeToSecret(
ctx context.Context,
sourceValue string,
desired *ackv1alpha1.FieldExport,
resolvedNamespace string,
) error {
// Construct the data key
key := fmt.Sprintf("%s.%s", desired.Namespace, desired.Name)
Expand All @@ -349,12 +413,8 @@ func (r *fieldExportReconciler) writeToSecret(

// Get the initial secret
nsn := types.NamespacedName{
Name: *desired.Spec.To.Name,
}
if desired.Spec.To.Namespace != nil {
nsn.Namespace = *desired.Spec.To.Namespace
} else {
nsn.Namespace = desired.Namespace
Name: *desired.Spec.To.Name,
Namespace: resolvedNamespace,
}

secret := &corev1.Secret{}
Expand Down
17 changes: 12 additions & 5 deletions pkg/runtime/reconciler.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,21 +131,28 @@ func (r *reconciler) SecretValueFromReference(
return "", nil
}

namespace := ref.Namespace
// During the reconcile process, the resourceNamespace is stored in the context
// and can be used to fetch the secret if the namespace is not provided in the
// SecretKeyReference.
//
// NOTE(a-hilaly): When refactoring the runtime, we might want to consider passing
// the ObjectMeta in the context.
ownerNamespace := ""
ctxResourceNamespace := ctx.Value("resourceNamespace")
if namespace == "" && ctxResourceNamespace != nil {
ctxNamespace, ok := ctxResourceNamespace.(string)
if ok {
namespace = ctxNamespace
if ctxResourceNamespace != nil {
if ctxNamespace, ok := ctxResourceNamespace.(string); ok {
ownerNamespace = ctxNamespace
}
}

// Determine the namespace to use for fetching the secret.
// Cross-namespace validation, logging, and condition-setting are handled
// by the generated code before calling this method.
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The PR description says this PR "Integrate[s] cross-namespace check into SecretValueFromReference", but this hunk does the opposite — it removes namespace logic and the comment defers validation to "the generated code before calling this method." That generated-side change isn't in this PR (and would live in code-generator, not runtime).

Net effect on this branch: --enable-cross-namespace=false does not block cross-namespace secret references — only field exports get gated, and resource references are gated indirectly by WithReferencesResolvedCondition. Two ways to close this:

  1. Wire ValidateCrossNamespaceReferenceString(r.cfg.EnableCrossNamespace, ownerNamespace, ref.Namespace, ref.Name) into SecretValueFromReference here, parallel to field_export_reconciler.go. Keeps the runtime self-contained for tagged releases.
  2. If you'd rather centralize in codegen, please trim the PR description to match scope and link the follow-up code-generator PR.

Copy link
Copy Markdown
Contributor

@kprahulraj kprahulraj May 22, 2026

Choose a reason for hiding this comment

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

Addressed in #248 - updated PR description to clarify that cross-namespace validation for secrets is handled by generated code in aws-controllers-k8s/code-generator#699.

SecretValueFromReference here only extracts ownerNamespace; validation is deferred to the caller

namespace := ref.Namespace
if namespace == "" {
namespace = ownerNamespace
}

nsn := client.ObjectKey{
Namespace: namespace,
Name: ref.Name,
Expand Down
71 changes: 71 additions & 0 deletions pkg/runtime/reconciler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2405,3 +2405,74 @@ func TestPreDeleteSync_UpdateAndDeleteBothFail(t *testing.T) {
// Delete should NOT have been called — fail fast on pre-delete sync error
rm.AssertNotCalled(t, "Delete", mock.Anything, mock.Anything)
}

func TestReconcilerSync_CrossNamespaceReferenceRejected(t *testing.T) {
require := require.New(t)
assert := assert.New(t)

ctx := context.TODO()

desired, _, _ := resourceMocks()
desired.On("ReplaceConditions", []*ackv1alpha1.Condition{}).Return()

crossNsErr := ackerr.ResourceReferenceCrossNamespaceNotAllowedFor(
"default", "other-ns", "my-ref",
)

// When ResolveReferences returns a cross-namespace error, the reconciler
// should set ReferencesResolved=False on the resource and return the
// error without calling ReadOne.
desired.On("Conditions").Return([]*ackv1alpha1.Condition{})
desired.On(
"ReplaceConditions",
mock.AnythingOfType("[]*v1alpha1.Condition"),
).Return().Run(func(args mock.Arguments) {
conditions := args.Get(0).([]*ackv1alpha1.Condition)
// Find the ReferencesResolved condition
var refCond *ackv1alpha1.Condition
for _, c := range conditions {
if c.Type == ackv1alpha1.ConditionTypeReferencesResolved {
refCond = c
break
}
}
require.NotNil(refCond, "expected ReferencesResolved condition to be set")
assert.Equal(corev1.ConditionFalse, refCond.Status,
"ReferencesResolved should be False for cross-namespace rejection")
assert.Equal(ackcondition.FailedReferenceResolutionMessage, *refCond.Message)
assert.Contains(*refCond.Reason, "cross-namespace resource reference is not allowed")
assert.Contains(*refCond.Reason, "default")
assert.Contains(*refCond.Reason, "other-ns")
assert.Contains(*refCond.Reason, "my-ref")
})

rm := &ackmocks.AWSResourceManager{}
rm.On("ResolveReferences", ctx, nil, desired).Return(
desired, true, crossNsErr,
)
rm.On("ClearResolvedReferences", desired).Return(desired)

latest, latestRTObj, _ := resourceMocks()
rm.On("ClearResolvedReferences", latest).Return(latest)
// ReadOne should NOT be called — set it up but assert it's never invoked
rm.On("ReadOne", ctx, desired).Return(latest, nil)

rmf, _ := managedResourceManagerFactoryMocks(desired, latest)

r, kc, scmd := reconcilerMocks(rmf)
rm.On("EnsureTags", ctx, desired, scmd).Return(nil)
kc.On("Patch", withoutCancelContextMatcher, latestRTObj, mock.AnythingOfType("*client.mergeFromPatch")).Return(nil)

_, err := r.Sync(ctx, rm, desired)
require.NotNil(err)
assert.True(errors.Is(err, ackerr.ResourceReferenceCrossNamespaceNotAllowed),
"error should wrap ResourceReferenceCrossNamespaceNotAllowed sentinel")

// ReadOne must NOT be invoked when references fail to resolve
rm.AssertNotCalled(t, "ReadOne", ctx, desired)
// No downstream operations should occur
rm.AssertNotCalled(t, "Create", ctx, desired)
rm.AssertNotCalled(t, "Update")
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Good coverage on the reference-resolution rejection path. Three gaps still uncovered for the new code paths most likely to regress later:

  1. field_export_reconciler.Sync rejection branch (line 158–159) when EnableCrossNamespace=false and the FieldExport target is a different namespace.
  2. setCrossNsOptInRequiredCondition is set on the FieldExport when flag=true and namespaces differ (the deprecation-warning happy path).
  3. WithReferencesResolvedCondition (pkg/condition/condition.go:317-318): a unit test asserting the new ResourceReferenceCrossNamespaceNotAllowed branch resolves to ConditionFalse (not ConditionUnknown).

Non-blocking, but easy adds while the helper signature is fresh.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Will add as a follow up

rm.AssertNotCalled(t, "LateInitialize")
rm.AssertNotCalled(t, "EnsureTags", ctx, desired, scmd)
}
Loading