diff --git a/packages/dashboard-api/internal/api/api.gen.go b/packages/dashboard-api/internal/api/api.gen.go index fbc8000a0b..511ba9b83f 100644 --- a/packages/dashboard-api/internal/api/api.gen.go +++ b/packages/dashboard-api/internal/api/api.gen.go @@ -191,6 +191,18 @@ type AdminTeamBootstrapRequest struct { Name string `json:"name"` } +// AdminUserBootstrapResponse defines model for AdminUserBootstrapResponse. +type AdminUserBootstrapResponse struct { + // Id The team ID for the bootstrapped user. + Id openapi_types.UUID `json:"id"` + + // Slug The team slug for the bootstrapped user. + Slug string `json:"slug"` + + // UserId The internal user ID assigned to the bootstrapped identity. + UserId openapi_types.UUID `json:"user_id"` +} + // BuildInfo defines model for BuildInfo. type BuildInfo struct { // CreatedAt Build creation timestamp in RFC3339 format. @@ -346,7 +358,7 @@ type SandboxRecord struct { // Domain Base domain where the sandbox traffic is accessible Domain *string `json:"domain,omitempty"` - // EventsRetentionExpired Whether the sandbox ended more than the team's events retention window ago, so its events data is no longer available. Best-effort - computed from the team's current retention limit, which may differ from the retention stamped on events written before a limit change. + // EventsRetentionExpired Whether the sandbox ended more than the team's events retention window ago, so its events data is no longer available EventsRetentionExpired bool `json:"eventsRetentionExpired"` // MemoryMB Memory for the sandbox in MiB @@ -1692,100 +1704,100 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options // const string: with thousands of chunks the chained `+` fold is several // times slower for the Go compiler than parsing a slice literal. var swaggerSpec = []string{ - "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", } // decodeSpec returns the embedded OpenAPI spec as raw JSON bytes, diff --git a/packages/dashboard-api/internal/handlers/admin_users_bootstrap.go b/packages/dashboard-api/internal/handlers/admin_users_bootstrap.go index a2e7993efa..9006fbc73a 100644 --- a/packages/dashboard-api/internal/handlers/admin_users_bootstrap.go +++ b/packages/dashboard-api/internal/handlers/admin_users_bootstrap.go @@ -50,9 +50,10 @@ func (s *APIStore) PostAdminUsersBootstrap(c *gin.Context) { return } - c.JSON(http.StatusOK, api.TeamResolveResponse{ - Id: team.ID, - Slug: team.Slug, + c.JSON(http.StatusOK, api.AdminUserBootstrapResponse{ + Id: team.ID, + Slug: team.Slug, + UserId: team.UserID, }) } diff --git a/packages/dashboard-api/internal/handlers/ory_fallback_test.go b/packages/dashboard-api/internal/handlers/ory_fallback_test.go new file mode 100644 index 0000000000..c9947195ed --- /dev/null +++ b/packages/dashboard-api/internal/handlers/ory_fallback_test.go @@ -0,0 +1,205 @@ +package handlers + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "github.com/e2b-dev/infra/packages/auth/pkg/auth" + authtypes "github.com/e2b-dev/infra/packages/auth/pkg/types" + "github.com/e2b-dev/infra/packages/dashboard-api/internal/identity" + "github.com/e2b-dev/infra/packages/dashboard-api/internal/provisioning" + authqueries "github.com/e2b-dev/infra/packages/db/pkg/auth/queries" + "github.com/e2b-dev/infra/packages/db/pkg/testutils" + "github.com/e2b-dev/infra/packages/shared/pkg/teamprovision" +) + +// oryAdminUnavailableIdentity simulates a deployment where the Ory admin API is +// unreachable — e.g. Hydra-only or a CDN that blocks /admin/ paths. +// ProfilesByUserID and FindProfilesByEmail return errors to trigger the DB fallback paths. +// Other methods succeed so that the bootstrap and team-provisioning flows are not blocked. +type oryAdminUnavailableIdentity struct{} + +func (oryAdminUnavailableIdentity) ProfilesByUserID(_ context.Context, _ []uuid.UUID) (map[uuid.UUID]identity.Profile, error) { + return nil, errors.New("ory admin api unavailable: connection refused") +} + +func (oryAdminUnavailableIdentity) FindProfilesByEmail(_ context.Context, _ string) ([]identity.Profile, error) { + return nil, errors.New("ory admin api unavailable: connection refused") +} + +func (oryAdminUnavailableIdentity) IdentityOrganizationID(_ context.Context, _, _ string) (uuid.UUID, error) { + return uuid.Nil, nil +} + +func (oryAdminUnavailableIdentity) SetIdentityExternalID(_ context.Context, _, _ string, _ uuid.UUID) error { + return nil +} + +func (oryAdminUnavailableIdentity) UserOrganizationID(_ context.Context, _ uuid.UUID) (uuid.UUID, error) { + return uuid.Nil, nil +} + +func (oryAdminUnavailableIdentity) TeamCreatorContext(_ context.Context, _ uuid.UUID) (*teamprovision.CreatorContextV1, error) { + return nil, nil +} + +func (oryAdminUnavailableIdentity) PrepareDeleteUser(_ context.Context, _ uuid.UUID) (identity.DeleteUserHandle, error) { + return nil, nil +} + +// --- PostAdminUsersBootstrap response includes user_id (Issue #3222 adjacent) --- + +func TestPostAdminUsersBootstrap_ResponseIncludesUserID(t *testing.T) { + t.Parallel() + + testDB := testutils.SetupDatabase(t) + ctx := t.Context() + + recorder := httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(recorder) + ginCtx.Request = httptest.NewRequestWithContext(ctx, http.MethodPost, "/", + strings.NewReader(`{"oidc_issuer":"https://ory.example.test","oidc_user_id":"`+uuid.NewString()+`","oidc_user_email":"ada@example.test"}`)) + ginCtx.Request.Header.Set("Content-Type", "application/json") + + idSvc := handlerTestIdentityProvider{} + store := &APIStore{ + db: testDB.SqlcClient, + authDB: testDB.AuthDB, + identityService: idSvc, + provisioningService: provisioning.New(testDB.AuthDB, idSvc, &fakeTeamProvisionSink{}), + } + store.PostAdminUsersBootstrap(ginCtx) + + if recorder.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d: %s", recorder.Code, recorder.Body.String()) + } + + body := recorder.Body.String() + for _, field := range []string{`"id"`, `"slug"`, `"user_id"`} { + if !strings.Contains(body, field) { + t.Fatalf("response missing %s field: %s", field, body) + } + } +} + +// --- GetTeamsTeamIDMembers DB fallback (Issue #3222) --- + +// When the Ory admin API is unreachable, member emails must still be returned +// from the default-team email stored in PostgreSQL. +func TestGetTeamsTeamIDMembers_OryUnavailableFallsBackToDBEmail(t *testing.T) { + t.Parallel() + + testDB := testutils.SetupDatabase(t) + ctx := t.Context() + + teamID := testutils.CreateTestTeam(t, testDB) + member1 := createHandlerTestUser(t, testDB) + member2 := createHandlerTestUser(t, testDB) + insertHandlerTestTeamMember(t, testDB, member1, teamID, false) + insertHandlerTestTeamMember(t, testDB, member2, teamID, false) + + recorder := httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(recorder) + ginCtx.Request = httptest.NewRequestWithContext(ctx, http.MethodGet, "/", nil) + auth.SetTeamInfoForTest(t, ginCtx, &authtypes.Team{ + Team: &authqueries.Team{ID: teamID}, + }) + + store := &APIStore{ + db: testDB.SqlcClient, + authDB: testDB.AuthDB, + identityService: oryAdminUnavailableIdentity{}, + } + store.GetTeamsTeamIDMembers(ginCtx, teamID) + + if recorder.Code != http.StatusOK { + t.Fatalf("expected 200 with DB fallback, got %d: %s", recorder.Code, recorder.Body.String()) + } + + body := recorder.Body.String() + if !strings.Contains(body, handlerTestUserEmail(member1)) { + t.Fatalf("response missing member1 email (DB fallback): %s", body) + } + if !strings.Contains(body, handlerTestUserEmail(member2)) { + t.Fatalf("response missing member2 email (DB fallback): %s", body) + } +} + +// --- PostTeamsTeamIDMembers DB fallback (Issue #3222) --- + +// When the Ory admin API is unreachable, adding a member by email must succeed +// by looking up the user from their default-team email in PostgreSQL. +func TestPostTeamsTeamIDMembers_OryUnavailableFallsBackToDBEmailLookup(t *testing.T) { + t.Parallel() + + testDB := testutils.SetupDatabase(t) + ctx := t.Context() + + teamID := testutils.CreateTestTeam(t, testDB) + inviteeID := createHandlerTestUser(t, testDB) + adderID := createHandlerTestUser(t, testDB) + + recorder := httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(recorder) + reqBody := `{"email":"` + handlerTestUserEmail(inviteeID) + `"}` + req := httptest.NewRequestWithContext(ctx, http.MethodPost, "/", strings.NewReader(reqBody)) + req.Header.Set("Content-Type", "application/json") + ginCtx.Request = req + auth.SetUserIDForTest(t, ginCtx, adderID) + auth.SetTeamInfoForTest(t, ginCtx, &authtypes.Team{ + Team: &authqueries.Team{ID: teamID}, + }) + + store := &APIStore{ + db: testDB.SqlcClient, + authDB: testDB.AuthDB, + authService: noopAuthService{}, + identityService: oryAdminUnavailableIdentity{}, + } + store.PostTeamsTeamIDMembers(ginCtx, teamID) + + if ginCtx.Writer.Status() != http.StatusCreated { + t.Fatalf("expected 201 with DB fallback, got %d: %s", ginCtx.Writer.Status(), recorder.Body.String()) + } +} + +// When the Ory admin API is unreachable and the email has no matching user in +// PostgreSQL, the response must be 404. +func TestPostTeamsTeamIDMembers_OryUnavailableUnknownEmailReturns404(t *testing.T) { + t.Parallel() + + testDB := testutils.SetupDatabase(t) + ctx := t.Context() + + teamID := testutils.CreateTestTeam(t, testDB) + adderID := createHandlerTestUser(t, testDB) + + recorder := httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(recorder) + reqBody := `{"email":"nobody-` + uuid.NewString() + `@example.test"}` + req := httptest.NewRequestWithContext(ctx, http.MethodPost, "/", strings.NewReader(reqBody)) + req.Header.Set("Content-Type", "application/json") + ginCtx.Request = req + auth.SetUserIDForTest(t, ginCtx, adderID) + auth.SetTeamInfoForTest(t, ginCtx, &authtypes.Team{ + Team: &authqueries.Team{ID: teamID}, + }) + + store := &APIStore{ + db: testDB.SqlcClient, + authDB: testDB.AuthDB, + identityService: oryAdminUnavailableIdentity{}, + } + store.PostTeamsTeamIDMembers(ginCtx, teamID) + + if recorder.Code != http.StatusNotFound { + t.Fatalf("expected 404 for unknown email with Ory unavailable, got %d: %s", recorder.Code, recorder.Body.String()) + } +} diff --git a/packages/dashboard-api/internal/handlers/team_members.go b/packages/dashboard-api/internal/handlers/team_members.go index 338c386e4d..d427a78299 100644 --- a/packages/dashboard-api/internal/handlers/team_members.go +++ b/packages/dashboard-api/internal/handlers/team_members.go @@ -10,6 +10,7 @@ import ( "github.com/e2b-dev/infra/packages/auth/pkg/auth" "github.com/e2b-dev/infra/packages/dashboard-api/internal/api" + "github.com/e2b-dev/infra/packages/dashboard-api/internal/identity" "github.com/e2b-dev/infra/packages/db/pkg/dberrors" "github.com/e2b-dev/infra/packages/db/queries" "github.com/e2b-dev/infra/packages/shared/pkg/ginutils" @@ -43,10 +44,23 @@ func (s *APIStore) GetTeamsTeamIDMembers(c *gin.Context, teamID api.TeamID) { profiles, err := s.identityService.ProfilesByUserID(ctx, userIDs) if err != nil { - logger.L().Error(ctx, "failed to get member profiles", zap.Error(err), logger.WithTeamID(authTeamID.String())) - s.sendAPIStoreError(c, http.StatusInternalServerError, "Failed to get team member profiles") + // Ory admin API unavailable (e.g. CDN blocks /admin/ paths in Hydra-only deployments). + // Fall back to emails stored in each user's default team record. + logger.L().Warn(ctx, "ory profile lookup failed, using DB email fallback", + zap.Error(err), logger.WithTeamID(authTeamID.String())) - return + emails, dbErr := s.authDB.GetUserEmailsByUserIDs(ctx, userIDs) + if dbErr != nil { + logger.L().Error(ctx, "DB email fallback also failed", zap.Error(dbErr), logger.WithTeamID(authTeamID.String())) + s.sendAPIStoreError(c, http.StatusInternalServerError, "Failed to get team member profiles") + + return + } + + profiles = make(map[uuid.UUID]identity.Profile, len(emails)) + for uid, email := range emails { + profiles[uid] = identity.Profile{UserID: uid, Email: email} + } } members := make([]api.TeamMember, 0, len(rows)) @@ -109,8 +123,66 @@ func (s *APIStore) PostTeamsTeamIDMembers(c *gin.Context, teamID api.TeamID) { profiles, err := s.identityService.FindProfilesByEmail(ctx, string(body.Email)) if err != nil { - logger.L().Error(ctx, "failed to look up user by email", zap.Error(err)) - s.sendAPIStoreError(c, http.StatusInternalServerError, "Failed to look up user") + // Ory admin API unavailable — fall back to DB email lookup. + // Fail closed for SSO-managed teams: org membership cannot be verified + // without Ory, so refuse the invite rather than bypass the check. + logger.L().Warn(ctx, "ory email lookup failed, using DB fallback", zap.Error(err)) + + if teamInfo, ok := auth.GetTeamInfo(c); ok && teamInfo != nil && teamInfo.Team != nil && teamInfo.Team.SsoOrganizationID != nil { + s.sendAPIStoreError(c, http.StatusServiceUnavailable, "Identity provider is unreachable; SSO-managed teams cannot add members at this time.") + + return + } + + dbMatches, dbErr := s.authDB.FindUserIDsByEmail(ctx, string(body.Email)) + if dbErr != nil { + logger.L().Error(ctx, "DB email fallback also failed", zap.Error(dbErr)) + s.sendAPIStoreError(c, http.StatusInternalServerError, "Failed to look up user") + + return + } + + if len(dbMatches) == 0 { + s.sendAPIStoreError(c, http.StatusNotFound, "User with this email does not exist. Please ask them to sign up first.") + + return + } + + if len(dbMatches) > 1 { + logger.L().Error(ctx, "ambiguous user email lookup (DB fallback)", zap.Int("matches", len(dbMatches))) + s.sendAPIStoreError(c, http.StatusConflict, "Multiple users with this email exist. Please contact support.") + + return + } + + targetUID := dbMatches[0] + + if err := s.authDB.Write.UpsertPublicUser(ctx, targetUID); err != nil { + logger.L().Error(ctx, "failed to create public user anchor (DB fallback)", zap.Error(err), logger.WithUserID(targetUID.String())) + s.sendAPIStoreError(c, http.StatusInternalServerError, "Failed to add team member") + + return + } + + if addErr := s.db.AddTeamMember(ctx, queries.AddTeamMemberParams{ + UserID: targetUID, + TeamID: authTeamID, + AddedBy: userID, + }); addErr != nil { + if dberrors.IsUniqueConstraintViolation(addErr) { + s.sendAPIStoreError(c, http.StatusBadRequest, "User is already a member of this team") + + return + } + + logger.L().Error(ctx, "failed to add team member (DB fallback)", zap.Error(addErr), logger.WithTeamID(authTeamID.String())) + s.sendAPIStoreError(c, http.StatusInternalServerError, "Failed to add team member") + + return + } + + s.authService.InvalidateTeamMemberCache(ctx, targetUID, authTeamID.String()) + c.Status(http.StatusCreated) return } diff --git a/packages/dashboard-api/internal/provisioning/bootstrap.go b/packages/dashboard-api/internal/provisioning/bootstrap.go index bced0251d2..987a9470e4 100644 --- a/packages/dashboard-api/internal/provisioning/bootstrap.go +++ b/packages/dashboard-api/internal/provisioning/bootstrap.go @@ -9,11 +9,13 @@ import ( "time" "github.com/google/uuid" + "go.uber.org/zap" "github.com/e2b-dev/infra/packages/dashboard-api/internal/identity" internalteamprovision "github.com/e2b-dev/infra/packages/dashboard-api/internal/teamprovision" authqueries "github.com/e2b-dev/infra/packages/db/pkg/auth/queries" "github.com/e2b-dev/infra/packages/db/pkg/dberrors" + "github.com/e2b-dev/infra/packages/shared/pkg/logger" "github.com/e2b-dev/infra/packages/shared/pkg/teamprovision" ) @@ -65,6 +67,38 @@ func (s *Service) bootstrapUser(ctx context.Context, profile bootstrapUserProfil profile.UserID = existing.UserID case !dberrors.IsNotFoundError(err): return ProvisionedTeam{}, fmt.Errorf("get user identity: %w", err) + default: + // No identity found: check if a pre-Ory user with this email exists and + // has not yet been linked to any identity for this issuer. If so, reuse + // their user_id so their existing teams and data are preserved on first login. + if profile.Email != "" { + emailMatches, emailErr := s.authDB.FindUserIDsByEmail(ctx, profile.Email) + if emailErr != nil { + return ProvisionedTeam{}, fmt.Errorf("find pre-Ory user by email: %w", emailErr) + } + // Only attempt email-based migration when there is exactly one match. + // Multiple matches indicate email reuse across accounts; skip to avoid + // linking the wrong user_id. + if len(emailMatches) == 1 { + linkedUserID := emailMatches[0] + // Lock the candidate user row before re-checking identities. Without + // this lock, two concurrent bootstraps with different subjects but the + // same email can both observe zero identities and both claim linkedUserID. + if _, lockErr := authTxDB.LockPublicUserForUpdate(ctx, linkedUserID); lockErr != nil { + return ProvisionedTeam{}, fmt.Errorf("lock pre-Ory user for migration: %w", lockErr) + } + identityRows, idErr := authTxDB.GetUserIdentitiesByUserIDsAndIssuers(ctx, authqueries.GetUserIdentitiesByUserIDsAndIssuersParams{ + OidcIssuers: []string{oidcIdentity.Issuer}, + UserIds: []uuid.UUID{linkedUserID}, + }) + if idErr != nil { + return ProvisionedTeam{}, fmt.Errorf("check existing identities for pre-Ory user: %w", idErr) + } + if len(identityRows) == 0 { + profile.UserID = linkedUserID + } + } + } } candidateUserID := profile.UserID @@ -109,10 +143,13 @@ func (s *Service) bootstrapUser(ctx context.Context, profile bootstrapUserProfil } if err := backfillIdentityExternalID(ctx, s.identityService, oidcIdentity, profile.UserID); err != nil { - return ProvisionedTeam{}, err + logger.L().Warn(ctx, "failed to backfill identity external_id (recoverable)", + zap.String("user_id", profile.UserID.String()), + zap.Error(err), + ) } - return newProvisionedTeam(existingTeam.ID, existingTeam.Name, existingTeam.Email, existingTeam.Slug, existingTeam.IsBlocked, existingTeam.BlockedReason), nil + return newProvisionedTeam(existingTeam.ID, existingTeam.Name, existingTeam.Email, existingTeam.Slug, existingTeam.IsBlocked, existingTeam.BlockedReason, profile.UserID), nil } if !dberrors.IsNotFoundError(err) { return ProvisionedTeam{}, fmt.Errorf("get default team: %w", err) @@ -129,9 +166,14 @@ func (s *Service) bootstrapUser(ctx context.Context, profile bootstrapUserProfil } if err := backfillIdentityExternalID(ctx, s.identityService, oidcIdentity, profile.UserID); err != nil { - return ProvisionedTeam{}, err + logger.L().Warn(ctx, "failed to backfill identity external_id (recoverable)", + zap.String("user_id", profile.UserID.String()), + zap.Error(err), + ) } + landing.UserID = profile.UserID + return landing, nil } @@ -175,10 +217,13 @@ func (s *Service) bootstrapUser(ctx context.Context, profile bootstrapUserProfil _ = s.billing.ProvisionTeam(ctx, req) if err := backfillIdentityExternalID(ctx, s.identityService, oidcIdentity, profile.UserID); err != nil { - return ProvisionedTeam{}, err + logger.L().Warn(ctx, "failed to backfill identity external_id (recoverable)", + zap.String("user_id", profile.UserID.String()), + zap.Error(err), + ) } - return newProvisionedTeam(team.ID, team.Name, team.Email, team.Slug, team.IsBlocked, team.BlockedReason), nil + return newProvisionedTeam(team.ID, team.Name, team.Email, team.Slug, team.IsBlocked, team.BlockedReason, profile.UserID), nil } func backfillIdentityExternalID(ctx context.Context, identityService identity.Service, oidcIdentity bootstrapUserIdentity, userID uuid.UUID) error { diff --git a/packages/dashboard-api/internal/provisioning/bootstrap_test.go b/packages/dashboard-api/internal/provisioning/bootstrap_test.go index da786ba45d..6efe3ec617 100644 --- a/packages/dashboard-api/internal/provisioning/bootstrap_test.go +++ b/packages/dashboard-api/internal/provisioning/bootstrap_test.go @@ -197,22 +197,27 @@ func TestBootstrapOIDCUser_ExternalIDFailureKeepsUserProvisioned(t *testing.T) { OIDCUserEmail: "ada@example.test", } - if _, err := svc.BootstrapOIDCUser(ctx, input); err == nil { - t.Fatal("expected bootstrap to fail when external_id patch fails") + team, err := svc.BootstrapOIDCUser(ctx, input) + if err != nil { + t.Fatalf("expected bootstrap to succeed despite external_id failure: %v", err) } if profiles.externalIDCalls != 1 { t.Fatalf("expected one external id attempt, got %d", profiles.externalIDCalls) } + if team.ID == (uuid.UUID{}) { + t.Fatal("expected provisioned team") + } + userIdentity, err := testDB.AuthDB.Read.GetUserIdentity(ctx, authqueries.GetUserIdentityParams{ OidcIss: input.OIDCIssuer, OidcSub: input.OIDCUserID, }) if err != nil { - t.Fatalf("expected user identity to survive external_id failure: %v", err) + t.Fatalf("expected user identity to be created: %v", err) } if _, err := testDB.AuthDB.Read.GetDefaultTeamByUserID(ctx, userIdentity.UserID); err != nil { - t.Fatalf("expected default team to survive external_id failure: %v", err) + t.Fatalf("expected default team to be created: %v", err) } if len(sink.requests) != 1 { @@ -235,8 +240,9 @@ func TestBootstrapOIDCUser_ReRunBackfillsExternalID(t *testing.T) { OIDCUserEmail: "ada@example.test", } - if _, err := svc.BootstrapOIDCUser(ctx, input); err == nil { - t.Fatal("expected first bootstrap to fail on external_id patch") + // First bootstrap succeeds but logs a warning — external_id is not set yet. + if _, err := svc.BootstrapOIDCUser(ctx, input); err != nil { + t.Fatalf("expected first bootstrap to succeed: %v", err) } userIdentity, err := testDB.AuthDB.Read.GetUserIdentity(ctx, authqueries.GetUserIdentityParams{ @@ -406,3 +412,153 @@ func TestBootstrapOIDCUser_UnknownIssuerReturnsBadRequest(t *testing.T) { t.Fatalf("expected no provisioning calls, got %d", len(sink.requests)) } } + +// TestBootstrapOIDCUser_PreOryUserEmailMatchReusesExistingAccount verifies that +// a pre-Ory user (in DB with no user_identities row) logs in via Ory for the +// first time and bootstrap reuses their existing user_id and team. +func TestBootstrapOIDCUser_PreOryUserEmailMatchReusesExistingAccount(t *testing.T) { + t.Parallel() + + testDB := testutils.SetupDatabase(t) + ctx := t.Context() + + existingUserID := createTestUser(t, testDB) + existingEmail := testUserEmail(existingUserID) + + existingTeam, err := testDB.AuthDB.Read.GetDefaultTeamByUserID(ctx, existingUserID) + if err != nil { + t.Fatalf("expected pre-Ory user to have a default team: %v", err) + } + + svc := New(testDB.AuthDB, testIdentityProvider{}, &fakeTeamProvisionSink{}) + + result, err := svc.BootstrapOIDCUser(ctx, OIDCUserBootstrapInput{ + OIDCIssuer: testIssuer, + OIDCUserID: uuid.NewString(), + OIDCUserEmail: existingEmail, + }) + if err != nil { + t.Fatalf("expected bootstrap to succeed: %v", err) + } + if result.ID != existingTeam.ID { + t.Fatalf("expected existing team %s to be reused, got %s — pre-Ory user got a new empty account", existingTeam.ID, result.ID) + } + if result.UserID != existingUserID { + t.Fatalf("expected existing user_id %s, got %s", existingUserID, result.UserID) + } +} + +// TestBootstrapOIDCUser_EmailMatchBlockedIfUserAlreadyLinked verifies that a +// user who already has a user_identities row cannot be merged with another +// subject that shares the same email, preventing account takeover. +func TestBootstrapOIDCUser_EmailMatchBlockedIfUserAlreadyLinked(t *testing.T) { + t.Parallel() + + testDB := testutils.SetupDatabase(t) + ctx := t.Context() + + existingUserID := createTestUser(t, testDB) + + if err := testDB.AuthDB.TestsRawSQL(ctx, + `INSERT INTO public.user_identities (oidc_iss, oidc_sub, user_id) VALUES ($1, $2, $3)`, + testIssuer, uuid.NewString(), existingUserID, + ); err != nil { + t.Fatalf("failed to insert pre-existing user_identities row: %v", err) + } + + svc := New(testDB.AuthDB, testIdentityProvider{}, &fakeTeamProvisionSink{}) + + result, err := svc.BootstrapOIDCUser(ctx, OIDCUserBootstrapInput{ + OIDCIssuer: testIssuer, + OIDCUserID: uuid.NewString(), + OIDCUserEmail: testUserEmail(existingUserID), + }) + if err != nil { + t.Fatalf("expected bootstrap to succeed (creating a new account): %v", err) + } + if result.UserID == existingUserID { + t.Fatal("email-match merged into an already-linked account — account hijack vulnerability") + } +} + +// TestBootstrapOIDCUser_PreOryUserSecondLoginUsesIdentityLookup verifies that +// after the email-match bootstrap, a subsequent login with the same sub resolves +// via the normal identity-lookup path without triggering a second email-match scan. +func TestBootstrapOIDCUser_PreOryUserSecondLoginUsesIdentityLookup(t *testing.T) { + t.Parallel() + + testDB := testutils.SetupDatabase(t) + ctx := t.Context() + + existingUserID := createTestUser(t, testDB) + oidcSub := uuid.NewString() + + svc := New(testDB.AuthDB, testIdentityProvider{}, &fakeTeamProvisionSink{}) + + input := OIDCUserBootstrapInput{ + OIDCIssuer: testIssuer, + OIDCUserID: oidcSub, + OIDCUserEmail: testUserEmail(existingUserID), + } + + first, err := svc.BootstrapOIDCUser(ctx, input) + if err != nil { + t.Fatalf("first bootstrap failed: %v", err) + } + second, err := svc.BootstrapOIDCUser(ctx, input) + if err != nil { + t.Fatalf("second bootstrap failed: %v", err) + } + if second.ID != first.ID { + t.Fatalf("second login returned different team: first=%s second=%s", first.ID, second.ID) + } +} + +// TestBootstrapOIDCUser_ConcurrentEmailMatchOnlyFirstSubMerges verifies that +// concurrent bootstraps with different subjects but the same email only merge +// one of them into the pre-Ory account. +func TestBootstrapOIDCUser_ConcurrentEmailMatchOnlyFirstSubMerges(t *testing.T) { + t.Parallel() + + testDB := testutils.SetupDatabase(t) + ctx := t.Context() + + existingUserID := createTestUser(t, testDB) + + svc := New(testDB.AuthDB, testIdentityProvider{}, &fakeTeamProvisionSink{}) + + const concurrency = 4 + type result struct { + team ProvisionedTeam + err error + } + + results := make(chan result, concurrency) + var wg sync.WaitGroup + for range concurrency { + wg.Go(func() { + team, err := svc.BootstrapOIDCUser(ctx, OIDCUserBootstrapInput{ + OIDCIssuer: testIssuer, + OIDCUserID: uuid.NewString(), + OIDCUserEmail: testUserEmail(existingUserID), + }) + results <- result{team, err} + }) + } + wg.Wait() + close(results) + + var mergedCount int + for r := range results { + if r.err != nil { + t.Fatalf("expected bootstrap to succeed: %v", r.err) + } + if r.team.UserID == existingUserID { + mergedCount++ + } + } + + if mergedCount != 1 { + t.Fatalf("expected exactly 1 bootstrap to merge into existing account, got %d", mergedCount) + } +} diff --git a/packages/dashboard-api/internal/provisioning/service.go b/packages/dashboard-api/internal/provisioning/service.go index 9d63af25f4..9768ddf780 100644 --- a/packages/dashboard-api/internal/provisioning/service.go +++ b/packages/dashboard-api/internal/provisioning/service.go @@ -41,6 +41,7 @@ type ProvisionedTeam struct { Slug string IsBlocked bool BlockedReason *string + UserID uuid.UUID } type bootstrapUserProfile struct { @@ -64,7 +65,7 @@ type OIDCUserBootstrapInput struct { SignupUserAgent string } -func newProvisionedTeam(id uuid.UUID, name, email, slug string, isBlocked bool, blockedReason *string) ProvisionedTeam { +func newProvisionedTeam(id uuid.UUID, name, email, slug string, isBlocked bool, blockedReason *string, userID uuid.UUID) ProvisionedTeam { return ProvisionedTeam{ ID: id, Name: name, @@ -72,5 +73,6 @@ func newProvisionedTeam(id uuid.UUID, name, email, slug string, isBlocked bool, Slug: slug, IsBlocked: isBlocked, BlockedReason: blockedReason, + UserID: userID, } } diff --git a/packages/dashboard-api/internal/provisioning/sso.go b/packages/dashboard-api/internal/provisioning/sso.go index 446a14ae8b..b8718dd79c 100644 --- a/packages/dashboard-api/internal/provisioning/sso.go +++ b/packages/dashboard-api/internal/provisioning/sso.go @@ -36,7 +36,7 @@ func (s *Service) enrollSSOMember(ctx context.Context, authTxDB *authqueries.Que landing := autoTeams[0].Team - return newProvisionedTeam(landing.ID, landing.Name, landing.Email, landing.Slug, landing.IsBlocked, landing.BlockedReason), nil + return newProvisionedTeam(landing.ID, landing.Name, landing.Email, landing.Slug, landing.IsBlocked, landing.BlockedReason, userID), nil } func (s *Service) ensureNotSSOManaged(ctx context.Context, userID uuid.UUID) error { diff --git a/packages/dashboard-api/internal/provisioning/team.go b/packages/dashboard-api/internal/provisioning/team.go index 3401472af0..e07c78dc7b 100644 --- a/packages/dashboard-api/internal/provisioning/team.go +++ b/packages/dashboard-api/internal/provisioning/team.go @@ -79,7 +79,7 @@ func (s *Service) CreateTeam(ctx context.Context, userID uuid.UUID, name string) return ProvisionedTeam{}, err } - return newProvisionedTeam(team.ID, team.Name, team.Email, team.Slug, team.IsBlocked, team.BlockedReason), nil + return newProvisionedTeam(team.ID, team.Name, team.Email, team.Slug, team.IsBlocked, team.BlockedReason, userID), nil } func (s *Service) BootstrapTeam(ctx context.Context, name string, email string) (ProvisionedTeam, error) { @@ -105,7 +105,7 @@ func (s *Service) BootstrapTeam(ctx context.Context, name string, email string) return ProvisionedTeam{}, err } - return newProvisionedTeam(team.ID, team.Name, team.Email, team.Slug, team.IsBlocked, team.BlockedReason), nil + return newProvisionedTeam(team.ID, team.Name, team.Email, team.Slug, team.IsBlocked, team.BlockedReason, uuid.Nil), nil } func (s *Service) resolveProfile(ctx context.Context, userID uuid.UUID) (identity.Profile, error) { diff --git a/packages/db/migrations/20260708120000_add_teams_email_lower_index.sql b/packages/db/migrations/20260708120000_add_teams_email_lower_index.sql new file mode 100644 index 0000000000..3b254dba81 --- /dev/null +++ b/packages/db/migrations/20260708120000_add_teams_email_lower_index.sql @@ -0,0 +1,11 @@ +-- +goose NO TRANSACTION +-- +goose Up +-- +goose StatementBegin +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_teams_email_lower + ON public.teams (lower(email)); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP INDEX CONCURRENTLY IF EXISTS idx_teams_email_lower; +-- +goose StatementEnd diff --git a/packages/db/pkg/auth/profiles.go b/packages/db/pkg/auth/profiles.go new file mode 100644 index 0000000000..6fa1bae56d --- /dev/null +++ b/packages/db/pkg/auth/profiles.go @@ -0,0 +1,74 @@ +package authdb + +import ( + "context" + "fmt" + + "github.com/google/uuid" +) + +// GetUserEmailsByUserIDs returns a map of userID → email by joining each user's +// default team. Used as a fallback when the Ory admin API is unavailable. +func (db *Client) GetUserEmailsByUserIDs(ctx context.Context, userIDs []uuid.UUID) (map[uuid.UUID]string, error) { + if len(userIDs) == 0 { + return map[uuid.UUID]string{}, nil + } + + const q = ` + SELECT ut.user_id, t.email + FROM public.users_teams ut + JOIN public.teams t ON t.id = ut.team_id + WHERE ut.user_id = ANY($1::uuid[]) + AND ut.is_default = true + ` + + rows, err := db.readConn.Query(ctx, q, userIDs) + if err != nil { + return nil, fmt.Errorf("query user emails from default teams: %w", err) + } + defer rows.Close() + + result := make(map[uuid.UUID]string, len(userIDs)) + for rows.Next() { + var userID uuid.UUID + var email string + if err := rows.Scan(&userID, &email); err != nil { + return nil, fmt.Errorf("scan user email row: %w", err) + } + result[userID] = email + } + return result, rows.Err() +} + +// FindUserIDsByEmail returns all user IDs whose default-team email matches. +// Callers must handle the ambiguous case (len > 1) explicitly rather than +// silently picking one. Used as a fallback when the Ory admin API is unavailable. +func (db *Client) FindUserIDsByEmail(ctx context.Context, email string) ([]uuid.UUID, error) { + if email == "" { + return nil, nil + } + + const q = ` + SELECT ut.user_id + FROM public.users_teams ut + JOIN public.teams t ON t.id = ut.team_id + WHERE lower(t.email) = lower($1::text) + AND ut.is_default = true + ` + + rows, err := db.readConn.Query(ctx, q, email) + if err != nil { + return nil, fmt.Errorf("find user ids by email: %w", err) + } + defer rows.Close() + + var userIDs []uuid.UUID + for rows.Next() { + var userID uuid.UUID + if scanErr := rows.Scan(&userID); scanErr != nil { + return nil, fmt.Errorf("scan user id by email: %w", scanErr) + } + userIDs = append(userIDs, userID) + } + return userIDs, rows.Err() +} diff --git a/spec/openapi-dashboard.yml b/spec/openapi-dashboard.yml index ae3578c10c..2fc3e9d36e 100644 --- a/spec/openapi-dashboard.yml +++ b/spec/openapi-dashboard.yml @@ -1148,6 +1148,25 @@ components: slug: type: string + AdminUserBootstrapResponse: + type: object + required: + - id + - slug + - user_id + properties: + id: + type: string + format: uuid + description: The team ID for the bootstrapped user. + slug: + type: string + description: The team slug for the bootstrapped user. + user_id: + type: string + format: uuid + description: The internal user ID assigned to the bootstrapped identity. + tags: - name: builds - name: sandboxes @@ -1331,7 +1350,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/TeamResolveResponse" + $ref: "#/components/schemas/AdminUserBootstrapResponse" "400": $ref: "#/components/responses/400" "401":