Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
202 changes: 107 additions & 95 deletions packages/dashboard-api/internal/api/api.gen.go

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
}

Expand Down
205 changes: 205 additions & 0 deletions packages/dashboard-api/internal/handlers/ory_fallback_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
}
82 changes: 77 additions & 5 deletions packages/dashboard-api/internal/handlers/team_members.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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 {
Comment thread
AdaAibaby marked this conversation as resolved.
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
}
Expand Down
Loading