You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@airavata.apache.org by di...@apache.org on 2026/05/18 00:31:47 UTC

(airavata-custos) 07/09: Implemented the service layer for Compute Allocation Memberships

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

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

commit 9d6333262129e7c01cc67ea451341ab62c5842db
Author: DImuthuUpe <di...@gmail.com>
AuthorDate: Sat May 16 21:32:55 2026 -0400

    Implemented the service layer for Compute Allocation Memberships
---
 docs/API-Docs.md                                   |  92 +++++++++
 .../000008_compute_allocation_memberships.down.sql |  18 ++
 .../000008_compute_allocation_memberships.up.sql   |  34 ++++
 internal/server/server.go                          | 105 ++++++++++
 .../store/compute_allocation_membership_store.go   | 122 ++++++++++++
 internal/store/store.go                            |  22 +++
 pkg/models/allocation.go                           |  14 +-
 pkg/service/compute_allocation_membership.go       | 220 +++++++++++++++++++++
 pkg/service/service.go                             |   4 +
 9 files changed, 624 insertions(+), 7 deletions(-)

diff --git a/docs/API-Docs.md b/docs/API-Docs.md
index 7854758d0..2a308db05 100644
--- a/docs/API-Docs.md
+++ b/docs/API-Docs.md
@@ -953,6 +953,98 @@ Return the most recent event for the given change request.
 
 ---
 
