Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add guards against using distinct subscriptions between owner and child resources #3546

Merged
merged 7 commits into from
Nov 23, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,7 @@ func (r *azureDeploymentReconcilerInstance) BeginCreateOrUpdateResource(

resourceID := genruntime.GetResourceIDOrDefault(r.Obj)
if resourceID != "" {
err = checkSubscription(resourceID, r.ARMConnection.SubscriptionID())
err := r.checkSubscription(resourceID)
if err != nil {
return ctrl.Result{}, err
}
Expand All @@ -303,7 +303,6 @@ func (r *azureDeploymentReconcilerInstance) BeginCreateOrUpdateResource(
if err != nil {
return ctrl.Result{}, err
}

// Use conditions.SetConditionReasonAware here to override any Warning conditions set earlier in the reconciliation process.
// Note that this call should be done after all validation has passed and all that is left to do is send the payload to ARM.
conditions.SetConditionReasonAware(r.Obj, r.PositiveConditions.Ready.Reconciling(r.Obj.GetGeneration()))
Expand Down Expand Up @@ -369,13 +368,15 @@ func (r *azureDeploymentReconcilerInstance) preReconciliationCheck(ctx context.C
return check, nil
}

func checkSubscription(resourceID string, clientSubID string) error {
// checkSubscription checks if subscription on resource matches with credentials used while creating a resource.
// Which prevents users to modify subscription in their credential.
func (r *azureDeploymentReconcilerInstance) checkSubscription(resourceID string) error {
parsedRID, err := arm.ParseResourceID(resourceID)
// Some resources like '/providers/Microsoft.Subscription/aliases' do not have subscriptionID, so we need to make sure subscriptionID exists before we check.
// TODO: we need a better way?
if err == nil {
if parsedRID.ResourceGroupName != "" && parsedRID.SubscriptionID != clientSubID {
err = errors.Errorf("SubscriptionID %q for %q resource does not match with Client Credential: %q", parsedRID.SubscriptionID, resourceID, clientSubID)
if parsedRID.ResourceGroupName != "" && parsedRID.SubscriptionID != r.ARMConnection.SubscriptionID() {
err = errors.Errorf("SubscriptionID %q for %q resource does not match with Client Credential: %q", parsedRID.SubscriptionID, resourceID, r.ARMConnection.SubscriptionID())
return conditions.NewReadyConditionImpactingError(err, conditions.ConditionSeverityError, conditions.ReasonSubscriptionMismatch)
}
}
Expand Down Expand Up @@ -809,7 +810,7 @@ func (r *azureDeploymentReconcilerInstance) deleteResource(
return ctrl.Result{}, nil
}

err := checkSubscription(resourceID, r.ARMConnection.SubscriptionID()) // TODO: Possibly we should pass this in as a parameter?
err := r.checkSubscription(resourceID) // TODO: Possibly we should pass this in as a parameter?
Copy link
Member

Choose a reason for hiding this comment

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

minor: Probably can remove this TODO? I am not sure what it was referencing but the code seems OK to me now

if err != nil {
return ctrl.Result{}, err
}
Expand Down
4 changes: 4 additions & 0 deletions v2/internal/resolver/resolver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ package resolver_test

import (
"context"
"fmt"
"testing"

. "github.com/onsi/gomega"
Expand Down Expand Up @@ -89,6 +90,9 @@ func createResourceGroup(name string) *resources.ResourceGroup {
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: testNamespace,
Annotations: map[string]string{
genruntime.ResourceIDAnnotation: fmt.Sprintf("/subscriptions/1234/resourceGroups/%s", name),
super-harsh marked this conversation as resolved.
Show resolved Hide resolved
},
},
Spec: resources.ResourceGroup_Spec{
Location: to.Ptr("West US"),
Expand Down
38 changes: 31 additions & 7 deletions v2/internal/resolver/resource_hierarchy.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,22 @@ func (h ResourceHierarchy) fullyQualifiedARMIDImpl(subscriptionID string, origin
return "", err
}

root := h[0]

err = genruntime.VerifyResourceOwnerARMID(root)
if err != nil {
return "", err
}

armID, err := genruntime.GetAndParseResourceID(root)
if err != nil {
return "", err
}

if err = h.matchOwnerSubscription(subscriptionID, armID); err != nil {
return "", err
}

// Ensure that we have the same number of names and types
if len(remainingNames) != len(resourceTypes) {
return "", errors.Errorf(
Expand Down Expand Up @@ -202,13 +218,9 @@ func (h ResourceHierarchy) fullyQualifiedARMIDImpl(subscriptionID string, origin
if err != nil {
Copy link
Member

Choose a reason for hiding this comment

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

If you take the checkARMIDHasSubscription suggestion below, also consider renaming this armID variable to ownerARMID so the check reads: checkARMIDHasSubscription(subscriptionID, ownerARMID)

return "", err
}
// armIDSub may be empty if there is no subscription in the user specified ARM ID (for example because the resource roots
// at the tenant level)
if armID.SubscriptionID != "" {
// Confirm that the subscription ID the user specified matches the subscription ID we're using from our credential
if !strings.EqualFold(armID.SubscriptionID, subscriptionID) {
return "", core.NewSubscriptionMismatchError(armID.SubscriptionID, subscriptionID)
}

if err = h.matchOwnerSubscription(subscriptionID, armID); err != nil {
return "", err
}

// Rooting to an ARM ID means that some of the resourceTypes may not actually be included explicitly in our
Expand Down Expand Up @@ -261,6 +273,18 @@ func (h ResourceHierarchy) fullyQualifiedARMIDImpl(subscriptionID string, origin
}
}

func (h ResourceHierarchy) matchOwnerSubscription(subscriptionID string, ownerRID *arm.ResourceID) error {
Copy link
Member

Choose a reason for hiding this comment

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

minor:

  1. This method doesn't need to be on ResourceHierarchy, as it doesn't seem to use h at all. It could just be a package scoped function.
  2. The name matchOwnerSubscription seems too specific to me. In reality this is just checking if the provided subscription matches the passed in ResourceID, so possibly we could rename to something like checkARMIDHasSubscription or checkARMIDMatchesSubscription or similar?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yep, I like it. Considering to move the method into genruntime/arm_id.go for re-usability.

// armIDSub may be empty if there is no subscription in the user specified ARM ID (for example because the resource roots
// at the tenant level)
if ownerRID.SubscriptionID != "" {
// Confirm that the subscription ID the user specified matches the subscription ID we're using from our credential
if !strings.EqualFold(ownerRID.SubscriptionID, subscriptionID) {
return core.NewSubscriptionMismatchError(ownerRID.SubscriptionID, subscriptionID)
}
}
return nil
}

// rootKind returns the ResourceHierarchyRoot type of the hierarchy.
// There are 6 cases here:
// 1. The hierarchy is comprised solely of a resource group. This is subscription rooted.
Expand Down
14 changes: 14 additions & 0 deletions v2/internal/resolver/resource_hierarchy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,20 @@ func Test_ResourceHierarchy_ResourceGroup_NestedResource(t *testing.T) {
g.Expect(hierarchy.FullyQualifiedARMID("1234")).To(Equal(expectedARMID))
}

func Test_ResourceHierarchy_ResourceGroup_NestedResource_MatchSubscriptionWithOwner(t *testing.T) {
t.Parallel()
g := NewGomegaWithT(t)

resourceGroupName := "myrg"
resourceName := "myresource"
childResourceName := "mychildresource"

hierarchy := createDeeplyNestedResource(resourceGroupName, resourceName, childResourceName)

_, err := hierarchy.FullyQualifiedARMID("4567")
g.Expect(err).To(MatchError("resource subscription \"4567\" does not match parent subscription \"1234\""))
}

func Test_ResourceHierarchy_ExtensionOnResourceGroup(t *testing.T) {
t.Parallel()
g := NewGomegaWithT(t)
Expand Down
39 changes: 39 additions & 0 deletions v2/test/single_operator_multitenant_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"os"
"testing"

"github.com/google/uuid"
"github.com/pkg/errors"
"sigs.k8s.io/controller-runtime/pkg/client"

Expand Down Expand Up @@ -133,6 +134,44 @@ func Test_Multitenant_SingleOperator_PerResourceCredential(t *testing.T) {
tc.DeleteResourcesAndWait(acct, rg)
}

func Test_Multitenant_SingleOperator_PerResourceCredential_MatchSubscriptionWithOwner(t *testing.T) {
t.Parallel()

tc := globalTestContext.ForTest(t)

secret, err := newClientSecretCredential(uuid.New().String(), tc.AzureTenant, "credential", tc.Namespace)
tc.Expect(err).To(BeNil())

tc.CreateResource(secret)

rg := tc.CreateTestResourceGroupAndWait()

acct := newStorageAccount(tc, rg)
acct.Annotations = map[string]string{annotations.PerResourceSecret: secret.Name}

// Creating new storage account in with restricted permissions per resource secret should fail.
tc.CreateResourceAndWaitForState(acct, metav1.ConditionFalse, conditions.ConditionSeverityError)
tc.Expect(acct.Status.Conditions[0].Message).To(ContainSubstring("does not match parent subscription"))

// Deleting the per-resource credential annotation would default to applying the global credential with all permissions
super-harsh marked this conversation as resolved.
Show resolved Hide resolved
old := acct.DeepCopy()
delete(acct.Annotations, annotations.PerResourceSecret)
tc.PatchResourceAndWait(old, acct)

objKey := client.ObjectKeyFromObject(acct)
tc.GetResource(objKey, acct)
tc.Expect(acct.Annotations).ToNot(HaveKey(annotations.PerResourceSecret))

resID := genruntime.GetResourceIDOrDefault(acct)

// Make sure the StorageAccount is created successfully in Azure.
exists, _, err := tc.AzureClient.CheckExistenceWithGetByID(tc.Ctx, resID, string(storage.APIVersion_Value))
tc.Expect(err).ToNot(HaveOccurred())
tc.Expect(exists).To(BeTrue())

tc.DeleteResourcesAndWait(acct, rg)
}

func newClientSecretCredential(subscriptionID, tenantID, name, namespace string) (*v1.Secret, error) {
clientSecret := os.Getenv(AzureClientSecretMultitenantVar)
if clientSecret == "" {
Expand Down
Loading