You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@airavata.apache.org by ya...@apache.org on 2026/09/30 23:11:28 UTC

(airavata-custos) 01/01: keep posix group and its provisioned status per compute allocation

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

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

commit b71e6820877f5576ba1ae077aeb4a8fcddf2fcb4
Author: Yasith Jayawardana <ya...@icloud.com>
AuthorDate: Wed Sep 30 19:11:11 2026 -0400

    keep posix group and its provisioned status per compute allocation
---
 api/core.openapi.yaml                              | 10 ++-
 .../Identity-Provisioner/config.example.yaml       |  1 +
 internal/db/migrations/000004_allocations.up.sql   |  3 +
 internal/store/compute_allocation_store.go         | 14 +++-
 internal/store/compute_cluster_user_store.go       | 31 +++++++-
 internal/store/store.go                            |  9 ++-
 pkg/models/allocation.go                           | 18 +++--
 pkg/posix/username.go                              | 40 +++++++++-
 pkg/posix/username_test.go                         | 33 ++++++++
 pkg/service/compute_allocation.go                  | 53 ++++++++++++-
 pkg/service/compute_allocation_integration_test.go | 87 ++++++++++++++++++++++
 pkg/service/compute_cluster_user.go                | 54 ++++++++++----
 .../compute_cluster_user_integration_test.go       | 78 ++++++++++++++++++-
 pkg/service/user_onboard.go                        |  4 +-
 web/src/generated/core/types.gen.ts                | 12 ++-
 web/src/generated/core/zod.gen.ts                  |  2 +
 16 files changed, 402 insertions(+), 47 deletions(-)

diff --git a/api/core.openapi.yaml b/api/core.openapi.yaml
index e9393567b..d09e632f6 100644
--- a/api/core.openapi.yaml
+++ b/api/core.openapi.yaml
@@ -120,7 +120,7 @@ definitions:
       reviewed_at:
         type: string
       reviewed_by:
-        description: The admin who approved or denied the account.
+        description: The admin who approved or denied the cluster account.
         type: string
       user_id:
         type: string
@@ -139,6 +139,12 @@ definitions:
         type: integer
       name:
         type: string
+      posix_group:
+        description: Fixed at creation; also the directory and quota name.
+        type: string
+      posix_group_provisioned_at:
+        description: When the group was provisioned into the registry; nil until then.
+        type: string
       project_id:
         type: string
       start_time:
@@ -405,7 +411,7 @@ definitions:
       reviewed_at:
         type: string
       reviewed_by:
-        description: The admin who approved or denied the account.
+        description: The admin who approved or denied the cluster account.
         type: string
       user_id:
         type: string
diff --git a/connectors/COmanage/Identity-Provisioner/config.example.yaml b/connectors/COmanage/Identity-Provisioner/config.example.yaml
index 7f4edb5e2..5350cb42b 100644
--- a/connectors/COmanage/Identity-Provisioner/config.example.yaml
+++ b/connectors/COmanage/Identity-Provisioner/config.example.yaml
@@ -35,3 +35,4 @@
 # COMANAGE_HOMEDIR_PREFIX=/home/
 # COMANAGE_HTTP_TIMEOUT=30s
 # POSIX_USERNAME_PREFIX=custos # read by pkg/posix; default "custos"
+# POSIX_GROUP_PREFIX=proj # read by pkg/posix; default "proj"
diff --git a/internal/db/migrations/000004_allocations.up.sql b/internal/db/migrations/000004_allocations.up.sql
index 31ef1fdc8..cd365400e 100644
--- a/internal/db/migrations/000004_allocations.up.sql
+++ b/internal/db/migrations/000004_allocations.up.sql
@@ -25,9 +25,12 @@ CREATE TABLE IF NOT EXISTS compute_allocations
     initial_su_amount  BIGINT       NOT NULL DEFAULT 0,
     start_time         TIMESTAMPTZ(6) NOT NULL,
     end_time           TIMESTAMPTZ(6) NOT NULL,
+    posix_group        VARCHAR(32)  NULL,
+    posix_group_provisioned_at TIMESTAMPTZ(6) NULL,
     created_at         TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
     updated_at         TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
     PRIMARY KEY (id),
+    CONSTRAINT uq_compute_allocations_cluster_posix_group UNIQUE (compute_cluster_id, posix_group),
     CONSTRAINT fk_compute_allocations_project FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE RESTRICT,
     CONSTRAINT fk_compute_allocations_cluster FOREIGN KEY (compute_cluster_id) REFERENCES compute_clusters (id) ON DELETE RESTRICT
 );
