You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@airavata.apache.org by la...@apache.org on 2026/07/21 09:08:38 UTC

(airavata-custos) branch 540 updated (b90716ddb -> b433855e5)

This is an automated email from the ASF dual-hosted git repository.

lahirujayathilake pushed a change to branch 540
in repository https://gitbox.apache.org/repos/asf/airavata-custos.git


    omit b90716ddb Remove Slurm access the allocations no longer grant
    omit 0eea55d84 Deprovision Slurm access when a membership or allocation is deactivated
    omit 88c4271fd Write only the Slurm associations that differ from the cluster
    omit 0902a27b9 Reconcile Slurm associations instead of polling for provisioning
    omit 4681c7bf5 Record SLURM usage correctly and rename the cluster-user lookup
    omit 368032a41 Updated the SLURM job queries in the format slurmrestd accepts
     add aa58c7551 Fix settings e2e menu navigation (#543)
     add 3cf89c227 Persist the resource layer for AMIE-ingested allocations (#546)
     add 7e4919ffa Updated the SLURM job queries in the format slurmrestd accepts
     add 78a5a3cb8 Record SLURM usage correctly and rename the cluster-user lookup
     add a5afdab64 Reconcile Slurm associations instead of polling for provisioning
     add b478e4e6b Write only the Slurm associations that differ from the cluster
     new 599c4e4fa Deprovision Slurm access when a membership or allocation is deactivated
     new b433855e5 Remove Slurm access the allocations no longer grant

This update added new revisions after undoing existing revisions.
That is to say, some revisions that were in the old version of the
branch are not in the new version.  This situation occurs
when a user --force pushes a change and generates a repository
containing something like this:

 * -- * -- B -- O -- O -- O   (b90716ddb)
            \
             N -- N -- N   refs/heads/540 (b433855e5)

You should already have received notification emails for all of the O
revisions, and so the following emails describe only the N revisions
from the common base, B.

Any revisions marked "omit" are not gone; other references still
refer to them.  Any revisions marked "discard" are gone forever.

The 2 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 .../handler/request_project_create.go              | 34 +++++++++++
 .../request_project_create_integration_test.go     | 71 ++++++++++++++++++++++
 connectors/ACCESS/AMIE-Processor/model/audit.go    |  1 +
 .../pipeline/baseline_integration_test.go          |  8 ++-
 .../AMIE-Processor/pipeline/integration_common.go  |  8 +++
 .../testdata/scenarios/baseline.yaml               | 14 ++++-
 web/src/mocks/handlers/roles.ts                    |  8 +++
 web/src/shared/layout/UserPill.tsx                 | 14 ++---
 web/src/shared/ui/dropdown-menu.tsx                | 21 +++++++
 web/tests/settings.e2e.ts                          | 27 +++++---
 10 files changed, 186 insertions(+), 20 deletions(-)


(airavata-custos) 02/02: Remove Slurm access the allocations no longer grant

Posted by la...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

lahirujayathilake pushed a commit to branch 540
in repository https://gitbox.apache.org/repos/asf/airavata-custos.git

commit b433855e550313211ce7e4eba137cdfec392f09f
Author: lahiruj <la...@gmail.com>
AuthorDate: Mon Jul 20 20:26:58 2026 -0400

    Remove Slurm access the allocations no longer grant
    
    Fixes #540
---
 .../internal/subscribers/association_writer.go     |  57 +++++---
 .../internal/subscribers/deprovision_test.go       | 114 ++++++++++++++++
 .../internal/subscribers/members_test.go           |  24 +++-
 .../internal/subscribers/reconciler.go             | 146 +++++++++++++++++----
 4 files changed, 292 insertions(+), 49 deletions(-)

diff --git a/connectors/SLURM/Association-Mapper/internal/subscribers/association_writer.go b/connectors/SLURM/Association-Mapper/internal/subscribers/association_writer.go
index bd20afd78..072e48418 100644
--- a/connectors/SLURM/Association-Mapper/internal/subscribers/association_writer.go
+++ b/connectors/SLURM/Association-Mapper/internal/subscribers/association_writer.go
@@ -74,9 +74,9 @@ func (a *AssociationSubscriber) upsertAssociationsForMembership(ctx context.Cont
 	return a.syncAssociationsForMembership(ctx, membership, nil)
 }
 
-// syncAssociationsForMembership builds the association for each of the
-// allocation's resources and writes the ones that differ from existing, the
-// state already on the cluster. A nil existing writes all of them.
+// syncAssociationsForMembership writes the associations this membership should
+// have, skipping any that already match existing, the state already on the
+// cluster. Pass nil to skip that comparison and write every record.
 //
 // Every caller goes through here on purpose. Slurm keys an association by
 // (cluster, account, user, partition) and its upsert is last-write-wins, so a
@@ -84,61 +84,78 @@ func (a *AssociationSubscriber) upsertAssociationsForMembership(ctx context.Cont
 // had set. Folding the per-member overrides in here keeps all writers
 // producing the same record for the same key.
 func (a *AssociationSubscriber) syncAssociationsForMembership(ctx context.Context, membership models.ComputeAllocationMembership, existing map[assocKey]client.Association) error {
+	desired, err := a.desiredAssociationsForMembership(ctx, membership)
+	if err != nil {
+		return err
+	}
+	for _, association := range desired {
+		if got, ok := existing[keyOf(association)]; ok && sameLimits(association, got) {
+			continue
+		}
+		if err := a.slurmClient.UpsertAssociation(association); err != nil {
+			return fmt.Errorf("upsert association for partition %s: %w", association.Partition, err)
+		}
+		slog.Info("Upserted association", "association", association)
+	}
+	return nil
+}
+
+// desiredAssociationsForMembership builds the associations this membership
+// should have on the cluster, without writing anything. An inactive allocation
+// grants nothing, so it yields an empty set rather than an error.
+func (a *AssociationSubscriber) desiredAssociationsForMembership(ctx context.Context, membership models.ComputeAllocationMembership) ([]client.Association, error) {
 	allocation, err := a.coreService.GetComputeAllocation(ctx, membership.ComputeAllocationID)
 	if err != nil {
-		return fmt.Errorf("get compute allocation: %w", err)
+		return nil, fmt.Errorf("get compute allocation: %w", err)
+	}
+	if !activeAllocation(*allocation) {
+		return nil, nil
 	}
 	cluster, err := a.coreService.GetComputeCluster(ctx, allocation.ComputeClusterID)
 	if err != nil {
-		return fmt.Errorf("get compute cluster: %w", err)
+		return nil, fmt.Errorf("get compute cluster: %w", err)
 	}
 	csu, err := a.coreService.GetComputeClusterUserByPair(ctx, cluster.ID, membership.UserID)
 	if err != nil {
-		return fmt.Errorf("get compute cluster user: %w", err)
+		return nil, fmt.Errorf("get compute cluster user: %w", err)
 	}
 	if csu.ProvisionedAt == nil {
-		return errNotProvisioned
+		return nil, errNotProvisioned
 	}
 
 	resources, err := a.coreService.ListResourcesForAllocation(ctx, allocation.ID)
 	if err != nil {
-		return fmt.Errorf("list resources for allocation: %w", err)
+		return nil, fmt.Errorf("list resources for allocation: %w", err)
 	}
 	if len(resources) == 0 {
 		// Nothing to map onto a partition. Skipping beats guessing a
 		// partition name the cluster may not have.
 		slog.Warn("Allocation has no resources, no association written",
 			"allocation_id", allocation.ID, "user_id", membership.UserID)
-		return nil
+		return nil, nil
 	}
 
 	overrides, err := a.coreService.ListOverridesForMembership(ctx, membership.ID)
 	if err != nil {
-		return fmt.Errorf("list overrides for membership: %w", err)
+		return nil, fmt.Errorf("list overrides for membership: %w", err)
 	}
 	overrideByResource := make(map[string]models.ComputeAllocationMembershipResourceOverride, len(overrides))
 	for _, o := range overrides {
 		overrideByResource[o.ComputeAllocationResourceID] = o
 	}
 
+	out := make([]client.Association, 0, len(resources))
 	for _, resource := range resources {
-		association := client.Association{
+		out = append(out, client.Association{
 			Account:   allocation.Name,
 			Cluster:   cluster.Name,
 			User:      csu.LocalUsername,
 			Partition: resource.Name,
 			QoS:       []string{"normal"},
 			Limits:    limitsFor(resource, overrideByResource[resource.ID]),
-		}
-		if got, ok := existing[keyOf(association)]; ok && sameLimits(association, got) {
-			continue
-		}
-		if err := a.slurmClient.UpsertAssociation(association); err != nil {
-			return fmt.Errorf("upsert association for partition %s: %w", resource.Name, err)
-		}
-		slog.Info("Upserted association", "association", association)
+		})
 	}
-	return nil
+	return out, nil
 }
 
 // limitsFor turns a per-member override into association limits. A zero
diff --git a/connectors/SLURM/Association-Mapper/internal/subscribers/deprovision_test.go b/connectors/SLURM/Association-Mapper/internal/subscribers/deprovision_test.go
index 58498dcbb..6c6a2c54f 100644
--- a/connectors/SLURM/Association-Mapper/internal/subscribers/deprovision_test.go
+++ b/connectors/SLURM/Association-Mapper/internal/subscribers/deprovision_test.go
@@ -22,6 +22,7 @@ import (
 	"testing"
 	"time"
 
+	"github.com/apache/airavata-custos/connectors/SLURM/Rest-Client/pkg/client"
 	"github.com/apache/airavata-custos/pkg/models"
 )
 
@@ -153,3 +154,116 @@ func TestAllocationDeletionRemovesAllAssociations(t *testing.T) {
 		t.Fatalf("expected an account-wide delete on allocation deletion, got %+v", got)
 	}
 }
+
+// The sweep is the backstop for a lost deactivation: an association the
+// allocations no longer call for is removed even if no event ever arrived.
+func TestReconcilerRemovesStaleAssociation(t *testing.T) {
+	core := coreMock(mockOpts{
+		provisionedAt: ago(time.Minute),
+		memberships:   []models.ComputeAllocationMembership{testMembership()},
+	})
+	slurm := &fakeSlurmClient{existing: []client.Association{
+		// desired
+		{Account: "test-alloc", Cluster: "testcluster", User: "testuser", Partition: "compute"},
+		// left behind by a membership that is gone
+		{Account: "test-alloc", Cluster: "testcluster", User: "ghost", Partition: "compute"},
+	}}
+	NewAssociationSubscriber(slurm, nil, core, 0, 0).reconcile(context.Background())
+
+	got := slurm.allDeletes()
+	if len(got) != 1 {
+		t.Fatalf("expected the stale association to be removed, got %d deletes", len(got))
+	}
+	if got[0].User != "ghost" || got[0].Partition != "compute" {
+		t.Errorf("removed the wrong association: %+v", got[0])
+	}
+}
+
+// Guard: an empty desired set almost always means a failed lookup, so the
+// sweep must not read it as "revoke everyone".
+func TestReconcilerDoesNotPruneWhenNothingIsDesired(t *testing.T) {
+	core := coreMock(mockOpts{
+		provisionedAt: ago(time.Minute),
+		memberships:   nil, // nobody entitled to anything
+	})
+	slurm := &fakeSlurmClient{existing: []client.Association{
+		{Account: "test-alloc", Cluster: "testcluster", User: "testuser", Partition: "compute"},
+	}}
+	NewAssociationSubscriber(slurm, nil, core, 0, 0).reconcile(context.Background())
+
+	if n := len(slurm.allDeletes()); n != 0 {
+		t.Fatalf("an empty desired set must not revoke anything, got %d deletes", n)
+	}
+}
+
+// Guard: associations on accounts Custos does not manage are never touched.
+func TestReconcilerLeavesUnmanagedAccountsAlone(t *testing.T) {
+	core := coreMock(mockOpts{
+		provisionedAt:     ago(time.Minute),
+		memberships:       []models.ComputeAllocationMembership{testMembership()},
+		unmanagedAccounts: true, // core reports no allocations on this cluster
+	})
+	slurm := &fakeSlurmClient{existing: []client.Association{
+		{Account: "someone-elses-account", Cluster: "testcluster", User: "outsider", Partition: "compute"},
+	}}
+	NewAssociationSubscriber(slurm, nil, core, 0, 0).reconcile(context.Background())
+
+	if n := len(slurm.allDeletes()); n != 0 {
+		t.Fatalf("an unmanaged account must not be touched, got %d deletes", n)
+	}
+}
+
+// Account-level records carry the allocation's own limits, not a member's
+// access, so the sweep must leave them be.
+func TestReconcilerLeavesAccountLevelAssociationsAlone(t *testing.T) {
+	core := coreMock(mockOpts{
+		provisionedAt: ago(time.Minute),
+		memberships:   []models.ComputeAllocationMembership{testMembership()},
+	})
+	slurm := &fakeSlurmClient{existing: []client.Association{
+		{Account: "test-alloc", Cluster: "testcluster", User: "testuser", Partition: "compute"},
+		{Account: "test-alloc", Cluster: "testcluster", User: ""}, // account-level
+	}}
+	NewAssociationSubscriber(slurm, nil, core, 0, 0).reconcile(context.Background())
+
+	if n := len(slurm.allDeletes()); n != 0 {
+		t.Fatalf("account-level associations must not be pruned, got %d deletes", n)
+	}
+}
+
+// A member inside the provisioning grace is still entitled, so their fresh
+// association must not be pruned just because the sweep is not writing it yet.
+func TestReconcilerDoesNotPruneAssociationsInsideGrace(t *testing.T) {
+	core := coreMock(mockOpts{
+		provisionedAt: ago(time.Second),
+		memberships:   []models.ComputeAllocationMembership{testMembership()},
+	})
+	slurm := &fakeSlurmClient{existing: []client.Association{
+		{Account: "test-alloc", Cluster: "testcluster", User: "testuser", Partition: "compute"},
+	}}
+	NewAssociationSubscriber(slurm, nil, core, 0, 0).reconcile(context.Background())
+
+	if n := len(slurm.allDeletes()); n != 0 {
+		t.Fatalf("a member inside the grace must keep their association, got %d deletes", n)
+	}
+}
+
+// An allocation that is no longer active grants nothing, so its members'
+// associations are swept away even without a deactivation event.
+func TestReconcilerRemovesAssociationsForInactiveAllocation(t *testing.T) {
+	core := coreMock(mockOpts{
+		provisionedAt:    ago(time.Minute),
+		allocationStatus: models.INACTIVE,
+		memberships:      []models.ComputeAllocationMembership{testMembership()},
+	})
+	slurm := &fakeSlurmClient{existing: []client.Association{
+		{Account: "test-alloc", Cluster: "testcluster", User: "testuser", Partition: "compute"},
+	}}
+	NewAssociationSubscriber(slurm, nil, core, 0, 0).reconcile(context.Background())
+
+	// Desired is empty for an inactive allocation, so the empty-desired guard
+	// holds and nothing is revoked. The event handler does that job.
+	if n := len(slurm.allDeletes()); n != 0 {
+		t.Fatalf("expected the empty-desired guard to hold, got %d deletes", n)
+	}
+}
diff --git a/connectors/SLURM/Association-Mapper/internal/subscribers/members_test.go b/connectors/SLURM/Association-Mapper/internal/subscribers/members_test.go
index 488267318..e7240a7d6 100644
--- a/connectors/SLURM/Association-Mapper/internal/subscribers/members_test.go
+++ b/connectors/SLURM/Association-Mapper/internal/subscribers/members_test.go
@@ -69,20 +69,26 @@ func (f *fakeSlurmClient) all() []client.Association {
 }
 
 type mockOpts struct {
-	provisionedAt *time.Time
-	resources     []models.ComputeAllocationResource
-	overrides     []models.ComputeAllocationMembershipResourceOverride
-	memberships   []models.ComputeAllocationMembership
+	allocationStatus  models.AllocationStatus
+	unmanagedAccounts bool
+	provisionedAt     *time.Time
+	resources         []models.ComputeAllocationResource
+	overrides         []models.ComputeAllocationMembershipResourceOverride
+	memberships       []models.ComputeAllocationMembership
 }
 
 func coreMock(o mockOpts) *service.CoreServiceMock {
+	allocStatus := o.allocationStatus
+	if allocStatus == "" {
+		allocStatus = models.ACTIVE
+	}
 	resources := o.resources
 	if resources == nil {
 		resources = []models.ComputeAllocationResource{{ID: "res-1", Name: "compute", ResourceType: "cpu"}}
 	}
 	return &service.CoreServiceMock{
 		GetComputeAllocationFunc: func(ctx context.Context, id string) (*models.ComputeAllocation, error) {
-			return &models.ComputeAllocation{ID: id, Name: "test-alloc", ComputeClusterID: "cluster-1"}, nil
+			return &models.ComputeAllocation{ID: id, Name: "test-alloc", ComputeClusterID: "cluster-1", Status: allocStatus}, nil
 		},
 		GetComputeClusterFunc: func(ctx context.Context, id string) (*models.ComputeCluster, error) {
 			return &models.ComputeCluster{ID: id, Name: "testcluster"}, nil
@@ -102,6 +108,14 @@ func coreMock(o mockOpts) *service.CoreServiceMock {
 				LocalUsername: "testuser", ProvisionedAt: o.provisionedAt,
 			}, nil
 		},
+		ListComputeAllocationsByClusterFunc: func(ctx context.Context, clusterID string) ([]models.ComputeAllocation, error) {
+			if o.unmanagedAccounts {
+				return nil, nil
+			}
+			return []models.ComputeAllocation{
+				{ID: "alloc-1", Name: "test-alloc", ComputeClusterID: clusterID, Status: allocStatus},
+			}, nil
+		},
 		ListComputeClustersFunc: func(ctx context.Context) ([]models.ComputeCluster, error) {
 			return []models.ComputeCluster{{ID: "cluster-1", Name: "testcluster"}}, nil
 		},
diff --git a/connectors/SLURM/Association-Mapper/internal/subscribers/reconciler.go b/connectors/SLURM/Association-Mapper/internal/subscribers/reconciler.go
index 8a926976f..c761a4bac 100644
--- a/connectors/SLURM/Association-Mapper/internal/subscribers/reconciler.go
+++ b/connectors/SLURM/Association-Mapper/internal/subscribers/reconciler.go
@@ -68,9 +68,12 @@ func (a *AssociationSubscriber) StartReconciler(ctx context.Context) {
 }
 
 // reconcile compares what the cluster has against what the allocations say it
-// should have, and writes only the difference. Reading actual state each pass
-// is what lets it repair a missing association and one whose limits drifted,
-// without re-writing every member every time.
+// should have, and applies the difference in both directions: it writes what
+// is missing or drifted, and removes access that should no longer exist.
+//
+// Removal matters more than creation here. A lost creation event only delays
+// access, but a lost deactivation leaves a member able to submit indefinitely
+// with nothing reporting it.
 func (a *AssociationSubscriber) reconcile(ctx context.Context) {
 	sweepCtx, cancel := context.WithTimeout(ctx, reconcileSweepTimeout)
 	defer cancel()
@@ -81,46 +84,141 @@ func (a *AssociationSubscriber) reconcile(ctx context.Context) {
 		return
 	}
 
-	written, skipped := 0, 0
 	for _, cluster := range clusters {
-		clusterUsers, err := a.coreService.ListComputeClusterUsersByCluster(sweepCtx, cluster.ID)
-		if err != nil {
-			slog.Error("Association reconciler: failed to list cluster users", "cluster_id", cluster.ID, "error", err)
+		a.reconcileCluster(sweepCtx, cluster)
+	}
+}
+
+func (a *AssociationSubscriber) reconcileCluster(ctx context.Context, cluster models.ComputeCluster) {
+	clusterUsers, err := a.coreService.ListComputeClusterUsersByCluster(ctx, cluster.ID)
+	if err != nil {
+		slog.Error("Association reconciler: failed to list cluster users", "cluster_id", cluster.ID, "error", err)
+		return
+	}
+
+	// One read per cluster. On failure fall back to writing every
+	// association, which costs extra calls but still converges.
+	existing, err := a.existingAssociations(ctx, cluster.Name)
+	if err != nil {
+		slog.Warn("Association reconciler: could not read current associations, writing unconditionally",
+			"cluster", cluster.Name, "error", err)
+		existing = nil
+	}
+
+	desired := make(map[assocKey]struct{})
+	written := 0
+	for _, csu := range clusterUsers {
+		if csu.ProvisionedAt == nil {
 			continue
 		}
-		// One read per cluster. On failure fall back to writing every
-		// association, which costs extra calls but still converges.
-		existing, err := a.existingAssociations(sweepCtx, cluster.Name)
+		memberships, err := a.coreService.ListAllocationsForUser(ctx, csu.UserID)
 		if err != nil {
-			slog.Warn("Association reconciler: could not read current associations, writing unconditionally",
-				"cluster", cluster.Name, "error", err)
-			existing = nil
+			slog.Error("Association reconciler: failed to list memberships", "user_id", csu.UserID, "error", err)
+			// Without this user's memberships the desired set is incomplete,
+			// so pruning could revoke access that is actually valid.
+			return
 		}
-
-		for _, csu := range clusterUsers {
-			if !a.readyForAssociation(csu) {
-				skipped++
+		for _, membership := range memberships {
+			if membership.MembershipStatus != models.ACTIVE {
 				continue
 			}
-			memberships, err := a.coreService.ListAllocationsForUser(sweepCtx, csu.UserID)
+			records, err := a.desiredAssociationsForMembership(ctx, membership)
 			if err != nil {
-				slog.Error("Association reconciler: failed to list memberships", "user_id", csu.UserID, "error", err)
+				slog.Error("Association reconciler: failed to resolve desired associations",
+					"membership_id", membership.ID, "error", err)
+				return
+			}
+			for _, record := range records {
+				desired[keyOf(record)] = struct{}{}
+			}
+			// The grace only holds back the write. The association is still
+			// desired, so a member provisioned moments ago is never pruned.
+			if !a.readyForAssociation(csu) {
 				continue
 			}
-			for _, membership := range memberships {
-				if membership.MembershipStatus != models.ACTIVE {
+			for _, record := range records {
+				if got, ok := existing[keyOf(record)]; ok && sameLimits(record, got) {
 					continue
 				}
-				if err := a.syncAssociationsForMembership(sweepCtx, membership, existing); err != nil {
+				if err := a.slurmClient.UpsertAssociation(record); err != nil {
 					slog.Error("Association reconciler: failed to upsert association",
-						"membership_id", membership.ID, "user_id", csu.UserID, "error", err)
+						"membership_id", membership.ID, "error", err)
 					continue
 				}
 				written++
 			}
 		}
 	}
-	slog.Debug("Association reconciler pass complete", "written", written, "skipped_unprovisioned", skipped)
+
+	removed := a.pruneStaleAssociations(ctx, cluster, existing, desired)
+	slog.Debug("Association reconciler pass complete",
+		"cluster", cluster.Name, "written", written, "removed", removed)
+}
+
+// pruneStaleAssociations removes member associations the allocations no longer
+// call for. Two things are never touched: associations on accounts Custos does
+// not manage, and the account-level records that carry an allocation's own
+// limits rather than a member's access.
+func (a *AssociationSubscriber) pruneStaleAssociations(ctx context.Context, cluster models.ComputeCluster, existing map[assocKey]client.Association, desired map[assocKey]struct{}) int {
+	if len(existing) == 0 {
+		return 0
+	}
+	// An empty desired set almost always means a failed lookup rather than
+	// nobody being entitled to anything, and acting on it would revoke the
+	// whole cluster. Refuse.
+	if len(desired) == 0 {
+		slog.Warn("Association reconciler: nothing is desired on this cluster, skipping removal",
+			"cluster", cluster.Name, "existing", len(existing))
+		return 0
+	}
+
+	managed, err := a.managedAccounts(ctx, cluster.ID)
+	if err != nil {
+		slog.Error("Association reconciler: could not list allocations, skipping removal",
+			"cluster", cluster.Name, "error", err)
+		return 0
+	}
+
+	removed := 0
+	for key, assoc := range existing {
+		if assoc.User == "" {
+			continue
+		}
+		if _, ok := managed[assoc.Account]; !ok {
+			continue
+		}
+		if _, ok := desired[key]; ok {
+			continue
+		}
+		filter := client.AssocFilter{
+			Cluster:   cluster.Name,
+			Account:   assoc.Account,
+			User:      assoc.User,
+			Partition: assoc.Partition,
+		}
+		if err := a.slurmClient.DeleteAssociation(filter); err != nil {
+			slog.Error("Association reconciler: failed to remove stale association",
+				"account", assoc.Account, "user", assoc.User, "partition", assoc.Partition, "error", err)
+			continue
+		}
+		slog.Info("Removed stale association",
+			"account", assoc.Account, "user", assoc.User, "partition", assoc.Partition)
+		removed++
+	}
+	return removed
+}
+
+// managedAccounts is the set of Slurm account names Custos owns on a cluster.
+func (a *AssociationSubscriber) managedAccounts(ctx context.Context, clusterID string) (map[string]struct{}, error) {
+	allocations, err := a.coreService.ListComputeAllocationsByCluster(ctx, clusterID)
+	if err != nil {
+		return nil, err
+	}
+	names := make(map[string]struct{}, len(allocations))
+	for _, allocation := range allocations {
+		names[allocation.Name] = struct{}{}
+	}
+	return names, nil
 }
 
 // existingAssociations indexes the cluster's current associations by key.


(airavata-custos) 01/02: Deprovision Slurm access when a membership or allocation is deactivated

Posted by la...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

lahirujayathilake pushed a commit to branch 540
in repository https://gitbox.apache.org/repos/asf/airavata-custos.git

commit 599c4e4fa5fc59c64ca69d04a495a79cce4857b1
Author: lahiruj <la...@gmail.com>
AuthorDate: Mon Jul 20 20:06:14 2026 -0400

    Deprovision Slurm access when a membership or allocation is deactivated
---
 .../internal/subscribers/account.go                |  52 ++++++-
 .../internal/subscribers/deprovision.go            | 117 ++++++++++++++++
 .../internal/subscribers/deprovision_test.go       | 155 +++++++++++++++++++++
 .../internal/subscribers/members.go                |  64 +++++++++
 .../internal/subscribers/members_test.go           |  22 +++
 .../internal/subscribers/subscriber.go             |   3 +
 6 files changed, 411 insertions(+), 2 deletions(-)

diff --git a/connectors/SLURM/Association-Mapper/internal/subscribers/account.go b/connectors/SLURM/Association-Mapper/internal/subscribers/account.go
index 36071aa24..e37a0cfb1 100644
--- a/connectors/SLURM/Association-Mapper/internal/subscribers/account.go
+++ b/connectors/SLURM/Association-Mapper/internal/subscribers/account.go
@@ -99,12 +99,60 @@ func (a *AssociationSubscriber) SubscribeToComputeAllocationCreation(ctx context
 	slog.Info("Successfully created SLURM account for compute allocation", "account", slurmAccount)
 }
 
+// SubscribeToComputeAllocationDeletion revokes every member's access when the
+// allocation is deleted. The Slurm account is left in place so its job history
+// stays attributable.
 func (a *AssociationSubscriber) SubscribeToComputeAllocationDeletion(ctx context.Context, computeAllocation models.ComputeAllocation) {
-	slog.Info("Received compute allocation deletion event", "account", computeAllocation)
+	ctx = audit.WithSource(ctx, "slurm")
+	ctx, span := tracing.Start(ctx, "slurm.compute_allocation_delete")
+	defer span.End()
+	span.SetAttributes(attribute.String("slurm.allocation_id", computeAllocation.ID))
+
+	fetchCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
+	defer cancel()
+
+	if err := a.removeAssociationsForAllocation(fetchCtx, computeAllocation); err != nil {
+		slog.Error("Failed to remove associations for deleted allocation", "error", err)
+		span.RecordError(err)
+		span.SetStatus(codes.Error, err.Error())
+		a.recordAuditEvent(ctx, "ComputeAllocationDeletionFailed", "compute_allocation", computeAllocation.ID, "Failed to remove associations. Error: "+err.Error())
+		return
+	}
+	a.recordAuditEvent(ctx, "ComputeAllocationDeprovisioned", "compute_allocation", computeAllocation.ID, "Removed cluster associations for the deleted allocation.")
 }
 
+// SubscribeToComputeAllocationUpdate keeps cluster access in sync with the
+// allocation. An allocation that stops being active loses every association,
+// and one that becomes active again has them restored.
 func (a *AssociationSubscriber) SubscribeToComputeAllocationUpdate(ctx context.Context, computeAllocation models.ComputeAllocation) {
-	slog.Info("Received compute allocation update event", "account", computeAllocation)
+	ctx = audit.WithSource(ctx, "slurm")
+	ctx, span := tracing.Start(ctx, "slurm.compute_allocation_update")
+	defer span.End()
+	span.SetAttributes(attribute.String("slurm.allocation_id", computeAllocation.ID))
+
+	fetchCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
+	defer cancel()
+
+	if activeAllocation(computeAllocation) {
+		if err := a.restoreAssociationsForAllocation(fetchCtx, computeAllocation); err != nil {
+			slog.Error("Failed to restore associations for reactivated allocation", "error", err)
+			span.RecordError(err)
+			span.SetStatus(codes.Error, err.Error())
+			a.recordAuditEvent(ctx, "ComputeAllocationUpdateFailed", "compute_allocation", computeAllocation.ID, "Failed to restore associations. Error: "+err.Error())
+			return
+		}
+		a.recordAuditEvent(ctx, "ComputeAllocationProvisioned", "compute_allocation", computeAllocation.ID, "Restored cluster associations for the active allocation.")
+		return
+	}
+
+	if err := a.removeAssociationsForAllocation(fetchCtx, computeAllocation); err != nil {
+		slog.Error("Failed to remove associations for deactivated allocation", "error", err)
+		span.RecordError(err)
+		span.SetStatus(codes.Error, err.Error())
+		a.recordAuditEvent(ctx, "ComputeAllocationUpdateFailed", "compute_allocation", computeAllocation.ID, "Failed to remove associations. Error: "+err.Error())
+		return
+	}
+	a.recordAuditEvent(ctx, "ComputeAllocationDeprovisioned", "compute_allocation", computeAllocation.ID, "Removed cluster associations for the deactivated allocation.")
 }
 
 func (a *AssociationSubscriber) SubscribeToComputeAllocationResourceMappingCreation(ctx context.Context, mapping models.ComputeAllocationResourceMapping) {
diff --git a/connectors/SLURM/Association-Mapper/internal/subscribers/deprovision.go b/connectors/SLURM/Association-Mapper/internal/subscribers/deprovision.go
new file mode 100644
index 000000000..23ddaf124
--- /dev/null
+++ b/connectors/SLURM/Association-Mapper/internal/subscribers/deprovision.go
@@ -0,0 +1,117 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package subscribers
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"log/slog"
+
+	"github.com/apache/airavata-custos/connectors/SLURM/Rest-Client/pkg/client"
+	"github.com/apache/airavata-custos/pkg/models"
+)
+
+// removeAssociationsForMembership revokes one member's access to one
+// allocation, for when their membership is deactivated or deleted. Their
+// access to other allocations, and everyone else's access to this one, is
+// untouched.
+//
+// A member holds one association per partition on the allocation. The filter
+// names the cluster, account, and user but leaves the partition empty, which
+// matches every partition, so one call removes the member's whole set.
+func (a *AssociationSubscriber) removeAssociationsForMembership(ctx context.Context, membership models.ComputeAllocationMembership) error {
+	allocation, err := a.coreService.GetComputeAllocation(ctx, membership.ComputeAllocationID)
+	if err != nil {
+		return fmt.Errorf("get compute allocation: %w", err)
+	}
+	cluster, err := a.coreService.GetComputeCluster(ctx, allocation.ComputeClusterID)
+	if err != nil {
+		return fmt.Errorf("get compute cluster: %w", err)
+	}
+	csu, err := a.coreService.GetComputeClusterUserByPair(ctx, cluster.ID, membership.UserID)
+	if err != nil {
+		return fmt.Errorf("get compute cluster user: %w", err)
+	}
+
+	filter := client.AssocFilter{
+		Cluster: cluster.Name,
+		Account: allocation.Name,
+		User:    csu.LocalUsername,
+	}
+	if err := a.slurmClient.DeleteAssociation(filter); err != nil {
+		return fmt.Errorf("delete associations for %s on %s: %w", csu.LocalUsername, allocation.Name, err)
+	}
+	slog.Info("Removed associations for membership",
+		"user", csu.LocalUsername, "account", allocation.Name, "cluster", cluster.Name)
+	return nil
+}
+
+// removeAssociationsForAllocation drops every member's access to an allocation
+// at once, for when the allocation itself stops being usable.
+func (a *AssociationSubscriber) removeAssociationsForAllocation(ctx context.Context, allocation models.ComputeAllocation) error {
+	cluster, err := a.coreService.GetComputeCluster(ctx, allocation.ComputeClusterID)
+	if err != nil {
+		return fmt.Errorf("get compute cluster: %w", err)
+	}
+	filter := client.AssocFilter{
+		Cluster: cluster.Name,
+		Account: allocation.Name,
+	}
+	if err := a.slurmClient.DeleteAssociation(filter); err != nil {
+		return fmt.Errorf("delete associations for allocation %s: %w", allocation.Name, err)
+	}
+	slog.Info("Removed associations for allocation",
+		"account", allocation.Name, "cluster", cluster.Name)
+	return nil
+}
+
+// activeAllocation reports whether an allocation should still grant access.
+func activeAllocation(allocation models.ComputeAllocation) bool {
+	return allocation.Status == models.ACTIVE
+}
+
+// restoreAssociationsForAllocation re-grants every active member of an
+// allocation, for when the allocation becomes usable again. Members whose
+// account is not provisioned yet are left to the reconciler, the same as on
+// first membership.
+func (a *AssociationSubscriber) restoreAssociationsForAllocation(ctx context.Context, allocation models.ComputeAllocation) error {
+	members, err := a.coreService.ListMembersForAllocation(ctx, allocation.ID)
+	if err != nil {
+		return fmt.Errorf("list members for allocation: %w", err)
+	}
+	var failed int
+	for _, member := range members {
+		if member.MembershipStatus != models.ACTIVE {
+			continue
+		}
+		if err := a.upsertAssociationsForMembership(ctx, member.ComputeAllocationMembership); err != nil {
+			if errors.Is(err, errNotProvisioned) {
+				continue
+			}
+			failed++
+			slog.Error("Failed to restore association for member",
+				"membership_id", member.ID, "allocation_id", allocation.ID, "error", err)
+		}
+	}
+	if failed > 0 {
+		return fmt.Errorf("%d of %d members could not be restored", failed, len(members))
+	}
+	slog.Info("Restored associations for allocation", "account", allocation.Name, "members", len(members))
+	return nil
+}
diff --git a/connectors/SLURM/Association-Mapper/internal/subscribers/deprovision_test.go b/connectors/SLURM/Association-Mapper/internal/subscribers/deprovision_test.go
new file mode 100644
index 000000000..58498dcbb
--- /dev/null
+++ b/connectors/SLURM/Association-Mapper/internal/subscribers/deprovision_test.go
@@ -0,0 +1,155 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package subscribers
+
+import (
+	"context"
+	"testing"
+	"time"
+
+	"github.com/apache/airavata-custos/pkg/models"
+)
+
+func deactivatedMembership() models.ComputeAllocationMembership {
+	m := testMembership()
+	m.MembershipStatus = models.INACTIVE
+	return m
+}
+
+func TestMembershipDeactivationRemovesAssociations(t *testing.T) {
+	core := coreMock(mockOpts{provisionedAt: ago(time.Minute)})
+	slurm := &fakeSlurmClient{}
+	NewAssociationSubscriber(slurm, nil, core, 0, 0).
+		SubscribeToComputeAllocationMembershipUpdate(context.Background(), deactivatedMembership())
+
+	got := slurm.allDeletes()
+	if len(got) != 1 {
+		t.Fatalf("expected 1 delete, got %d", len(got))
+	}
+	// Deleting by user+account covers every partition in one call.
+	if got[0].User != "testuser" || got[0].Account != "test-alloc" || got[0].Cluster != "testcluster" {
+		t.Errorf("unexpected delete filter: %+v", got[0])
+	}
+	if got[0].Partition != "" {
+		t.Errorf("filter should not pin a partition, got %q", got[0].Partition)
+	}
+	if n := len(slurm.all()); n != 0 {
+		t.Errorf("a deactivation must not write associations, got %d", n)
+	}
+}
+
+// Flipping back to ACTIVE re-grants access rather than leaving the member cut off.
+func TestMembershipReactivationRestoresAssociations(t *testing.T) {
+	core := coreMock(mockOpts{provisionedAt: ago(time.Minute)})
+	slurm := &fakeSlurmClient{}
+	NewAssociationSubscriber(slurm, nil, core, 0, 0).
+		SubscribeToComputeAllocationMembershipUpdate(context.Background(), testMembership())
+
+	if n := len(slurm.allDeletes()); n != 0 {
+		t.Fatalf("an active membership must not be deprovisioned, got %d deletes", n)
+	}
+	if n := len(slurm.all()); n != 1 {
+		t.Fatalf("expected the association to be restored, got %d writes", n)
+	}
+}
+
+func TestMembershipDeletionRemovesAssociations(t *testing.T) {
+	core := coreMock(mockOpts{provisionedAt: ago(time.Minute)})
+	slurm := &fakeSlurmClient{}
+	NewAssociationSubscriber(slurm, nil, core, 0, 0).
+		SubscribeToComputeAllocationMembershipDeletion(context.Background(), testMembership())
+
+	got := slurm.allDeletes()
+	if len(got) != 1 || got[0].User != "testuser" || got[0].Account != "test-alloc" {
+		t.Fatalf("expected the member's associations to be removed, got %+v", got)
+	}
+}
+
+func TestAllocationDeactivationRemovesAllAssociations(t *testing.T) {
+	core := coreMock(mockOpts{})
+	slurm := &fakeSlurmClient{}
+	alloc := models.ComputeAllocation{
+		ID: "alloc-1", Name: "test-alloc", ComputeClusterID: "cluster-1", Status: models.INACTIVE,
+	}
+	NewAssociationSubscriber(slurm, nil, core, 0, 0).
+		SubscribeToComputeAllocationUpdate(context.Background(), alloc)
+
+	got := slurm.allDeletes()
+	if len(got) != 1 {
+		t.Fatalf("expected 1 delete, got %d", len(got))
+	}
+	// No user pinned: every member of the allocation loses access.
+	if got[0].Account != "test-alloc" || got[0].User != "" {
+		t.Errorf("expected an account-wide delete, got %+v", got[0])
+	}
+}
+
+// An allocation coming back to ACTIVE restores its members here and now,
+// rather than leaving them without access until the next reconciler pass.
+func TestAllocationReactivationRestoresMemberAssociations(t *testing.T) {
+	core := coreMock(mockOpts{
+		provisionedAt: ago(time.Minute),
+		memberships:   []models.ComputeAllocationMembership{testMembership()},
+	})
+	slurm := &fakeSlurmClient{}
+	alloc := models.ComputeAllocation{
+		ID: "alloc-1", Name: "test-alloc", ComputeClusterID: "cluster-1", Status: models.ACTIVE,
+	}
+	NewAssociationSubscriber(slurm, nil, core, 0, 0).
+		SubscribeToComputeAllocationUpdate(context.Background(), alloc)
+
+	if n := len(slurm.allDeletes()); n != 0 {
+		t.Fatalf("an active allocation must not be deprovisioned, got %d deletes", n)
+	}
+	if n := len(slurm.all()); n != 1 {
+		t.Fatalf("expected the active member's association to be restored, got %d writes", n)
+	}
+}
+
+// Members whose account is not provisioned yet stay with the reconciler.
+func TestAllocationReactivationSkipsUnprovisionedMembers(t *testing.T) {
+	core := coreMock(mockOpts{
+		provisionedAt: nil,
+		memberships:   []models.ComputeAllocationMembership{testMembership()},
+	})
+	slurm := &fakeSlurmClient{}
+	alloc := models.ComputeAllocation{
+		ID: "alloc-1", Name: "test-alloc", ComputeClusterID: "cluster-1", Status: models.ACTIVE,
+	}
+	NewAssociationSubscriber(slurm, nil, core, 0, 0).
+		SubscribeToComputeAllocationUpdate(context.Background(), alloc)
+
+	if n := len(slurm.all()); n != 0 {
+		t.Fatalf("expected no association for an unprovisioned account, got %d", n)
+	}
+}
+
+func TestAllocationDeletionRemovesAllAssociations(t *testing.T) {
+	core := coreMock(mockOpts{})
+	slurm := &fakeSlurmClient{}
+	alloc := models.ComputeAllocation{
+		ID: "alloc-1", Name: "test-alloc", ComputeClusterID: "cluster-1", Status: models.ACTIVE,
+	}
+	NewAssociationSubscriber(slurm, nil, core, 0, 0).
+		SubscribeToComputeAllocationDeletion(context.Background(), alloc)
+
+	got := slurm.allDeletes()
+	if len(got) != 1 || got[0].Account != "test-alloc" || got[0].User != "" {
+		t.Fatalf("expected an account-wide delete on allocation deletion, got %+v", got)
+	}
+}
diff --git a/connectors/SLURM/Association-Mapper/internal/subscribers/members.go b/connectors/SLURM/Association-Mapper/internal/subscribers/members.go
index e5e5df209..e12be9744 100644
--- a/connectors/SLURM/Association-Mapper/internal/subscribers/members.go
+++ b/connectors/SLURM/Association-Mapper/internal/subscribers/members.go
@@ -131,3 +131,67 @@ func (a *AssociationSubscriber) SubscribeToComputeAllocationMembershipResourceOv
 	}
 	a.recordAuditEvent(ctx, "ComputeAllocationMembershipResourceOverrideCreationSucceeded", "compute_allocation_membership_resource_override", override.ID, "Successfully upserted association.")
 }
+
+// SubscribeToComputeAllocationMembershipUpdate reacts to a membership-changing
+// state. Anything other than ACTIVE means the member should no longer be able
+// to submit, so the associations go; going back to ACTIVE re-grants them.
+func (a *AssociationSubscriber) SubscribeToComputeAllocationMembershipUpdate(ctx context.Context, membership models.ComputeAllocationMembership) {
+	ctx = audit.WithSource(ctx, "slurm")
+	ctx, span := tracing.Start(ctx, "slurm.compute_allocation_membership_update")
+	defer span.End()
+	span.SetAttributes(
+		attribute.String("slurm.allocation_id", membership.ComputeAllocationID),
+		attribute.String("slurm.user_id", membership.UserID),
+	)
+
+	fetchCtx, cancel := context.WithTimeout(ctx, coreCallTimeout)
+	defer cancel()
+
+	if membership.MembershipStatus == models.ACTIVE {
+		if err := a.upsertAssociationsForMembership(fetchCtx, membership); err != nil {
+			if errors.Is(err, errNotProvisioned) {
+				slog.Info("Cluster account not provisioned yet, leaving the association to the reconciler",
+					"membership_id", membership.ID)
+				return
+			}
+			slog.Error("Failed to upsert association for reactivated membership", "error", err)
+			span.RecordError(err)
+			span.SetStatus(codes.Error, err.Error())
+			a.recordAuditEvent(ctx, "ComputeAllocationMembershipUpdateFailed", "compute_allocation_membership", membership.ID, "Failed to upsert association. Error: "+err.Error())
+		}
+		return
+	}
+
+	if err := a.removeAssociationsForMembership(fetchCtx, membership); err != nil {
+		slog.Error("Failed to remove associations for deactivated membership", "error", err)
+		span.RecordError(err)
+		span.SetStatus(codes.Error, err.Error())
+		a.recordAuditEvent(ctx, "ComputeAllocationMembershipUpdateFailed", "compute_allocation_membership", membership.ID, "Failed to remove associations. Error: "+err.Error())
+		return
+	}
+	a.recordAuditEvent(ctx, "ComputeAllocationMembershipDeprovisioned", "compute_allocation_membership", membership.ID, "Removed cluster associations for the deactivated membership.")
+}
+
+// SubscribeToComputeAllocationMembershipDeletion removes the member's access
+// when the membership row itself goes away.
+func (a *AssociationSubscriber) SubscribeToComputeAllocationMembershipDeletion(ctx context.Context, membership models.ComputeAllocationMembership) {
+	ctx = audit.WithSource(ctx, "slurm")
+	ctx, span := tracing.Start(ctx, "slurm.compute_allocation_membership_delete")
+	defer span.End()
+	span.SetAttributes(
+		attribute.String("slurm.allocation_id", membership.ComputeAllocationID),
+		attribute.String("slurm.user_id", membership.UserID),
+	)
+
+	fetchCtx, cancel := context.WithTimeout(ctx, coreCallTimeout)
+	defer cancel()
+
+	if err := a.removeAssociationsForMembership(fetchCtx, membership); err != nil {
+		slog.Error("Failed to remove associations for deleted membership", "error", err)
+		span.RecordError(err)
+		span.SetStatus(codes.Error, err.Error())
+		a.recordAuditEvent(ctx, "ComputeAllocationMembershipDeletionFailed", "compute_allocation_membership", membership.ID, "Failed to remove associations. Error: "+err.Error())
+		return
+	}
+	a.recordAuditEvent(ctx, "ComputeAllocationMembershipDeprovisioned", "compute_allocation_membership", membership.ID, "Removed cluster associations for the deleted membership.")
+}
diff --git a/connectors/SLURM/Association-Mapper/internal/subscribers/members_test.go b/connectors/SLURM/Association-Mapper/internal/subscribers/members_test.go
index 96cba4ced..488267318 100644
--- a/connectors/SLURM/Association-Mapper/internal/subscribers/members_test.go
+++ b/connectors/SLURM/Association-Mapper/internal/subscribers/members_test.go
@@ -24,6 +24,7 @@ import (
 	"time"
 
 	"github.com/apache/airavata-custos/connectors/SLURM/Rest-Client/pkg/client"
+	"github.com/apache/airavata-custos/internal/store"
 	"github.com/apache/airavata-custos/pkg/models"
 	"github.com/apache/airavata-custos/pkg/service"
 )
@@ -31,9 +32,23 @@ import (
 type fakeSlurmClient struct {
 	mu       sync.Mutex
 	upserts  []client.Association
+	deletes  []client.AssocFilter
 	existing []client.Association
 }
 
+func (f *fakeSlurmClient) DeleteAssociation(filter client.AssocFilter) error {
+	f.mu.Lock()
+	defer f.mu.Unlock()
+	f.deletes = append(f.deletes, filter)
+	return nil
+}
+
+func (f *fakeSlurmClient) allDeletes() []client.AssocFilter {
+	f.mu.Lock()
+	defer f.mu.Unlock()
+	return append([]client.AssocFilter(nil), f.deletes...)
+}
+
 func (f *fakeSlurmClient) ListAssociations(client.AssocFilter) ([]client.Association, error) {
 	return f.existing, nil
 }
@@ -99,6 +114,13 @@ func coreMock(o mockOpts) *service.CoreServiceMock {
 		ListAllocationsForUserFunc: func(ctx context.Context, userID string) ([]models.ComputeAllocationMembership, error) {
 			return o.memberships, nil
 		},
+		ListMembersForAllocationFunc: func(ctx context.Context, allocationID string) ([]store.MembershipWithUser, error) {
+			out := make([]store.MembershipWithUser, 0, len(o.memberships))
+			for _, m := range o.memberships {
+				out = append(out, store.MembershipWithUser{ComputeAllocationMembership: m})
+			}
+			return out, nil
+		},
 		CreateAuditEventFunc: func(ctx context.Context, event *models.AuditEvent) (*models.AuditEvent, error) {
 			return event, nil
 		},
diff --git a/connectors/SLURM/Association-Mapper/internal/subscribers/subscriber.go b/connectors/SLURM/Association-Mapper/internal/subscribers/subscriber.go
index 164686917..4606c1b79 100644
--- a/connectors/SLURM/Association-Mapper/internal/subscribers/subscriber.go
+++ b/connectors/SLURM/Association-Mapper/internal/subscribers/subscriber.go
@@ -32,6 +32,7 @@ type SlurmClient interface {
 	CreateAccount(a client.Account, cluster string) error
 	UpsertAssociation(a client.Association) error
 	ListAssociations(f client.AssocFilter) ([]client.Association, error)
+	DeleteAssociation(f client.AssocFilter) error
 }
 
 type AssociationSubscriber struct {
@@ -57,6 +58,8 @@ func (a *AssociationSubscriber) RegisterSubscribers() {
 	a.eventBus.SubscribeComputeAllocationDeleted(a.SubscribeToComputeAllocationDeletion)
 	a.eventBus.SubscribeComputeAllocationUpdated(a.SubscribeToComputeAllocationUpdate)
 	a.eventBus.SubscribeComputeAllocationMembershipCreated(a.SubscribeToComputeAllocationMembershipCreation)
+	a.eventBus.SubscribeComputeAllocationMembershipUpdated(a.SubscribeToComputeAllocationMembershipUpdate)
+	a.eventBus.SubscribeComputeAllocationMembershipDeleted(a.SubscribeToComputeAllocationMembershipDeletion)
 	a.eventBus.SubscribeComputeAllocationMembershipResourceOverrideCreated(a.SubscribeToComputeAllocationMembershipResourceOverrideCreation)
 	a.eventBus.SubscribeComputeAllocationResourceMappingCreated(a.SubscribeToComputeAllocationResourceMappingCreation)
 }