diff --git a/config/controller-manager/overlays/core-control-plane/rbac/role.yaml b/config/controller-manager/overlays/core-control-plane/rbac/role.yaml index ff153a78..08e308fc 100644 --- a/config/controller-manager/overlays/core-control-plane/rbac/role.yaml +++ b/config/controller-manager/overlays/core-control-plane/rbac/role.yaml @@ -129,9 +129,8 @@ rules: - iam.miloapis.com resources: - platforminvitations - - userinvitations + - users verbs: - - delete - get - list - update @@ -139,20 +138,21 @@ rules: - apiGroups: - iam.miloapis.com resources: - - userpreferences + - userinvitations verbs: + - delete - get - list - - patch - update - watch - apiGroups: - iam.miloapis.com resources: - - users + - userpreferences verbs: - get - list + - patch - update - watch - apiGroups: @@ -247,6 +247,7 @@ rules: resources: - organizations verbs: + - delete - get - list - watch diff --git a/internal/controllers/resourcemanager/organization_controller.go b/internal/controllers/resourcemanager/organization_controller.go index 497ac657..dd4cf74c 100644 --- a/internal/controllers/resourcemanager/organization_controller.go +++ b/internal/controllers/resourcemanager/organization_controller.go @@ -8,21 +8,34 @@ import ( apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/types" ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/handler" "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + iamv1alpha1 "go.miloapis.com/milo/pkg/apis/iam/v1alpha1" resourcemanagerv1alpha "go.miloapis.com/milo/pkg/apis/resourcemanager/v1alpha1" ) +const ( + OrganizationMembershipUserIndexName = "organizationmembership-user-index" +) + // OrganizationController reconciles an Organization object type OrganizationController struct { - Client client.Client + Client client.Client + APIReader client.Reader } -// +kubebuilder:rbac:groups=resourcemanager.miloapis.com,resources=organizations,verbs=get;list;watch +// +kubebuilder:rbac:groups=resourcemanager.miloapis.com,resources=organizations,verbs=get;list;watch;delete // +kubebuilder:rbac:groups="",resources=namespaces,verbs=get;list;watch;update;patch // +kubebuilder:rbac:groups=authorization.k8s.io,resources=subjectaccessreviews,verbs=create +// +kubebuilder:rbac:groups=iam.miloapis.com,resources=users,verbs=get;list;watch +// +kubebuilder:rbac:groups=resourcemanager.miloapis.com,resources=organizationmemberships,verbs=get;list;watch func (r *OrganizationController) Reconcile(ctx context.Context, req ctrl.Request) (_ ctrl.Result, err error) { logger := log.FromContext(ctx) @@ -53,6 +66,14 @@ func (r *OrganizationController) Reconcile(ctx context.Context, req ctrl.Request return ctrl.Result{}, fmt.Errorf("failed to get organization namespace: %w", err) } + // Check for last-member condition + deleted, err := r.ensureOrganizationDeletedIfNoMembers(ctx, &organization) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to ensure organization deleted if no members: %w", err) + } else if deleted { + return ctrl.Result{}, nil + } + // Check if the organization is already set as the controller owner reference hasOwnerRef, err := controllerutil.HasOwnerReference(namespace.OwnerReferences, &organization, r.Client.Scheme()) if err != nil { @@ -79,9 +100,118 @@ func (r *OrganizationController) Reconcile(ctx context.Context, req ctrl.Request // SetupWithManager sets up the controller with the Manager. func (r *OrganizationController) SetupWithManager(mgr ctrl.Manager) error { r.Client = mgr.GetClient() + r.APIReader = mgr.GetAPIReader() + + // Index OrganizationMemberships by spec.userRef.name for efficient lookups + if err := mgr.GetFieldIndexer().IndexField(context.Background(), + &resourcemanagerv1alpha.OrganizationMembership{}, + OrganizationMembershipUserIndexName, + func(rawObj client.Object) []string { + obj := rawObj.(*resourcemanagerv1alpha.OrganizationMembership) + if obj.Spec.UserRef.Name == "" { + return nil + } + return []string{obj.Spec.UserRef.Name} + }, + ); err != nil { + return err + } return ctrl.NewControllerManagedBy(mgr). For(&resourcemanagerv1alpha.Organization{}). + Watches(&iamv1alpha1.User{}, + handler.EnqueueRequestsFromMapFunc(r.findOrganizationsForUser), + // Only react on user deletions + builder.WithPredicates(predicate.Funcs{ + CreateFunc: func(e event.CreateEvent) bool { return false }, + UpdateFunc: func(e event.UpdateEvent) bool { return false }, + GenericFunc: func(e event.GenericEvent) bool { return false }, + DeleteFunc: func(e event.DeleteEvent) bool { return true }, + }), + ). Named("organization"). Complete(r) } + +// findOrganizationsForUser maps a deleted User to the Organizations they were a member of. +func (r *OrganizationController) findOrganizationsForUser(ctx context.Context, obj client.Object) []reconcile.Request { + user := obj.(*iamv1alpha1.User) + logger := log.FromContext(ctx) + logger.Info("user deleted, enqueuing organizations for reconcile", "user", user.Name) + + // List all Organization + var memberships resourcemanagerv1alpha.OrganizationMembershipList + if err := r.Client.List(ctx, &memberships, client.MatchingFields{OrganizationMembershipUserIndexName: user.Name}); err != nil { + logger.Error(err, "failed to list organization memberships for deleted user", "user", user.Name) + return nil + } + + var requests []reconcile.Request + for _, m := range memberships.Items { + requests = append(requests, reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: m.Spec.OrganizationRef.Name, + }, + }) + } + return requests +} + +// ensureOrganizationDeletedIfNoMembers deletes the Organization if it no longer has any memberships. +func (r *OrganizationController) ensureOrganizationDeletedIfNoMembers(ctx context.Context, organization *resourcemanagerv1alpha.Organization) (bool, error) { + // Determine the organization namespace and list memberships within it + namespaceName := fmt.Sprintf("organization-%s", organization.Name) + var memberships resourcemanagerv1alpha.OrganizationMembershipList + if err := r.Client.List(ctx, &memberships, client.InNamespace(namespaceName)); err != nil { + return false, fmt.Errorf("failed to list organization memberships in namespace %s: %w", namespaceName, err) + } + + // Filter to memberships that reference this organization + var filtered []resourcemanagerv1alpha.OrganizationMembership + for _, m := range memberships.Items { + if m.Spec.OrganizationRef.Name == organization.Name { + filtered = append(filtered, m) + } + } + + // Len = 0: No memberships reference this organization (organization just created) + // Len > 1: Multiple memberships reference this organization + if len(filtered) == 0 || len(filtered) > 1 { + return false, nil + } + + // If there is exactly one membership left, check if the referenced user still exists. + // If the user does not exist (was deleted) while the membership remains, delete the organization. + // By webhook design, the last membership cannot be deleted. + + userName := filtered[0].Spec.UserRef.Name + user := &iamv1alpha1.User{} + // Use live API reader to avoid cache race; also treat terminating users as deleted + if err := r.APIReader.Get(ctx, client.ObjectKey{Name: userName}, user); err != nil { + if apierrors.IsNotFound(err) { + if err := r.deleteOrganization(ctx, organization, "single remaining membership references deleted user; deleting organization"); err != nil { + return false, err + } + return true, nil + } + return false, fmt.Errorf("failed to get user %s: %w", userName, err) + } + if !user.DeletionTimestamp.IsZero() { + if err := r.deleteOrganization(ctx, organization, "single remaining membership references terminating user; deleting organization"); err != nil { + return false, err + } + return true, nil + } + + return false, nil +} + +// deleteOrganization deletes the given Organization, logging a reason and ignoring NotFound errors. +func (r *OrganizationController) deleteOrganization(ctx context.Context, organization *resourcemanagerv1alpha.Organization, reason string) error { + logger := log.FromContext(ctx) + logger.Info(reason, "organization", organization.Name) + if err := r.Client.Delete(ctx, organization); err != nil && !apierrors.IsNotFound(err) { + return fmt.Errorf("failed to delete organization %s: %w", organization.Name, err) + } + return nil +} diff --git a/test/resource-management/organization-last-member-deletion/01-organization.yaml b/test/resource-management/organization-last-member-deletion/01-organization.yaml new file mode 100644 index 00000000..59cfa1a7 --- /dev/null +++ b/test/resource-management/organization-last-member-deletion/01-organization.yaml @@ -0,0 +1,10 @@ +apiVersion: resourcemanager.miloapis.com/v1alpha1 +kind: Organization +metadata: + name: last-member-deletion + annotations: + kubernetes.io/display-name: "Last Member Deletion Test Org" +spec: + type: Standard + + diff --git a/test/resource-management/organization-last-member-deletion/02-users.yaml b/test/resource-management/organization-last-member-deletion/02-users.yaml new file mode 100644 index 00000000..3daca0be --- /dev/null +++ b/test/resource-management/organization-last-member-deletion/02-users.yaml @@ -0,0 +1,28 @@ +apiVersion: iam.miloapis.com/v1alpha1 +kind: User +metadata: + name: test-lmd-alice +spec: + email: alice.lmd@example.com + givenName: Alice + familyName: Lmd +--- +apiVersion: iam.miloapis.com/v1alpha1 +kind: User +metadata: + name: test-lmd-bob +spec: + email: bob.lmd@example.com + givenName: Bob + familyName: Lmd +--- +apiVersion: iam.miloapis.com/v1alpha1 +kind: User +metadata: + name: test-lmd-carol +spec: + email: carol.lmd@example.com + givenName: Carol + familyName: Lmd + + diff --git a/test/resource-management/organization-last-member-deletion/03-memberships.yaml b/test/resource-management/organization-last-member-deletion/03-memberships.yaml new file mode 100644 index 00000000..56a9df5b --- /dev/null +++ b/test/resource-management/organization-last-member-deletion/03-memberships.yaml @@ -0,0 +1,43 @@ +apiVersion: resourcemanager.miloapis.com/v1alpha1 +kind: OrganizationMembership +metadata: + name: lmd-membership-alice + namespace: organization-last-member-deletion +spec: + organizationRef: + name: last-member-deletion + userRef: + name: test-lmd-alice + roles: + - name: resourcemanager.miloapis.com-organizationowner + namespace: milo-system +--- +apiVersion: resourcemanager.miloapis.com/v1alpha1 +kind: OrganizationMembership +metadata: + name: lmd-membership-bob + namespace: organization-last-member-deletion +spec: + organizationRef: + name: last-member-deletion + userRef: + name: test-lmd-bob + roles: + - name: resourcemanager.miloapis.com-organizationowner + namespace: milo-system +--- +apiVersion: resourcemanager.miloapis.com/v1alpha1 +kind: OrganizationMembership +metadata: + name: lmd-membership-carol + namespace: organization-last-member-deletion +spec: + organizationRef: + name: last-member-deletion + userRef: + name: test-lmd-carol + roles: + - name: resourcemanager.miloapis.com-organizationowner + namespace: milo-system + + diff --git a/test/resource-management/organization-last-member-deletion/04-owner-role.yaml b/test/resource-management/organization-last-member-deletion/04-owner-role.yaml new file mode 100644 index 00000000..4e8444dc --- /dev/null +++ b/test/resource-management/organization-last-member-deletion/04-owner-role.yaml @@ -0,0 +1,15 @@ +apiVersion: iam.miloapis.com/v1alpha1 +kind: Role +metadata: + name: resourcemanager.miloapis.com-organizationowner + namespace: milo-system + labels: + test.miloapis.com/scenario: "organization-last-member-deletion" +spec: + launchStage: Beta + includedPermissions: + - resourcemanager.miloapis.com/organizations.get + - resourcemanager.miloapis.com/organizations.update + - resourcemanager.miloapis.com/organizations.delete + + diff --git a/test/resource-management/organization-last-member-deletion/chainsaw-test.yaml b/test/resource-management/organization-last-member-deletion/chainsaw-test.yaml new file mode 100644 index 00000000..6150c61d --- /dev/null +++ b/test/resource-management/organization-last-member-deletion/chainsaw-test.yaml @@ -0,0 +1,218 @@ +apiVersion: chainsaw.kyverno.io/v1alpha1 +kind: Test +metadata: + name: organization-last-member-deletion +spec: + description: | + Verifies that an Organization is deleted when the only remaining OrganizationMembership + references a User that is deleted. The flow: + - Create Organization and namespace + - Create 3 Users and 3 OrganizationMemberships (one per user) + - Delete first User → Organization should still exist (2 memberships remain) + - Delete second User → Organization should still exist (1 membership remains but user still exists) + - Delete final User → Controller deletes Organization + + clusters: + org: + kubeconfig: kubeconfig-org + + steps: + - name: setup + cluster: org + description: Ensure a clean environment and create Organization and Users + try: + - description: Allow webhook/controller to be ready + sleep: + duration: 5s + - description: Create Organization + apply: + file: 01-organization.yaml + - description: Remove any auto-created admin membership (if present) + continueOnError: true + delete: + ref: + apiVersion: resourcemanager.miloapis.com/v1alpha1 + kind: OrganizationMembership + name: member-test-admin + namespace: organization-last-member-deletion + - description: Wait for Organization namespace to become Active + wait: + apiVersion: v1 + kind: Namespace + name: organization-last-member-deletion + timeout: 60s + for: + jsonPath: + path: '{.status.phase}' + value: Active + - description: Create Users + apply: + file: 02-users.yaml + - description: Ensure owner role exists in milo-system + apply: + file: 04-owner-role.yaml + - description: Wait for Users to be Ready (Alice) + wait: + apiVersion: iam.miloapis.com/v1alpha1 + kind: User + name: test-lmd-alice + timeout: 60s + for: + condition: + name: Ready + value: 'True' + - description: Wait for Users to be Ready (Bob) + wait: + apiVersion: iam.miloapis.com/v1alpha1 + kind: User + name: test-lmd-bob + timeout: 60s + for: + condition: + name: Ready + value: 'True' + - description: Wait for Users to be Ready (Carol) + wait: + apiVersion: iam.miloapis.com/v1alpha1 + kind: User + name: test-lmd-carol + timeout: 60s + for: + condition: + name: Ready + value: 'True' + + - name: create-organization-memberships + cluster: org + description: Create 3 OrganizationMemberships for the 3 users + try: + - apply: + file: 03-memberships.yaml + - description: Mark PolicyBindings as Ready (test workaround) + script: + timeout: 30s + content: | + # Wait for PolicyBindings to be created + sleep 2 + # Get all PolicyBindings in the namespace and mark them Ready + kubectl get policybindings -n organization-last-member-deletion -o name | while read pb; do + kubectl patch "$pb" -n organization-last-member-deletion --type=merge --subresource=status -p '{"status":{"conditions":[{"type":"Ready","status":"True","reason":"TestReady","message":"Marked ready for testing","lastTransitionTime":"'$(date -u +"%Y-%m-%dT%H:%M:%SZ")'"}],"observedGeneration":1}}' + done + - wait: + apiVersion: resourcemanager.miloapis.com/v1alpha1 + kind: OrganizationMembership + namespace: organization-last-member-deletion + name: lmd-membership-alice + timeout: 60s + for: + condition: + name: Ready + value: 'True' + - wait: + apiVersion: resourcemanager.miloapis.com/v1alpha1 + kind: OrganizationMembership + namespace: organization-last-member-deletion + name: lmd-membership-bob + timeout: 60s + for: + condition: + name: Ready + value: 'True' + - wait: + apiVersion: resourcemanager.miloapis.com/v1alpha1 + kind: OrganizationMembership + namespace: organization-last-member-deletion + name: lmd-membership-carol + timeout: 60s + for: + condition: + name: Ready + value: 'True' + + - name: delete-first-user-organization-still-exists + cluster: org + description: Delete first user membership; org should still exist (2 memberships remain) + try: + - description: Remove Alice's membership to reduce membership count + delete: + ref: + apiVersion: resourcemanager.miloapis.com/v1alpha1 + kind: OrganizationMembership + name: lmd-membership-alice + namespace: organization-last-member-deletion + - wait: + apiVersion: resourcemanager.miloapis.com/v1alpha1 + kind: OrganizationMembership + name: lmd-membership-alice + namespace: organization-last-member-deletion + timeout: 60s + for: + deletion: {} + - wait: + apiVersion: resourcemanager.miloapis.com/v1alpha1 + kind: Organization + name: last-member-deletion + timeout: 180s + for: + jsonPath: + path: '{.metadata.name}' + value: last-member-deletion + + - name: delete-second-user-organization-still-exists + cluster: org + description: Delete second user membership; org should still exist (1 membership remains, user still exists) + try: + - description: Remove Bob's membership to reach single remaining membership + delete: + ref: + apiVersion: resourcemanager.miloapis.com/v1alpha1 + kind: OrganizationMembership + name: lmd-membership-bob + namespace: organization-last-member-deletion + - wait: + apiVersion: resourcemanager.miloapis.com/v1alpha1 + kind: OrganizationMembership + name: lmd-membership-bob + namespace: organization-last-member-deletion + timeout: 60s + for: + deletion: {} + - wait: + apiVersion: resourcemanager.miloapis.com/v1alpha1 + kind: Organization + name: last-member-deletion + timeout: 180s + for: + jsonPath: + path: '{.metadata.name}' + value: last-member-deletion + + - name: delete-last-user-organization-gets-deleted + cluster: org + description: Delete last user; controller should delete the Organization + try: + - delete: + ref: + apiVersion: iam.miloapis.com/v1alpha1 + kind: User + name: test-lmd-carol + - sleep: + duration: 5s + - wait: + apiVersion: resourcemanager.miloapis.com/v1alpha1 + kind: Organization + name: last-member-deletion + timeout: 180s + for: + deletion: {} + - description: Ensure Organization namespace is eventually removed as well + continueOnError: true + wait: + apiVersion: v1 + kind: Namespace + name: organization-last-member-deletion + timeout: 180s + for: + deletion: {} + + diff --git a/test/resource-management/organization-last-member-deletion/kubeconfig-org b/test/resource-management/organization-last-member-deletion/kubeconfig-org new file mode 100644 index 00000000..cef34997 --- /dev/null +++ b/test/resource-management/organization-last-member-deletion/kubeconfig-org @@ -0,0 +1,20 @@ +apiVersion: v1 +kind: Config +current-context: org-last-member-deletion +clusters: + - name: org-last-member-deletion + cluster: + insecure-skip-tls-verify: true + server: https://localhost:30443 +contexts: + - name: org-last-member-deletion + context: + cluster: org-last-member-deletion + namespace: organization-last-member-deletion + user: test-admin +users: + - name: test-admin + user: + token: test-admin-token + +