diff --git a/internal/store/compute_allocation_store.go b/internal/store/compute_allocation_store.go
index e6b76784b..4aff16afc 100644
--- a/internal/store/compute_allocation_store.go
+++ b/internal/store/compute_allocation_store.go
@@ -22,6 +22,7 @@ import (
 	"database/sql"
 	"errors"
 	"strings"
+	"time"
 
 	"github.com/jmoiron/sqlx"
 
@@ -37,7 +38,7 @@ func NewComputeAllocationStore(db *sqlx.DB) ComputeAllocationStore {
 	return &pgComputeAllocationStore{db: db}
 }
 
-const computeAllocationColumns = `id, project_id, name, status, compute_cluster_id, initial_su_amount, start_time, end_time`
+const computeAllocationColumns = `id, project_id, name, status, compute_cluster_id, initial_su_amount, start_time, end_time, posix_group, posix_group_provisioned_at`
 
 func (s *pgComputeAllocationStore) FindByID(ctx context.Context, id string) (*models.ComputeAllocation, error) {
 	var a models.ComputeAllocation
@@ -74,12 +75,17 @@ func (s *pgComputeAllocationStore) FindByCluster(ctx context.Context, clusterID
 
 func (s *pgComputeAllocationStore) Create(ctx context.Context, tx *sql.Tx, a *models.ComputeAllocation) error {
 	_, err := tx.ExecContext(ctx,
-		`INSERT INTO compute_allocations (`+computeAllocationColumns+`)
-		 VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
-		a.ID, a.ProjectID, a.Name, string(a.Status), a.ComputeClusterID, a.InitialSUAmount, a.StartTime, a.EndTime)
+		`INSERT INTO compute_allocations (id, project_id, name, status, compute_cluster_id, initial_su_amount, start_time, end_time, posix_group)
+		 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
+		a.ID, a.ProjectID, a.Name, string(a.Status), a.ComputeClusterID, a.InitialSUAmount, a.StartTime, a.EndTime, a.PosixGroup)
 	return err
 }
 
+func (s *pgComputeAllocationStore) MarkGroupProvisioned(ctx context.Context, tx *sql.Tx, id string) (*time.Time, error) {
+	return markOnce(ctx, tx, `UPDATE compute_allocations SET posix_group_provisioned_at = NOW()
+		 WHERE id = $1 AND posix_group_provisioned_at IS NULL RETURNING posix_group_provisioned_at`, id)
+}
+
 func (s *pgComputeAllocationStore) Update(ctx context.Context, tx *sql.Tx, a *models.ComputeAllocation) error {
 	_, err := tx.ExecContext(ctx,
 		`UPDATE compute_allocations
diff --git a/internal/store/compute_cluster_user_store.go b/internal/store/compute_cluster_user_store.go
index 5769a81c8..3debb8cc4 100644
--- a/internal/store/compute_cluster_user_store.go
+++ b/internal/store/compute_cluster_user_store.go
@@ -171,9 +171,34 @@ func (s *pgComputeClusterUserStore) Update(ctx context.Context, tx *sql.Tx, c *m
 	return err
 }
 
-func (s *pgComputeClusterUserStore) MarkProvisioned(ctx context.Context, tx *sql.Tx, id string) error {
-	_, err := tx.ExecContext(ctx, `UPDATE compute_cluster_users SET provisioned_at = NOW() WHERE id = $1`, id)
-	return err
+func (s *pgComputeClusterUserStore) MarkProvisioned(ctx context.Context, tx *sql.Tx, id string) (*time.Time, error) {
+	return markOnce(ctx, tx, `UPDATE compute_cluster_users SET provisioned_at = NOW()
+		 WHERE id = $1 AND provisioned_at IS NULL RETURNING provisioned_at`, id)
+}
+
+func markOnce(ctx context.Context, tx *sql.Tx, query, id string) (*time.Time, error) {
+	var at time.Time
+	if err := tx.QueryRowContext(ctx, query, id).Scan(&at); err != nil {
+		if errors.Is(err, sql.ErrNoRows) {
+			return nil, nil
+		}
+		return nil, err
+	}
+	return &at, nil
+}
+
+func (s *pgComputeClusterUserStore) FindByAllocation(ctx context.Context, allocationID string) ([]models.ComputeClusterUser, error) {
+	var rows []models.ComputeClusterUser
+	err := s.db.SelectContext(ctx, &rows,
+		`SELECT `+computeClusterUserColumns+` FROM compute_cluster_users cu
+		  WHERE approval_status = 'APPROVED' AND provisioned_at IS NOT NULL
+		    AND EXISTS (SELECT 1 FROM compute_allocations a
+		                  JOIN projects p ON p.id = a.project_id
+		                  JOIN compute_allocation_memberships m ON m.compute_allocation_id = a.id
+		                 WHERE a.id = $1 AND a.compute_cluster_id = cu.compute_cluster_id AND m.user_id = cu.user_id
+		                   AND a.status = 'ACTIVE' AND p.status = 'ACTIVE' AND m.membership_status = 'ACTIVE')
+		  ORDER BY local_username`, allocationID)
+	return rows, err
 }
 
 func (s *pgComputeClusterUserStore) ReassignUser(ctx context.Context, tx *sql.Tx, fromUserID, toUserID string) error {
diff --git a/internal/store/store.go b/internal/store/store.go
index 433834cba..84e5b8893 100644
--- a/internal/store/store.go
+++ b/internal/store/store.go
@@ -101,8 +101,11 @@ type ComputeClusterUserStore interface {
 	Review(ctx context.Context, tx *sql.Tx, id string, status models.ClusterAccountApproval, reviewedBy, note string, at time.Time) error
 	// Update replaces mutable fields of an existing mapping within the provided transaction.
 	Update(ctx context.Context, tx *sql.Tx, c *models.ComputeClusterUser) error
-	// MarkProvisioned stamps provisioned_at on the mapping within the provided transaction.
-	MarkProvisioned(ctx context.Context, tx *sql.Tx, id string) error
+	// MarkProvisioned stamps provisioned_at if unset, returning the stamp or nil.
+	MarkProvisioned(ctx context.Context, tx *sql.Tx, id string) (*time.Time, error)
+	// FindByAllocation returns the allocation's POSIX group members: approved, provisioned accounts with an active
+	// membership, while the allocation and its project are active.
+	FindByAllocation(ctx context.Context, allocationID string) ([]models.ComputeClusterUser, error)
 	// ReassignUser moves every mapping owned by fromUserID over to toUserID,
 	// dropping fromUserID's rows on clusters where toUserID already has one.
 	ReassignUser(ctx context.Context, tx *sql.Tx, fromUserID, toUserID string) error
@@ -186,6 +189,8 @@ type ComputeAllocationStore interface {
 	FindByCluster(ctx context.Context, clusterID string) ([]models.ComputeAllocation, error)
 	// Create inserts a new allocation within the provided transaction.
 	Create(ctx context.Context, tx *sql.Tx, a *models.ComputeAllocation) error
+	// MarkGroupProvisioned stamps posix_group_provisioned_at if unset, returning the stamp or nil.
+	MarkGroupProvisioned(ctx context.Context, tx *sql.Tx, id string) (*time.Time, error)
 	// Update replaces mutable fields of an existing allocation within the provided transaction.
 	Update(ctx context.Context, tx *sql.Tx, a *models.ComputeAllocation) error
 	// Delete removes an allocation by ID within the provided transaction.
diff --git a/pkg/models/allocation.go b/pkg/models/allocation.go
index 675815cfc..f0268d260 100644
--- a/pkg/models/allocation.go
+++ b/pkg/models/allocation.go
@@ -65,14 +65,16 @@ type ComputeClusterUser struct {
 }
 
 type ComputeAllocation struct {
-	ID               string           `json:"id"                 db:"id"`
-	ProjectID        string           `json:"project_id"         db:"project_id"`
-	Name             string           `json:"name"               db:"name"`
-	Status           AllocationStatus `json:"status"             db:"status"`             // ACTIVE, INACTIVE, DELETED, etc.
-	ComputeClusterID string           `json:"compute_cluster_id" db:"compute_cluster_id"` // The ID of the compute cluster where the allocation is provisioned.
-	InitialSUAmount  int64            `json:"initial_su_amount"  db:"initial_su_amount"`  // SUs allocated at the time of allocation creation.
-	StartTime        time.Time        `json:"start_time"         db:"start_time"`
-	EndTime          time.Time        `json:"end_time"           db:"end_time"`
+	ID                      string           `json:"id"                 db:"id"`
+	ProjectID               string           `json:"project_id"         db:"project_id"`
+	Name                    string           `json:"name"               db:"name"`
+	Status                  AllocationStatus `json:"status"             db:"status"`             // ACTIVE, INACTIVE, DELETED, etc.
+	ComputeClusterID        string           `json:"compute_cluster_id" db:"compute_cluster_id"` // The ID of the compute cluster where the allocation is provisioned.
+	InitialSUAmount         int64            `json:"initial_su_amount"  db:"initial_su_amount"`  // SUs allocated at the time of allocation creation.
+	StartTime               time.Time        `json:"start_time"         db:"start_time"`
+	EndTime                 time.Time        `json:"end_time"           db:"end_time"`
+	PosixGroup              *string          `json:"posix_group"        db:"posix_group"`                        // Fixed at creation; also the directory and quota name.
+	PosixGroupProvisionedAt *time.Time       `json:"posix_group_provisioned_at" db:"posix_group_provisioned_at"` // When the group was provisioned into the registry; nil until then.
 }
 
 // Typically store the a paritition information
diff --git a/pkg/posix/username.go b/pkg/posix/username.go
index 2e60f5e9f..549b493f3 100644
--- a/pkg/posix/username.go
+++ b/pkg/posix/username.go
@@ -15,8 +15,8 @@
 // specific language governing permissions and limitations
 // under the License.
 
-// Package posix builds and validates POSIX-conformant usernames for HPC
-// account provisioning.
+// Package posix builds and validates POSIX-conformant usernames and group
+// names for HPC account provisioning.
 package posix
 
 import (
@@ -36,6 +36,8 @@ const MaxLoginLen = 32
 
 var ErrUnbuildableUsername = errors.New("posix: cannot build username from empty first and last name")
 
+var ErrUnbuildableGroupName = errors.New("posix: cannot build group name from an allocation name without letters or digits")
+
 // BuildBase returns the unsuffixed username. 'truncated' is set when the name
 // portion was shortened to fit the 32-char POSIX login cap.
 func BuildBase(u *models.User, prefix string) (string, bool, error) {
@@ -53,7 +55,21 @@ func BuildBase(u *models.User, prefix string) (string, bool, error) {
 	default:
 		return "", false, fmt.Errorf("%w: user %q (first=%q last=%q)", ErrUnbuildableUsername, u.ID, u.FirstName, u.LastName)
 	}
+	base, truncated := join(prefix, local)
+	return base, truncated, nil
+}
 
+// BuildGroupBase returns the unsuffixed group name of an allocation, built like BuildBase.
+func BuildGroupBase(name, prefix string) (string, bool, error) {
+	local := Normalize(name)
+	if local == "" {
+		return "", false, fmt.Errorf("%w: %q", ErrUnbuildableGroupName, name)
+	}
+	base, truncated := join(prefix, local)
+	return base, truncated, nil
+}
+
+func join(prefix, local string) (string, bool) {
 	// -1 separator, -3 reserved for collision suffix (up to "999").
 	maxLocal := MaxLoginLen - len(prefix) - 1 - 3
 	truncated := false
@@ -61,7 +77,18 @@ func BuildBase(u *models.User, prefix string) (string, bool, error) {
 		local = local[:maxLocal]
 		truncated = true
 	}
-	return prefix + "-" + local, truncated, nil
+	return prefix + "-" + local, truncated
+}
+
+// ValidateUsername rejects usernames over the POSIX cap or under the group prefix.
+func ValidateUsername(name string) error {
+	if len(name) > MaxLoginLen {
+		return fmt.Errorf("posix: username %q exceeds %d characters", name, MaxLoginLen)
+	}
+	if strings.HasPrefix(name, GroupPrefix()+"-") {
+		return fmt.Errorf("posix: username %q uses the group prefix %q", name, GroupPrefix())
+	}
+	return nil
 }
 
 func Normalize(s string) string {
@@ -84,3 +111,10 @@ func Prefix() string {
 	}
 	return "custos"
 }
+
+func GroupPrefix() string {
+	if v := os.Getenv("POSIX_GROUP_PREFIX"); v != "" {
+		return v
+	}
+	return "proj"
+}
diff --git a/pkg/posix/username_test.go b/pkg/posix/username_test.go
index 95cb6b8f2..800fc01e9 100644
--- a/pkg/posix/username_test.go
+++ b/pkg/posix/username_test.go
@@ -154,3 +154,36 @@ func TestPrefix(t *testing.T) {
 		t.Errorf("override = %q, want %q", got, "alt")
 	}
 }
+
+func TestBuildGroupBase(t *testing.T) {
+	tests := []struct {
+		name, in, wantBase string
+		wantTrunc          bool
+	}{
+		{name: "grant number", in: "CIS250123", wantBase: "proj-cis250123"},
+		{name: "punctuation stripped", in: "Aurora GPU / Mixed", wantBase: "proj-auroragpumixed"},
+		{name: "truncated", in: "abcdefghijklmnopqrstuvwxyz0123456789", wantBase: "proj-abcdefghijklmnopqrstuvwx", wantTrunc: true},
+	}
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			got, trunc, err := BuildGroupBase(tc.in, "proj")
+			if err != nil || got != tc.wantBase || trunc != tc.wantTrunc {
+				t.Errorf("BuildGroupBase(%q) = %q, %v, %v; want %q, %v", tc.in, got, trunc, err, tc.wantBase, tc.wantTrunc)
+			}
+		})
+	}
+	if _, _, err := BuildGroupBase("日本", "proj"); !errors.Is(err, ErrUnbuildableGroupName) {
+		t.Errorf("expected ErrUnbuildableGroupName, got %v", err)
+	}
+}
+
+func TestGroupPrefix(t *testing.T) {
+	t.Setenv("POSIX_GROUP_PREFIX", "")
+	if got := GroupPrefix(); got != "proj" {
+		t.Errorf("default = %q, want %q", got, "proj")
+	}
+	t.Setenv("POSIX_GROUP_PREFIX", "alt")
+	if got := GroupPrefix(); got != "alt" {
+		t.Errorf("override = %q, want %q", got, "alt")
+	}
+}
diff --git a/pkg/service/compute_allocation.go b/pkg/service/compute_allocation.go
index 28ff999e0..dc410e8e0 100644
--- a/pkg/service/compute_allocation.go
+++ b/pkg/service/compute_allocation.go
@@ -21,10 +21,12 @@ import (
 	"context"
 	"database/sql"
 	"fmt"
+	"strconv"
 
 	"github.com/apache/airavata-custos/internal/store"
 	"github.com/apache/airavata-custos/pkg/events"
 	"github.com/apache/airavata-custos/pkg/models"
+	"github.com/apache/airavata-custos/pkg/posix"
 )
 
 // ListComputeAllocations returns a paginated, filterable slice of compute
@@ -86,15 +88,58 @@ func (s *Service) CreateComputeAllocation(ctx context.Context, alloc *models.Com
 		return nil, fmt.Errorf("%w: compute cluster %q not found", ErrInvalidInput, alloc.ComputeClusterID)
 	}
 
+	base, _, err := posix.BuildGroupBase(alloc.Name, posix.GroupPrefix())
+	if err != nil {
+		return nil, fmt.Errorf("%w: %v", ErrInvalidInput, err)
+	}
+	// A unique violation aborts the transaction, so each suffix retries in a fresh one.
+	for n := 0; n < posix.MaxCollisionSuffix; n++ {
+		candidate := base
+		if n > 0 {
+			candidate = base + strconv.Itoa(n+1)
+		}
+		alloc.PosixGroup = &candidate
+		err = s.inTx(ctx, func(tx *sql.Tx) error {
+			if err := s.allocs.Create(ctx, tx, alloc); err != nil {
+				return err
+			}
+			return s.eventBus.Publish(ctx, tx, events.ComputeAllocationCreateEvent, alloc)
+		})
+		if !violates(err, "uq_compute_allocations_cluster_posix_group") {
+			break
+		}
+	}
+	if violates(err, "uq_compute_allocations_cluster_posix_group") {
+		return nil, fmt.Errorf("posix group allocator exhausted for base %q", base)
+	}
+	if err != nil {
+		return nil, fmt.Errorf("create compute allocation: %w", err)
+	}
+	return alloc, nil
+}
+
+// MarkComputeAllocationGroupProvisioned stamps posix_group_provisioned_at,
+// signaling that the allocation's group exists in the registry. Only the first
+// stamp publishes an update event.
+func (s *Service) MarkComputeAllocationGroupProvisioned(ctx context.Context, id string) error {
+	if id == "" {
+		return fmt.Errorf("%w: compute allocation id is required", ErrInvalidInput)
+	}
+	alloc, err := s.GetComputeAllocation(ctx, id)
+	if err != nil {
+		return err
+	}
 	if err := s.inTx(ctx, func(tx *sql.Tx) error {
-		if err := s.allocs.Create(ctx, tx, alloc); err != nil {
+		at, err := s.allocs.MarkGroupProvisioned(ctx, tx, id)
+		if err != nil || at == nil {
 			return err
 		}
-		return s.eventBus.Publish(ctx, tx, events.ComputeAllocationCreateEvent, alloc)
+		alloc.PosixGroupProvisionedAt = at
+		return s.eventBus.Publish(ctx, tx, events.ComputeAllocationUpdateEvent, alloc)
 	}); err != nil {
-		return nil, fmt.Errorf("create compute allocation: %w", err)
+		return fmt.Errorf("mark compute allocation group provisioned: %w", err)
 	}
-	return alloc, nil
+	return nil
 }
 
 // GetComputeAllocation retrieves a compute allocation by its ID. Returns
diff --git a/pkg/service/compute_allocation_integration_test.go b/pkg/service/compute_allocation_integration_test.go
new file mode 100644
index 000000000..25a7f4e07
--- /dev/null
+++ b/pkg/service/compute_allocation_integration_test.go
@@ -0,0 +1,87 @@
+//go:build integration
+
+// 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"
+	"fmt"
+	"testing"
+	"time"
+
+	"github.com/google/uuid"
+	"github.com/jmoiron/sqlx"
+
+	"github.com/apache/airavata-custos/pkg/models"
+)
+
+// Make sure an allocation gets a group name, a taken name gets the next suffix,
+// and only the first mark publishes.
+func TestCreateComputeAllocation_NamesPosixGroup(t *testing.T) {
+	database := setupTestDB(t)
+	svc := newTestService(database)
+	svc.EventBus().SubscribeComputeAllocationUpdated("test-subscriber", func(context.Context, models.ComputeAllocation) error { return nil })
+	cluster, project := newProject(t, svc, database)
+
+	first := newAllocation(t, svc, cluster.ID, project.ID)
+	second := newAllocation(t, svc, cluster.ID, project.ID)
+	if *first.PosixGroup != "proj-grant1" {
+		t.Fatalf("expected proj-grant1, got %q", *first.PosixGroup)
+	}
+	if *second.PosixGroup != "proj-grant12" {
+		t.Fatalf("expected proj-grant12, got %q", *second.PosixGroup)
+	}
+
+	for range 2 {
+		if err := svc.MarkComputeAllocationGroupProvisioned(ctx(), first.ID); err != nil {
+			t.Fatalf("mark: %v", err)
+		}
+	}
+	if n := countDeliveries(t, svc); n != 1 {
+		t.Fatalf("expected 1 delivery for 2 marks, got %d", n)
+	}
+}
+
+func newProject(t *testing.T, svc *Service, database *sqlx.DB) (*models.ComputeCluster, *models.Project) {
+	t.Helper()
+	cluster, err := svc.CreateComputeCluster(ctx(), &models.ComputeCluster{Name: "grp-" + uuid.NewString()[:8]})
+	if err != nil {
+		t.Fatalf("create cluster: %v", err)
+	}
+	pi := seedUser(t, database, fmt.Sprintf("pi-%s@example.edu", uuid.NewString()))
+	project, err := svc.CreateProject(ctx(), &models.Project{
+		OriginatedID: uuid.NewString(), Title: "grp", Origination: "TEST", ProjectPIID: pi, Status: models.ProjectActive,
+	})
+	if err != nil {
+		t.Fatalf("create project: %v", err)
+	}
+	return cluster, project
+}
+
+func newAllocation(t *testing.T, svc *Service, clusterID, projectID string) *models.ComputeAllocation {
+	t.Helper()
+	alloc, err := svc.CreateComputeAllocation(ctx(), &models.ComputeAllocation{
+		Name: "Grant 1", ProjectID: projectID, ComputeClusterID: clusterID,
+		StartTime: time.Now(), EndTime: time.Now().AddDate(1, 0, 0),
+	})
+	if err != nil {
+		t.Fatalf("create allocation: %v", err)
+	}
+	return alloc
+}
diff --git a/pkg/service/compute_cluster_user.go b/pkg/service/compute_cluster_user.go
index 0e59f08ee..a31bf4ae9 100644
--- a/pkg/service/compute_cluster_user.go
+++ b/pkg/service/compute_cluster_user.go
@@ -27,6 +27,7 @@ import (
 	"github.com/apache/airavata-custos/internal/store"
 	"github.com/apache/airavata-custos/pkg/events"
 	"github.com/apache/airavata-custos/pkg/models"
+	"github.com/apache/airavata-custos/pkg/posix"
 )
 
 const (
@@ -66,6 +67,9 @@ func (s *Service) CreateComputeClusterUser(ctx context.Context, cu *models.Compu
 	if cu.LocalUsername == "" {
 		return nil, fmt.Errorf("%w: local_username is required", ErrInvalidInput)
 	}
+	if err := posix.ValidateUsername(cu.LocalUsername); err != nil {
+		return nil, fmt.Errorf("%w: %v", ErrInvalidInput, err)
+	}
 	if cu.ID == "" {
 		cu.ID = newID()
 	}
@@ -127,9 +131,9 @@ func (s *Service) CreateComputeClusterUser(ctx context.Context, cu *models.Compu
 		return nil
 	}); err != nil {
 		switch {
-		case isLocalUsernameDuplicate(err):
+		case violates(err, "uq_compute_cluster_users_local_username"):
 			return nil, fmt.Errorf("%w: %s", ErrAlreadyExists, cu.LocalUsername)
-		case isPairDuplicate(err):
+		case violates(err, "uq_compute_cluster_users_pair"):
 			return nil, fmt.Errorf("%w: user %q is already mapped on cluster %q",
 				ErrAlreadyExists, cu.UserID, cu.ComputeClusterID)
 		default:
@@ -246,12 +250,9 @@ func (s *Service) clusterUserForReview(ctx context.Context, id, reviewerID strin
 	return cu, nil
 }
 
-func isLocalUsernameDuplicate(err error) bool {
-	return err != nil && strings.Contains(err.Error(), "uq_compute_cluster_users_local_username")
-}
-
-func isPairDuplicate(err error) bool {
-	return err != nil && strings.Contains(err.Error(), "uq_compute_cluster_users_pair")
+// violates reports whether err violates the named constraint.
+func violates(err error, constraint string) bool {
+	return err != nil && strings.Contains(err.Error(), constraint)
 }
 
 // GetComputeClusterUser retrieves a compute-cluster user by its ID.
@@ -353,6 +354,14 @@ func (s *Service) UpdateComputeClusterUser(ctx context.Context, cu *models.Compu
 	if cu.LocalUsername == "" {
 		cu.LocalUsername = existing.LocalUsername
 	}
+	if err := posix.ValidateUsername(cu.LocalUsername); err != nil {
+		return fmt.Errorf("%w: %v", ErrInvalidInput, err)
+	}
+	// The registry and storage are keyed by these, so they freeze once provisioned.
+	if existing.ProvisionedAt != nil && (cu.ComputeClusterID != existing.ComputeClusterID ||
+		cu.UserID != existing.UserID || cu.LocalUsername != existing.LocalUsername) {
+		return fmt.Errorf("%w: compute cluster user %q is provisioned; its cluster, user and username are fixed", ErrInvalidInput, cu.ID)
+	}
 	if err := s.inTx(ctx, func(tx *sql.Tx) error {
 		if err := s.clusterUsers.Update(ctx, tx, cu); err != nil {
 			return err
@@ -365,26 +374,41 @@ func (s *Service) UpdateComputeClusterUser(ctx context.Context, cu *models.Compu
 }
 
 // MarkComputeClusterUserProvisioned stamps provisioned_at on the mapping,
-// signaling that the account exists in the registry.
+// signaling that the account exists in the registry. Only the first stamp
+// publishes an update event.
 func (s *Service) MarkComputeClusterUserProvisioned(ctx context.Context, id string) error {
 	if id == "" {
 		return fmt.Errorf("%w: compute cluster user id is required", ErrInvalidInput)
 	}
-	cu, err := s.clusterUsers.FindByID(ctx, id)
+	cu, err := s.GetComputeClusterUser(ctx, id)
 	if err != nil {
-		return fmt.Errorf("lookup compute cluster user: %w", err)
-	}
-	if cu == nil {
-		return ErrNotFound
+		return err
 	}
 	if err := s.inTx(ctx, func(tx *sql.Tx) error {
-		return s.clusterUsers.MarkProvisioned(ctx, tx, id)
+		at, err := s.clusterUsers.MarkProvisioned(ctx, tx, id)
+		if err != nil || at == nil {
+			return err
+		}
+		cu.ProvisionedAt = at
+		return s.eventBus.Publish(ctx, tx, events.ComputeClusterUserUpdateEvent, cu)
 	}); err != nil {
 		return fmt.Errorf("mark compute cluster user provisioned: %w", err)
 	}
 	return nil
 }
 
+// ListGroupMembersForAllocation returns the members of the allocation's POSIX group.
+func (s *Service) ListGroupMembersForAllocation(ctx context.Context, allocationID string) ([]models.ComputeClusterUser, error) {
+	if allocationID == "" {
+		return nil, fmt.Errorf("%w: compute_allocation_id is required", ErrInvalidInput)
+	}
+	members, err := s.clusterUsers.FindByAllocation(ctx, allocationID)
+	if err != nil {
+		return nil, fmt.Errorf("list group members for allocation: %w", err)
+	}
+	return members, nil
+}
+
 // DeleteComputeClusterUser removes a compute-cluster user mapping by ID.
 func (s *Service) DeleteComputeClusterUser(ctx context.Context, id string) error {
 	if id == "" {
diff --git a/pkg/service/compute_cluster_user_integration_test.go b/pkg/service/compute_cluster_user_integration_test.go
index fe631f32a..0a01f4f60 100644
--- a/pkg/service/compute_cluster_user_integration_test.go
+++ b/pkg/service/compute_cluster_user_integration_test.go
@@ -31,9 +31,11 @@ import (
 	"github.com/apache/airavata-custos/pkg/models"
 )
 
+// Make sure only the first mark publishes, so waiting connectors run once.
 func TestMarkComputeClusterUserProvisioned_RoundTrip(t *testing.T) {
 	database := setupTestDB(t)
 	svc := newTestService(database)
+	svc.EventBus().SubscribeComputeClusterUserUpdated("test-subscriber", func(context.Context, models.ComputeClusterUser) error { return nil })
 
 	cluster, err := svc.CreateComputeCluster(ctx(), &models.ComputeCluster{
 		Name: "provisioned-" + uuid.NewString()[:8],
@@ -70,8 +72,13 @@ func TestMarkComputeClusterUserProvisioned_RoundTrip(t *testing.T) {
 		t.Fatalf("new mapping should not be provisioned yet")
 	}
 
-	if err := svc.MarkComputeClusterUserProvisioned(ctx(), ccu.ID); err != nil {
-		t.Fatalf("mark provisioned: %v", err)
+	for range 2 {
+		if err := svc.MarkComputeClusterUserProvisioned(ctx(), ccu.ID); err != nil {
+			t.Fatalf("mark provisioned: %v", err)
+		}
+	}
+	if n := countDeliveries(t, svc); n != 1 {
+		t.Fatalf("expected 1 delivery for 2 marks, got %d", n)
 	}
 
 	got, err := svc.GetComputeClusterUser(ctx(), ccu.ID)
@@ -260,3 +267,70 @@ func TestOnboardUser_ClusterAccountIsApproved(t *testing.T) {
 		t.Fatalf("expected one delivery of the approve event, got %d", n)
 	}
 }
+
+// Make sure the group holds only approved, provisioned, active members, and
+// empties with the allocation.
+func TestListGroupMembersForAllocation(t *testing.T) {
+	database := setupTestDB(t)
+	svc := newTestService(database)
+	cluster, project := newProject(t, svc, database)
+	alloc := newAllocation(t, svc, cluster.ID, project.ID)
+	admin := seedUser(t, database, fmt.Sprintf("admin-%s@example.edu", uuid.NewString()))
+
+	member := func(approve, provision bool, status models.AllocationStatus) string {
+		userID := seedUser(t, database, fmt.Sprintf("m-%s@example.edu", uuid.NewString()))
+		cu, err := svc.CreateComputeClusterUser(ctx(), &models.ComputeClusterUser{
+			ComputeClusterID: cluster.ID, UserID: userID, LocalUsername: "m-" + uuid.NewString()[:8],
+		})
+		if err != nil {
+			t.Fatalf("create cluster user: %v", err)
+		}
+		if approve {
+			if _, err := svc.ApproveComputeClusterUser(ctx(), cu.ID, admin); err != nil {
+				t.Fatalf("approve: %v", err)
+			}
+		}
+		if provision {
+			if err := svc.MarkComputeClusterUserProvisioned(ctx(), cu.ID); err != nil {
+				t.Fatalf("mark: %v", err)
+			}
+		}
+		if _, err := svc.CreateComputeAllocationMembership(ctx(), &models.ComputeAllocationMembership{
+			ComputeAllocationID: alloc.ID, UserID: userID, MembershipStatus: status,
+		}); err != nil {
+			t.Fatalf("create membership: %v", err)
+		}
+		return cu.LocalUsername
+	}
+	want := member(true, true, models.ACTIVE)
+	member(false, true, models.ACTIVE)
+	member(true, false, models.ACTIVE)
+	member(true, true, models.INACTIVE)
+
+	got, err := svc.ListGroupMembersForAllocation(ctx(), alloc.ID)
+	if err != nil || len(got) != 1 || got[0].LocalUsername != want {
+		t.Fatalf("expected only %s, got %+v (%v)", want, got, err)
+	}
+
+	alloc.Status = models.INACTIVE
+	if err := svc.UpdateComputeAllocation(ctx(), alloc); err != nil {
+		t.Fatalf("inactivate: %v", err)
+	}
+	if got, _ := svc.ListGroupMembersForAllocation(ctx(), alloc.ID); len(got) != 0 {
+		t.Fatalf("expected no members once inactive, got %+v", got)
+	}
+}
+
+// Make sure a provisioned account cannot be renamed.
+func TestUpdateComputeClusterUser_UsernameFixedOnceProvisioned(t *testing.T) {
+	database := setupTestDB(t)
+	svc := newTestService(database)
+	cu := newClusterUser(t, svc, database)
+	if err := svc.MarkComputeClusterUserProvisioned(ctx(), cu.ID); err != nil {
+		t.Fatalf("mark: %v", err)
+	}
+	cu.LocalUsername = "renamed-" + uuid.NewString()[:8]
+	if err := svc.UpdateComputeClusterUser(ctx(), cu); !errors.Is(err, ErrInvalidInput) {
+		t.Fatalf("expected ErrInvalidInput, got %v", err)
+	}
+}
diff --git a/pkg/service/user_onboard.go b/pkg/service/user_onboard.go
index 4733e773a..bd2301293 100644
--- a/pkg/service/user_onboard.go
+++ b/pkg/service/user_onboard.go
@@ -135,8 +135,8 @@ func (s *Service) OnboardUser(ctx context.Context, in OnboardUserInput) (*models
 			}
 			in.Username = generated
 		} else {
-			if len(in.Username) > posix.MaxLoginLen {
-				return nil, fmt.Errorf("%w: username %q is longer than %d characters", ErrInvalidInput, in.Username, posix.MaxLoginLen)
+			if err := posix.ValidateUsername(in.Username); err != nil {
+				return nil, fmt.Errorf("%w: %v", ErrInvalidInput, err)
 			}
 			taken, err := s.clusterUsers.FindByClusterAndLocalUsername(ctx, in.ComputeClusterID, in.Username)
 			if err != nil {
diff --git a/web/src/generated/core/types.gen.ts b/web/src/generated/core/types.gen.ts
index c1e4a02ef..4734f4054 100644
--- a/web/src/generated/core/types.gen.ts
+++ b/web/src/generated/core/types.gen.ts
@@ -66,7 +66,7 @@ export type ClusterAccountResponse = {
     review_note?: string;
     reviewed_at?: string;
     /**
-     * The admin who approved or denied the account.
+     * The admin who approved or denied the cluster account.
      */
     reviewed_by?: string;
     user_id?: string;
@@ -84,6 +84,14 @@ export type ComputeAllocation = {
      */
     initial_su_amount?: number;
     name?: string;
+    /**
+     * Fixed at creation; also the directory and quota name.
+     */
+    posix_group?: string;
+    /**
+     * When the group was provisioned into the registry; nil until then.
+     */
+    posix_group_provisioned_at?: string;
     project_id?: string;
     start_time?: string;
     /**
@@ -326,7 +334,7 @@ export type ComputeClusterUser = {
     review_note?: string;
     reviewed_at?: string;
     /**
-     * The admin who approved or denied the account.
+     * The admin who approved or denied the cluster account.
      */
     reviewed_by?: string;
     user_id?: string;
diff --git a/web/src/generated/core/zod.gen.ts b/web/src/generated/core/zod.gen.ts
index b2eda507e..9b1a0daad 100644
--- a/web/src/generated/core/zod.gen.ts
+++ b/web/src/generated/core/zod.gen.ts
@@ -74,6 +74,8 @@ export const zComputeAllocation = z.object({
     id: z.string().optional(),
     initial_su_amount: z.int().optional(),
     name: z.string().optional(),
+    posix_group: z.string().optional(),
+    posix_group_provisioned_at: z.string().optional(),
     project_id: z.string().optional(),
     start_time: z.string().optional(),
     status: zAllocationStatus.optional()