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/06/30 22:19:40 UTC

(airavata-custos) branch collab-readiness created (now 9705bda6a)

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

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


      at 9705bda6a Bootstrap admin user and link upstream-provisioned users on first OIDC sign-in

This branch includes the following new commits:

     new 9705bda6a Bootstrap admin user and link upstream-provisioned users on first OIDC sign-in

The 1 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.



(airavata-custos) 01/01: Bootstrap admin user and link upstream-provisioned users on first OIDC sign-in

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

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

commit 9705bda6a359257cb4d023e42d9a02b4dead9f89
Author: lahiruj <la...@gmail.com>
AuthorDate: Tue Jun 30 18:19:33 2026 -0400

    Bootstrap admin user and link upstream-provisioned users on first OIDC sign-in
---
 .../handler/request_account_create.go              |   1 +
 .../handler/request_project_create.go              |   1 +
 dev-ops/compose/seeds/dev_portal_data.sql          |  18 +-
 internal/db/migrations/000001_identity.up.sql      |   2 +-
 internal/store/store.go                            |   2 +
 internal/store/user_identity_store.go              |  13 ++
 pkg/identity/identity.go                           |  18 +-
 pkg/identity/middleware.go                         |   4 +-
 pkg/identity/verifier.go                           |   6 +-
 pkg/models/project.go                              |   2 +
 pkg/service/identity_resolver.go                   | 108 ++++++++-
 pkg/service/identity_resolver_integration_test.go  | 248 +++++++++++++++++++++
 pkg/service/user.go                                |   2 +-
 pkg/service/user_role.go                           |  69 +++++-
 14 files changed, 462 insertions(+), 32 deletions(-)