+## Compute Allocation Memberships
+
+A `ComputeAllocationMembership` records a user's sub-allocation against a
+parent `ComputeAllocation` — i.e. how many SUs of the parent allocation the
+user is entitled to consume, and the time window plus lifecycle status of
+that grant. At most one membership can exist per `(compute_allocation_id,
+user_id)` pair (enforced by a unique key). Memberships are cascade-deleted
+when their parent allocation is removed.
+
+### POST `/compute-allocation-memberships`
+
+Create a new membership.
+
+**Request body**
+
+```json
+{
+  "compute_allocation_id": "alloc-123",
+  "user_id":               "user-456",
+  "allocation_amount":     50000,
+  "start_time":            "2026-01-01T00:00:00Z",
+  "end_time":              "2026-12-31T23:59:59Z",
+  "membership_status":     "ACTIVE"
+}
+```
+
+- `compute_allocation_id` and `user_id` are required and must reference
+  existing rows.
+- `membership_status` defaults to `ACTIVE` when omitted.
+- `id` is generated server-side when omitted.
+
+**Errors**
+
+- `400` — missing required fields, or referenced allocation/user not found.
+- `409` — a membership already exists for this `(allocation, user)` pair.
+
+### GET `/compute-allocation-memberships/{id}`
+
+Retrieve a membership by ID.
+
+### PUT `/compute-allocation-memberships/{id}`
+
+Replace mutable fields of a membership. Fields left blank/zero in the request
+body fall back to the stored value (partial updates).
+
+### PUT `/compute-allocation-memberships/{id}/allocation-amount`
+
+Update only the SU sub-allocation granted to the user.
+
+**Request body**
+
+```json
+{ "allocation_amount": 75000 }
+```
+
+**Errors**
+
+- `400` — negative `allocation_amount`.
+- `404` — no membership with the given ID.
+
+### PUT `/compute-allocation-memberships/{id}/status`
+
+Update only the lifecycle status of the membership (`ACTIVE`, `INACTIVE`,
+`DELETED`, etc.).
+
+**Request body**
+
+```json
+{ "membership_status": "INACTIVE" }
+```
+
+**Errors**
+
+- `400` — empty `membership_status`.
+- `404` — no membership with the given ID.
+
+### DELETE `/compute-allocation-memberships/{id}`
+
+Remove a membership.
+
+### GET `/compute-allocations/{id}/memberships`
+
+List every membership recorded against the given allocation, ordered by
+`start_time` ascending.
+
+### GET `/users/{id}/compute-allocation-memberships`
+
+List every allocation membership held by the given user, ordered by
+`start_time` ascending.
+
+---
+
 ## End-to-end example
 
 ```bash
diff --git a/internal/db/migrations/000008_compute_allocation_memberships.down.sql b/internal/db/migrations/000008_compute_allocation_memberships.down.sql
new file mode 100644
index 000000000..731073898
--- /dev/null
+++ b/internal/db/migrations/000008_compute_allocation_memberships.down.sql
@@ -0,0 +1,18 @@
+-- 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.
+
+DROP TABLE IF EXISTS compute_allocation_memberships;
diff --git a/internal/db/migrations/000008_compute_allocation_memberships.up.sql b/internal/db/migrations/000008_compute_allocation_memberships.up.sql
new file mode 100644
index 000000000..17f753566
--- /dev/null
+++ b/internal/db/migrations/000008_compute_allocation_memberships.up.sql
@@ -0,0 +1,34 @@
+-- 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.
+
+CREATE TABLE IF NOT EXISTS compute_allocation_memberships
+(
+    id                    VARCHAR(255) NOT NULL,
+    compute_allocation_id VARCHAR(255) NOT NULL,
+    user_id               VARCHAR(255) NOT NULL,
+    allocation_amount     BIGINT       NOT NULL DEFAULT 0,
+    start_time            TIMESTAMP(6) NOT NULL,
+    end_time              TIMESTAMP(6) NOT NULL,
+    membership_status     VARCHAR(64)  NOT NULL,
+    created_at            TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
+    PRIMARY KEY (id),
+    UNIQUE KEY uq_compute_allocation_memberships_allocation_user (compute_allocation_id, user_id),
+    KEY idx_compute_allocation_memberships_user (user_id),
+    KEY idx_compute_allocation_memberships_status (membership_status),
+    CONSTRAINT fk_compute_allocation_memberships_allocation FOREIGN KEY (compute_allocation_id)
+        REFERENCES compute_allocations (id) ON DELETE CASCADE
+) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci;
diff --git a/internal/server/server.go b/internal/server/server.go
index 70f1e5948..bcf633a8c 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -99,6 +99,15 @@ func (s *Server) routes() {
 	s.mux.HandleFunc("DELETE /compute-allocation-change-request-events/{id}", s.deleteComputeAllocationChangeRequestEvent)
 	s.mux.HandleFunc("GET /compute-allocation-change-requests/{id}/events", s.listEventsForChangeRequest)
 	s.mux.HandleFunc("GET /compute-allocation-change-requests/{id}/events/latest", s.getLatestEventForChangeRequest)
+
+	s.mux.HandleFunc("POST /compute-allocation-memberships", s.createComputeAllocationMembership)
+	s.mux.HandleFunc("GET /compute-allocation-memberships/{id}", s.getComputeAllocationMembership)
+	s.mux.HandleFunc("PUT /compute-allocation-memberships/{id}", s.updateComputeAllocationMembership)
+	s.mux.HandleFunc("PUT /compute-allocation-memberships/{id}/allocation-amount", s.updateMembershipAllocationAmount)
+	s.mux.HandleFunc("PUT /compute-allocation-memberships/{id}/status", s.updateMembershipStatus)
+	s.mux.HandleFunc("DELETE /compute-allocation-memberships/{id}", s.deleteComputeAllocationMembership)
+	s.mux.HandleFunc("GET /compute-allocations/{id}/memberships", s.listMembersForAllocation)
+	s.mux.HandleFunc("GET /users/{id}/compute-allocation-memberships", s.listAllocationsForUser)
 }
 
 func (s *Server) healthz(w http.ResponseWriter, _ *http.Request) {
@@ -517,6 +526,102 @@ func (s *Server) getLatestEventForChangeRequest(w http.ResponseWriter, r *http.R
 	writeJSON(w, http.StatusOK, evt)
 }
 
+func (s *Server) createComputeAllocationMembership(w http.ResponseWriter, r *http.Request) {
+	var m models.ComputeAllocationMembership
+	if err := decodeJSON(r, &m); err != nil {
+		writeError(w, http.StatusBadRequest, err)
+		return
+	}
+	created, err := s.svc.CreateComputeAllocationMembership(r.Context(), &m)
+	if err != nil {
+		writeServiceError(w, err)
+		return
+	}
+	writeJSON(w, http.StatusCreated, created)
+}
+
+func (s *Server) getComputeAllocationMembership(w http.ResponseWriter, r *http.Request) {
+	m, err := s.svc.GetComputeAllocationMembership(r.Context(), r.PathValue("id"))
+	if err != nil {
+		writeServiceError(w, err)
+		return
+	}
+	writeJSON(w, http.StatusOK, m)
+}
+
+func (s *Server) updateComputeAllocationMembership(w http.ResponseWriter, r *http.Request) {
+	var m models.ComputeAllocationMembership
+	if err := decodeJSON(r, &m); err != nil {
+		writeError(w, http.StatusBadRequest, err)
+		return
+	}
+	m.ID = r.PathValue("id")
+	updated, err := s.svc.UpdateComputeAllocationMembership(r.Context(), &m)
+	if err != nil {
+		writeServiceError(w, err)
+		return
+	}
+	writeJSON(w, http.StatusOK, updated)
+}
+
+func (s *Server) updateMembershipAllocationAmount(w http.ResponseWriter, r *http.Request) {
+	var body struct {
+		AllocationAmount int64 `json:"allocation_amount"`
+	}
+	if err := decodeJSON(r, &body); err != nil {
+		writeError(w, http.StatusBadRequest, err)
+		return
+	}
+	updated, err := s.svc.UpdateMembershipAllocationAmount(r.Context(), r.PathValue("id"), body.AllocationAmount)
+	if err != nil {
+		writeServiceError(w, err)
+		return
+	}
+	writeJSON(w, http.StatusOK, updated)
+}
+
+func (s *Server) updateMembershipStatus(w http.ResponseWriter, r *http.Request) {
+	var body struct {
+		MembershipStatus models.AllocationStatus `json:"membership_status"`
+	}
+	if err := decodeJSON(r, &body); err != nil {
+		writeError(w, http.StatusBadRequest, err)
+		return
+	}
+	updated, err := s.svc.UpdateMembershipStatus(r.Context(), r.PathValue("id"), body.MembershipStatus)
+	if err != nil {
+		writeServiceError(w, err)
+		return
+	}
+	writeJSON(w, http.StatusOK, updated)
+}
+
+func (s *Server) deleteComputeAllocationMembership(w http.ResponseWriter, r *http.Request) {
+	if err := s.svc.DeleteComputeAllocationMembership(r.Context(), r.PathValue("id")); err != nil {
+		writeServiceError(w, err)
+		return
+	}
+	w.WriteHeader(http.StatusNoContent)
+}
+
+func (s *Server) listMembersForAllocation(w http.ResponseWriter, r *http.Request) {
+	rows, err := s.svc.ListMembersForAllocation(r.Context(), r.PathValue("id"))
+	if err != nil {
+		writeServiceError(w, err)
+		return
+	}
+	writeJSON(w, http.StatusOK, rows)
+}
+
+func (s *Server) listAllocationsForUser(w http.ResponseWriter, r *http.Request) {
+	rows, err := s.svc.ListAllocationsForUser(r.Context(), r.PathValue("id"))
+	if err != nil {
+		writeServiceError(w, err)
+		return
+	}
+	writeJSON(w, http.StatusOK, rows)
+}
+
 // LoggingMiddleware logs every request once it completes.
 func LoggingMiddleware(next http.Handler) http.Handler {
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/store/compute_allocation_membership_store.go b/internal/store/compute_allocation_membership_store.go
new file mode 100644
index 000000000..aae350ac9
--- /dev/null
+++ b/internal/store/compute_allocation_membership_store.go
@@ -0,0 +1,122 @@
+// 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 store
+
+import (
+	"context"
+	"database/sql"
+	"errors"
+
+	"github.com/jmoiron/sqlx"
+
+	"github.com/apache/airavata-custos/pkg/models"
+)
+
+const computeAllocationMembershipColumns = "id, compute_allocation_id, user_id, allocation_amount, start_time, end_time, membership_status"
+
+type mysqlComputeAllocationMembershipStore struct {
+	db *sqlx.DB
+}
+
+// NewComputeAllocationMembershipStore returns a MySQL-backed
+// ComputeAllocationMembershipStore.
+func NewComputeAllocationMembershipStore(db *sqlx.DB) ComputeAllocationMembershipStore {
+	return &mysqlComputeAllocationMembershipStore{db: db}
+}
+
+func (s *mysqlComputeAllocationMembershipStore) FindByID(ctx context.Context, id string) (*models.ComputeAllocationMembership, error) {
+	var m models.ComputeAllocationMembership
+	err := s.db.GetContext(ctx, &m,
+		`SELECT `+computeAllocationMembershipColumns+` FROM compute_allocation_memberships WHERE id = ?`, id)
+	if err != nil {
+		if errors.Is(err, sql.ErrNoRows) {
+			return nil, nil
+		}
+		return nil, err
+	}
+	return &m, nil
+}
+
+func (s *mysqlComputeAllocationMembershipStore) FindByPair(ctx context.Context, allocationID, userID string) (*models.ComputeAllocationMembership, error) {
+	var m models.ComputeAllocationMembership
+	err := s.db.GetContext(ctx, &m,
+		`SELECT `+computeAllocationMembershipColumns+`
+		 FROM compute_allocation_memberships
+		 WHERE compute_allocation_id = ? AND user_id = ?`, allocationID, userID)
+	if err != nil {
+		if errors.Is(err, sql.ErrNoRows) {
+			return nil, nil
+		}
+		return nil, err
+	}
+	return &m, nil
+}
+
+func (s *mysqlComputeAllocationMembershipStore) FindByAllocation(ctx context.Context, allocationID string) ([]models.ComputeAllocationMembership, error) {
+	var rows []models.ComputeAllocationMembership
+	err := s.db.SelectContext(ctx, &rows,
+		`SELECT `+computeAllocationMembershipColumns+`
+		 FROM compute_allocation_memberships
+		 WHERE compute_allocation_id = ?
+		 ORDER BY start_time`, allocationID)
+	if err != nil {
+		return nil, err
+	}
+	return rows, nil
+}
+
+func (s *mysqlComputeAllocationMembershipStore) FindByUser(ctx context.Context, userID string) ([]models.ComputeAllocationMembership, error) {
+	var rows []models.ComputeAllocationMembership
+	err := s.db.SelectContext(ctx, &rows,
+		`SELECT `+computeAllocationMembershipColumns+`
+		 FROM compute_allocation_memberships
+		 WHERE user_id = ?
+		 ORDER BY start_time`, userID)
+	if err != nil {
+		return nil, err
+	}
+	return rows, nil
+}
+
+func (s *mysqlComputeAllocationMembershipStore) Create(ctx context.Context, tx *sql.Tx, m *models.ComputeAllocationMembership) error {
+	_, err := tx.ExecContext(ctx,
+		`INSERT INTO compute_allocation_memberships
+		     (id, compute_allocation_id, user_id, allocation_amount, start_time, end_time, membership_status)
+		 VALUES (?, ?, ?, ?, ?, ?, ?)`,
+		m.ID, m.ComputeAllocationID, m.UserID, m.AllocationAmount, m.StartTime, m.EndTime, string(m.MembershipStatus))
+	return err
+}
+
+func (s *mysqlComputeAllocationMembershipStore) Update(ctx context.Context, tx *sql.Tx, m *models.ComputeAllocationMembership) error {
+	_, err := tx.ExecContext(ctx,
+		`UPDATE compute_allocation_memberships
+		    SET compute_allocation_id = ?,
+		        user_id               = ?,
+		        allocation_amount     = ?,
+		        start_time            = ?,
+		        end_time              = ?,
+		        membership_status     = ?
+		  WHERE id = ?`,
+		m.ComputeAllocationID, m.UserID, m.AllocationAmount, m.StartTime, m.EndTime, string(m.MembershipStatus), m.ID)
+	return err
+}
+
+func (s *mysqlComputeAllocationMembershipStore) Delete(ctx context.Context, tx *sql.Tx, id string) error {
+	_, err := tx.ExecContext(ctx, `DELETE FROM compute_allocation_memberships WHERE id = ?`, id)
+	return err
+}
diff --git a/internal/store/store.go b/internal/store/store.go
index 54a367c49..3498072a3 100644
--- a/internal/store/store.go
+++ b/internal/store/store.go
@@ -208,3 +208,25 @@ type ComputeAllocationChangeRequestEventStore interface {
 	// Delete removes an event by ID within the provided transaction.
 	Delete(ctx context.Context, tx *sql.Tx, id string) error
 }
+
+// ComputeAllocationMembershipStore defines persistence operations for the
+// per-user membership of a compute allocation, including the SU sub-allocation
+// granted to that user and the membership lifecycle.
+type ComputeAllocationMembershipStore interface {
+	// FindByID returns the membership with the given ID, or nil if it does not exist.
+	FindByID(ctx context.Context, id string) (*models.ComputeAllocationMembership, error)
+	// FindByPair returns the membership for a (allocation, user) pair, or nil if absent.
+	FindByPair(ctx context.Context, allocationID, userID string) (*models.ComputeAllocationMembership, error)
+	// FindByAllocation returns every membership recorded against the given
+	// allocation, ordered by start_time ascending.
+	FindByAllocation(ctx context.Context, allocationID string) ([]models.ComputeAllocationMembership, error)
+	// FindByUser returns every membership held by the given user, ordered by
+	// start_time ascending.
+	FindByUser(ctx context.Context, userID string) ([]models.ComputeAllocationMembership, error)
+	// Create inserts a new membership within the provided transaction.
+	Create(ctx context.Context, tx *sql.Tx, m *models.ComputeAllocationMembership) error
+	// Update replaces mutable fields of an existing membership within the provided transaction.
+	Update(ctx context.Context, tx *sql.Tx, m *models.ComputeAllocationMembership) error
+	// Delete removes a membership by ID within the provided transaction.
+	Delete(ctx context.Context, tx *sql.Tx, id string) error
+}
diff --git a/pkg/models/allocation.go b/pkg/models/allocation.go
index a6003f987..04bd11b4b 100644
--- a/pkg/models/allocation.go
+++ b/pkg/models/allocation.go
@@ -89,11 +89,11 @@ type ComputeAllocationUsage struct { // Represents the usage of a compute alloca
 }
 
 type ComputeAllocationMembership struct {
-	ID                  string           `json:"id"`
-	ComputeAllocationID string           `json:"compute_allocation_id"`
-	UserID              string           `json:"user_id"`
-	AllocationAmount    int64            `json:"allocation_amount"` // SUs allocated to the user, e.g., 100 CPU hours, 50 GPU hours, etc.
-	StartTime           time.Time        `json:"start_time"`
-	EndTime             time.Time        `json:"end_time"`
-	MembershipStatus    AllocationStatus `json:"membership_status"` // ACTIVE, INACTIVE, etc.
+	ID                  string           `json:"id"                    db:"id"`
+	ComputeAllocationID string           `json:"compute_allocation_id" db:"compute_allocation_id"`
+	UserID              string           `json:"user_id"               db:"user_id"`
+	AllocationAmount    int64            `json:"allocation_amount"     db:"allocation_amount"` // SUs allocated to the user, e.g., 100 CPU hours, 50 GPU hours, etc.
+	StartTime           time.Time        `json:"start_time"            db:"start_time"`
+	EndTime             time.Time        `json:"end_time"              db:"end_time"`
+	MembershipStatus    AllocationStatus `json:"membership_status"     db:"membership_status"` // ACTIVE, INACTIVE, etc.
 }
diff --git a/pkg/service/compute_allocation_membership.go b/pkg/service/compute_allocation_membership.go
new file mode 100644
index 000000000..b010224c5
--- /dev/null
+++ b/pkg/service/compute_allocation_membership.go
@@ -0,0 +1,220 @@
+// 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 service
+
+import (
+	"context"
+	"database/sql"
+	"fmt"
+
+	"github.com/apache/airavata-custos/pkg/models"
+)
+
+// CreateComputeAllocationMembership grants a user a sub-allocation against a
+// compute allocation. Both the referenced allocation and user must exist, and
+// the (allocation, user) pair must not already have a membership.
+// MembershipStatus defaults to ACTIVE when unset.
+func (s *Service) CreateComputeAllocationMembership(ctx context.Context, m *models.ComputeAllocationMembership) (*models.ComputeAllocationMembership, error) {
+	if m == nil {
+		return nil, fmt.Errorf("%w: compute allocation membership is nil", ErrInvalidInput)
+	}
+	if m.ComputeAllocationID == "" {
+		return nil, fmt.Errorf("%w: compute_allocation_id is required", ErrInvalidInput)
+	}
+	if m.UserID == "" {
+		return nil, fmt.Errorf("%w: user_id is required", ErrInvalidInput)
+	}
+
+	if alloc, err := s.allocs.FindByID(ctx, m.ComputeAllocationID); err != nil {
+		return nil, fmt.Errorf("lookup compute allocation: %w", err)
+	} else if alloc == nil {
+		return nil, fmt.Errorf("%w: compute allocation %q not found", ErrInvalidInput, m.ComputeAllocationID)
+	}
+	if u, err := s.users.FindByID(ctx, m.UserID); err != nil {
+		return nil, fmt.Errorf("lookup user: %w", err)
+	} else if u == nil {
+		return nil, fmt.Errorf("%w: user %q not found", ErrInvalidInput, m.UserID)
+	}
+
+	if existing, err := s.memberships.FindByPair(ctx, m.ComputeAllocationID, m.UserID); err != nil {
+		return nil, fmt.Errorf("lookup compute allocation membership: %w", err)
+	} else if existing != nil {
+		return nil, fmt.Errorf("%w: user %q already has a membership on allocation %q", ErrAlreadyExists, m.UserID, m.ComputeAllocationID)
+	}
+
+	if m.ID == "" {
+		m.ID = newID()
+	}
+	if m.MembershipStatus == "" {
+		m.MembershipStatus = models.ACTIVE
+	}
+
+	if err := s.inTx(ctx, func(tx *sql.Tx) error {
+		return s.memberships.Create(ctx, tx, m)
+	}); err != nil {
+		return nil, fmt.Errorf("create compute allocation membership: %w", err)
+	}
+	return m, nil
+}
+
+// GetComputeAllocationMembership retrieves a membership by its ID. Returns
+// ErrNotFound when no membership matches.
+func (s *Service) GetComputeAllocationMembership(ctx context.Context, id string) (*models.ComputeAllocationMembership, error) {
+	m, err := s.memberships.FindByID(ctx, id)
+	if err != nil {
+		return nil, fmt.Errorf("get compute allocation membership: %w", err)
+	}
+	if m == nil {
+		return nil, ErrNotFound
+	}
+	return m, nil
+}
+
+// ListMembersForAllocation returns every membership recorded against the
+// given allocation, ordered by start_time ascending.
+func (s *Service) ListMembersForAllocation(ctx context.Context, allocationID string) ([]models.ComputeAllocationMembership, error) {
+	if allocationID == "" {
+		return nil, fmt.Errorf("%w: compute_allocation_id is required", ErrInvalidInput)
+	}
+	rows, err := s.memberships.FindByAllocation(ctx, allocationID)
+	if err != nil {
+		return nil, fmt.Errorf("list members for allocation: %w", err)
+	}
+	return rows, nil
+}
+
+// ListAllocationsForUser returns every membership held by the given user,
+// ordered by start_time ascending.
+func (s *Service) ListAllocationsForUser(ctx context.Context, userID string) ([]models.ComputeAllocationMembership, error) {
+	if userID == "" {
+		return nil, fmt.Errorf("%w: user_id is required", ErrInvalidInput)
+	}
+	rows, err := s.memberships.FindByUser(ctx, userID)
+	if err != nil {
+		return nil, fmt.Errorf("list allocations for user: %w", err)
+	}
+	return rows, nil
+}
+
+// UpdateComputeAllocationMembership replaces mutable fields of an existing
+// membership. Fields left blank/zero on the supplied record fall back to the
+// stored value (so callers can perform partial updates).
+func (s *Service) UpdateComputeAllocationMembership(ctx context.Context, m *models.ComputeAllocationMembership) (*models.ComputeAllocationMembership, error) {
+	if m == nil || m.ID == "" {
+		return nil, fmt.Errorf("%w: compute allocation membership id is required", ErrInvalidInput)
+	}
+	existing, err := s.memberships.FindByID(ctx, m.ID)
+	if err != nil {
+		return nil, fmt.Errorf("lookup compute allocation membership: %w", err)
+	}
+	if existing == nil {
+		return nil, ErrNotFound
+	}
+	if m.ComputeAllocationID == "" {
+		m.ComputeAllocationID = existing.ComputeAllocationID
+	}
+	if m.UserID == "" {
+		m.UserID = existing.UserID
+	}
+	if m.StartTime.IsZero() {
+		m.StartTime = existing.StartTime
+	}
+	if m.EndTime.IsZero() {
+		m.EndTime = existing.EndTime
+	}
+	if m.MembershipStatus == "" {
+		m.MembershipStatus = existing.MembershipStatus
+	}
+
+	if err := s.inTx(ctx, func(tx *sql.Tx) error {
+		return s.memberships.Update(ctx, tx, m)
+	}); err != nil {
+		return nil, fmt.Errorf("update compute allocation membership: %w", err)
+	}
+	return m, nil
+}
+
+// UpdateMembershipAllocationAmount sets the SU sub-allocation granted to the
+// user identified by the given membership ID. Other fields are preserved.
+func (s *Service) UpdateMembershipAllocationAmount(ctx context.Context, id string, amount int64) (*models.ComputeAllocationMembership, error) {
+	if id == "" {
+		return nil, fmt.Errorf("%w: compute allocation membership id is required", ErrInvalidInput)
+	}
+	if amount < 0 {
+		return nil, fmt.Errorf("%w: allocation_amount must be non-negative", ErrInvalidInput)
+	}
+	existing, err := s.memberships.FindByID(ctx, id)
+	if err != nil {
+		return nil, fmt.Errorf("lookup compute allocation membership: %w", err)
+	}
+	if existing == nil {
+		return nil, ErrNotFound
+	}
+	existing.AllocationAmount = amount
+	if err := s.inTx(ctx, func(tx *sql.Tx) error {
+		return s.memberships.Update(ctx, tx, existing)
+	}); err != nil {
+		return nil, fmt.Errorf("update compute allocation membership amount: %w", err)
+	}
+	return existing, nil
+}
+
+// UpdateMembershipStatus sets the lifecycle status (ACTIVE, INACTIVE, etc.)
+// of the membership identified by the given ID. Other fields are preserved.
+func (s *Service) UpdateMembershipStatus(ctx context.Context, id string, status models.AllocationStatus) (*models.ComputeAllocationMembership, error) {
+	if id == "" {
+		return nil, fmt.Errorf("%w: compute allocation membership id is required", ErrInvalidInput)
+	}
+	if status == "" {
+		return nil, fmt.Errorf("%w: membership_status is required", ErrInvalidInput)
+	}
+	existing, err := s.memberships.FindByID(ctx, id)
+	if err != nil {
+		return nil, fmt.Errorf("lookup compute allocation membership: %w", err)
+	}
+	if existing == nil {
+		return nil, ErrNotFound
+	}
+	existing.MembershipStatus = status
+	if err := s.inTx(ctx, func(tx *sql.Tx) error {
+		return s.memberships.Update(ctx, tx, existing)
+	}); err != nil {
+		return nil, fmt.Errorf("update compute allocation membership status: %w", err)
+	}
+	return existing, nil
+}
+
+// DeleteComputeAllocationMembership removes a membership by ID.
+func (s *Service) DeleteComputeAllocationMembership(ctx context.Context, id string) error {
+	if id == "" {
+		return fmt.Errorf("%w: compute allocation membership id is required", ErrInvalidInput)
+	}
+	existing, err := s.memberships.FindByID(ctx, id)
+	if err != nil {
+		return fmt.Errorf("lookup compute allocation membership: %w", err)
+	}
+	if existing == nil {
+		return ErrNotFound
+	}
+	if err := s.inTx(ctx, func(tx *sql.Tx) error {
+		return s.memberships.Delete(ctx, tx, id)
+	}); err != nil {
+		return fmt.Errorf("delete compute allocation membership: %w", err)
+	}
+	return nil
+}
diff --git a/pkg/service/service.go b/pkg/service/service.go
index 1942451be..e8145c0d5 100644
--- a/pkg/service/service.go
+++ b/pkg/service/service.go
@@ -45,6 +45,7 @@ type Service struct {
 	allocDiffs       store.ComputeAllocationDiffStore
 	changeRequests   store.ComputeAllocationChangeRequestStore
 	changeEvents     store.ComputeAllocationChangeRequestEventStore
+	memberships      store.ComputeAllocationMembershipStore
 }
 
 // New constructs a Service backed by the supplied database handle.
@@ -63,6 +64,7 @@ func New(database *sqlx.DB) *Service {
 		allocDiffs:       store.NewComputeAllocationDiffStore(database),
 		changeRequests:   store.NewComputeAllocationChangeRequestStore(database),
 		changeEvents:     store.NewComputeAllocationChangeRequestEventStore(database),
+		memberships:      store.NewComputeAllocationMembershipStore(database),
 	}
 }
 
@@ -82,6 +84,7 @@ func NewWithStores(
 	allocDiffs store.ComputeAllocationDiffStore,
 	changeRequests store.ComputeAllocationChangeRequestStore,
 	changeEvents store.ComputeAllocationChangeRequestEventStore,
+	memberships store.ComputeAllocationMembershipStore,
 ) *Service {
 	return &Service{
 		db:               database,
@@ -96,6 +99,7 @@ func NewWithStores(
 		allocDiffs:       allocDiffs,
 		changeRequests:   changeRequests,
 		changeEvents:     changeEvents,
+		memberships:      memberships,
 	}
 }