diff --git a/connectors/ACCESS/AMIE-Processor/handler/request_account_create.go b/connectors/ACCESS/AMIE-Processor/handler/request_account_create.go
index 380735d08..340350809 100644
--- a/connectors/ACCESS/AMIE-Processor/handler/request_account_create.go
+++ b/connectors/ACCESS/AMIE-Processor/handler/request_account_create.go
@@ -160,6 +160,7 @@ func (h *RequestAccountCreateHandler) ensureUser(ctx context.Context, body map[s
 		FirstName:      getString(body, "UserFirstName"),
 		LastName:       getString(body, "UserLastName"),
 		Email:          email,
+		Status:         models.UserPending,
 	})
 	if err != nil {
 		return nil, fmt.Errorf("create user: %w", err)
diff --git a/connectors/ACCESS/AMIE-Processor/handler/request_project_create.go b/connectors/ACCESS/AMIE-Processor/handler/request_project_create.go
index 987ad6216..1574293bf 100644
--- a/connectors/ACCESS/AMIE-Processor/handler/request_project_create.go
+++ b/connectors/ACCESS/AMIE-Processor/handler/request_project_create.go
@@ -160,6 +160,7 @@ func (h *RequestProjectCreateHandler) ensurePIUser(ctx context.Context, body map
 		FirstName:      getString(body, "PiFirstName"),
 		LastName:       getString(body, "PiLastName"),
 		Email:          email,
+		Status:         models.UserPending,
 	})
 	if err != nil {
 		return nil, fmt.Errorf("create PI user: %w", err)
diff --git a/dev-ops/compose/seeds/dev_portal_data.sql b/dev-ops/compose/seeds/dev_portal_data.sql
index bd7f531c1..e467110a4 100644
--- a/dev-ops/compose/seeds/dev_portal_data.sql
+++ b/dev-ops/compose/seeds/dev_portal_data.sql
@@ -13,17 +13,15 @@ SET
 time_zone = '+00:00';
 
 -- ---------------------------------------------------------------------------
--- dev-admin direct privilege grants (bypasses the bootstrap super_admin →
--- API-grant flow that would otherwise need a separate per-privilege POST).
+-- Extend super_admin with the six operational privileges. No-op until the
+-- role is created by the Go bootstrap path; re-apply after first server start.
 -- ---------------------------------------------------------------------------
-INSERT
-IGNORE INTO user_privileges (id, user_id, privilege, granted_by) VALUES
-  (UUID(), 'dev-admin', 'amie:read',    'dev-admin'),
-  (UUID(), 'dev-admin', 'amie:write',   'dev-admin'),
-  (UUID(), 'dev-admin', 'hpc:read',     'dev-admin'),
-  (UUID(), 'dev-admin', 'hpc:write',    'dev-admin'),
-  (UUID(), 'dev-admin', 'signer:read',  'dev-admin'),
-  (UUID(), 'dev-admin', 'signer:write', 'dev-admin');
+INSERT IGNORE INTO role_privileges (role_id, privilege) SELECT id, 'amie:read'    FROM roles WHERE name = 'super_admin';
+INSERT IGNORE INTO role_privileges (role_id, privilege) SELECT id, 'amie:write'   FROM roles WHERE name = 'super_admin';
+INSERT IGNORE INTO role_privileges (role_id, privilege) SELECT id, 'hpc:read'     FROM roles WHERE name = 'super_admin';
+INSERT IGNORE INTO role_privileges (role_id, privilege) SELECT id, 'hpc:write'    FROM roles WHERE name = 'super_admin';
+INSERT IGNORE INTO role_privileges (role_id, privilege) SELECT id, 'signer:read'  FROM roles WHERE name = 'super_admin';
+INSERT IGNORE INTO role_privileges (role_id, privilege) SELECT id, 'signer:write' FROM roles WHERE name = 'super_admin';
 
 -- ---------------------------------------------------------------------------
 -- Resources (cluster-scoped resource catalog)
diff --git a/internal/db/migrations/000001_identity.up.sql b/internal/db/migrations/000001_identity.up.sql
index c7c142f30..6c0482b9b 100644
--- a/internal/db/migrations/000001_identity.up.sql
+++ b/internal/db/migrations/000001_identity.up.sql
@@ -37,7 +37,7 @@ CREATE TABLE IF NOT EXISTS users
     last_name       VARCHAR(255) NOT NULL,
     middle_name     VARCHAR(255) NULL,
     email           VARCHAR(255) NOT NULL,
-    status          VARCHAR(32)  NOT NULL DEFAULT 'ACTIVE',
+    status          VARCHAR(32)  NOT NULL DEFAULT 'PENDING',
     created_at      TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
     updated_at      TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
     PRIMARY KEY (id),
diff --git a/internal/store/store.go b/internal/store/store.go
index 604e1d58e..107622dd0 100644
--- a/internal/store/store.go
+++ b/internal/store/store.go
@@ -112,6 +112,8 @@ type UserIdentityStore interface {
 	FindByOIDCSub(ctx context.Context, oidcSub string) (*models.UserIdentity, error)
 	// FindByUser returns every user identity bound to the given user, ordered by created_at.
 	FindByUser(ctx context.Context, userID string) ([]models.UserIdentity, error)
+	// FindByUserAndSource returns the binding for (user, source), or nil if absent.
+	FindByUserAndSource(ctx context.Context, userID, source string) (*models.UserIdentity, error)
 	// Create inserts a new user identity within the provided transaction.
 	Create(ctx context.Context, tx *sql.Tx, e *models.UserIdentity) error
 	// Update replaces mutable fields of an existing user identity within the provided transaction.
diff --git a/internal/store/user_identity_store.go b/internal/store/user_identity_store.go
index 6dd74b08c..a7143a1e4 100644
--- a/internal/store/user_identity_store.go
+++ b/internal/store/user_identity_store.go
@@ -102,6 +102,19 @@ func (s *mysqlUserIdentityStore) FindByUser(ctx context.Context, userID string)
 	return out, nil
 }
 
+func (s *mysqlUserIdentityStore) FindByUserAndSource(ctx context.Context, userID, source string) (*models.UserIdentity, error) {
+	var e models.UserIdentity
+	err := s.db.GetContext(ctx, &e,
+		`SELECT `+userIdentityColumns+` FROM user_identities WHERE user_id = ? AND source = ? LIMIT 1`, userID, source)
+	if errors.Is(err, sql.ErrNoRows) {
+		return nil, nil
+	}
+	if err != nil {
+		return nil, err
+	}
+	return &e, nil
+}
+
 func (s *mysqlUserIdentityStore) Create(ctx context.Context, tx *sql.Tx, e *models.UserIdentity) error {
 	_, err := tx.ExecContext(ctx,
 		`INSERT INTO user_identities (id, user_id, source, external_id, email, oidc_sub, metadata)
diff --git a/pkg/identity/identity.go b/pkg/identity/identity.go
index 3122527ed..b270c9aec 100644
--- a/pkg/identity/identity.go
+++ b/pkg/identity/identity.go
@@ -23,6 +23,7 @@ package identity
 import (
 	"context"
 	"errors"
+	"fmt"
 
 	"github.com/apache/airavata-custos/pkg/models"
 )
@@ -32,15 +33,20 @@ type Caller struct {
 	UserID string
 }
 
-// ErrNotLinked is returned by a UserResolver when the OIDC sub has no row in
-// the Custos user table. The middleware translates this into 401
-// identity_not_linked.
-var ErrNotLinked = errors.New("identity not linked")
+// Wrap the ErrNotLinked to keep the specific cause in the log while the middleware
+// returns a single 401 identity_not_linked to clients.
+var (
+	ErrNotLinked             = errors.New("identity not linked")
+	ErrEmailNotVerified      = fmt.Errorf("%w: verified email claim missing", ErrNotLinked)
+	ErrNoUserMatchesEmail    = fmt.Errorf("%w: no user matches email", ErrNotLinked)
+	ErrUserNotPending        = fmt.Errorf("%w: user is not pending", ErrNotLinked)
+	ErrUserAlreadyOIDCLinked = fmt.Errorf("%w: user already has an OIDC binding", ErrNotLinked)
+)
 
-// UserResolver maps a verified OIDC sub to a Caller and the effective
+// UserResolver maps verified OIDC claims to a Caller and the effective
 // privilege set.
 type UserResolver interface {
-	ResolveCaller(ctx context.Context, oidcSub string) (*Caller, []models.PrivilegeKey, error)
+	ResolveCaller(ctx context.Context, claims *Claims) (*Caller, []models.PrivilegeKey, error)
 }
 
 type ctxKey string
diff --git a/pkg/identity/middleware.go b/pkg/identity/middleware.go
index 97a7005c4..148461813 100644
--- a/pkg/identity/middleware.go
+++ b/pkg/identity/middleware.go
@@ -20,6 +20,7 @@ package identity
 import (
 	"encoding/json"
 	"errors"
+	"log/slog"
 	"net/http"
 	"strings"
 
@@ -49,12 +50,13 @@ func Middleware(verifier *JWTVerifier, resolver UserResolver, publicPaths []stri
 			writeJSONError(w, http.StatusUnauthorized, "invalid_token", "Invalid or expired bearer token")
 			return
 		}
-		caller, privileges, err := resolver.ResolveCaller(req.Context(), claims.Sub)
+		caller, privileges, err := resolver.ResolveCaller(req.Context(), claims)
 		if errors.Is(err, ErrNotLinked) {
 			writeJSONError(w, http.StatusUnauthorized, "identity_not_linked", "OIDC identity is not linked to a portal user")
 			return
 		}
 		if err != nil {
+			slog.Error("identity resolution failed", "sub", claims.Sub, "email", claims.Email, "error", err.Error())
 			writeJSONError(w, http.StatusServiceUnavailable, "auth_lookup_failed", "Identity resolution failed")
 			return
 		}
diff --git a/pkg/identity/verifier.go b/pkg/identity/verifier.go
index 24314d063..fc0ccaeee 100644
--- a/pkg/identity/verifier.go
+++ b/pkg/identity/verifier.go
@@ -27,9 +27,11 @@ import (
 // issuer, audience, and expiry.
 type JWTVerifier struct{ inner *oidc.IDTokenVerifier }
 
-// Claims are the minimal subset the middleware needs after verification.
+// Claims are the subset the middleware needs after verification.
 type Claims struct {
-	Sub string `json:"sub"`
+	Sub           string `json:"sub"`
+	Email         string `json:"email"`
+	EmailVerified bool   `json:"email_verified"`
 }
 
 // NewJWTVerifier resolves the provider's discovery doc and builds a verifier
diff --git a/pkg/models/project.go b/pkg/models/project.go
index 50a70756f..9ebecac7c 100644
--- a/pkg/models/project.go
+++ b/pkg/models/project.go
@@ -23,6 +23,7 @@ import "time"
 type UserStatus string
 
 const (
+	UserPending   UserStatus = "PENDING"
 	UserActive    UserStatus = "ACTIVE"
 	UserInactive  UserStatus = "INACTIVE"
 	UserSuspended UserStatus = "SUSPENDED"
@@ -60,6 +61,7 @@ type UserType string
 const (
 	UserTypeClusterLocal UserType = "CLUSTER_LOCAL"
 	UserTypeVirtual      UserType = "VIRTUAL"
+	UserTypeSystem       UserType = "SYSTEM"
 )
 
 type User struct {
diff --git a/pkg/service/identity_resolver.go b/pkg/service/identity_resolver.go
index 84512e6ff..e03e28cd7 100644
--- a/pkg/service/identity_resolver.go
+++ b/pkg/service/identity_resolver.go
@@ -19,6 +19,10 @@ package service
 
 import (
 	"context"
+	"database/sql"
+	"encoding/json"
+	"fmt"
+	"log/slog"
 	"sync"
 	"time"
 
@@ -26,6 +30,11 @@ import (
 	"github.com/apache/airavata-custos/pkg/models"
 )
 
+const (
+	identityAuditLinked = "IDENTITY_LINKED"
+	identitySourceOIDC  = "oidc"
+)
+
 // identityCacheDefaultTTL is the fallback TTL when SetIdentityCacheTTL is never
 // invoked or receives a non-positive value. The config loader applies the
 // same default and enforces a 60s ceiling before the value reaches the cache.
@@ -95,25 +104,110 @@ func (s *Service) InvalidateAllIdentities() {
 	s.identityCache.invalidateAll()
 }
 
-// ResolveCaller maps a verified OIDC sub to a Caller + effective privilege set.
-// Returns ErrNotLinked when no user_identities row references sub.
-func (s *Service) ResolveCaller(ctx context.Context, oidcSub string) (*identity.Caller, []models.PrivilegeKey, error) {
+// ResolveCaller maps verified OIDC claims to a Caller + effective privilege set
+func (s *Service) ResolveCaller(ctx context.Context, claims *identity.Claims) (*identity.Caller, []models.PrivilegeKey, error) {
+	if claims == nil || claims.Sub == "" {
+		return nil, nil, identity.ErrNotLinked
+	}
 	now := time.Now()
-	if hit, ok := s.identityCache.get(oidcSub, now); ok {
+	if hit, ok := s.identityCache.get(claims.Sub, now); ok {
 		return hit.caller, hit.privs, nil
 	}
-	user, err := s.users.GetUserByOIDCSub(ctx, oidcSub)
+	user, err := s.users.GetUserByOIDCSub(ctx, claims.Sub)
 	if err != nil {
 		return nil, nil, err
 	}
 	if user == nil {
-		return nil, nil, identity.ErrNotLinked
+		user, err = s.linkBySub(ctx, claims)
+		if err != nil {
+			return nil, nil, err
+		}
 	}
 	privs, err := s.EffectivePrivileges(ctx, user.ID)
 	if err != nil {
 		return nil, nil, err
 	}
 	caller := &identity.Caller{UserID: user.ID}
-	s.identityCache.set(oidcSub, caller, privs, now)
+	s.identityCache.set(claims.Sub, caller, privs, now)
 	return caller, privs, nil
 }
+
+// linkBySub creates the first OIDC binding for a PENDING user matched by verified email.
+func (s *Service) linkBySub(ctx context.Context, claims *identity.Claims) (*models.User, error) {
+	slog.Debug("linkBySub: evaluating claims",
+		"sub", claims.Sub,
+		"email", claims.Email,
+		"email_verified", claims.EmailVerified,
+	)
+	if !claims.EmailVerified || claims.Email == "" {
+		return nil, identity.ErrEmailNotVerified
+	}
+	user, err := s.users.FindByEmail(ctx, claims.Email)
+	if err != nil {
+		return nil, fmt.Errorf("email-fallback lookup: %w", err)
+	}
+	if user == nil {
+		return nil, identity.ErrNoUserMatchesEmail
+	}
+	existingOIDC, err := s.userIdentities.FindByUserAndSource(ctx, user.ID, identitySourceOIDC)
+	if err != nil {
+		return nil, fmt.Errorf("email-fallback identity scan: %w", err)
+	}
+	if existingOIDC != nil {
+		return nil, identity.ErrUserAlreadyOIDCLinked
+	}
+	if user.Status != models.UserPending {
+		// A non-MERGED user without an OIDC binding is an inconsistent state;
+		// merged users legitimately lose theirs to the surviving user.
+		if user.Status != models.UserMerged {
+			slog.Error("inconsistent user state: reached non-PENDING lifecycle without an OIDC binding",
+				"user_id", user.ID,
+				"email", claims.Email,
+				"sub", claims.Sub,
+				"status", user.Status,
+			)
+		}
+		return nil, fmt.Errorf("%w: status=%s", identity.ErrUserNotPending, user.Status)
+	}
+	if err := s.inTx(ctx, func(tx *sql.Tx) error {
+		binding := &models.UserIdentity{
+			ID:         newID(),
+			UserID:     user.ID,
+			Source:     identitySourceOIDC,
+			ExternalID: claims.Sub,
+			Email:      claims.Email,
+			OIDCSub:    claims.Sub,
+		}
+		if err := s.userIdentities.Create(ctx, tx, binding); err != nil {
+			return fmt.Errorf("create oidc identity: %w", err)
+		}
+		if err := s.users.UpdateStatus(ctx, tx, user.ID, models.UserActive); err != nil {
+			return fmt.Errorf("activate user: %w", err)
+		}
+		return s.writeIdentityAuditTx(ctx, tx, user.ID, map[string]any{
+			"oidc_sub": claims.Sub,
+			"email":    claims.Email,
+			"source":   "oidc_email_fallback",
+		})
+	}); err != nil {
+		return nil, err
+	}
+	slog.Info("identity linked via email fallback", "user_id", user.ID, "email", claims.Email)
+	user.Status = models.UserActive
+	return user, nil
+}
+
+func (s *Service) writeIdentityAuditTx(ctx context.Context, tx *sql.Tx, userID string, details map[string]any) error {
+	payload, err := json.Marshal(details)
+	if err != nil {
+		return fmt.Errorf("marshal audit details: %w", err)
+	}
+	return s.auditEvents.Create(ctx, tx, &models.AuditEvent{
+		ID:         newID(),
+		EventType:  identityAuditLinked,
+		EventTime:  nowUTC(),
+		EntityID:   userID,
+		EntityType: "user",
+		Details:    string(payload),
+	})
+}
diff --git a/pkg/service/identity_resolver_integration_test.go b/pkg/service/identity_resolver_integration_test.go
new file mode 100644
index 000000000..e88d0b4d0
--- /dev/null
+++ b/pkg/service/identity_resolver_integration_test.go
@@ -0,0 +1,248 @@
+//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 (
+	"errors"
+	"testing"
+
+	"github.com/apache/airavata-custos/pkg/identity"
+	"github.com/apache/airavata-custos/pkg/models"
+	"github.com/google/uuid"
+	"github.com/jmoiron/sqlx"
+)
+
+func seedPendingUserWithEmail(t *testing.T, database *sqlx.DB, email string) string {
+	t.Helper()
+	orgID := seedOrg(t, database)
+	userID := uuid.NewString()
+	if _, err := database.Exec(
+		"INSERT INTO users (id, organization_id, first_name, last_name, middle_name, email, status, type) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
+		userID, orgID, "Test", "Pending", "", email, string(models.UserPending), string(models.UserTypeClusterLocal),
+	); err != nil {
+		t.Fatalf("seed pending user %s: %v", email, err)
+	}
+	return userID
+}
+
+func seedAMIEIdentity(t *testing.T, database *sqlx.DB, userID, email string) {
+	t.Helper()
+	if _, err := database.Exec(
+		"INSERT INTO user_identities (id, user_id, source, external_id, email) VALUES (?, ?, ?, ?, ?)",
+		uuid.NewString(), userID, "amie", "amie-"+userID[:8], email,
+	); err != nil {
+		t.Fatalf("seed AMIE identity: %v", err)
+	}
+}
+
+func seedOIDCIdentity(t *testing.T, database *sqlx.DB, userID, sub, email string) {
+	t.Helper()
+	if _, err := database.Exec(
+		"INSERT INTO user_identities (id, user_id, source, external_id, email, oidc_sub) VALUES (?, ?, ?, ?, ?, ?)",
+		uuid.NewString(), userID, "oidc", sub, email, sub,
+	); err != nil {
+		t.Fatalf("seed OIDC identity: %v", err)
+	}
+}
+
+func TestResolveCaller_NilClaims_ErrNotLinked(t *testing.T) {
+	_ = setupTestDB(t)
+	svc := newTestService(setupTestDB(t))
+	_, _, err := svc.ResolveCaller(ctx(), nil)
+	if !errors.Is(err, identity.ErrNotLinked) {
+		t.Errorf("err: got %v, want ErrNotLinked", err)
+	}
+}
+
+func TestResolveCaller_SubAlreadyBound_FastPath(t *testing.T) {
+	database := setupTestDB(t)
+	svc := newTestService(database)
+	user := seedUser(t, database, "active@example.edu")
+	seedOIDCIdentity(t, database, user, "sub-123", "active@example.edu")
+
+	caller, _, err := svc.ResolveCaller(ctx(), &identity.Claims{Sub: "sub-123"})
+	if err != nil {
+		t.Fatalf("resolve: %v", err)
+	}
+	if caller.UserID != user {
+		t.Errorf("user_id: got %s, want %s", caller.UserID, user)
+	}
+}
+
+func TestResolveCaller_EmailFallback_HappyPath_LinksAndActivates(t *testing.T) {
+	database := setupTestDB(t)
+	svc := newTestService(database)
+	user := seedPendingUserWithEmail(t, database, "pending@example.edu")
+
+	caller, _, err := svc.ResolveCaller(ctx(), &identity.Claims{
+		Sub:           "sub-new",
+		Email:         "pending@example.edu",
+		EmailVerified: true,
+	})
+	if err != nil {
+		t.Fatalf("resolve: %v", err)
+	}
+	if caller.UserID != user {
+		t.Errorf("user_id: got %s, want %s", caller.UserID, user)
+	}
+	// User should now be ACTIVE.
+	var status string
+	if err := database.Get(&status, "SELECT status FROM users WHERE id = ?", user); err != nil {
+		t.Fatalf("read status: %v", err)
+	}
+	if status != string(models.UserActive) {
+		t.Errorf("status: got %s, want ACTIVE", status)
+	}
+	// Audit event emitted.
+	if got := countAuditEventsOfType(t, database, identityAuditLinked, user); got != 1 {
+		t.Errorf("audit IDENTITY_LINKED: got %d, want 1", got)
+	}
+	// oidc_sub binding now in place.
+	var sub string
+	if err := database.Get(&sub,
+		"SELECT oidc_sub FROM user_identities WHERE user_id = ? AND source = 'oidc'", user,
+	); err != nil {
+		t.Fatalf("read binding: %v", err)
+	}
+	if sub != "sub-new" {
+		t.Errorf("oidc_sub: got %s, want sub-new", sub)
+	}
+}
+
+func TestResolveCaller_EmailFallback_RefusesUnverifiedEmail(t *testing.T) {
+	database := setupTestDB(t)
+	svc := newTestService(database)
+	seedPendingUserWithEmail(t, database, "pending@example.edu")
+
+	_, _, err := svc.ResolveCaller(ctx(), &identity.Claims{
+		Sub:           "sub-x",
+		Email:         "pending@example.edu",
+		EmailVerified: false,
+	})
+	if !errors.Is(err, identity.ErrNotLinked) {
+		t.Errorf("err: got %v, want ErrNotLinked", err)
+	}
+}
+
+func TestResolveCaller_EmailFallback_RefusesEmptyEmail(t *testing.T) {
+	database := setupTestDB(t)
+	svc := newTestService(database)
+
+	_, _, err := svc.ResolveCaller(ctx(), &identity.Claims{
+		Sub:           "sub-x",
+		Email:         "",
+		EmailVerified: true,
+	})
+	if !errors.Is(err, identity.ErrNotLinked) {
+		t.Errorf("err: got %v, want ErrNotLinked", err)
+	}
+}
+
+func TestResolveCaller_EmailFallback_RefusesActiveUser(t *testing.T) {
+	database := setupTestDB(t)
+	svc := newTestService(database)
+	seedUser(t, database, "active@example.edu") // ACTIVE
+
+	_, _, err := svc.ResolveCaller(ctx(), &identity.Claims{
+		Sub:           "sub-other",
+		Email:         "active@example.edu",
+		EmailVerified: true,
+	})
+	if !errors.Is(err, identity.ErrNotLinked) {
+		t.Errorf("err: got %v, want ErrNotLinked (refuse to relink ACTIVE)", err)
+	}
+}
+
+func TestResolveCaller_EmailFallback_RefusesUserWithExistingOIDCSub(t *testing.T) {
+	database := setupTestDB(t)
+	svc := newTestService(database)
+	user := seedPendingUserWithEmail(t, database, "pending@example.edu")
+	seedOIDCIdentity(t, database, user, "sub-old", "pending@example.edu")
+
+	_, _, err := svc.ResolveCaller(ctx(), &identity.Claims{
+		Sub:           "sub-new",
+		Email:         "pending@example.edu",
+		EmailVerified: true,
+	})
+	if !errors.Is(err, identity.ErrNotLinked) {
+		t.Errorf("err: got %v, want ErrNotLinked (refuse a second sub)", err)
+	}
+}
+
+func TestResolveCaller_EmailFallback_LinksAMIEProvisioned(t *testing.T) {
+	database := setupTestDB(t)
+	svc := newTestService(database)
+	user := seedPendingUserWithEmail(t, database, "pi@example.edu")
+	seedAMIEIdentity(t, database, user, "pi@example.edu") // source=amie, oidc_sub=NULL
+
+	caller, _, err := svc.ResolveCaller(ctx(), &identity.Claims{
+		Sub:           "sub-pi",
+		Email:         "pi@example.edu",
+		EmailVerified: true,
+	})
+	if err != nil {
+		t.Fatalf("resolve: %v", err)
+	}
+	if caller.UserID != user {
+		t.Errorf("user_id: got %s, want %s", caller.UserID, user)
+	}
+	// Both identities now coexist for this user: amie + oidc.
+	var count int
+	if err := database.Get(&count, "SELECT COUNT(*) FROM user_identities WHERE user_id = ?", user); err != nil {
+		t.Fatalf("count identities: %v", err)
+	}
+	if count != 2 {
+		t.Errorf("identity count: got %d, want 2 (amie + oidc)", count)
+	}
+}
+
+func TestBootstrapSuperAdmin_CreatesPendingUserWhenMissing(t *testing.T) {
+	database := setupTestDB(t)
+	svc := newTestService(database)
+	const email = "newadmin@custos.local"
+
+	if err := svc.BootstrapSuperAdmin(ctx(), email, "env:TEST"); err != nil {
+		t.Fatalf("bootstrap: %v", err)
+	}
+
+	var (
+		userID, status, userType, orgID string
+	)
+	if err := database.QueryRow(
+		"SELECT id, status, type, organization_id FROM users WHERE email = ?", email,
+	).Scan(&userID, &status, &userType, &orgID); err != nil {
+		t.Fatalf("read user: %v", err)
+	}
+	if status != string(models.UserPending) {
+		t.Errorf("status: got %s, want PENDING", status)
+	}
+	if userType != string(models.UserTypeSystem) {
+		t.Errorf("type: got %s, want SYSTEM", userType)
+	}
+	if orgID != "system" {
+		t.Errorf("organization_id: got %s, want system", orgID)
+	}
+	if got := countAuditEventsOfType(t, database, "USER_BOOTSTRAPPED", userID); got != 1 {
+		t.Errorf("audit USER_BOOTSTRAPPED: got %d, want 1", got)
+	}
+	if has, err := svc.HasPrivilege(ctx(), userID, models.PrivilegeGrant); err != nil || !has {
+		t.Errorf("super_admin grant after bootstrap: has=%v err=%v", has, err)
+	}
+}
diff --git a/pkg/service/user.go b/pkg/service/user.go
index cc3be72fe..d86c89b03 100644
--- a/pkg/service/user.go
+++ b/pkg/service/user.go
@@ -55,7 +55,7 @@ func (s *Service) CreateUser(ctx context.Context, user *models.User) (*models.Us
 		user.ID = newID()
 	}
 	if user.Status == "" {
-		user.Status = models.UserActive
+		user.Status = models.UserPending
 	}
 	if user.Type == "" {
 		user.Type = models.UserTypeClusterLocal
diff --git a/pkg/service/user_role.go b/pkg/service/user_role.go
index 0f11fb35a..2daa36cf2 100644
--- a/pkg/service/user_role.go
+++ b/pkg/service/user_role.go
@@ -20,6 +20,7 @@ package service
 import (
 	"context"
 	"database/sql"
+	"encoding/json"
 	"fmt"
 	"log/slog"
 
@@ -30,6 +31,8 @@ const (
 	userRoleAuditGranted   = "ROLE_GRANTED"
 	userRoleAuditRevoked   = "ROLE_REVOKED"
 	userRoleAuditBootstrap = "ROLE_BOOTSTRAPPED"
+	userAuditBootstrapped  = "USER_BOOTSTRAPPED"
+	bootstrapSystemOrgID   = "system"
 )
 
 // GrantRoleToUser requires granterID to hold roles:manage.
@@ -143,8 +146,8 @@ func (s *Service) ListRoleHolders(ctx context.Context, roleID string) ([]models.
 }
 
 // BootstrapSuperAdmin ensures the super_admin role exists and grants it to
-// the user with the named email. Idempotent — returns nil on every no-op
-// case so a failed bootstrap never blocks server start.
+// the user with the named email, creating a PENDING SYSTEM user if missing.
+// Idempotent.
 func (s *Service) BootstrapSuperAdmin(ctx context.Context, email, source string) error {
 	if email == "" {
 		return nil
@@ -154,8 +157,10 @@ func (s *Service) BootstrapSuperAdmin(ctx context.Context, email, source string)
 		return fmt.Errorf("lookup bootstrap user: %w", err)
 	}
 	if user == nil {
-		slog.Warn("bootstrap: user not found, skipping", "email", email)
-		return nil
+		user, err = s.createBootstrapUser(ctx, email, source)
+		if err != nil {
+			return fmt.Errorf("create bootstrap user: %w", err)
+		}
 	}
 	return s.inTx(ctx, func(tx *sql.Tx) error {
 		role, err := s.ensureSuperAdminRoleTx(ctx, tx)
@@ -201,6 +206,62 @@ func (s *Service) BootstrapSuperAdmin(ctx context.Context, email, source string)
 	})
 }
 
+// createBootstrapUser provisions the bootstrap admin row and the system org
+// it belongs to, both PENDING until the first matching OIDC sign-in.
+func (s *Service) createBootstrapUser(ctx context.Context, email, source string) (*models.User, error) {
+	var created *models.User
+	err := s.inTx(ctx, func(tx *sql.Tx) error {
+		org, err := s.orgs.FindByID(ctx, bootstrapSystemOrgID)
+		if err != nil {
+			return fmt.Errorf("lookup system org: %w", err)
+		}
+		if org == nil {
+			org = &models.Organization{
+				ID:           bootstrapSystemOrgID,
+				OriginatedID: bootstrapSystemOrgID,
+				Name:         "System",
+			}
+			if err := s.orgs.Create(ctx, tx, org); err != nil {
+				return fmt.Errorf("create system org: %w", err)
+			}
+		}
+		created = &models.User{
+			ID:             newID(),
+			OrganizationID: org.ID,
+			Email:          email,
+			Status:         models.UserPending,
+			Type:           models.UserTypeSystem,
+		}
+		if err := s.users.Create(ctx, tx, created); err != nil {
+			return fmt.Errorf("insert bootstrap user: %w", err)
+		}
+		return s.writeUserBootstrappedAuditTx(ctx, tx, created.ID, map[string]any{
+			"email":  email,
+			"source": source,
+		})
+	})
+	if err != nil {
+		return nil, err
+	}
+	slog.Info("bootstrap: created PENDING super_admin user", "user_id", created.ID, "email", email)
+	return created, nil
+}
+
+func (s *Service) writeUserBootstrappedAuditTx(ctx context.Context, tx *sql.Tx, userID string, details map[string]any) error {
+	payload, err := json.Marshal(details)
+	if err != nil {
+		return fmt.Errorf("marshal audit details: %w", err)
+	}
+	return s.auditEvents.Create(ctx, tx, &models.AuditEvent{
+		ID:         newID(),
+		EventType:  userAuditBootstrapped,
+		EventTime:  nowUTC(),
+		EntityID:   userID,
+		EntityType: "user",
+		Details:    string(payload),
+	})
+}
+
 // ensureSuperAdminRoleTx creates the super_admin role with the meta
 // privileges if it doesn't exist, otherwise returns the existing row.
 func (s *Service) ensureSuperAdminRoleTx(ctx context.Context, tx *sql.Tx) (*models.Role, error) {