diff --git a/packages/dashboard-api/internal/handlers/admin_auth_provider_profiles.go b/packages/dashboard-api/internal/handlers/admin_auth_provider_profiles.go index 90877e9e96..03e6b98466 100644 --- a/packages/dashboard-api/internal/handlers/admin_auth_provider_profiles.go +++ b/packages/dashboard-api/internal/handlers/admin_auth_provider_profiles.go @@ -8,7 +8,7 @@ import ( "go.uber.org/zap" "github.com/e2b-dev/infra/packages/dashboard-api/internal/api" - "github.com/e2b-dev/infra/packages/dashboard-api/internal/userprofile" + "github.com/e2b-dev/infra/packages/dashboard-api/internal/identity" "github.com/e2b-dev/infra/packages/shared/pkg/ginutils" "github.com/e2b-dev/infra/packages/shared/pkg/logger" ) @@ -41,7 +41,7 @@ func (s *APIStore) PostAdminUserProfilesResolve(c *gin.Context) { seen[userID] = struct{}{} } - profiles, err := s.userProfiles.GetProfilesByUserID(ctx, body.UserIds) + profiles, err := s.idp.GetProfilesByUserID(ctx, body.UserIds) if err != nil { logger.L().Error(ctx, "failed to resolve auth provider profiles", zap.Error(err)) s.sendAPIStoreError(c, http.StatusInternalServerError, "Failed to resolve auth provider profiles") @@ -64,7 +64,7 @@ func (s *APIStore) PostAdminUserProfilesByEmail(c *gin.Context) { return } - profiles, err := s.userProfiles.FindProfilesByEmail(ctx, string(body.Email)) + profiles, err := s.idp.FindProfilesByEmail(ctx, string(body.Email)) if err != nil { logger.L().Error(ctx, "failed to look up auth provider profiles by email", zap.Error(err)) s.sendAPIStoreError(c, http.StatusInternalServerError, "Failed to look up auth provider profiles") @@ -79,7 +79,7 @@ func (s *APIStore) PostAdminUserProfilesByEmail(c *gin.Context) { func (s *APIStore) GetAdminUserProfilesUserId(c *gin.Context, userId api.UserId) { ctx := c.Request.Context() - profiles, err := s.userProfiles.GetProfilesByUserID(ctx, []uuid.UUID{userId}) + profiles, err := s.idp.GetProfilesByUserID(ctx, []uuid.UUID{userId}) if err != nil { logger.L().Error(ctx, "failed to resolve auth provider profile", zap.Error(err)) s.sendAPIStoreError(c, http.StatusInternalServerError, "Failed to resolve auth provider profile") @@ -92,7 +92,7 @@ func (s *APIStore) GetAdminUserProfilesUserId(c *gin.Context, userId api.UserId) }) } -func apiProfilesFromMap(userIDs []uuid.UUID, profiles map[uuid.UUID]userprofile.Profile) []api.AdminAuthProviderProfile { +func apiProfilesFromMap(userIDs []uuid.UUID, profiles map[uuid.UUID]identity.Profile) []api.AdminAuthProviderProfile { result := make([]api.AdminAuthProviderProfile, 0, len(profiles)) seen := make(map[uuid.UUID]struct{}, len(userIDs)) @@ -113,7 +113,7 @@ func apiProfilesFromMap(userIDs []uuid.UUID, profiles map[uuid.UUID]userprofile. return result } -func apiProfilesFromProfiles(profiles []userprofile.Profile) []api.AdminAuthProviderProfile { +func apiProfilesFromProfiles(profiles []identity.Profile) []api.AdminAuthProviderProfile { result := make([]api.AdminAuthProviderProfile, 0, len(profiles)) for _, profile := range profiles { @@ -123,7 +123,7 @@ func apiProfilesFromProfiles(profiles []userprofile.Profile) []api.AdminAuthProv return result } -func apiProfileFromProfile(profile userprofile.Profile) api.AdminAuthProviderProfile { +func apiProfileFromProfile(profile identity.Profile) api.AdminAuthProviderProfile { var email *string if profile.Email != "" { email = new(profile.Email) diff --git a/packages/dashboard-api/internal/handlers/admin_teams_bootstrap.go b/packages/dashboard-api/internal/handlers/admin_teams_bootstrap.go index 433f5b3c9d..b61d8f3f93 100644 --- a/packages/dashboard-api/internal/handlers/admin_teams_bootstrap.go +++ b/packages/dashboard-api/internal/handlers/admin_teams_bootstrap.go @@ -29,7 +29,7 @@ func (s *APIStore) PostAdminTeamsBootstrap(c *gin.Context) { return } - team, err := s.bootstrapTeam(ctx, name, email) + team, err := s.provisioning.BootstrapTeam(ctx, name, email) if err != nil { s.handleProvisioningError(ctx, c, "provision team", err) diff --git a/packages/dashboard-api/internal/handlers/admin_teams_bootstrap_test.go b/packages/dashboard-api/internal/handlers/admin_teams_bootstrap_test.go index 8bd5368e3c..9f2cf3c20e 100644 --- a/packages/dashboard-api/internal/handlers/admin_teams_bootstrap_test.go +++ b/packages/dashboard-api/internal/handlers/admin_teams_bootstrap_test.go @@ -12,6 +12,7 @@ import ( "github.com/google/uuid" "github.com/jackc/pgx/v5" + "github.com/e2b-dev/infra/packages/dashboard-api/internal/provisioning" internalteamprovision "github.com/e2b-dev/infra/packages/dashboard-api/internal/teamprovision" "github.com/e2b-dev/infra/packages/db/pkg/testutils" "github.com/e2b-dev/infra/packages/shared/pkg/teamprovision" @@ -33,8 +34,7 @@ func TestPostAdminTeamsBootstrapCreatesTeam(t *testing.T) { ginCtx.Request.Header.Set("Content-Type", "application/json") store := &APIStore{ - authDB: testDB.AuthDB, - teamProvisionSink: sink, + provisioning: provisioning.New(testDB.AuthDB, nil, sink, nil), } store.PostAdminTeamsBootstrap(ginCtx) @@ -109,8 +109,7 @@ func TestPostAdminTeamsBootstrapRollsBackOnProvisioningFailure(t *testing.T) { ginCtx.Request.Header.Set("Content-Type", "application/json") store := &APIStore{ - authDB: testDB.AuthDB, - teamProvisionSink: sink, + provisioning: provisioning.New(testDB.AuthDB, nil, sink, nil), } store.PostAdminTeamsBootstrap(ginCtx) @@ -149,8 +148,7 @@ func TestPostAdminTeamsBootstrapRejectsMissingFields(t *testing.T) { ginCtx.Request.Header.Set("Content-Type", "application/json") store := &APIStore{ - authDB: testDB.AuthDB, - teamProvisionSink: &fakeTeamProvisionSink{err: errors.New("should not provision")}, + provisioning: provisioning.New(testDB.AuthDB, nil, &fakeTeamProvisionSink{err: errors.New("should not provision")}, nil), } store.PostAdminTeamsBootstrap(ginCtx) diff --git a/packages/dashboard-api/internal/handlers/admin_users_bootstrap.go b/packages/dashboard-api/internal/handlers/admin_users_bootstrap.go index 8a1465c0eb..301b2c7c87 100644 --- a/packages/dashboard-api/internal/handlers/admin_users_bootstrap.go +++ b/packages/dashboard-api/internal/handlers/admin_users_bootstrap.go @@ -9,6 +9,7 @@ import ( "github.com/gin-gonic/gin" "github.com/e2b-dev/infra/packages/dashboard-api/internal/api" + "github.com/e2b-dev/infra/packages/dashboard-api/internal/provisioning" "github.com/e2b-dev/infra/packages/shared/pkg/ginutils" "github.com/e2b-dev/infra/packages/shared/pkg/telemetry" ) @@ -35,7 +36,7 @@ func (s *APIStore) PostAdminUsersBootstrap(c *gin.Context) { return } - team, err := s.bootstrapOIDCUser(ctx, oidcUserBootstrapInput{ + team, err := s.provisioning.BootstrapOIDCUser(ctx, provisioning.OIDCUserBootstrapInput{ OIDCIssuer: oidcIssuer, OIDCUserID: oidcUserID, OIDCUserEmail: oidcUserEmail, diff --git a/packages/dashboard-api/internal/handlers/admin_users_delete.go b/packages/dashboard-api/internal/handlers/admin_users_delete.go index 41e8be8daa..19fccc22f4 100644 --- a/packages/dashboard-api/internal/handlers/admin_users_delete.go +++ b/packages/dashboard-api/internal/handlers/admin_users_delete.go @@ -11,7 +11,7 @@ import ( "go.uber.org/zap" "github.com/e2b-dev/infra/packages/dashboard-api/internal/api" - "github.com/e2b-dev/infra/packages/dashboard-api/internal/userprofile" + "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/shared/pkg/logger" ) @@ -22,9 +22,9 @@ func (s *APIStore) DeleteAdminUsersUserId(c *gin.Context, userId api.UserId) { ctx := c.Request.Context() // Resolve the external identity references while user_identities still exists. - handle, err := s.userProfiles.PrepareDeleteUser(ctx, userId) + handle, err := s.idp.PrepareDeleteUser(ctx, userId) if err != nil { - if errors.Is(err, userprofile.ErrUserNotFound) { + if errors.Is(err, identity.ErrUserNotFound) { s.sendAPIStoreError(c, http.StatusNotFound, fmt.Sprintf("User %s not found or has no identity provider record", userId)) return diff --git a/packages/dashboard-api/internal/handlers/sso_test.go b/packages/dashboard-api/internal/handlers/sso_test.go deleted file mode 100644 index 7c37d761cf..0000000000 --- a/packages/dashboard-api/internal/handlers/sso_test.go +++ /dev/null @@ -1,302 +0,0 @@ -package handlers - -import ( - "context" - "errors" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" - - "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/cfg" - internalteamprovision "github.com/e2b-dev/infra/packages/dashboard-api/internal/teamprovision" - "github.com/e2b-dev/infra/packages/dashboard-api/internal/userprofile" - authqueries "github.com/e2b-dev/infra/packages/db/pkg/auth/queries" - "github.com/e2b-dev/infra/packages/db/pkg/testutils" -) - -// ssoUserProfiles is a Provider whose SSO-organization lookups are configurable, -// so tests can simulate identities that belong to an Ory organization. -type ssoUserProfiles struct { - handlerTestUserProfiles - - orgBySubject map[string]uuid.UUID - orgByUser map[uuid.UUID]uuid.UUID -} - -func (p ssoUserProfiles) GetIdentityOrganizationID(_ context.Context, subject string) (uuid.UUID, error) { - return p.orgBySubject[subject], nil -} - -func (p ssoUserProfiles) GetUserOrganizationID(_ context.Context, userID uuid.UUID) (uuid.UUID, error) { - return p.orgByUser[userID], nil -} - -func setTeamSSOOrg(t *testing.T, db *testutils.Database, teamID, orgID uuid.UUID, autoJoin bool, createdAt time.Time) { - t.Helper() - - if err := db.SqlcClient.TestsRawSQL(t.Context(), - "UPDATE public.teams SET sso_organization_id = $1, sso_auto_join = $2, created_at = $3 WHERE id = $4", - orgID, autoJoin, createdAt, teamID, - ); err != nil { - t.Fatalf("failed to set team sso org: %v", err) - } -} - -func TestBootstrapOIDCUser_SSOJoinsMappedTeams(t *testing.T) { - t.Parallel() - - testDB := testutils.SetupDatabase(t) - ctx := t.Context() - sink := &fakeTeamProvisionSink{} - - orgID := uuid.New() - subject := uuid.NewString() - - // teamOlder is given an earlier created_at, so it is returned as the landing team. - teamNewer := testutils.CreateTestTeam(t, testDB) - teamOlder := testutils.CreateTestTeam(t, testDB) - setTeamSSOOrg(t, testDB, teamNewer, orgID, true, time.Now().Add(-1*time.Hour)) - setTeamSSOOrg(t, testDB, teamOlder, orgID, true, time.Now().Add(-2*time.Hour)) - - store := &APIStore{ - config: cfg.Config{OryIssuerURL: "https://ory.example.test"}, - db: testDB.SqlcClient, - authDB: testDB.AuthDB, - teamProvisionSink: sink, - userProfiles: ssoUserProfiles{orgBySubject: map[string]uuid.UUID{subject: orgID}}, - idps: userprofile.Registry{"https://ory.example.test": ssoUserProfiles{orgBySubject: map[string]uuid.UUID{subject: orgID}}}, - } - - input := oidcUserBootstrapInput{ - OIDCIssuer: "https://ory.example.test", - OIDCUserID: subject, - OIDCUserEmail: "ada@example.test", - } - - team, err := store.bootstrapOIDCUser(ctx, input) - if err != nil { - t.Fatalf("expected sso bootstrap to succeed: %v", err) - } - if team.ID != teamOlder { - t.Fatalf("expected landing team %s (earliest created), got %s", teamOlder, team.ID) - } - - userIdentity, err := testDB.AuthDB.Read.GetUserIdentity(ctx, authqueries.GetUserIdentityParams{ - OidcIss: input.OIDCIssuer, - OidcSub: input.OIDCUserID, - }) - if err != nil { - t.Fatalf("expected user identity to be created: %v", err) - } - - // SSO members never get a default team; selection is not pinned. - if _, err := testDB.AuthDB.Read.GetDefaultTeamByUserID(ctx, userIdentity.UserID); err == nil { - t.Fatal("expected no default team for an SSO member") - } - - memberships, err := testDB.AuthDB.Read.GetTeamsWithUsersTeams(ctx, userIdentity.UserID) - if err != nil { - t.Fatalf("failed to read memberships: %v", err) - } - if len(memberships) != 2 { - t.Fatalf("expected membership in both mapped teams, got %d", len(memberships)) - } - joined := map[uuid.UUID]bool{} - for _, m := range memberships { - if m.IsDefault { - t.Fatalf("expected no default SSO membership, but %s is default", m.Team.ID) - } - joined[m.Team.ID] = true - } - if !joined[teamOlder] || !joined[teamNewer] { - t.Fatalf("expected membership in both %s and %s, got %v", teamOlder, teamNewer, joined) - } - - if len(sink.requests) != 0 { - t.Fatalf("expected no billing provisioning for SSO teams, got %d", len(sink.requests)) - } -} - -func TestBootstrapOIDCUser_SSOFailsClosedWhenNoTeamMapped(t *testing.T) { - t.Parallel() - - testDB := testutils.SetupDatabase(t) - ctx := t.Context() - sink := &fakeTeamProvisionSink{} - - orgID := uuid.New() - subject := uuid.NewString() - - store := &APIStore{ - config: cfg.Config{OryIssuerURL: "https://ory.example.test"}, - db: testDB.SqlcClient, - authDB: testDB.AuthDB, - teamProvisionSink: sink, - userProfiles: ssoUserProfiles{orgBySubject: map[string]uuid.UUID{subject: orgID}}, - idps: userprofile.Registry{"https://ory.example.test": ssoUserProfiles{orgBySubject: map[string]uuid.UUID{subject: orgID}}}, - } - - input := oidcUserBootstrapInput{ - OIDCIssuer: "https://ory.example.test", - OIDCUserID: subject, - OIDCUserEmail: "grace@example.test", - } - - _, err := store.bootstrapOIDCUser(ctx, input) - if err == nil { - t.Fatal("expected fail-closed error when organization maps to no team") - } - - var provErr *internalteamprovision.ProvisionError - if !errors.As(err, &provErr) || provErr.StatusCode != http.StatusForbidden { - t.Fatalf("expected 403 ProvisionError, got %v", err) - } - - // The transaction must roll back: no identity or personal team is left behind. - if _, err := testDB.AuthDB.Read.GetUserIdentity(ctx, authqueries.GetUserIdentityParams{ - OidcIss: input.OIDCIssuer, - OidcSub: input.OIDCUserID, - }); err == nil { - t.Fatal("expected no user identity after fail-closed bootstrap") - } - - if len(sink.requests) != 0 { - t.Fatalf("expected no billing provisioning, got %d", len(sink.requests)) - } -} - -func TestBootstrapOIDCUser_SSOSkipsManualTeams(t *testing.T) { - t.Parallel() - - testDB := testutils.SetupDatabase(t) - ctx := t.Context() - sink := &fakeTeamProvisionSink{} - - orgID := uuid.New() - subject := uuid.NewString() - - // The org's only team is manual (sso_auto_join = false): it must not be - // auto-joined, so bootstrap fails closed rather than enrolling the user. - manualTeam := testutils.CreateTestTeam(t, testDB) - setTeamSSOOrg(t, testDB, manualTeam, orgID, false, time.Now().Add(-1*time.Hour)) - - store := &APIStore{ - config: cfg.Config{OryIssuerURL: "https://ory.example.test"}, - db: testDB.SqlcClient, - authDB: testDB.AuthDB, - teamProvisionSink: sink, - userProfiles: ssoUserProfiles{orgBySubject: map[string]uuid.UUID{subject: orgID}}, - idps: userprofile.Registry{"https://ory.example.test": ssoUserProfiles{orgBySubject: map[string]uuid.UUID{subject: orgID}}}, - } - - input := oidcUserBootstrapInput{ - OIDCIssuer: "https://ory.example.test", - OIDCUserID: subject, - OIDCUserEmail: "manual@example.test", - } - - _, err := store.bootstrapOIDCUser(ctx, input) - var provErr *internalteamprovision.ProvisionError - if !errors.As(err, &provErr) || provErr.StatusCode != http.StatusForbidden { - t.Fatalf("expected 403 fail-closed when the org has only manual teams, got %v", err) - } -} - -func TestCreateTeam_SSOUserRejected(t *testing.T) { - t.Parallel() - - ctx := t.Context() - userID := uuid.New() - - store := &APIStore{ - userProfiles: ssoUserProfiles{orgByUser: map[uuid.UUID]uuid.UUID{userID: uuid.New()}}, - idps: userprofile.Registry{"https://ory.example.test": ssoUserProfiles{orgByUser: map[uuid.UUID]uuid.UUID{userID: uuid.New()}}}, - } - - _, err := store.createTeam(ctx, userID, "My Team") - if err == nil { - t.Fatal("expected SSO user to be blocked from creating a team") - } - - var provErr *internalteamprovision.ProvisionError - if !errors.As(err, &provErr) || provErr.StatusCode != http.StatusForbidden { - t.Fatalf("expected 403 ProvisionError, got %v", err) - } -} - -func TestPostTeamsTeamIDMembers_RejectsInviteOutsideSSOOrg(t *testing.T) { - t.Parallel() - - ctx := t.Context() - teamID := uuid.New() - orgID := uuid.New() - actingUserID := uuid.New() - inviteeID := uuid.New() - - recorder := httptest.NewRecorder() - ginCtx, _ := gin.CreateTestContext(recorder) - auth.SetUserIDForTest(t, ginCtx, actingUserID) - auth.SetTeamInfoForTest(t, ginCtx, &authtypes.Team{ - Team: &authqueries.Team{ID: teamID, SsoOrganizationID: &orgID}, - }) - ginCtx.Request = httptest.NewRequestWithContext(ctx, http.MethodPost, "/", strings.NewReader(`{"email":"`+handlerTestUserEmail(inviteeID)+`"}`)) - ginCtx.Request.Header.Set("Content-Type", "application/json") - - // invitee belongs to no org (orgByUser empty) → outside the team's org. - store := &APIStore{userProfiles: ssoUserProfiles{}, idps: userprofile.Registry{"https://ory.example.test": ssoUserProfiles{}}} - store.PostTeamsTeamIDMembers(ginCtx, teamID) - - if recorder.Code != http.StatusForbidden { - t.Fatalf("expected 403 for an invite outside the SSO org, got %d: %s", recorder.Code, recorder.Body.String()) - } -} - -func TestPostTeamsTeamIDMembers_AllowsInviteFromSSOOrg(t *testing.T) { - t.Parallel() - - testDB := testutils.SetupDatabase(t) - ctx := t.Context() - - orgID := uuid.New() - teamID := testutils.CreateTestTeam(t, testDB) - actingUserID := createHandlerTestUser(t, testDB) - inviteeID := uuid.New() - - recorder := httptest.NewRecorder() - ginCtx, _ := gin.CreateTestContext(recorder) - auth.SetUserIDForTest(t, ginCtx, actingUserID) - auth.SetTeamInfoForTest(t, ginCtx, &authtypes.Team{ - Team: &authqueries.Team{ID: teamID, SsoOrganizationID: &orgID}, - }) - ginCtx.Request = httptest.NewRequestWithContext(ctx, http.MethodPost, "/", strings.NewReader(`{"email":"`+handlerTestUserEmail(inviteeID)+`"}`)) - ginCtx.Request.Header.Set("Content-Type", "application/json") - - // invitee belongs to the same org as the team → allowed. - store := &APIStore{ - db: testDB.SqlcClient, - authDB: testDB.AuthDB, - authService: noopAuthService{}, - userProfiles: ssoUserProfiles{orgByUser: map[uuid.UUID]uuid.UUID{inviteeID: orgID}}, - idps: userprofile.Registry{"https://ory.example.test": ssoUserProfiles{orgByUser: map[uuid.UUID]uuid.UUID{inviteeID: orgID}}}, - } - store.PostTeamsTeamIDMembers(ginCtx, teamID) - - if ginCtx.Writer.Status() != http.StatusCreated { - t.Fatalf("expected 201 for an in-org invite, got %d: %s", ginCtx.Writer.Status(), recorder.Body.String()) - } - - memberships, err := testDB.AuthDB.Read.GetTeamsWithUsersTeams(ctx, inviteeID) - if err != nil { - t.Fatalf("failed to read memberships: %v", err) - } - if len(memberships) != 1 || memberships[0].Team.ID != teamID { - t.Fatalf("expected invitee to be a member of %s, got %v", teamID, memberships) - } -} diff --git a/packages/dashboard-api/internal/handlers/store.go b/packages/dashboard-api/internal/handlers/store.go index b55f483588..6a3d84f417 100644 --- a/packages/dashboard-api/internal/handlers/store.go +++ b/packages/dashboard-api/internal/handlers/store.go @@ -12,8 +12,9 @@ import ( clickhouse "github.com/e2b-dev/infra/packages/clickhouse/pkg" "github.com/e2b-dev/infra/packages/dashboard-api/internal/api" "github.com/e2b-dev/infra/packages/dashboard-api/internal/cfg" + "github.com/e2b-dev/infra/packages/dashboard-api/internal/identity" + "github.com/e2b-dev/infra/packages/dashboard-api/internal/provisioning" internalteamprovision "github.com/e2b-dev/infra/packages/dashboard-api/internal/teamprovision" - "github.com/e2b-dev/infra/packages/dashboard-api/internal/userprofile" sqlcdb "github.com/e2b-dev/infra/packages/db/client" authdb "github.com/e2b-dev/infra/packages/db/pkg/auth" "github.com/e2b-dev/infra/packages/shared/pkg/apierrors" @@ -22,14 +23,13 @@ import ( var _ api.ServerInterface = (*APIStore)(nil) type APIStore struct { - config cfg.Config - db *sqlcdb.Client - authDB *authdb.Client - clickhouse clickhouse.Clickhouse - authService sharedauth.Service - teamProvisionSink internalteamprovision.TeamProvisionSink - userProfiles userprofile.Provider - idps userprofile.Registry + config cfg.Config + db *sqlcdb.Client + authDB *authdb.Client + clickhouse clickhouse.Clickhouse + authService sharedauth.Service + idp identity.Provider + provisioning *provisioning.Service } func NewAPIStore( @@ -39,17 +39,16 @@ func NewAPIStore( ch clickhouse.Clickhouse, authService sharedauth.Service, teamProvisionSink internalteamprovision.TeamProvisionSink, - userProfiles userprofile.Provider, + idp identity.Provider, ) *APIStore { return &APIStore{ - config: config, - db: db, - authDB: authDB, - clickhouse: ch, - authService: authService, - teamProvisionSink: teamProvisionSink, - userProfiles: userProfiles, - idps: userprofile.Registry{config.OryIssuerURL: userProfiles}, + config: config, + db: db, + authDB: authDB, + clickhouse: ch, + authService: authService, + idp: idp, + provisioning: provisioning.New(authDB, idp, teamProvisionSink, identity.Registry{config.OryIssuerURL: idp}), } } diff --git a/packages/dashboard-api/internal/handlers/team_creation.go b/packages/dashboard-api/internal/handlers/team_creation.go index 21eebacc95..bc5d07971a 100644 --- a/packages/dashboard-api/internal/handlers/team_creation.go +++ b/packages/dashboard-api/internal/handlers/team_creation.go @@ -38,7 +38,7 @@ func (s *APIStore) PostTeams(c *gin.Context) { return } - team, err := s.createTeam(ctx, userID, name) + team, err := s.provisioning.CreateTeam(ctx, userID, name) if err != nil { s.handleProvisioningError(ctx, c, "create team", err) diff --git a/packages/dashboard-api/internal/handlers/team_handlers_test.go b/packages/dashboard-api/internal/handlers/team_handlers_test.go index d2014d71b2..ee1641330f 100644 --- a/packages/dashboard-api/internal/handlers/team_handlers_test.go +++ b/packages/dashboard-api/internal/handlers/team_handlers_test.go @@ -3,7 +3,6 @@ package handlers import ( "context" "encoding/json" - "errors" "net/http" "net/http/httptest" "strings" @@ -13,20 +12,20 @@ import ( "github.com/gin-gonic/gin" "github.com/google/uuid" - "github.com/jackc/pgx/v5" "github.com/e2b-dev/infra/packages/auth/pkg/auth" - "github.com/e2b-dev/infra/packages/auth/pkg/auth/oidc" authtypes "github.com/e2b-dev/infra/packages/auth/pkg/types" - "github.com/e2b-dev/infra/packages/dashboard-api/internal/cfg" + "github.com/e2b-dev/infra/packages/dashboard-api/internal/identity" + "github.com/e2b-dev/infra/packages/dashboard-api/internal/provisioning" internalteamprovision "github.com/e2b-dev/infra/packages/dashboard-api/internal/teamprovision" - "github.com/e2b-dev/infra/packages/dashboard-api/internal/userprofile" 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/db/queries" "github.com/e2b-dev/infra/packages/shared/pkg/teamprovision" ) +const testBaseTier = "base_v1" + func TestParseUpdateTeamBody_ProfilePictureNullClearsValue(t *testing.T) { t.Parallel() @@ -126,10 +125,9 @@ func TestPostTeamsTeamIDMembers_DuplicateMemberReturnsBadRequest(t *testing.T) { }) store := &APIStore{ - db: testDB.SqlcClient, - authDB: testDB.AuthDB, - userProfiles: newHandlerTestUserProfiles(), - idps: userprofile.Registry{"https://ory.example.test": newHandlerTestUserProfiles()}, + db: testDB.SqlcClient, + authDB: testDB.AuthDB, + idp: handlerTestIdentityProvider{}, } store.PostTeamsTeamIDMembers(ginCtx, teamID) @@ -165,11 +163,10 @@ func TestPostTeamsTeamIDMembers_CreatesPublicUserAnchorForInvitee(t *testing.T) }) store := &APIStore{ - db: testDB.SqlcClient, - authDB: testDB.AuthDB, - authService: noopAuthService{}, - userProfiles: newHandlerTestUserProfiles(), - idps: userprofile.Registry{"https://ory.example.test": newHandlerTestUserProfiles()}, + db: testDB.SqlcClient, + authDB: testDB.AuthDB, + authService: noopAuthService{}, + idp: handlerTestIdentityProvider{}, } store.PostTeamsTeamIDMembers(ginCtx, teamID) @@ -189,6 +186,92 @@ func TestPostTeamsTeamIDMembers_CreatesPublicUserAnchorForInvitee(t *testing.T) } } +// ssoIdentityProvider is a Provider whose organization lookups are configurable, so +// tests can simulate identities that belong to an Ory organization. +type ssoIdentityProvider struct { + handlerTestIdentityProvider + + orgByUser map[uuid.UUID]uuid.UUID +} + +func (p ssoIdentityProvider) GetUserOrganizationID(_ context.Context, userID uuid.UUID) (uuid.UUID, error) { + return p.orgByUser[userID], nil +} + +func TestPostTeamsTeamIDMembers_RejectsInviteOutsideSSOOrg(t *testing.T) { + t.Parallel() + + ctx := t.Context() + teamID := uuid.New() + orgID := uuid.New() + actingUserID := uuid.New() + inviteeID := uuid.New() + + recorder := httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(recorder) + auth.SetUserIDForTest(t, ginCtx, actingUserID) + auth.SetTeamInfoForTest(t, ginCtx, &authtypes.Team{ + Team: &authqueries.Team{ID: teamID, SsoOrganizationID: &orgID}, + }) + ginCtx.Request = httptest.NewRequestWithContext(ctx, http.MethodPost, "/", strings.NewReader(`{"email":"`+handlerTestUserEmail(inviteeID)+`"}`)) + ginCtx.Request.Header.Set("Content-Type", "application/json") + + // invitee belongs to no org (orgByUser empty) → outside the team's org. + store := &APIStore{ + idp: ssoIdentityProvider{}, + provisioning: provisioning.New(nil, ssoIdentityProvider{}, nil, identity.Registry{"": ssoIdentityProvider{}}), + } + store.PostTeamsTeamIDMembers(ginCtx, teamID) + + if recorder.Code != http.StatusForbidden { + t.Fatalf("expected 403 for an invite outside the SSO org, got %d: %s", recorder.Code, recorder.Body.String()) + } +} + +func TestPostTeamsTeamIDMembers_AllowsInviteFromSSOOrg(t *testing.T) { + t.Parallel() + + testDB := testutils.SetupDatabase(t) + ctx := t.Context() + + orgID := uuid.New() + teamID := testutils.CreateTestTeam(t, testDB) + actingUserID := createHandlerTestUser(t, testDB) + inviteeID := uuid.New() + + recorder := httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(recorder) + auth.SetUserIDForTest(t, ginCtx, actingUserID) + auth.SetTeamInfoForTest(t, ginCtx, &authtypes.Team{ + Team: &authqueries.Team{ID: teamID, SsoOrganizationID: &orgID}, + }) + ginCtx.Request = httptest.NewRequestWithContext(ctx, http.MethodPost, "/", strings.NewReader(`{"email":"`+handlerTestUserEmail(inviteeID)+`"}`)) + ginCtx.Request.Header.Set("Content-Type", "application/json") + + // invitee belongs to the same org as the team → allowed. + profiles := ssoIdentityProvider{orgByUser: map[uuid.UUID]uuid.UUID{inviteeID: orgID}} + store := &APIStore{ + db: testDB.SqlcClient, + authDB: testDB.AuthDB, + authService: noopAuthService{}, + idp: profiles, + provisioning: provisioning.New(testDB.AuthDB, profiles, nil, identity.Registry{"": profiles}), + } + store.PostTeamsTeamIDMembers(ginCtx, teamID) + + if ginCtx.Writer.Status() != http.StatusCreated { + t.Fatalf("expected 201 for an in-org invite, got %d: %s", ginCtx.Writer.Status(), recorder.Body.String()) + } + + memberships, err := testDB.AuthDB.Read.GetTeamsWithUsersTeams(ctx, inviteeID) + if err != nil { + t.Fatalf("failed to read memberships: %v", err) + } + if len(memberships) != 1 || memberships[0].Team.ID != teamID { + t.Fatalf("expected invitee to be a member of %s, got %v", teamID, memberships) + } +} + func TestDeleteTeamsTeamIDMembersUserId_NonMemberReturnsBadRequest(t *testing.T) { t.Parallel() @@ -326,23 +409,8 @@ func TestDeleteTeamsTeamIDMembersUserId_RechecksDefaultAfterLock(t *testing.T) { func createHandlerTestUser(t *testing.T, db *testutils.Database) uuid.UUID { t.Helper() - createdAt := time.Now() - - return createHandlerTestUserWithCreatedAt(t, db, &createdAt) -} - -func createHandlerTestUserAt(t *testing.T, db *testutils.Database, createdAt time.Time) uuid.UUID { - t.Helper() - - return createHandlerTestUserWithCreatedAt(t, db, &createdAt) -} - -func createHandlerTestUserWithCreatedAt(t *testing.T, db *testutils.Database, createdAt *time.Time) uuid.UUID { - t.Helper() - userID := uuid.New() email := handlerTestUserEmail(userID) - _ = createdAt if err := db.AuthDB.Write.UpsertPublicUser(t.Context(), userID); err != nil { t.Fatalf("failed to create public user: %v", err) @@ -350,7 +418,7 @@ func createHandlerTestUserWithCreatedAt(t *testing.T, db *testutils.Database, cr team, err := db.AuthDB.Write.CreateTeam(t.Context(), authqueries.CreateTeamParams{ Name: email, - Tier: baseTierID, + Tier: testBaseTier, Email: email, }) if err != nil { @@ -373,19 +441,15 @@ func handlerTestUserEmail(userID uuid.UUID) string { return "user-" + userID.String() + "@example.com" } -type handlerTestUserProfiles struct{} - -func newHandlerTestUserProfiles() userprofile.Provider { - return handlerTestUserProfiles{} -} +type handlerTestIdentityProvider struct{} -func (handlerTestUserProfiles) GetProfilesByUserID(_ context.Context, userIDs []uuid.UUID) (map[uuid.UUID]userprofile.Profile, error) { - profiles := make(map[uuid.UUID]userprofile.Profile, len(userIDs)) +func (handlerTestIdentityProvider) GetProfilesByUserID(_ context.Context, userIDs []uuid.UUID) (map[uuid.UUID]identity.Profile, error) { + profiles := make(map[uuid.UUID]identity.Profile, len(userIDs)) for _, userID := range userIDs { if userID == uuid.Nil { continue } - profiles[userID] = userprofile.Profile{ + profiles[userID] = identity.Profile{ UserID: userID, Email: handlerTestUserEmail(userID), } @@ -394,89 +458,39 @@ func (handlerTestUserProfiles) GetProfilesByUserID(_ context.Context, userIDs [] return profiles, nil } -func (handlerTestUserProfiles) FindProfilesByEmail(_ context.Context, email string) ([]userprofile.Profile, error) { +func (handlerTestIdentityProvider) FindProfilesByEmail(_ context.Context, email string) ([]identity.Profile, error) { userIDText := strings.TrimSuffix(strings.TrimPrefix(email, "user-"), "@example.com") userID, _ := uuid.Parse(userIDText) if userID == uuid.Nil { - return []userprofile.Profile{}, nil + return []identity.Profile{}, nil } - return []userprofile.Profile{{ + return []identity.Profile{{ UserID: userID, Email: email, }}, nil } -func (handlerTestUserProfiles) GetTeamCreatorContext(context.Context, uuid.UUID) (*teamprovision.CreatorContextV1, error) { +func (handlerTestIdentityProvider) GetTeamCreatorContext(context.Context, uuid.UUID) (*teamprovision.CreatorContextV1, error) { return nil, nil } -func (handlerTestUserProfiles) GetIdentityOrganizationID(context.Context, string) (uuid.UUID, error) { +func (handlerTestIdentityProvider) GetIdentityOrganizationID(context.Context, string) (uuid.UUID, error) { return uuid.Nil, nil } -func (handlerTestUserProfiles) GetUserOrganizationID(context.Context, uuid.UUID) (uuid.UUID, error) { +func (handlerTestIdentityProvider) GetUserOrganizationID(context.Context, uuid.UUID) (uuid.UUID, error) { return uuid.Nil, nil } -func (handlerTestUserProfiles) SetIdentityExternalID(context.Context, string, uuid.UUID) error { +func (handlerTestIdentityProvider) SetIdentityExternalID(context.Context, string, uuid.UUID) error { return nil } -func (handlerTestUserProfiles) PrepareDeleteUser(context.Context, uuid.UUID) (userprofile.DeleteUserHandle, error) { +func (handlerTestIdentityProvider) PrepareDeleteUser(context.Context, uuid.UUID) (identity.DeleteUserHandle, error) { return nil, nil } -func TestDefaultTeamNameFromProfile(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - profile userprofile.Profile - want string - }{ - { - name: "profile name", - profile: userprofile.Profile{ - Email: "fallback@example.com", - Name: "ada", - }, - want: "Ada's Default Team", - }, - { - name: "profile full name first word", - profile: userprofile.Profile{ - Email: "fallback@example.com", - Name: "grace hopper", - }, - want: "Grace's Default Team", - }, - { - name: "email prefix", - profile: userprofile.Profile{ - Email: "barbara@example.com", - }, - want: "Barbara's Default Team", - }, - { - name: "no base name", - profile: userprofile.Profile{}, - want: "Default Team", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - got := defaultTeamNameFromProfile(tt.profile) - if got != tt.want { - t.Fatalf("defaultTeamNameFromProfile() = %q, want %q", got, tt.want) - } - }) - } -} - func insertHandlerTestTeamMember(t *testing.T, db *testutils.Database, userID, teamID uuid.UUID, isDefault bool) { t.Helper() @@ -499,384 +513,6 @@ VALUES ($1, $2, $3) } } -func TestBootstrapAuthProviderUser_CreatesIdentityAndDefaultTeam(t *testing.T) { - t.Parallel() - - testDB := testutils.SetupDatabase(t) - ctx := t.Context() - sink := &fakeTeamProvisionSink{} - - store := &APIStore{ - config: cfg.Config{ - OryIssuerURL: "https://ory.example.test", - AuthProvider: auth.ProviderConfig{ - JWT: []oidc.Config{ - { - Issuer: oidc.Issuer{ - URL: "https://ory.example.test", - Audiences: []string{"https://dashboard-api.example.test"}, - }, - }, - }, - }, - }, - db: testDB.SqlcClient, - authDB: testDB.AuthDB, - teamProvisionSink: sink, - userProfiles: newHandlerTestUserProfiles(), - idps: userprofile.Registry{"https://ory.example.test": newHandlerTestUserProfiles()}, - } - - input := oidcUserBootstrapInput{ - OIDCIssuer: "https://ory.example.test", - OIDCUserID: uuid.NewString(), - OIDCUserEmail: "ada@example.test", - OIDCUserName: nil, - SignupIP: "198.51.100.20", - SignupUserAgent: "Mozilla/5.0", - } - - team, err := store.bootstrapOIDCUser(ctx, input) - if err != nil { - t.Fatalf("expected bootstrap to succeed: %v", err) - } - - userIdentity, err := testDB.AuthDB.Read.GetUserIdentity(ctx, authqueries.GetUserIdentityParams{ - OidcIss: input.OIDCIssuer, - OidcSub: input.OIDCUserID, - }) - if err != nil { - t.Fatalf("expected user identity to be created: %v", err) - } - - defaultTeam, err := testDB.AuthDB.Read.GetDefaultTeamByUserID(ctx, userIdentity.UserID) - if err != nil { - t.Fatalf("expected default team to be created: %v", err) - } - if defaultTeam.ID != team.ID { - t.Fatalf("expected response team %s, got %s", defaultTeam.ID, team.ID) - } - if defaultTeam.Name != "Default Team" { - t.Fatalf("expected team name %q, got %q", "Default Team", defaultTeam.Name) - } - if defaultTeam.Email != "ada@example.test" { - t.Fatalf("expected team email %q, got %q", "ada@example.test", defaultTeam.Email) - } - - if len(sink.requests) != 1 { - t.Fatalf("expected one billing provisioning call, got %d", len(sink.requests)) - } - if sink.requests[0].CreatorUserID != userIdentity.UserID { - t.Fatalf("expected sink creator %s, got %s", userIdentity.UserID, sink.requests[0].CreatorUserID) - } - if sink.requests[0].CreatorContext == nil { - t.Fatal("expected sink creator context") - } - if sink.requests[0].CreatorContext.IPAddress != "198.51.100.20" { - t.Fatalf("expected sink creator ip %q, got %q", "198.51.100.20", sink.requests[0].CreatorContext.IPAddress) - } - if sink.requests[0].CreatorContext.UserAgent != "Mozilla/5.0" { - t.Fatalf("expected sink creator user agent %q, got %q", "Mozilla/5.0", sink.requests[0].CreatorContext.UserAgent) - } - if sink.requests[0].CreatorContext.AuthMethod != teamprovision.AuthMethodSocial { - t.Fatalf("expected sink creator auth method %q, got %q", teamprovision.AuthMethodSocial, sink.requests[0].CreatorContext.AuthMethod) - } -} - -type recordingUserProfiles struct { - handlerTestUserProfiles - - externalIDSubject string - externalID uuid.UUID - externalIDCalls int -} - -func (r *recordingUserProfiles) SetIdentityExternalID(_ context.Context, subject string, externalID uuid.UUID) error { - r.externalIDSubject = subject - r.externalID = externalID - r.externalIDCalls++ - - return nil -} - -func TestBootstrapOIDCUser_PopulatesOryExternalID(t *testing.T) { - t.Parallel() - - testDB := testutils.SetupDatabase(t) - ctx := t.Context() - sink := &fakeTeamProvisionSink{} - profiles := &recordingUserProfiles{} - - store := &APIStore{ - config: cfg.Config{ - OryIssuerURL: "https://ory.example.test", - }, - db: testDB.SqlcClient, - authDB: testDB.AuthDB, - teamProvisionSink: sink, - userProfiles: profiles, - idps: userprofile.Registry{"https://ory.example.test": profiles}, - } - - input := oidcUserBootstrapInput{ - OIDCIssuer: "https://ory.example.test", - OIDCUserID: uuid.NewString(), - OIDCUserEmail: "ada@example.test", - } - - if _, err := store.bootstrapOIDCUser(ctx, input); err != nil { - t.Fatalf("expected bootstrap to succeed: %v", err) - } - - userIdentity, err := testDB.AuthDB.Read.GetUserIdentity(ctx, authqueries.GetUserIdentityParams{ - OidcIss: input.OIDCIssuer, - OidcSub: input.OIDCUserID, - }) - if err != nil { - t.Fatalf("expected user identity to be created: %v", err) - } - - if profiles.externalIDCalls != 1 { - t.Fatalf("expected one external id update, got %d", profiles.externalIDCalls) - } - if profiles.externalIDSubject != input.OIDCUserID { - t.Fatalf("expected external id set on subject %q, got %q", input.OIDCUserID, profiles.externalIDSubject) - } - if profiles.externalID != userIdentity.UserID { - t.Fatalf("expected external id %s, got %s", userIdentity.UserID, profiles.externalID) - } -} - -// failingUserProfiles fails the Ory external_id PATCH to simulate the IdP being -// unavailable after the bootstrap transaction has already committed. -type failingUserProfiles struct { - handlerTestUserProfiles - - externalIDCalls int -} - -func (f *failingUserProfiles) SetIdentityExternalID(context.Context, string, uuid.UUID) error { - f.externalIDCalls++ - - return errors.New("ory unavailable") -} - -// A failed external_id PATCH must not roll back the committed user/identity/team. -// The PATCH now runs after the transaction commits, so the user stays provisioned -// and the next login can backfill external_id rather than being stranded. -func TestBootstrapOIDCUser_ExternalIDFailureKeepsUserProvisioned(t *testing.T) { - t.Parallel() - - testDB := testutils.SetupDatabase(t) - ctx := t.Context() - sink := &fakeTeamProvisionSink{} - profiles := &failingUserProfiles{} - - store := &APIStore{ - config: cfg.Config{OryIssuerURL: "https://ory.example.test"}, - db: testDB.SqlcClient, - authDB: testDB.AuthDB, - teamProvisionSink: sink, - userProfiles: profiles, - idps: userprofile.Registry{"https://ory.example.test": profiles}, - } - - input := oidcUserBootstrapInput{ - OIDCIssuer: "https://ory.example.test", - OIDCUserID: uuid.NewString(), - OIDCUserEmail: "ada@example.test", - } - - if _, err := store.bootstrapOIDCUser(ctx, input); err == nil { - t.Fatal("expected bootstrap to fail when external_id patch fails") - } - if profiles.externalIDCalls != 1 { - t.Fatalf("expected one external id attempt, got %d", profiles.externalIDCalls) - } - - 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) - } - if _, err := testDB.AuthDB.Read.GetDefaultTeamByUserID(ctx, userIdentity.UserID); err != nil { - t.Fatalf("expected default team to survive external_id failure: %v", err) - } - - // The billing event must be emitted before the external_id backfill, otherwise - // a PATCH failure leaves the committed team billing-orphaned. - if len(sink.requests) != 1 { - t.Fatalf("expected one billing provisioning call despite external_id failure, got %d", len(sink.requests)) - } -} - -// A user provisioned by a prior bootstrap whose external_id PATCH failed re-runs -// here on the next login; the existing-team path re-asserts the PATCH and -// backfills external_id without creating a duplicate team. -func TestBootstrapOIDCUser_ReRunBackfillsExternalID(t *testing.T) { - t.Parallel() - - testDB := testutils.SetupDatabase(t) - ctx := t.Context() - sink := &fakeTeamProvisionSink{} - - store := &APIStore{ - config: cfg.Config{OryIssuerURL: "https://ory.example.test"}, - db: testDB.SqlcClient, - authDB: testDB.AuthDB, - teamProvisionSink: sink, - userProfiles: &failingUserProfiles{}, - idps: userprofile.Registry{"https://ory.example.test": &failingUserProfiles{}}, - } - - input := oidcUserBootstrapInput{ - OIDCIssuer: "https://ory.example.test", - OIDCUserID: uuid.NewString(), - OIDCUserEmail: "ada@example.test", - } - - if _, err := store.bootstrapOIDCUser(ctx, input); err == nil { - t.Fatal("expected first bootstrap to fail on external_id patch") - } - - userIdentity, err := testDB.AuthDB.Read.GetUserIdentity(ctx, authqueries.GetUserIdentityParams{ - OidcIss: input.OIDCIssuer, - OidcSub: input.OIDCUserID, - }) - if err != nil { - t.Fatalf("expected user identity from first bootstrap: %v", err) - } - existingTeam, err := testDB.AuthDB.Read.GetDefaultTeamByUserID(ctx, userIdentity.UserID) - if err != nil { - t.Fatalf("expected default team from first bootstrap: %v", err) - } - - recording := &recordingUserProfiles{} - store.userProfiles = recording - - secondTeam, err := store.bootstrapOIDCUser(ctx, input) - if err != nil { - t.Fatalf("expected re-run bootstrap to succeed: %v", err) - } - if secondTeam.ID != existingTeam.ID { - t.Fatalf("expected re-run to reuse existing team %s, got %s", existingTeam.ID, secondTeam.ID) - } - if recording.externalIDCalls != 1 { - t.Fatalf("expected re-run to backfill external_id once, got %d", recording.externalIDCalls) - } - if recording.externalID != userIdentity.UserID { - t.Fatalf("expected external id %s, got %s", userIdentity.UserID, recording.externalID) - } -} - -func TestBootstrapOIDCUser_ConcurrentRequestsSingleIdentityAndTeam(t *testing.T) { - t.Parallel() - - testDB := testutils.SetupDatabase(t) - ctx := t.Context() - sink := &fakeTeamProvisionSink{} - - store := &APIStore{ - config: cfg.Config{ - OryIssuerURL: "https://ory.example.test", - AuthProvider: auth.ProviderConfig{ - JWT: []oidc.Config{ - { - Issuer: oidc.Issuer{ - URL: "https://ory.example.test", - Audiences: []string{"https://dashboard-api.example.test"}, - }, - }, - }, - }, - }, - db: testDB.SqlcClient, - authDB: testDB.AuthDB, - teamProvisionSink: sink, - userProfiles: newHandlerTestUserProfiles(), - idps: userprofile.Registry{"https://ory.example.test": newHandlerTestUserProfiles()}, - } - - input := oidcUserBootstrapInput{ - OIDCIssuer: "https://ory.example.test", - OIDCUserID: uuid.NewString(), - OIDCUserEmail: "ada@example.test", - OIDCUserName: nil, - } - - const concurrency = 4 - var wg sync.WaitGroup - results := make(chan provisionedTeam, concurrency) - errs := make(chan error, concurrency) - - for range concurrency { - wg.Go(func() { - team, err := store.bootstrapOIDCUser(ctx, input) - if err != nil { - errs <- err - - return - } - - results <- team - }) - } - - wg.Wait() - close(results) - close(errs) - - for err := range errs { - if err != nil { - t.Fatalf("expected bootstrap to succeed, got %v", err) - } - } - - var teamIDs []uuid.UUID - for team := range results { - teamIDs = append(teamIDs, team.ID) - } - if len(teamIDs) != concurrency { - t.Fatalf("expected %d bootstrap results, got %d", concurrency, len(teamIDs)) - } - for _, id := range teamIDs[1:] { - if id != teamIDs[0] { - t.Fatalf("expected all bootstrap calls to share team %s, got %s", teamIDs[0], id) - } - } - - userIdentity, err := testDB.AuthDB.Read.GetUserIdentity(ctx, authqueries.GetUserIdentityParams{ - OidcIss: input.OIDCIssuer, - OidcSub: input.OIDCUserID, - }) - if err != nil { - t.Fatalf("expected single user identity to exist: %v", err) - } - - var defaultTeamCount int - err = testDB.AuthDB.TestsRawSQLQuery(ctx, - `SELECT count(*) - FROM public.users_teams - WHERE user_id = $1 AND is_default = true`, - func(rows pgx.Rows) error { - if !rows.Next() { - return errors.New("missing default team count row") - } - - return rows.Scan(&defaultTeamCount) - }, - userIdentity.UserID, - ) - if err != nil { - t.Fatalf("failed to count default team memberships: %v", err) - } - if defaultTeamCount != 1 { - t.Fatalf("expected exactly one default team for canonical user, got %d", defaultTeamCount) - } -} - func TestPostAdminUsersBootstrap_EmptyOIDCUserIDReturnsBadRequest(t *testing.T) { t.Parallel() @@ -893,239 +529,6 @@ func TestPostAdminUsersBootstrap_EmptyOIDCUserIDReturnsBadRequest(t *testing.T) } } -func TestBootstrapOIDCUser_OryIssuerWithoutJWTConfigIsAccepted(t *testing.T) { - t.Parallel() - - testDB := testutils.SetupDatabase(t) - ctx := t.Context() - sink := &fakeTeamProvisionSink{} - - const oryIssuer = "https://ory.example.test" - - store := &APIStore{ - config: cfg.Config{ - OryIssuerURL: oryIssuer, - }, - db: testDB.SqlcClient, - authDB: testDB.AuthDB, - teamProvisionSink: sink, - userProfiles: newHandlerTestUserProfiles(), - idps: userprofile.Registry{"https://ory.example.test": newHandlerTestUserProfiles()}, - } - - team, err := store.bootstrapOIDCUser(ctx, oidcUserBootstrapInput{ - OIDCIssuer: oryIssuer, - OIDCUserID: uuid.NewString(), - OIDCUserEmail: "ada@example.test", - OIDCUserName: nil, - }) - if err != nil { - t.Fatalf("expected bootstrap to succeed with Ory issuer but no JWT config: %v", err) - } - if team.ID == uuid.Nil { - t.Fatal("expected provisioned team") - } -} - -func TestBootstrapOIDCUser_OryModeRejectsNonOryJWTIssuer(t *testing.T) { - t.Parallel() - - testDB := testutils.SetupDatabase(t) - ctx := t.Context() - sink := &fakeTeamProvisionSink{} - - const oryIssuer = "https://ory.example.test" - const otherIssuer = "https://workos.example.test" - - store := &APIStore{ - config: cfg.Config{ - OryIssuerURL: oryIssuer, - AuthProvider: auth.ProviderConfig{ - JWT: []oidc.Config{ - {Issuer: oidc.Issuer{URL: otherIssuer}}, - }, - }, - }, - db: testDB.SqlcClient, - authDB: testDB.AuthDB, - teamProvisionSink: sink, - userProfiles: newHandlerTestUserProfiles(), - idps: userprofile.Registry{"https://ory.example.test": newHandlerTestUserProfiles()}, - } - - _, err := store.bootstrapOIDCUser(ctx, oidcUserBootstrapInput{ - OIDCIssuer: otherIssuer, - OIDCUserID: uuid.NewString(), - OIDCUserEmail: "ada@example.test", - OIDCUserName: nil, - }) - if err == nil { - t.Fatal("expected ory mode to reject a non-Ory JWT issuer at bootstrap") - } - var provErr *internalteamprovision.ProvisionError - if !errors.As(err, &provErr) || provErr.StatusCode != http.StatusBadRequest { - t.Fatalf("expected ProvisionError with status 400, got %v", err) - } -} - -func TestBootstrapOIDCUser_UnknownIssuerReturnsBadRequest(t *testing.T) { - t.Parallel() - - testDB := testutils.SetupDatabase(t) - ctx := t.Context() - sink := &fakeTeamProvisionSink{} - - store := &APIStore{ - config: cfg.Config{ - OryIssuerURL: "https://ory.example.test", - AuthProvider: auth.ProviderConfig{ - JWT: []oidc.Config{ - {Issuer: oidc.Issuer{URL: "https://ory.example.test"}}, - }, - }, - }, - db: testDB.SqlcClient, - authDB: testDB.AuthDB, - teamProvisionSink: sink, - userProfiles: newHandlerTestUserProfiles(), - idps: userprofile.Registry{"https://ory.example.test": newHandlerTestUserProfiles()}, - } - - _, err := store.bootstrapOIDCUser(ctx, oidcUserBootstrapInput{ - OIDCIssuer: "https://attacker.example.test", - OIDCUserID: uuid.NewString(), - OIDCUserEmail: "ada@example.test", - OIDCUserName: nil, - }) - if err == nil { - t.Fatal("expected unknown issuer to be rejected") - } - - var provErr *internalteamprovision.ProvisionError - if !errors.As(err, &provErr) || provErr.StatusCode != http.StatusBadRequest { - t.Fatalf("expected ProvisionError with status 400, got %v", err) - } - if len(sink.requests) != 0 { - t.Fatalf("expected no provisioning calls, got %d", len(sink.requests)) - } -} - -func TestBootstrapUser_ConcurrentRequestsCreateSingleDefaultTeam(t *testing.T) { - t.Parallel() - - testDB := testutils.SetupDatabase(t) - ctx := t.Context() - userID := createHandlerTestUser(t, testDB) - sink := &fakeTeamProvisionSink{} - - existingTeam, err := testDB.AuthDB.Write.GetDefaultTeamByUserID(ctx, userID) - if err != nil { - t.Fatalf("expected default team: %v", err) - } - if err := testDB.AuthDB.Write.DeleteTeamByID(ctx, existingTeam.ID); err != nil { - t.Fatalf("failed to remove default team: %v", err) - } - - store := &APIStore{ - db: testDB.SqlcClient, - authDB: testDB.AuthDB, - teamProvisionSink: sink, - userProfiles: newHandlerTestUserProfiles(), - idps: userprofile.Registry{"https://ory.example.test": newHandlerTestUserProfiles()}, - } - profile := bootstrapUserProfile{ - UserID: userID, - Email: handlerTestUserEmail(userID), - DefaultTeamName: defaultTeamNameFromProfile(userprofile.Profile{Email: handlerTestUserEmail(userID)}), - } - - var wg sync.WaitGroup - results := make(chan provisionedTeam, 2) - errs := make(chan error, 2) - - for range 2 { - wg.Go(func() { - team, err := store.bootstrapUser(ctx, profile) - if err != nil { - errs <- err - - return - } - - results <- team - }) - } - - wg.Wait() - close(results) - close(errs) - - for err := range errs { - if err != nil { - t.Fatalf("expected bootstrap to succeed, got %v", err) - } - } - - var teamIDs []uuid.UUID - for team := range results { - teamIDs = append(teamIDs, team.ID) - } - if len(teamIDs) != 2 { - t.Fatalf("expected two bootstrap results, got %d", len(teamIDs)) - } - if teamIDs[0] != teamIDs[1] { - t.Fatalf("expected both bootstrap requests to resolve to the same team, got %s and %s", teamIDs[0], teamIDs[1]) - } - - var defaultTeamCount int - err = testDB.AuthDB.TestsRawSQLQuery(ctx, - `SELECT count(*) - FROM public.users_teams - WHERE user_id = $1 AND is_default = true`, - func(rows pgx.Rows) error { - if !rows.Next() { - return errors.New("missing default team count row") - } - - return rows.Scan(&defaultTeamCount) - }, - userID, - ) - if err != nil { - t.Fatalf("failed to count default team memberships: %v", err) - } - if defaultTeamCount != 1 { - t.Fatalf("expected exactly one default team membership, got %d", defaultTeamCount) - } -} - -func TestCreateTeam_RecentUserCreatesUnblockedTeam(t *testing.T) { - t.Parallel() - - testDB := testutils.SetupDatabase(t) - ctx := t.Context() - userID := createHandlerTestUserAt(t, testDB, time.Now().Add(-time.Hour)) - - store := &APIStore{ - db: testDB.SqlcClient, - authDB: testDB.AuthDB, - teamProvisionSink: &fakeTeamProvisionSink{}, - userProfiles: newHandlerTestUserProfiles(), - idps: userprofile.Registry{"https://ory.example.test": newHandlerTestUserProfiles()}, - } - - team, err := store.createTeam(ctx, userID, "Acme") - if err != nil { - t.Fatalf("expected team creation to succeed for recent user, got %v", err) - } - if team.IsBlocked { - t.Fatal("expected recent user team to remain unblocked") - } - if team.BlockedReason != nil { - t.Fatalf("expected nil blocked reason, got %v", team.BlockedReason) - } -} - func TestPostTeams_LocalPolicyDeniedReturnsBadRequestWithoutCreatingTeam(t *testing.T) { t.Parallel() @@ -1137,7 +540,7 @@ func TestPostTeams_LocalPolicyDeniedReturnsBadRequestWithoutCreatingTeam(t *test for range 2 { team, err := testDB.AuthDB.Write.CreateTeam(ctx, authqueries.CreateTeamParams{ Name: "extra", - Tier: baseTierID, + Tier: testBaseTier, Email: handlerTestUserEmail(userID), }) if err != nil { @@ -1160,11 +563,9 @@ func TestPostTeams_LocalPolicyDeniedReturnsBadRequestWithoutCreatingTeam(t *test auth.SetUserIDForTest(t, ginCtx, userID) store := &APIStore{ - db: testDB.SqlcClient, - authDB: testDB.AuthDB, - teamProvisionSink: sink, - userProfiles: newHandlerTestUserProfiles(), - idps: userprofile.Registry{"https://ory.example.test": newHandlerTestUserProfiles()}, + db: testDB.SqlcClient, + authDB: testDB.AuthDB, + provisioning: provisioning.New(testDB.AuthDB, handlerTestIdentityProvider{}, sink, identity.Registry{"": handlerTestIdentityProvider{}}), } store.PostTeams(ginCtx) @@ -1200,11 +601,9 @@ func TestPostTeams_InvalidNameReturnsBadRequest(t *testing.T) { sink := &fakeTeamProvisionSink{} store := &APIStore{ - db: testDB.SqlcClient, - authDB: testDB.AuthDB, - teamProvisionSink: sink, - userProfiles: newHandlerTestUserProfiles(), - idps: userprofile.Registry{"https://ory.example.test": newHandlerTestUserProfiles()}, + db: testDB.SqlcClient, + authDB: testDB.AuthDB, + provisioning: provisioning.New(testDB.AuthDB, handlerTestIdentityProvider{}, sink, identity.Registry{"": handlerTestIdentityProvider{}}), } store.PostTeams(ginCtx) @@ -1232,11 +631,9 @@ func TestPostTeams_InvalidRequestBodyReturnsBadRequest(t *testing.T) { auth.SetUserIDForTest(t, ginCtx, userID) store := &APIStore{ - db: testDB.SqlcClient, - authDB: testDB.AuthDB, - teamProvisionSink: sink, - userProfiles: newHandlerTestUserProfiles(), - idps: userprofile.Registry{"https://ory.example.test": newHandlerTestUserProfiles()}, + db: testDB.SqlcClient, + authDB: testDB.AuthDB, + provisioning: provisioning.New(testDB.AuthDB, handlerTestIdentityProvider{}, sink, identity.Registry{"": handlerTestIdentityProvider{}}), } store.PostTeams(ginCtx) @@ -1266,11 +663,9 @@ func TestPostTeams_TrimsNameBeforeCreate(t *testing.T) { auth.SetUserIDForTest(t, ginCtx, userID) store := &APIStore{ - db: testDB.SqlcClient, - authDB: testDB.AuthDB, - teamProvisionSink: sink, - userProfiles: newHandlerTestUserProfiles(), - idps: userprofile.Registry{"https://ory.example.test": newHandlerTestUserProfiles()}, + db: testDB.SqlcClient, + authDB: testDB.AuthDB, + provisioning: provisioning.New(testDB.AuthDB, handlerTestIdentityProvider{}, sink, identity.Registry{"": handlerTestIdentityProvider{}}), } store.PostTeams(ginCtx) @@ -1319,11 +714,9 @@ func TestPostTeams_ProvisioningFailureRollsBackCreatedTeam(t *testing.T) { auth.SetUserIDForTest(t, ginCtx, userID) store := &APIStore{ - db: testDB.SqlcClient, - authDB: testDB.AuthDB, - teamProvisionSink: sink, - userProfiles: newHandlerTestUserProfiles(), - idps: userprofile.Registry{"https://ory.example.test": newHandlerTestUserProfiles()}, + db: testDB.SqlcClient, + authDB: testDB.AuthDB, + provisioning: provisioning.New(testDB.AuthDB, handlerTestIdentityProvider{}, sink, identity.Registry{"": handlerTestIdentityProvider{}}), } store.PostTeams(ginCtx) @@ -1379,11 +772,9 @@ func TestPostTeams_ProvisioningFailurePreservesProvisionErrorStatus(t *testing.T auth.SetUserIDForTest(t, ginCtx, userID) store := &APIStore{ - db: testDB.SqlcClient, - authDB: testDB.AuthDB, - teamProvisionSink: sink, - userProfiles: newHandlerTestUserProfiles(), - idps: userprofile.Registry{"https://ory.example.test": newHandlerTestUserProfiles()}, + db: testDB.SqlcClient, + authDB: testDB.AuthDB, + provisioning: provisioning.New(testDB.AuthDB, handlerTestIdentityProvider{}, sink, identity.Registry{"": handlerTestIdentityProvider{}}), } store.PostTeams(ginCtx) @@ -1429,74 +820,6 @@ func TestPostTeams_ProvisioningFailurePreservesProvisionErrorStatus(t *testing.T } } -func TestCreateTeam_ConcurrentRequestsRespectLocalPolicyWithZeroMemberships(t *testing.T) { - t.Parallel() - - testDB := testutils.SetupDatabase(t) - ctx := t.Context() - userID := createHandlerTestUser(t, testDB) - - existingTeam, err := testDB.AuthDB.Write.GetDefaultTeamByUserID(ctx, userID) - if err != nil { - t.Fatalf("expected default team: %v", err) - } - if err := testDB.AuthDB.Write.DeleteTeamByID(ctx, existingTeam.ID); err != nil { - t.Fatalf("failed to remove default team: %v", err) - } - - store := &APIStore{ - db: testDB.SqlcClient, - authDB: testDB.AuthDB, - teamProvisionSink: &fakeTeamProvisionSink{}, - userProfiles: newHandlerTestUserProfiles(), - idps: userprofile.Registry{"https://ory.example.test": newHandlerTestUserProfiles()}, - } - - var wg sync.WaitGroup - results := make(chan error, 4) - - for _, name := range []string{"Acme-1", "Acme-2", "Acme-3", "Acme-4"} { - wg.Add(1) - go func(teamName string) { - defer wg.Done() - _, err := store.createTeam(ctx, userID, teamName) - results <- err - }(name) - } - - wg.Wait() - close(results) - - var successCount int - var badRequestCount int - for err := range results { - if err == nil { - successCount++ - - continue - } - - var provisionErr *internalteamprovision.ProvisionError - if !errors.As(err, &provisionErr) { - t.Fatalf("expected provisioning error, got %T: %v", err, err) - } - if provisionErr.StatusCode == http.StatusBadRequest { - badRequestCount++ - - continue - } - - t.Fatalf("expected bad request or success, got %d", provisionErr.StatusCode) - } - - if successCount != maxTeamsPerUser { - t.Fatalf("expected %d successes, got %d", maxTeamsPerUser, successCount) - } - if badRequestCount != 1 { - t.Fatalf("expected one bad request, got %d", badRequestCount) - } -} - type fakeTeamProvisionSink struct { mu sync.Mutex requests []teamprovision.TeamBillingProvisionRequestedV1 diff --git a/packages/dashboard-api/internal/handlers/team_members.go b/packages/dashboard-api/internal/handlers/team_members.go index 81b5dc46a9..d0f3ff2201 100644 --- a/packages/dashboard-api/internal/handlers/team_members.go +++ b/packages/dashboard-api/internal/handlers/team_members.go @@ -40,7 +40,7 @@ func (s *APIStore) GetTeamsTeamIDMembers(c *gin.Context, teamID api.TeamID) { userIDs = append(userIDs, row.UserID) } - profiles, err := s.userProfiles.GetProfilesByUserID(ctx, userIDs) + profiles, err := s.idp.GetProfilesByUserID(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") @@ -87,34 +87,6 @@ func (s *APIStore) GetTeamsTeamIDMembers(c *gin.Context, teamID api.TeamID) { }) } -// rejectInviteOutsideSSOOrg blocks adding a user to an SSO-managed team unless -// the invitee's Ory identity belongs to that team's organization (i.e. an -// org-domain account). Non-SSO teams are unaffected. Returns true when the -// request was already answered with an error. -func (s *APIStore) rejectInviteOutsideSSOOrg(c *gin.Context, inviteeUserID uuid.UUID) bool { - teamInfo, ok := auth.GetTeamInfo(c) - if !ok || teamInfo == nil || teamInfo.Team == nil || teamInfo.Team.SsoOrganizationID == nil { - return false - } - - ctx := c.Request.Context() - inviteeOrgID, err := s.idps.GetUserOrganizationID(ctx, inviteeUserID) - if err != nil { - logger.L().Error(ctx, "failed to resolve invitee sso organization", zap.Error(err), logger.WithUserID(inviteeUserID.String())) - s.sendAPIStoreError(c, http.StatusInternalServerError, "Failed to add team member") - - return true - } - - if inviteeOrgID != *teamInfo.Team.SsoOrganizationID { - s.sendAPIStoreError(c, http.StatusForbidden, "Only accounts from your organization can be added to this team.") - - return true - } - - return false -} - func (s *APIStore) PostTeamsTeamIDMembers(c *gin.Context, teamID api.TeamID) { ctx := c.Request.Context() telemetry.ReportEvent(ctx, "add team member") @@ -134,7 +106,7 @@ func (s *APIStore) PostTeamsTeamIDMembers(c *gin.Context, teamID api.TeamID) { return } - profiles, err := s.userProfiles.FindProfilesByEmail(ctx, string(body.Email)) + profiles, err := s.idp.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") @@ -157,8 +129,13 @@ func (s *APIStore) PostTeamsTeamIDMembers(c *gin.Context, teamID api.TeamID) { user := profiles[0] - if s.rejectInviteOutsideSSOOrg(c, user.UserID) { - return + // SSO-managed teams only accept accounts from their own organization. + if teamInfo, ok := auth.GetTeamInfo(c); ok && teamInfo != nil && teamInfo.Team != nil && teamInfo.Team.SsoOrganizationID != nil { + if err := s.provisioning.ValidateInviteeOrganization(ctx, *teamInfo.Team.SsoOrganizationID, user.UserID); err != nil { + s.handleProvisioningError(ctx, c, "add team member", err) + + return + } } if err := s.authDB.Write.UpsertPublicUser(ctx, user.UserID); err != nil { diff --git a/packages/dashboard-api/internal/handlers/utils_provisioning.go b/packages/dashboard-api/internal/handlers/utils_provisioning.go new file mode 100644 index 0000000000..138e51749e --- /dev/null +++ b/packages/dashboard-api/internal/handlers/utils_provisioning.go @@ -0,0 +1,37 @@ +package handlers + +import ( + "context" + "errors" + "net/http" + + "github.com/gin-gonic/gin" + "go.opentelemetry.io/otel/attribute" + + internalteamprovision "github.com/e2b-dev/infra/packages/dashboard-api/internal/teamprovision" + "github.com/e2b-dev/infra/packages/shared/pkg/telemetry" +) + +func (s *APIStore) handleProvisioningError(ctx context.Context, c *gin.Context, operation string, err error) { + attrs := []attribute.KeyValue{ + attribute.String("team.provision.operation", operation), + } + + var provisionErr *internalteamprovision.ProvisionError + if errors.As(err, &provisionErr) { + if provisionErr.StatusCode < http.StatusBadRequest || provisionErr.StatusCode >= 600 { + telemetry.ReportErrorByCode(ctx, http.StatusInternalServerError, operation+" failed", err, attrs...) + s.sendAPIStoreError(c, http.StatusInternalServerError, "Failed to "+operation) + + return + } + + telemetry.ReportErrorByCode(ctx, provisionErr.StatusCode, operation+" failed", err, attrs...) + s.sendAPIStoreError(c, provisionErr.StatusCode, provisionErr.Error()) + + return + } + + telemetry.ReportErrorByCode(ctx, http.StatusInternalServerError, operation+" failed", err, attrs...) + s.sendAPIStoreError(c, http.StatusInternalServerError, "Failed to "+operation) +} diff --git a/packages/dashboard-api/internal/handlers/utils_team_provisioning.go b/packages/dashboard-api/internal/handlers/utils_team_provisioning.go deleted file mode 100644 index 8dc5ab5b9f..0000000000 --- a/packages/dashboard-api/internal/handlers/utils_team_provisioning.go +++ /dev/null @@ -1,658 +0,0 @@ -package handlers - -import ( - "context" - "errors" - "fmt" - "net/http" - "strings" - "time" - "unicode" - - "github.com/gin-gonic/gin" - "github.com/google/uuid" - "go.opentelemetry.io/otel/attribute" - "go.uber.org/zap" - - internalteamprovision "github.com/e2b-dev/infra/packages/dashboard-api/internal/teamprovision" - "github.com/e2b-dev/infra/packages/dashboard-api/internal/userprofile" - 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" - "github.com/e2b-dev/infra/packages/shared/pkg/telemetry" - "github.com/e2b-dev/infra/packages/shared/pkg/utils" -) - -const ( - baseTierID = "base_v1" - maxTeamsPerUser = 3 - maxTeamsPerUserWithProTier = 10 - bootstrapProvisionRetryAge = 30 * time.Second - teamProvisionRollbackTimeout = 5 * time.Second - creatorContextResolveTimeout = 2 * time.Second -) - -type provisionedTeam struct { - ID uuid.UUID - Name string - Email string - Slug string - IsBlocked bool - BlockedReason *string -} - -type bootstrapUserProfile struct { - UserID uuid.UUID - Email string - DefaultTeamName string - CreatorContext *teamprovision.CreatorContextV1 -} - -type bootstrapUserIdentity struct { - Issuer string - Subject string -} - -type oidcUserBootstrapInput struct { - OIDCIssuer string - OIDCUserID string - OIDCUserEmail string - OIDCUserName *string - SignupIP string - SignupUserAgent string -} - -// resolveProfile fetches a single user's profile through the configured profile -// provider, returning a 404 ProvisionError when the user is unknown. -func (s *APIStore) resolveProfile(ctx context.Context, userID uuid.UUID) (userprofile.Profile, error) { - profiles, err := s.userProfiles.GetProfilesByUserID(ctx, []uuid.UUID{userID}) - if err != nil { - return userprofile.Profile{}, fmt.Errorf("get user profile: %w", err) - } - - profile, ok := profiles[userID] - if !ok { - return userprofile.Profile{}, &internalteamprovision.ProvisionError{ - StatusCode: http.StatusNotFound, - Message: "User not found", - } - } - - return profile, nil -} - -func (s *APIStore) bootstrapOIDCUser(ctx context.Context, input oidcUserBootstrapInput) (provisionedTeam, error) { - idp, err := s.providerForIssuer(input.OIDCIssuer) - if err != nil { - return provisionedTeam{}, err - } - - profile := bootstrapUserProfile{ - UserID: uuid.New(), - Email: input.OIDCUserEmail, - DefaultTeamName: defaultTeamNameFromOIDCUserName(input.OIDCUserName), - CreatorContext: creatorContextFromSignupMetadata(input.SignupIP, input.SignupUserAgent, teamprovision.AuthMethodSocial), - } - - return s.bootstrapUserWithIdentity(ctx, idp, profile, &bootstrapUserIdentity{ - Issuer: input.OIDCIssuer, - Subject: input.OIDCUserID, - }) -} - -// providerForIssuer resolves the identity provider responsible for the issuer, -// rejecting bootstrap requests from issuers no provider is registered for. -func (s *APIStore) providerForIssuer(issuer string) (userprofile.Provider, error) { - idp, ok := s.idps.ForIssuer(strings.TrimSpace(issuer)) - if !ok { - return nil, &internalteamprovision.ProvisionError{ - StatusCode: http.StatusBadRequest, - Message: "oidc_issuer does not match a configured identity provider", - } - } - - return idp, nil -} - -func (s *APIStore) bootstrapUser(ctx context.Context, profile bootstrapUserProfile) (provisionedTeam, error) { - return s.bootstrapUserWithIdentity(ctx, s.userProfiles, profile, nil) -} - -func (s *APIStore) bootstrapUserWithIdentity(ctx context.Context, idp userprofile.Provider, profile bootstrapUserProfile, identity *bootstrapUserIdentity) (provisionedTeam, error) { - // Resolve the identity's SSO organization before opening the transaction: the - // Kratos lookup is a network call that must not run under the per-user lock. - var ssoOrgID uuid.UUID - if identity != nil { - orgID, err := idp.GetIdentityOrganizationID(ctx, identity.Subject) - if err != nil { - return provisionedTeam{}, fmt.Errorf("resolve sso organization: %w", err) - } - ssoOrgID = orgID - } - - authTxDB, tx, err := s.authDB.WithTx(ctx) - if err != nil { - return provisionedTeam{}, fmt.Errorf("start transaction: %w", err) - } - defer func() { - _ = tx.Rollback(ctx) - }() - - if identity != nil { - existing, err := authTxDB.GetUserIdentity(ctx, authqueries.GetUserIdentityParams{ - OidcIss: identity.Issuer, - OidcSub: identity.Subject, - }) - switch { - case err == nil: - profile.UserID = existing.UserID - case !dberrors.IsNotFoundError(err): - return provisionedTeam{}, fmt.Errorf("get user identity: %w", err) - } - } - - candidateUserID := profile.UserID - if err := authTxDB.UpsertPublicUser(ctx, candidateUserID); err != nil { - return provisionedTeam{}, fmt.Errorf("upsert public user: %w", err) - } - if identity != nil { - canonicalUserID, err := authTxDB.UpsertPublicIdentity(ctx, authqueries.UpsertPublicIdentityParams{ - OidcIss: identity.Issuer, - OidcSub: identity.Subject, - UserID: candidateUserID, - }) - if err != nil { - return provisionedTeam{}, fmt.Errorf("upsert public identity: %w", err) - } - if canonicalUserID != candidateUserID { - // concurrent bootstrap claimed the identity first; drop the orphan candidate row - if err := authTxDB.DeletePublicUser(ctx, candidateUserID); err != nil { - return provisionedTeam{}, fmt.Errorf("delete orphan public user: %w", err) - } - profile.UserID = canonicalUserID - } - } - - // Serialize bootstrap for a user even when they have no team memberships yet. - if _, err := authTxDB.LockPublicUserForUpdate(ctx, profile.UserID); err != nil { - return provisionedTeam{}, fmt.Errorf("lock public user: %w", err) - } - - existingTeam, err := authTxDB.GetDefaultTeamByUserID(ctx, profile.UserID) - if err == nil { - if err := tx.Commit(ctx); err != nil { - return provisionedTeam{}, fmt.Errorf("commit existing user bootstrap transaction: %w", err) - } - - if time.Since(existingTeam.CreatedAt) < bootstrapProvisionRetryAge { - req := teamprovision.TeamBillingProvisionRequestedV1{ - TeamID: existingTeam.ID, - TeamName: existingTeam.Name, - TeamEmail: existingTeam.Email, - CreatorUserID: profile.UserID, - CreatorContext: s.teamCreatorContextForProvisioning(ctx, profile), - Reason: teamprovision.ReasonDefaultSignupTeam, - } - _ = s.teamProvisionSink.ProvisionTeam(ctx, req) - } - - // Backfill the Ory identity's external_id only after the user/identity/team - // are durably committed, so a failed PATCH never outlives a rolled-back - // transaction. This is also the recovery path: a user whose external_id was - // never set (e.g. a prior bootstrap whose PATCH failed after commit) re-runs - // here and the PATCH is re-asserted. setOIDCIdentityExternalID is idempotent. - if err := s.setOIDCIdentityExternalID(ctx, idp, identity, profile.UserID); err != nil { - return provisionedTeam{}, err - } - - return provisionedTeam{ - ID: existingTeam.ID, - Name: existingTeam.Name, - Email: existingTeam.Email, - Slug: existingTeam.Slug, - IsBlocked: existingTeam.IsBlocked, - BlockedReason: existingTeam.BlockedReason, - }, nil - } - if !dberrors.IsNotFoundError(err) { - return provisionedTeam{}, fmt.Errorf("get default team: %w", err) - } - - // Also reached by returning SSO members, who never have a default team. - if ssoOrgID != uuid.Nil { - landing, err := s.enrollSSOMember(ctx, authTxDB, profile.UserID, ssoOrgID) - if err != nil { - return provisionedTeam{}, err - } - - if err := tx.Commit(ctx); err != nil { - return provisionedTeam{}, fmt.Errorf("commit sso bootstrap transaction: %w", err) - } - - // No billing: SSO teams are provisioned out of band. - if err := s.setOIDCIdentityExternalID(ctx, idp, identity, profile.UserID); err != nil { - return provisionedTeam{}, err - } - - return landing, nil - } - - team, err := authTxDB.CreateTeam(ctx, authqueries.CreateTeamParams{ - Name: profile.DefaultTeamName, - Tier: baseTierID, - Email: profile.Email, - IsBlocked: false, - BlockedReason: nil, - }) - if err != nil { - return provisionedTeam{}, fmt.Errorf("create default team: %w", err) - } - - if err := authTxDB.CreateTeamMembership(ctx, authqueries.CreateTeamMembershipParams{ - UserID: profile.UserID, - TeamID: team.ID, - IsDefault: true, - AddedBy: nil, - }); err != nil { - return provisionedTeam{}, fmt.Errorf("create default team membership: %w", err) - } - - if err := tx.Commit(ctx); err != nil { - return provisionedTeam{}, fmt.Errorf("commit user bootstrap transaction: %w", err) - } - - // Emit the billing event before the external_id backfill: ProvisionTeam is - // fire-and-forget, but setOIDCIdentityExternalID returns early on failure, and - // the existing-team recovery path only re-fires ProvisionTeam within - // bootstrapProvisionRetryAge of team creation. Provisioning first guarantees the - // freshly committed team is never left billing-orphaned by an Ory PATCH failure. - req := teamprovision.TeamBillingProvisionRequestedV1{ - TeamID: team.ID, - TeamName: team.Name, - TeamEmail: team.Email, - CreatorUserID: profile.UserID, - CreatorContext: s.teamCreatorContextForProvisioning(ctx, profile), - Reason: teamprovision.ReasonDefaultSignupTeam, - } - _ = s.teamProvisionSink.ProvisionTeam(ctx, req) - - // Backfill external_id only after the user/identity/team are durably committed. - // A PATCH failure here is recoverable: external_id stays unset, the dashboard - // re-runs bootstrap on the next login and re-asserts it via the existing-team - // path above. - if err := s.setOIDCIdentityExternalID(ctx, idp, identity, profile.UserID); err != nil { - return provisionedTeam{}, err - } - - return provisionedTeam{ - ID: team.ID, - Name: team.Name, - Email: team.Email, - Slug: team.Slug, - IsBlocked: team.IsBlocked, - BlockedReason: team.BlockedReason, - }, nil -} - -// setOIDCIdentityExternalID stores the canonical public.users id on the Ory -// identity. It is a no-op for non-OIDC bootstrap (identity == nil). -func (s *APIStore) setOIDCIdentityExternalID(ctx context.Context, idp userprofile.Provider, identity *bootstrapUserIdentity, userID uuid.UUID) error { - if identity == nil { - return nil - } - - if err := idp.SetIdentityExternalID(ctx, identity.Subject, userID); err != nil { - return fmt.Errorf("set ory identity external id: %w", err) - } - - return nil -} - -// enrollSSOMember adds the user to their SSO organization's auto-join teams and -// returns the team the response lands on. It fails closed when the org has no -// auto-join team, since every SSO org must have at least one — that state is a -// misconfiguration. No billing is emitted; SSO teams are provisioned out of band. -// The caller must hold the per-user lock. -func (s *APIStore) enrollSSOMember(ctx context.Context, authTxDB *authqueries.Queries, userID, orgID uuid.UUID) (provisionedTeam, error) { - autoTeams, err := authTxDB.GetAutoJoinTeamsBySSOOrganizationID(ctx, orgID) - if err != nil { - return provisionedTeam{}, fmt.Errorf("get auto-join teams for sso organization: %w", err) - } - if len(autoTeams) == 0 { - return provisionedTeam{}, &internalteamprovision.ProvisionError{ - StatusCode: http.StatusForbidden, - Message: "Your organization's SSO is not fully set up yet. Please contact support.", - } - } - - for _, row := range autoTeams { - if err := authTxDB.CreateTeamMembership(ctx, authqueries.CreateTeamMembershipParams{ - UserID: userID, - TeamID: row.Team.ID, - IsDefault: false, - AddedBy: nil, - }); err != nil { - return provisionedTeam{}, fmt.Errorf("create sso team membership: %w", err) - } - } - - // Land on the earliest auto-join team (the query orders by created_at). - landing := autoTeams[0].Team - - return provisionedTeam{ - ID: landing.ID, - Name: landing.Name, - Email: landing.Email, - Slug: landing.Slug, - IsBlocked: landing.IsBlocked, - BlockedReason: landing.BlockedReason, - }, nil -} - -// ensureNotSSOManaged blocks team creation for SSO-managed users; their team -// membership is driven entirely by their identity provider. -func (s *APIStore) ensureNotSSOManaged(ctx context.Context, userID uuid.UUID) error { - orgID, err := s.idps.GetUserOrganizationID(ctx, userID) - if err != nil { - return fmt.Errorf("resolve sso organization: %w", err) - } - if orgID != uuid.Nil { - return &internalteamprovision.ProvisionError{ - StatusCode: http.StatusForbidden, - Message: "SSO-managed accounts can't create teams. Contact your organization admin.", - } - } - - return nil -} - -func (s *APIStore) createTeam(ctx context.Context, userID uuid.UUID, name string) (provisionedTeam, error) { - if err := s.ensureNotSSOManaged(ctx, userID); err != nil { - return provisionedTeam{}, err - } - - profile, err := s.resolveProfile(ctx, userID) - if err != nil { - return provisionedTeam{}, err - } - - authTxDB, tx, err := s.authDB.WithTx(ctx) - if err != nil { - return provisionedTeam{}, fmt.Errorf("start transaction: %w", err) - } - defer func() { - _ = tx.Rollback(ctx) - }() - - if err := authTxDB.UpsertPublicUser(ctx, userID); err != nil { - return provisionedTeam{}, fmt.Errorf("upsert public user: %w", err) - } - - // Serialize team creation even when the user currently has no team memberships. - if _, err := authTxDB.LockPublicUserForUpdate(ctx, userID); err != nil { - return provisionedTeam{}, fmt.Errorf("lock public user: %w", err) - } - - if err := validateTeamCreationAllowed(ctx, authTxDB, userID); err != nil { - return provisionedTeam{}, err - } - - team, err := authTxDB.CreateTeam(ctx, authqueries.CreateTeamParams{ - Name: name, - Tier: baseTierID, - Email: profile.Email, - IsBlocked: false, - BlockedReason: nil, - }) - if err != nil { - return provisionedTeam{}, fmt.Errorf("create team: %w", err) - } - - if err := authTxDB.CreateTeamMembership(ctx, authqueries.CreateTeamMembershipParams{ - UserID: userID, - TeamID: team.ID, - IsDefault: false, - AddedBy: &userID, - }); err != nil { - return provisionedTeam{}, fmt.Errorf("create team membership: %w", err) - } - - if err := tx.Commit(ctx); err != nil { - return provisionedTeam{}, fmt.Errorf("commit team creation transaction: %w", err) - } - - req := teamprovision.TeamBillingProvisionRequestedV1{ - TeamID: team.ID, - TeamName: team.Name, - TeamEmail: team.Email, - CreatorUserID: userID, - CreatorContext: s.resolveTeamCreatorContext(ctx, userID), - Reason: teamprovision.ReasonAdditionalTeam, - } - if err := s.teamProvisionSink.ProvisionTeam(ctx, req); err != nil { - rollbackCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), teamProvisionRollbackTimeout) - defer cancel() - - if deleteErr := s.authDB.Write.DeleteTeamByID(rollbackCtx, team.ID); deleteErr != nil { - return provisionedTeam{}, fmt.Errorf("delete team after provisioning failure: provision=%s delete=%w", err.Error(), deleteErr) - } - - return provisionedTeam{}, err - } - - return provisionedTeam{ - ID: team.ID, - Name: team.Name, - Email: team.Email, - Slug: team.Slug, - IsBlocked: team.IsBlocked, - BlockedReason: team.BlockedReason, - }, nil -} - -func (s *APIStore) bootstrapTeam(ctx context.Context, name string, email string) (provisionedTeam, error) { - team, err := s.authDB.Write.CreateTeam(ctx, authqueries.CreateTeamParams{ - Name: name, - Tier: baseTierID, - Email: email, - IsBlocked: false, - BlockedReason: nil, - }) - if err != nil { - return provisionedTeam{}, fmt.Errorf("create team: %w", err) - } - - req := teamprovision.TeamBillingProvisionRequestedV1{ - TeamID: team.ID, - TeamName: team.Name, - TeamEmail: team.Email, - CreatorUserID: uuid.Nil, - Reason: teamprovision.ReasonAdditionalTeam, - } - if err := s.teamProvisionSink.ProvisionTeam(ctx, req); err != nil { - rollbackCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), teamProvisionRollbackTimeout) - defer cancel() - - if deleteErr := s.authDB.Write.DeleteTeamByID(rollbackCtx, team.ID); deleteErr != nil { - return provisionedTeam{}, fmt.Errorf("delete team after provisioning failure: provision=%s delete=%w", err.Error(), deleteErr) - } - - return provisionedTeam{}, err - } - - return provisionedTeam{ - ID: team.ID, - Name: team.Name, - Email: team.Email, - Slug: team.Slug, - IsBlocked: team.IsBlocked, - BlockedReason: team.BlockedReason, - }, nil -} - -func validateTeamCreationAllowed(ctx context.Context, authTxDB *authqueries.Queries, ownerUserID uuid.UUID) error { - teams, err := authTxDB.GetTeamsWithUsersTeamsWithTierForUpdate(ctx, ownerUserID) - if err != nil { - return fmt.Errorf("query user teams for limit check: %w", err) - } - - hasProTier := false - for _, row := range teams { - if row.Tier != baseTierID { - hasProTier = true - } - if row.IsBanned { - return &internalteamprovision.ProvisionError{ - StatusCode: http.StatusBadRequest, - Message: "You're unable to create a team right now. Please contact support if this persists.", - } - } - } - - if hasProTier { - if len(teams) >= maxTeamsPerUserWithProTier { - return &internalteamprovision.ProvisionError{ - StatusCode: http.StatusBadRequest, - Message: fmt.Sprintf("You can't create more than %d teams", maxTeamsPerUserWithProTier), - } - } - } else { - if len(teams) >= maxTeamsPerUser { - return &internalteamprovision.ProvisionError{ - StatusCode: http.StatusBadRequest, - Message: fmt.Sprintf( - "You can't create more than %d teams, you can upgrade to Pro tier to create up to %d teams", - maxTeamsPerUser, - maxTeamsPerUserWithProTier, - ), - } - } - } - - return nil -} - -func (s *APIStore) resolveTeamCreatorContext(ctx context.Context, userID uuid.UUID) *teamprovision.CreatorContextV1 { - if userID == uuid.Nil || s.userProfiles == nil { - return nil - } - - resolveCtx, cancel := context.WithTimeout(ctx, creatorContextResolveTimeout) - defer cancel() - - creatorContext, err := s.userProfiles.GetTeamCreatorContext(resolveCtx, userID) - if err != nil { - logger.L().Warn(ctx, "failed to resolve creator context for team provisioning", - zap.String("user_id", userID.String()), - zap.Error(err), - ) - - return nil - } - - return normalizeCreatorContext(creatorContext) -} - -func (s *APIStore) teamCreatorContextForProvisioning(ctx context.Context, profile bootstrapUserProfile) *teamprovision.CreatorContextV1 { - if profile.CreatorContext != nil { - return normalizeCreatorContext(profile.CreatorContext) - } - - return s.resolveTeamCreatorContext(ctx, profile.UserID) -} - -func creatorContextFromSignupMetadata(signupIP, signupUserAgent, authMethod string) *teamprovision.CreatorContextV1 { - return normalizeCreatorContext(&teamprovision.CreatorContextV1{ - IPAddress: signupIP, - UserAgent: signupUserAgent, - AuthMethod: authMethod, - }) -} - -func normalizeCreatorContext(creatorContext *teamprovision.CreatorContextV1) *teamprovision.CreatorContextV1 { - if creatorContext == nil { - return nil - } - - ipAddress := strings.TrimSpace(creatorContext.IPAddress) - if ipAddress == "" { - return nil - } - - return &teamprovision.CreatorContextV1{ - IPAddress: ipAddress, - UserAgent: strings.TrimSpace(creatorContext.UserAgent), - AuthMethod: strings.TrimSpace(creatorContext.AuthMethod), - } -} - -func defaultTeamNameFromProfile(profile userprofile.Profile) string { - baseName := utils.FirstNonEmpty( - firstWord(profile.Name), - emailPrefix(profile.Email), - ) - if baseName == "" { - return "Default Team" - } - - return capitalizeFirstLetter(baseName) + "'s Default Team" -} - -func defaultTeamNameFromOIDCUserName(name *string) string { - if name == nil || strings.TrimSpace(*name) == "" { - return "Default Team" - } - - return capitalizeFirstLetter(firstWord(*name)) + "'s Default Team" -} - -func firstWord(value string) string { - parts := strings.Fields(value) - if len(parts) == 0 { - return "" - } - - return parts[0] -} - -func emailPrefix(email string) string { - prefix, _, _ := strings.Cut(strings.TrimSpace(email), "@") - - return prefix -} - -func capitalizeFirstLetter(value string) string { - runes := []rune(strings.TrimSpace(value)) - if len(runes) == 0 { - return "" - } - - runes[0] = unicode.ToUpper(runes[0]) - - return string(runes) -} - -func (s *APIStore) handleProvisioningError(ctx context.Context, c *gin.Context, operation string, err error) { - attrs := []attribute.KeyValue{ - attribute.String("team.provision.operation", operation), - } - - var provisionErr *internalteamprovision.ProvisionError - if errors.As(err, &provisionErr) { - if provisionErr.StatusCode < http.StatusBadRequest || provisionErr.StatusCode >= 600 { - telemetry.ReportErrorByCode(ctx, http.StatusInternalServerError, operation+" failed", err, attrs...) - s.sendAPIStoreError(c, http.StatusInternalServerError, "Failed to "+operation) - - return - } - - telemetry.ReportErrorByCode(ctx, provisionErr.StatusCode, operation+" failed", err, attrs...) - s.sendAPIStoreError(c, provisionErr.StatusCode, provisionErr.Error()) - - return - } - - telemetry.ReportErrorByCode(ctx, http.StatusInternalServerError, operation+" failed", err, attrs...) - s.sendAPIStoreError(c, http.StatusInternalServerError, "Failed to "+operation) -} diff --git a/packages/dashboard-api/internal/userprofile/creator_context.go b/packages/dashboard-api/internal/identity/creator_context.go similarity index 97% rename from packages/dashboard-api/internal/userprofile/creator_context.go rename to packages/dashboard-api/internal/identity/creator_context.go index d191983105..064a4fd9bb 100644 --- a/packages/dashboard-api/internal/userprofile/creator_context.go +++ b/packages/dashboard-api/internal/identity/creator_context.go @@ -1,4 +1,4 @@ -package userprofile +package identity import ( "strings" diff --git a/packages/dashboard-api/internal/userprofile/ory.go b/packages/dashboard-api/internal/identity/ory.go similarity index 99% rename from packages/dashboard-api/internal/userprofile/ory.go rename to packages/dashboard-api/internal/identity/ory.go index 4b669b7ee1..78c72e76c0 100644 --- a/packages/dashboard-api/internal/userprofile/ory.go +++ b/packages/dashboard-api/internal/identity/ory.go @@ -1,4 +1,4 @@ -package userprofile +package identity import ( "context" diff --git a/packages/dashboard-api/internal/userprofile/ory_test.go b/packages/dashboard-api/internal/identity/ory_test.go similarity index 99% rename from packages/dashboard-api/internal/userprofile/ory_test.go rename to packages/dashboard-api/internal/identity/ory_test.go index 8b40cda02b..2fecf7b955 100644 --- a/packages/dashboard-api/internal/userprofile/ory_test.go +++ b/packages/dashboard-api/internal/identity/ory_test.go @@ -1,4 +1,4 @@ -package userprofile +package identity import ( "context" diff --git a/packages/dashboard-api/internal/userprofile/provider.go b/packages/dashboard-api/internal/identity/provider.go similarity index 94% rename from packages/dashboard-api/internal/userprofile/provider.go rename to packages/dashboard-api/internal/identity/provider.go index 6c47e322d4..3004cbc341 100644 --- a/packages/dashboard-api/internal/userprofile/provider.go +++ b/packages/dashboard-api/internal/identity/provider.go @@ -1,4 +1,6 @@ -package userprofile +// Package identity abstracts the external identity provider (Ory): profile +// lookup, organization membership, and identity lifecycle. +package identity import ( "context" diff --git a/packages/dashboard-api/internal/userprofile/providers.go b/packages/dashboard-api/internal/identity/providers.go similarity index 98% rename from packages/dashboard-api/internal/userprofile/providers.go rename to packages/dashboard-api/internal/identity/providers.go index d3e79dc593..54b00b844e 100644 --- a/packages/dashboard-api/internal/userprofile/providers.go +++ b/packages/dashboard-api/internal/identity/providers.go @@ -1,4 +1,4 @@ -package userprofile +package identity import "strings" diff --git a/packages/dashboard-api/internal/userprofile/registry.go b/packages/dashboard-api/internal/identity/registry.go similarity index 97% rename from packages/dashboard-api/internal/userprofile/registry.go rename to packages/dashboard-api/internal/identity/registry.go index 673e741354..d71b74091f 100644 --- a/packages/dashboard-api/internal/userprofile/registry.go +++ b/packages/dashboard-api/internal/identity/registry.go @@ -1,4 +1,4 @@ -package userprofile +package identity import ( "context" diff --git a/packages/dashboard-api/internal/provisioning/bootstrap.go b/packages/dashboard-api/internal/provisioning/bootstrap.go new file mode 100644 index 0000000000..fd5547905c --- /dev/null +++ b/packages/dashboard-api/internal/provisioning/bootstrap.go @@ -0,0 +1,222 @@ +package provisioning + +import ( + "context" + "fmt" + "net/http" + "strings" + "time" + + "github.com/google/uuid" + + "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/teamprovision" +) + +func (s *Service) BootstrapOIDCUser(ctx context.Context, input OIDCUserBootstrapInput) (ProvisionedTeam, error) { + idp, err := s.providerForIssuer(input.OIDCIssuer) + if err != nil { + return ProvisionedTeam{}, err + } + + profile := bootstrapUserProfile{ + UserID: uuid.New(), + Email: input.OIDCUserEmail, + DefaultTeamName: defaultTeamNameFromOIDCUserName(input.OIDCUserName), + CreatorContext: normalizeCreatorContext(&teamprovision.CreatorContextV1{ + IPAddress: input.SignupIP, + UserAgent: input.SignupUserAgent, + AuthMethod: teamprovision.AuthMethodSocial, + }), + } + + return s.bootstrapUser(ctx, idp, profile, bootstrapUserIdentity{ + Issuer: input.OIDCIssuer, + Subject: input.OIDCUserID, + }) +} + +// providerForIssuer resolves the identity provider responsible for the issuer, +// rejecting bootstrap requests from issuers no provider is registered for. +func (s *Service) providerForIssuer(issuer string) (identity.Provider, error) { + idp, ok := s.idps.ForIssuer(strings.TrimSpace(issuer)) + if !ok { + return nil, &internalteamprovision.ProvisionError{ + StatusCode: http.StatusBadRequest, + Message: "oidc_issuer does not match a configured identity provider", + } + } + + return idp, nil +} + +func (s *Service) bootstrapUser(ctx context.Context, idp identity.Provider, profile bootstrapUserProfile, oidcIdentity bootstrapUserIdentity) (ProvisionedTeam, error) { + // Resolve the identity's SSO organization before opening the transaction: the + // Kratos lookup is a network call that must not run under the per-user lock. + ssoOrgID, err := idp.GetIdentityOrganizationID(ctx, oidcIdentity.Subject) + if err != nil { + return ProvisionedTeam{}, fmt.Errorf("resolve sso organization: %w", err) + } + + authTxDB, tx, err := s.authDB.WithTx(ctx) + if err != nil { + return ProvisionedTeam{}, fmt.Errorf("start transaction: %w", err) + } + defer func() { + _ = tx.Rollback(ctx) + }() + + existing, err := authTxDB.GetUserIdentity(ctx, authqueries.GetUserIdentityParams{ + OidcIss: oidcIdentity.Issuer, + OidcSub: oidcIdentity.Subject, + }) + switch { + case err == nil: + profile.UserID = existing.UserID + case !dberrors.IsNotFoundError(err): + return ProvisionedTeam{}, fmt.Errorf("get user identity: %w", err) + } + + candidateUserID := profile.UserID + if err := authTxDB.UpsertPublicUser(ctx, candidateUserID); err != nil { + return ProvisionedTeam{}, fmt.Errorf("upsert public user: %w", err) + } + canonicalUserID, err := authTxDB.UpsertPublicIdentity(ctx, authqueries.UpsertPublicIdentityParams{ + OidcIss: oidcIdentity.Issuer, + OidcSub: oidcIdentity.Subject, + UserID: candidateUserID, + }) + if err != nil { + return ProvisionedTeam{}, fmt.Errorf("upsert public identity: %w", err) + } + if canonicalUserID != candidateUserID { + // concurrent bootstrap claimed the identity first; drop the orphan candidate row + if err := authTxDB.DeletePublicUser(ctx, candidateUserID); err != nil { + return ProvisionedTeam{}, fmt.Errorf("delete orphan public user: %w", err) + } + profile.UserID = canonicalUserID + } + + // Serialize bootstrap for a user even when they have no team memberships yet. + if _, err := authTxDB.LockPublicUserForUpdate(ctx, profile.UserID); err != nil { + return ProvisionedTeam{}, fmt.Errorf("lock public user: %w", err) + } + + existingTeam, err := authTxDB.GetDefaultTeamByUserID(ctx, profile.UserID) + if err == nil { + if err := tx.Commit(ctx); err != nil { + return ProvisionedTeam{}, fmt.Errorf("commit existing user bootstrap transaction: %w", err) + } + + if time.Since(existingTeam.CreatedAt) < bootstrapProvisionRetryAge { + req := teamprovision.TeamBillingProvisionRequestedV1{ + TeamID: existingTeam.ID, + TeamName: existingTeam.Name, + TeamEmail: existingTeam.Email, + CreatorUserID: profile.UserID, + CreatorContext: s.teamCreatorContextForProvisioning(ctx, profile), + Reason: teamprovision.ReasonDefaultSignupTeam, + } + _ = s.billing.ProvisionTeam(ctx, req) + } + + // Backfill the Ory identity's external_id only after the user/identity/team + // are durably committed, so a failed PATCH never outlives a rolled-back + // transaction. This is also the recovery path: a user whose external_id was + // never set (e.g. a prior bootstrap whose PATCH failed after commit) re-runs + // here and the idempotent PATCH is re-asserted. + if err := idp.SetIdentityExternalID(ctx, oidcIdentity.Subject, profile.UserID); err != nil { + return ProvisionedTeam{}, fmt.Errorf("set ory identity external id: %w", err) + } + + return ProvisionedTeam{ + ID: existingTeam.ID, + Name: existingTeam.Name, + Email: existingTeam.Email, + Slug: existingTeam.Slug, + IsBlocked: existingTeam.IsBlocked, + BlockedReason: existingTeam.BlockedReason, + }, nil + } + if !dberrors.IsNotFoundError(err) { + return ProvisionedTeam{}, fmt.Errorf("get default team: %w", err) + } + + // Also reached by returning SSO members, who never have a default team. + if ssoOrgID != uuid.Nil { + landing, err := s.enrollSSOMember(ctx, authTxDB, profile.UserID, ssoOrgID) + if err != nil { + return ProvisionedTeam{}, err + } + + if err := tx.Commit(ctx); err != nil { + return ProvisionedTeam{}, fmt.Errorf("commit sso bootstrap transaction: %w", err) + } + + // No billing: SSO teams are provisioned out of band. + if err := idp.SetIdentityExternalID(ctx, oidcIdentity.Subject, profile.UserID); err != nil { + return ProvisionedTeam{}, fmt.Errorf("set ory identity external id: %w", err) + } + + return landing, nil + } + + team, err := authTxDB.CreateTeam(ctx, authqueries.CreateTeamParams{ + Name: profile.DefaultTeamName, + Tier: baseTierID, + Email: profile.Email, + IsBlocked: false, + BlockedReason: nil, + }) + if err != nil { + return ProvisionedTeam{}, fmt.Errorf("create default team: %w", err) + } + + if err := authTxDB.CreateTeamMembership(ctx, authqueries.CreateTeamMembershipParams{ + UserID: profile.UserID, + TeamID: team.ID, + IsDefault: true, + AddedBy: nil, + }); err != nil { + return ProvisionedTeam{}, fmt.Errorf("create default team membership: %w", err) + } + + if err := tx.Commit(ctx); err != nil { + return ProvisionedTeam{}, fmt.Errorf("commit user bootstrap transaction: %w", err) + } + + // Emit the billing event before the external_id backfill: ProvisionTeam is + // fire-and-forget, but the backfill returns early on failure, and the + // existing-team recovery path only re-fires ProvisionTeam within + // bootstrapProvisionRetryAge of team creation. Provisioning first guarantees the + // freshly committed team is never left billing-orphaned by an Ory PATCH failure. + req := teamprovision.TeamBillingProvisionRequestedV1{ + TeamID: team.ID, + TeamName: team.Name, + TeamEmail: team.Email, + CreatorUserID: profile.UserID, + CreatorContext: s.teamCreatorContextForProvisioning(ctx, profile), + Reason: teamprovision.ReasonDefaultSignupTeam, + } + _ = s.billing.ProvisionTeam(ctx, req) + + // Backfill external_id only after the user/identity/team are durably committed. + // A PATCH failure here is recoverable: external_id stays unset, the dashboard + // re-runs bootstrap on the next login and re-asserts it via the existing-team + // path above. + if err := idp.SetIdentityExternalID(ctx, oidcIdentity.Subject, profile.UserID); err != nil { + return ProvisionedTeam{}, fmt.Errorf("set ory identity external id: %w", err) + } + + return ProvisionedTeam{ + ID: team.ID, + Name: team.Name, + Email: team.Email, + Slug: team.Slug, + IsBlocked: team.IsBlocked, + BlockedReason: team.BlockedReason, + }, nil +} diff --git a/packages/dashboard-api/internal/provisioning/bootstrap_test.go b/packages/dashboard-api/internal/provisioning/bootstrap_test.go new file mode 100644 index 0000000000..a2c2ed62ce --- /dev/null +++ b/packages/dashboard-api/internal/provisioning/bootstrap_test.go @@ -0,0 +1,386 @@ +package provisioning + +import ( + "context" + "errors" + "net/http" + "sync" + "testing" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + + "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/testutils" + "github.com/e2b-dev/infra/packages/shared/pkg/teamprovision" +) + +func TestBootstrapAuthProviderUser_CreatesIdentityAndDefaultTeam(t *testing.T) { + t.Parallel() + + testDB := testutils.SetupDatabase(t) + ctx := t.Context() + sink := &fakeTeamProvisionSink{} + + svc := New(testDB.AuthDB, testIdentityProvider{}, sink, identity.Registry{testIssuer: testIdentityProvider{}}) + + input := OIDCUserBootstrapInput{ + OIDCIssuer: testIssuer, + OIDCUserID: uuid.NewString(), + OIDCUserEmail: "ada@example.test", + OIDCUserName: nil, + SignupIP: "198.51.100.20", + SignupUserAgent: "Mozilla/5.0", + } + + team, err := svc.BootstrapOIDCUser(ctx, input) + if err != nil { + t.Fatalf("expected bootstrap to succeed: %v", err) + } + + userIdentity, err := testDB.AuthDB.Read.GetUserIdentity(ctx, authqueries.GetUserIdentityParams{ + OidcIss: input.OIDCIssuer, + OidcSub: input.OIDCUserID, + }) + if err != nil { + t.Fatalf("expected user identity to be created: %v", err) + } + + defaultTeam, err := testDB.AuthDB.Read.GetDefaultTeamByUserID(ctx, userIdentity.UserID) + if err != nil { + t.Fatalf("expected default team to be created: %v", err) + } + if defaultTeam.ID != team.ID { + t.Fatalf("expected response team %s, got %s", defaultTeam.ID, team.ID) + } + if defaultTeam.Name != "Default Team" { + t.Fatalf("expected team name %q, got %q", "Default Team", defaultTeam.Name) + } + if defaultTeam.Email != "ada@example.test" { + t.Fatalf("expected team email %q, got %q", "ada@example.test", defaultTeam.Email) + } + + if len(sink.requests) != 1 { + t.Fatalf("expected one billing provisioning call, got %d", len(sink.requests)) + } + if sink.requests[0].CreatorUserID != userIdentity.UserID { + t.Fatalf("expected sink creator %s, got %s", userIdentity.UserID, sink.requests[0].CreatorUserID) + } + if sink.requests[0].CreatorContext == nil { + t.Fatal("expected sink creator context") + } + if sink.requests[0].CreatorContext.IPAddress != "198.51.100.20" { + t.Fatalf("expected sink creator ip %q, got %q", "198.51.100.20", sink.requests[0].CreatorContext.IPAddress) + } + if sink.requests[0].CreatorContext.UserAgent != "Mozilla/5.0" { + t.Fatalf("expected sink creator user agent %q, got %q", "Mozilla/5.0", sink.requests[0].CreatorContext.UserAgent) + } + if sink.requests[0].CreatorContext.AuthMethod != teamprovision.AuthMethodSocial { + t.Fatalf("expected sink creator auth method %q, got %q", teamprovision.AuthMethodSocial, sink.requests[0].CreatorContext.AuthMethod) + } +} + +type recordingIdentityProvider struct { + testIdentityProvider + + externalIDSubject string + externalID uuid.UUID + externalIDCalls int +} + +func (r *recordingIdentityProvider) SetIdentityExternalID(_ context.Context, subject string, externalID uuid.UUID) error { + r.externalIDSubject = subject + r.externalID = externalID + r.externalIDCalls++ + + return nil +} + +func TestBootstrapOIDCUser_PopulatesOryExternalID(t *testing.T) { + t.Parallel() + + testDB := testutils.SetupDatabase(t) + ctx := t.Context() + sink := &fakeTeamProvisionSink{} + profiles := &recordingIdentityProvider{} + + svc := New(testDB.AuthDB, profiles, sink, identity.Registry{testIssuer: profiles}) + + input := OIDCUserBootstrapInput{ + OIDCIssuer: testIssuer, + OIDCUserID: uuid.NewString(), + OIDCUserEmail: "ada@example.test", + } + + if _, err := svc.BootstrapOIDCUser(ctx, input); err != nil { + t.Fatalf("expected bootstrap to succeed: %v", err) + } + + userIdentity, err := testDB.AuthDB.Read.GetUserIdentity(ctx, authqueries.GetUserIdentityParams{ + OidcIss: input.OIDCIssuer, + OidcSub: input.OIDCUserID, + }) + if err != nil { + t.Fatalf("expected user identity to be created: %v", err) + } + + if profiles.externalIDCalls != 1 { + t.Fatalf("expected one external id update, got %d", profiles.externalIDCalls) + } + if profiles.externalIDSubject != input.OIDCUserID { + t.Fatalf("expected external id set on subject %q, got %q", input.OIDCUserID, profiles.externalIDSubject) + } + if profiles.externalID != userIdentity.UserID { + t.Fatalf("expected external id %s, got %s", userIdentity.UserID, profiles.externalID) + } +} + +// failingIdentityProvider fails the Ory external_id PATCH to simulate the IdP being +// unavailable after the bootstrap transaction has already committed. +type failingIdentityProvider struct { + testIdentityProvider + + externalIDCalls int +} + +func (f *failingIdentityProvider) SetIdentityExternalID(context.Context, string, uuid.UUID) error { + f.externalIDCalls++ + + return errors.New("ory unavailable") +} + +// A failed external_id PATCH must not roll back the committed user/identity/team. +// The PATCH now runs after the transaction commits, so the user stays provisioned +// and the next login can backfill external_id rather than being stranded. +func TestBootstrapOIDCUser_ExternalIDFailureKeepsUserProvisioned(t *testing.T) { + t.Parallel() + + testDB := testutils.SetupDatabase(t) + ctx := t.Context() + sink := &fakeTeamProvisionSink{} + profiles := &failingIdentityProvider{} + + svc := New(testDB.AuthDB, profiles, sink, identity.Registry{testIssuer: profiles}) + + input := OIDCUserBootstrapInput{ + OIDCIssuer: testIssuer, + OIDCUserID: uuid.NewString(), + OIDCUserEmail: "ada@example.test", + } + + if _, err := svc.BootstrapOIDCUser(ctx, input); err == nil { + t.Fatal("expected bootstrap to fail when external_id patch fails") + } + if profiles.externalIDCalls != 1 { + t.Fatalf("expected one external id attempt, got %d", profiles.externalIDCalls) + } + + 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) + } + if _, err := testDB.AuthDB.Read.GetDefaultTeamByUserID(ctx, userIdentity.UserID); err != nil { + t.Fatalf("expected default team to survive external_id failure: %v", err) + } + + // The billing event must be emitted before the external_id backfill, otherwise + // a PATCH failure leaves the committed team billing-orphaned. + if len(sink.requests) != 1 { + t.Fatalf("expected one billing provisioning call despite external_id failure, got %d", len(sink.requests)) + } +} + +// A user provisioned by a prior bootstrap whose external_id PATCH failed re-runs +// here on the next login; the existing-team path re-asserts the PATCH and +// backfills external_id without creating a duplicate team. +func TestBootstrapOIDCUser_ReRunBackfillsExternalID(t *testing.T) { + t.Parallel() + + testDB := testutils.SetupDatabase(t) + ctx := t.Context() + sink := &fakeTeamProvisionSink{} + + svc := New(testDB.AuthDB, &failingIdentityProvider{}, sink, identity.Registry{testIssuer: &failingIdentityProvider{}}) + + input := OIDCUserBootstrapInput{ + OIDCIssuer: testIssuer, + OIDCUserID: uuid.NewString(), + OIDCUserEmail: "ada@example.test", + } + + if _, err := svc.BootstrapOIDCUser(ctx, input); err == nil { + t.Fatal("expected first bootstrap to fail on external_id patch") + } + + userIdentity, err := testDB.AuthDB.Read.GetUserIdentity(ctx, authqueries.GetUserIdentityParams{ + OidcIss: input.OIDCIssuer, + OidcSub: input.OIDCUserID, + }) + if err != nil { + t.Fatalf("expected user identity from first bootstrap: %v", err) + } + existingTeam, err := testDB.AuthDB.Read.GetDefaultTeamByUserID(ctx, userIdentity.UserID) + if err != nil { + t.Fatalf("expected default team from first bootstrap: %v", err) + } + + recording := &recordingIdentityProvider{} + svc = New(testDB.AuthDB, recording, sink, identity.Registry{testIssuer: recording}) + + secondTeam, err := svc.BootstrapOIDCUser(ctx, input) + if err != nil { + t.Fatalf("expected re-run bootstrap to succeed: %v", err) + } + if secondTeam.ID != existingTeam.ID { + t.Fatalf("expected re-run to reuse existing team %s, got %s", existingTeam.ID, secondTeam.ID) + } + if recording.externalIDCalls != 1 { + t.Fatalf("expected re-run to backfill external_id once, got %d", recording.externalIDCalls) + } + if recording.externalID != userIdentity.UserID { + t.Fatalf("expected external id %s, got %s", userIdentity.UserID, recording.externalID) + } +} + +func TestBootstrapOIDCUser_ConcurrentRequestsSingleIdentityAndTeam(t *testing.T) { + t.Parallel() + + testDB := testutils.SetupDatabase(t) + ctx := t.Context() + sink := &fakeTeamProvisionSink{} + + svc := New(testDB.AuthDB, testIdentityProvider{}, sink, identity.Registry{testIssuer: testIdentityProvider{}}) + + input := OIDCUserBootstrapInput{ + OIDCIssuer: testIssuer, + OIDCUserID: uuid.NewString(), + OIDCUserEmail: "ada@example.test", + OIDCUserName: nil, + } + + const concurrency = 4 + var wg sync.WaitGroup + results := make(chan ProvisionedTeam, concurrency) + errs := make(chan error, concurrency) + + for range concurrency { + wg.Go(func() { + team, err := svc.BootstrapOIDCUser(ctx, input) + if err != nil { + errs <- err + + return + } + + results <- team + }) + } + + wg.Wait() + close(results) + close(errs) + + for err := range errs { + if err != nil { + t.Fatalf("expected bootstrap to succeed, got %v", err) + } + } + + var teamIDs []uuid.UUID + for team := range results { + teamIDs = append(teamIDs, team.ID) + } + if len(teamIDs) != concurrency { + t.Fatalf("expected %d bootstrap results, got %d", concurrency, len(teamIDs)) + } + for _, id := range teamIDs[1:] { + if id != teamIDs[0] { + t.Fatalf("expected all bootstrap calls to share team %s, got %s", teamIDs[0], id) + } + } + + userIdentity, err := testDB.AuthDB.Read.GetUserIdentity(ctx, authqueries.GetUserIdentityParams{ + OidcIss: input.OIDCIssuer, + OidcSub: input.OIDCUserID, + }) + if err != nil { + t.Fatalf("expected single user identity to exist: %v", err) + } + + var defaultTeamCount int + err = testDB.AuthDB.TestsRawSQLQuery(ctx, + `SELECT count(*) + FROM public.users_teams + WHERE user_id = $1 AND is_default = true`, + func(rows pgx.Rows) error { + if !rows.Next() { + return errors.New("missing default team count row") + } + + return rows.Scan(&defaultTeamCount) + }, + userIdentity.UserID, + ) + if err != nil { + t.Fatalf("failed to count default team memberships: %v", err) + } + if defaultTeamCount != 1 { + t.Fatalf("expected exactly one default team for canonical user, got %d", defaultTeamCount) + } +} + +func TestBootstrapOIDCUser_AcceptsConfiguredIssuer(t *testing.T) { + t.Parallel() + + testDB := testutils.SetupDatabase(t) + ctx := t.Context() + sink := &fakeTeamProvisionSink{} + + svc := New(testDB.AuthDB, testIdentityProvider{}, sink, identity.Registry{testIssuer: testIdentityProvider{}}) + + team, err := svc.BootstrapOIDCUser(ctx, OIDCUserBootstrapInput{ + OIDCIssuer: testIssuer, + OIDCUserID: uuid.NewString(), + OIDCUserEmail: "ada@example.test", + OIDCUserName: nil, + }) + if err != nil { + t.Fatalf("expected bootstrap to succeed with the configured Ory issuer: %v", err) + } + if team.ID == uuid.Nil { + t.Fatal("expected provisioned team") + } +} + +func TestBootstrapOIDCUser_UnknownIssuerReturnsBadRequest(t *testing.T) { + t.Parallel() + + testDB := testutils.SetupDatabase(t) + ctx := t.Context() + sink := &fakeTeamProvisionSink{} + + svc := New(testDB.AuthDB, testIdentityProvider{}, sink, identity.Registry{testIssuer: testIdentityProvider{}}) + + _, err := svc.BootstrapOIDCUser(ctx, OIDCUserBootstrapInput{ + OIDCIssuer: "https://attacker.example.test", + OIDCUserID: uuid.NewString(), + OIDCUserEmail: "ada@example.test", + OIDCUserName: nil, + }) + if err == nil { + t.Fatal("expected unknown issuer to be rejected") + } + + var provErr *internalteamprovision.ProvisionError + if !errors.As(err, &provErr) || provErr.StatusCode != http.StatusBadRequest { + t.Fatalf("expected ProvisionError with status 400, got %v", err) + } + if len(sink.requests) != 0 { + t.Fatalf("expected no provisioning calls, got %d", len(sink.requests)) + } +} diff --git a/packages/dashboard-api/internal/provisioning/creator_context.go b/packages/dashboard-api/internal/provisioning/creator_context.go new file mode 100644 index 0000000000..79dcc933ae --- /dev/null +++ b/packages/dashboard-api/internal/provisioning/creator_context.go @@ -0,0 +1,58 @@ +package provisioning + +import ( + "context" + "strings" + + "github.com/google/uuid" + "go.uber.org/zap" + + "github.com/e2b-dev/infra/packages/shared/pkg/logger" + "github.com/e2b-dev/infra/packages/shared/pkg/teamprovision" +) + +func (s *Service) resolveTeamCreatorContext(ctx context.Context, userID uuid.UUID) *teamprovision.CreatorContextV1 { + if userID == uuid.Nil || s.idp == nil { + return nil + } + + resolveCtx, cancel := context.WithTimeout(ctx, creatorContextResolveTimeout) + defer cancel() + + creatorContext, err := s.idp.GetTeamCreatorContext(resolveCtx, userID) + if err != nil { + logger.L().Warn(ctx, "failed to resolve creator context for team provisioning", + zap.String("user_id", userID.String()), + zap.Error(err), + ) + + return nil + } + + return normalizeCreatorContext(creatorContext) +} + +func (s *Service) teamCreatorContextForProvisioning(ctx context.Context, profile bootstrapUserProfile) *teamprovision.CreatorContextV1 { + if profile.CreatorContext != nil { + return normalizeCreatorContext(profile.CreatorContext) + } + + return s.resolveTeamCreatorContext(ctx, profile.UserID) +} + +func normalizeCreatorContext(creatorContext *teamprovision.CreatorContextV1) *teamprovision.CreatorContextV1 { + if creatorContext == nil { + return nil + } + + ipAddress := strings.TrimSpace(creatorContext.IPAddress) + if ipAddress == "" { + return nil + } + + return &teamprovision.CreatorContextV1{ + IPAddress: ipAddress, + UserAgent: strings.TrimSpace(creatorContext.UserAgent), + AuthMethod: strings.TrimSpace(creatorContext.AuthMethod), + } +} diff --git a/packages/dashboard-api/internal/provisioning/names.go b/packages/dashboard-api/internal/provisioning/names.go new file mode 100644 index 0000000000..3fdb1f4453 --- /dev/null +++ b/packages/dashboard-api/internal/provisioning/names.go @@ -0,0 +1,34 @@ +package provisioning + +import ( + "strings" + "unicode" +) + +func defaultTeamNameFromOIDCUserName(name *string) string { + if name == nil || strings.TrimSpace(*name) == "" { + return "Default Team" + } + + return capitalizeFirstLetter(firstWord(*name)) + "'s Default Team" +} + +func firstWord(value string) string { + parts := strings.Fields(value) + if len(parts) == 0 { + return "" + } + + return parts[0] +} + +func capitalizeFirstLetter(value string) string { + runes := []rune(strings.TrimSpace(value)) + if len(runes) == 0 { + return "" + } + + runes[0] = unicode.ToUpper(runes[0]) + + return string(runes) +} diff --git a/packages/dashboard-api/internal/provisioning/service.go b/packages/dashboard-api/internal/provisioning/service.go new file mode 100644 index 0000000000..75848db7e3 --- /dev/null +++ b/packages/dashboard-api/internal/provisioning/service.go @@ -0,0 +1,95 @@ +// Package provisioning owns the user/team bootstrap and creation sagas: turning +// an authenticated identity into a user with team memberships, self-serve team +// creation, and the SSO organization rules that constrain both. It orchestrates +// the auth DB, the identity provider (identity), and the billing sink +// (teamprovision — the outbound port this package drives, not to be confused +// with this package); it never touches HTTP. +package provisioning + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/google/uuid" + + "github.com/e2b-dev/infra/packages/dashboard-api/internal/identity" + internalteamprovision "github.com/e2b-dev/infra/packages/dashboard-api/internal/teamprovision" + authdb "github.com/e2b-dev/infra/packages/db/pkg/auth" + "github.com/e2b-dev/infra/packages/shared/pkg/teamprovision" +) + +const ( + baseTierID = "base_v1" + maxTeamsPerUser = 3 + maxTeamsPerUserWithProTier = 10 + bootstrapProvisionRetryAge = 30 * time.Second + teamProvisionRollbackTimeout = 5 * time.Second + creatorContextResolveTimeout = 2 * time.Second +) + +type Service struct { + authDB *authdb.Client + idp identity.Provider + billing internalteamprovision.TeamProvisionSink + idps identity.Registry +} + +func New(authDB *authdb.Client, idp identity.Provider, billing internalteamprovision.TeamProvisionSink, idps identity.Registry) *Service { + return &Service{ + authDB: authDB, + idp: idp, + billing: billing, + idps: idps, + } +} + +type ProvisionedTeam struct { + ID uuid.UUID + Name string + Email string + Slug string + IsBlocked bool + BlockedReason *string +} + +type bootstrapUserProfile struct { + UserID uuid.UUID + Email string + DefaultTeamName string + CreatorContext *teamprovision.CreatorContextV1 +} + +type bootstrapUserIdentity struct { + Issuer string + Subject string +} + +type OIDCUserBootstrapInput struct { + OIDCIssuer string + OIDCUserID string + OIDCUserEmail string + OIDCUserName *string + SignupIP string + SignupUserAgent string +} + +// resolveProfile fetches a single user's profile through the configured profile +// provider, returning a 404 ProvisionError when the user is unknown. +func (s *Service) resolveProfile(ctx context.Context, userID uuid.UUID) (identity.Profile, error) { + profiles, err := s.idp.GetProfilesByUserID(ctx, []uuid.UUID{userID}) + if err != nil { + return identity.Profile{}, fmt.Errorf("get user profile: %w", err) + } + + profile, ok := profiles[userID] + if !ok { + return identity.Profile{}, &internalteamprovision.ProvisionError{ + StatusCode: http.StatusNotFound, + Message: "User not found", + } + } + + return profile, nil +} diff --git a/packages/dashboard-api/internal/provisioning/service_test.go b/packages/dashboard-api/internal/provisioning/service_test.go new file mode 100644 index 0000000000..39d68dda9d --- /dev/null +++ b/packages/dashboard-api/internal/provisioning/service_test.go @@ -0,0 +1,133 @@ +package provisioning + +import ( + "context" + "strings" + "sync" + "testing" + "time" + + "github.com/google/uuid" + + "github.com/e2b-dev/infra/packages/dashboard-api/internal/identity" + 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" +) + +const testIssuer = "https://ory.example.test" + +func createTestUser(t *testing.T, db *testutils.Database) uuid.UUID { + t.Helper() + + createdAt := time.Now() + + return createTestUserWithCreatedAt(t, db, &createdAt) +} + +func createTestUserAt(t *testing.T, db *testutils.Database, createdAt time.Time) uuid.UUID { + t.Helper() + + return createTestUserWithCreatedAt(t, db, &createdAt) +} + +func createTestUserWithCreatedAt(t *testing.T, db *testutils.Database, createdAt *time.Time) uuid.UUID { + t.Helper() + + userID := uuid.New() + email := testUserEmail(userID) + _ = createdAt + + if err := db.AuthDB.Write.UpsertPublicUser(t.Context(), userID); err != nil { + t.Fatalf("failed to create public user: %v", err) + } + + team, err := db.AuthDB.Write.CreateTeam(t.Context(), authqueries.CreateTeamParams{ + Name: email, + Tier: baseTierID, + Email: email, + }) + if err != nil { + t.Fatalf("failed to create default team: %v", err) + } + + if err := db.AuthDB.Write.CreateTeamMembership(t.Context(), authqueries.CreateTeamMembershipParams{ + UserID: userID, + TeamID: team.ID, + IsDefault: true, + AddedBy: nil, + }); err != nil { + t.Fatalf("failed to create default team membership: %v", err) + } + + return userID +} + +func testUserEmail(userID uuid.UUID) string { + return "user-" + userID.String() + "@example.com" +} + +type testIdentityProvider struct{} + +func (testIdentityProvider) GetProfilesByUserID(_ context.Context, userIDs []uuid.UUID) (map[uuid.UUID]identity.Profile, error) { + profiles := make(map[uuid.UUID]identity.Profile, len(userIDs)) + for _, userID := range userIDs { + if userID == uuid.Nil { + continue + } + profiles[userID] = identity.Profile{ + UserID: userID, + Email: testUserEmail(userID), + } + } + + return profiles, nil +} + +func (testIdentityProvider) FindProfilesByEmail(_ context.Context, email string) ([]identity.Profile, error) { + userIDText := strings.TrimSuffix(strings.TrimPrefix(email, "user-"), "@example.com") + userID, _ := uuid.Parse(userIDText) + if userID == uuid.Nil { + return []identity.Profile{}, nil + } + + return []identity.Profile{{ + UserID: userID, + Email: email, + }}, nil +} + +func (testIdentityProvider) GetTeamCreatorContext(context.Context, uuid.UUID) (*teamprovision.CreatorContextV1, error) { + return nil, nil +} + +func (testIdentityProvider) GetIdentityOrganizationID(context.Context, string) (uuid.UUID, error) { + return uuid.Nil, nil +} + +func (testIdentityProvider) GetUserOrganizationID(context.Context, uuid.UUID) (uuid.UUID, error) { + return uuid.Nil, nil +} + +func (testIdentityProvider) SetIdentityExternalID(context.Context, string, uuid.UUID) error { + return nil +} + +func (testIdentityProvider) PrepareDeleteUser(context.Context, uuid.UUID) (identity.DeleteUserHandle, error) { + return nil, nil +} + +type fakeTeamProvisionSink struct { + mu sync.Mutex + requests []teamprovision.TeamBillingProvisionRequestedV1 + err error +} + +func (s *fakeTeamProvisionSink) ProvisionTeam(_ context.Context, req teamprovision.TeamBillingProvisionRequestedV1) error { + s.mu.Lock() + defer s.mu.Unlock() + + s.requests = append(s.requests, req) + + return s.err +} diff --git a/packages/dashboard-api/internal/provisioning/sso.go b/packages/dashboard-api/internal/provisioning/sso.go new file mode 100644 index 0000000000..a9129db36e --- /dev/null +++ b/packages/dashboard-api/internal/provisioning/sso.go @@ -0,0 +1,88 @@ +package provisioning + +import ( + "context" + "fmt" + "net/http" + + "github.com/google/uuid" + + internalteamprovision "github.com/e2b-dev/infra/packages/dashboard-api/internal/teamprovision" + authqueries "github.com/e2b-dev/infra/packages/db/pkg/auth/queries" +) + +// enrollSSOMember adds the user to their SSO organization's auto-join teams and +// returns the team the response lands on. It fails closed when the org has no +// auto-join team, since every SSO org must have at least one — that state is a +// misconfiguration. No billing is emitted; SSO teams are provisioned out of band. +// The caller must hold the per-user lock. +func (s *Service) enrollSSOMember(ctx context.Context, authTxDB *authqueries.Queries, userID, orgID uuid.UUID) (ProvisionedTeam, error) { + autoTeams, err := authTxDB.GetAutoJoinTeamsBySSOOrganizationID(ctx, orgID) + if err != nil { + return ProvisionedTeam{}, fmt.Errorf("get auto-join teams for sso organization: %w", err) + } + if len(autoTeams) == 0 { + return ProvisionedTeam{}, &internalteamprovision.ProvisionError{ + StatusCode: http.StatusForbidden, + Message: "Your organization's SSO is not fully set up yet. Please contact support.", + } + } + + for _, row := range autoTeams { + if err := authTxDB.CreateTeamMembership(ctx, authqueries.CreateTeamMembershipParams{ + UserID: userID, + TeamID: row.Team.ID, + IsDefault: false, + AddedBy: nil, + }); err != nil { + return ProvisionedTeam{}, fmt.Errorf("create sso team membership: %w", err) + } + } + + // Land on the earliest auto-join team (the query orders by created_at). + landing := autoTeams[0].Team + + return ProvisionedTeam{ + ID: landing.ID, + Name: landing.Name, + Email: landing.Email, + Slug: landing.Slug, + IsBlocked: landing.IsBlocked, + BlockedReason: landing.BlockedReason, + }, nil +} + +// ensureNotSSOManaged blocks team creation for SSO-managed users; their team +// membership is driven entirely by their identity provider. +func (s *Service) ensureNotSSOManaged(ctx context.Context, userID uuid.UUID) error { + orgID, err := s.idps.GetUserOrganizationID(ctx, userID) + if err != nil { + return fmt.Errorf("resolve sso organization: %w", err) + } + if orgID != uuid.Nil { + return &internalteamprovision.ProvisionError{ + StatusCode: http.StatusForbidden, + Message: "SSO-managed accounts can't create teams. Contact your organization admin.", + } + } + + return nil +} + +// ValidateInviteeOrganization rejects adding a user to an SSO-managed team when +// the invitee's Ory identity belongs to a different organization. +func (s *Service) ValidateInviteeOrganization(ctx context.Context, teamOrgID, inviteeUserID uuid.UUID) error { + inviteeOrgID, err := s.idps.GetUserOrganizationID(ctx, inviteeUserID) + if err != nil { + return fmt.Errorf("resolve invitee organization: %w", err) + } + + if inviteeOrgID != teamOrgID { + return &internalteamprovision.ProvisionError{ + StatusCode: http.StatusForbidden, + Message: "Only accounts from your organization can be added to this team.", + } + } + + return nil +} diff --git a/packages/dashboard-api/internal/provisioning/sso_test.go b/packages/dashboard-api/internal/provisioning/sso_test.go new file mode 100644 index 0000000000..1342834e8c --- /dev/null +++ b/packages/dashboard-api/internal/provisioning/sso_test.go @@ -0,0 +1,202 @@ +package provisioning + +import ( + "context" + "errors" + "net/http" + "testing" + "time" + + "github.com/google/uuid" + + "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/testutils" +) + +// ssoIdentityProvider is a Provider whose organization lookups are configurable, so +// tests can simulate identities that belong to an Ory organization. +type ssoIdentityProvider struct { + testIdentityProvider + + orgBySubject map[string]uuid.UUID + orgByUser map[uuid.UUID]uuid.UUID +} + +func (p ssoIdentityProvider) GetIdentityOrganizationID(_ context.Context, subject string) (uuid.UUID, error) { + return p.orgBySubject[subject], nil +} + +func (p ssoIdentityProvider) GetUserOrganizationID(_ context.Context, userID uuid.UUID) (uuid.UUID, error) { + return p.orgByUser[userID], nil +} + +func setTeamSSOOrg(t *testing.T, db *testutils.Database, teamID, orgID uuid.UUID, autoJoin bool, createdAt time.Time) { + t.Helper() + + if err := db.SqlcClient.TestsRawSQL(t.Context(), + "UPDATE public.teams SET sso_organization_id = $1, sso_auto_join = $2, created_at = $3 WHERE id = $4", + orgID, autoJoin, createdAt, teamID, + ); err != nil { + t.Fatalf("failed to set team sso org: %v", err) + } +} + +func TestBootstrapOIDCUser_SSOJoinsMappedTeams(t *testing.T) { + t.Parallel() + + testDB := testutils.SetupDatabase(t) + ctx := t.Context() + sink := &fakeTeamProvisionSink{} + + orgID := uuid.New() + subject := uuid.NewString() + + // teamOlder is given an earlier created_at, so it is returned as the landing team. + teamNewer := testutils.CreateTestTeam(t, testDB) + teamOlder := testutils.CreateTestTeam(t, testDB) + setTeamSSOOrg(t, testDB, teamNewer, orgID, true, time.Now().Add(-1*time.Hour)) + setTeamSSOOrg(t, testDB, teamOlder, orgID, true, time.Now().Add(-2*time.Hour)) + + svc := New(testDB.AuthDB, ssoIdentityProvider{orgBySubject: map[string]uuid.UUID{subject: orgID}}, sink, identity.Registry{testIssuer: ssoIdentityProvider{orgBySubject: map[string]uuid.UUID{subject: orgID}}}) + + input := OIDCUserBootstrapInput{ + OIDCIssuer: testIssuer, + OIDCUserID: subject, + OIDCUserEmail: "ada@example.test", + } + + team, err := svc.BootstrapOIDCUser(ctx, input) + if err != nil { + t.Fatalf("expected sso bootstrap to succeed: %v", err) + } + if team.ID != teamOlder { + t.Fatalf("expected landing team %s (earliest created), got %s", teamOlder, team.ID) + } + + userIdentity, err := testDB.AuthDB.Read.GetUserIdentity(ctx, authqueries.GetUserIdentityParams{ + OidcIss: input.OIDCIssuer, + OidcSub: input.OIDCUserID, + }) + if err != nil { + t.Fatalf("expected user identity to be created: %v", err) + } + + // SSO members never get a default team; selection is not pinned. + if _, err := testDB.AuthDB.Read.GetDefaultTeamByUserID(ctx, userIdentity.UserID); err == nil { + t.Fatal("expected no default team for an SSO member") + } + + memberships, err := testDB.AuthDB.Read.GetTeamsWithUsersTeams(ctx, userIdentity.UserID) + if err != nil { + t.Fatalf("failed to read memberships: %v", err) + } + if len(memberships) != 2 { + t.Fatalf("expected membership in both mapped teams, got %d", len(memberships)) + } + joined := map[uuid.UUID]bool{} + for _, m := range memberships { + if m.IsDefault { + t.Fatalf("expected no default SSO membership, but %s is default", m.Team.ID) + } + joined[m.Team.ID] = true + } + if !joined[teamOlder] || !joined[teamNewer] { + t.Fatalf("expected membership in both %s and %s, got %v", teamOlder, teamNewer, joined) + } + + if len(sink.requests) != 0 { + t.Fatalf("expected no billing provisioning for SSO teams, got %d", len(sink.requests)) + } +} + +func TestBootstrapOIDCUser_SSOFailsClosedWhenNoTeamMapped(t *testing.T) { + t.Parallel() + + testDB := testutils.SetupDatabase(t) + ctx := t.Context() + sink := &fakeTeamProvisionSink{} + + orgID := uuid.New() + subject := uuid.NewString() + + svc := New(testDB.AuthDB, ssoIdentityProvider{orgBySubject: map[string]uuid.UUID{subject: orgID}}, sink, identity.Registry{testIssuer: ssoIdentityProvider{orgBySubject: map[string]uuid.UUID{subject: orgID}}}) + + input := OIDCUserBootstrapInput{ + OIDCIssuer: testIssuer, + OIDCUserID: subject, + OIDCUserEmail: "grace@example.test", + } + + _, err := svc.BootstrapOIDCUser(ctx, input) + if err == nil { + t.Fatal("expected fail-closed error when organization maps to no team") + } + + var provErr *internalteamprovision.ProvisionError + if !errors.As(err, &provErr) || provErr.StatusCode != http.StatusForbidden { + t.Fatalf("expected 403 ProvisionError, got %v", err) + } + + // The transaction must roll back: no identity or personal team is left behind. + if _, err := testDB.AuthDB.Read.GetUserIdentity(ctx, authqueries.GetUserIdentityParams{ + OidcIss: input.OIDCIssuer, + OidcSub: input.OIDCUserID, + }); err == nil { + t.Fatal("expected no user identity after fail-closed bootstrap") + } + + if len(sink.requests) != 0 { + t.Fatalf("expected no billing provisioning, got %d", len(sink.requests)) + } +} + +func TestBootstrapOIDCUser_SSOSkipsManualTeams(t *testing.T) { + t.Parallel() + + testDB := testutils.SetupDatabase(t) + ctx := t.Context() + sink := &fakeTeamProvisionSink{} + + orgID := uuid.New() + subject := uuid.NewString() + + // The org's only team is manual (sso_auto_join = false): it must not be + // auto-joined, so bootstrap fails closed rather than enrolling the user. + manualTeam := testutils.CreateTestTeam(t, testDB) + setTeamSSOOrg(t, testDB, manualTeam, orgID, false, time.Now().Add(-1*time.Hour)) + + svc := New(testDB.AuthDB, ssoIdentityProvider{orgBySubject: map[string]uuid.UUID{subject: orgID}}, sink, identity.Registry{testIssuer: ssoIdentityProvider{orgBySubject: map[string]uuid.UUID{subject: orgID}}}) + + input := OIDCUserBootstrapInput{ + OIDCIssuer: testIssuer, + OIDCUserID: subject, + OIDCUserEmail: "manual@example.test", + } + + _, err := svc.BootstrapOIDCUser(ctx, input) + var provErr *internalteamprovision.ProvisionError + if !errors.As(err, &provErr) || provErr.StatusCode != http.StatusForbidden { + t.Fatalf("expected 403 fail-closed when the org has only manual teams, got %v", err) + } +} + +func TestCreateTeam_SSOUserRejected(t *testing.T) { + t.Parallel() + + ctx := t.Context() + userID := uuid.New() + + svc := New(nil, ssoIdentityProvider{orgByUser: map[uuid.UUID]uuid.UUID{userID: uuid.New()}}, nil, identity.Registry{testIssuer: ssoIdentityProvider{orgByUser: map[uuid.UUID]uuid.UUID{userID: uuid.New()}}}) + + _, err := svc.CreateTeam(ctx, userID, "My Team") + if err == nil { + t.Fatal("expected SSO user to be blocked from creating a team") + } + + var provErr *internalteamprovision.ProvisionError + if !errors.As(err, &provErr) || provErr.StatusCode != http.StatusForbidden { + t.Fatalf("expected 403 ProvisionError, got %v", err) + } +} diff --git a/packages/dashboard-api/internal/provisioning/team.go b/packages/dashboard-api/internal/provisioning/team.go new file mode 100644 index 0000000000..05f72b6122 --- /dev/null +++ b/packages/dashboard-api/internal/provisioning/team.go @@ -0,0 +1,179 @@ +package provisioning + +import ( + "context" + "fmt" + "net/http" + + "github.com/google/uuid" + + 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/shared/pkg/teamprovision" +) + +func (s *Service) CreateTeam(ctx context.Context, userID uuid.UUID, name string) (ProvisionedTeam, error) { + if err := s.ensureNotSSOManaged(ctx, userID); err != nil { + return ProvisionedTeam{}, err + } + + profile, err := s.resolveProfile(ctx, userID) + if err != nil { + return ProvisionedTeam{}, err + } + + authTxDB, tx, err := s.authDB.WithTx(ctx) + if err != nil { + return ProvisionedTeam{}, fmt.Errorf("start transaction: %w", err) + } + defer func() { + _ = tx.Rollback(ctx) + }() + + if err := authTxDB.UpsertPublicUser(ctx, userID); err != nil { + return ProvisionedTeam{}, fmt.Errorf("upsert public user: %w", err) + } + + // Serialize team creation even when the user currently has no team memberships. + if _, err := authTxDB.LockPublicUserForUpdate(ctx, userID); err != nil { + return ProvisionedTeam{}, fmt.Errorf("lock public user: %w", err) + } + + if err := validateTeamCreationAllowed(ctx, authTxDB, userID); err != nil { + return ProvisionedTeam{}, err + } + + team, err := authTxDB.CreateTeam(ctx, authqueries.CreateTeamParams{ + Name: name, + Tier: baseTierID, + Email: profile.Email, + IsBlocked: false, + BlockedReason: nil, + }) + if err != nil { + return ProvisionedTeam{}, fmt.Errorf("create team: %w", err) + } + + if err := authTxDB.CreateTeamMembership(ctx, authqueries.CreateTeamMembershipParams{ + UserID: userID, + TeamID: team.ID, + IsDefault: false, + AddedBy: &userID, + }); err != nil { + return ProvisionedTeam{}, fmt.Errorf("create team membership: %w", err) + } + + if err := tx.Commit(ctx); err != nil { + return ProvisionedTeam{}, fmt.Errorf("commit team creation transaction: %w", err) + } + + req := teamprovision.TeamBillingProvisionRequestedV1{ + TeamID: team.ID, + TeamName: team.Name, + TeamEmail: team.Email, + CreatorUserID: userID, + CreatorContext: s.resolveTeamCreatorContext(ctx, userID), + Reason: teamprovision.ReasonAdditionalTeam, + } + if err := s.billing.ProvisionTeam(ctx, req); err != nil { + rollbackCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), teamProvisionRollbackTimeout) + defer cancel() + + if deleteErr := s.authDB.Write.DeleteTeamByID(rollbackCtx, team.ID); deleteErr != nil { + return ProvisionedTeam{}, fmt.Errorf("delete team after provisioning failure: provision=%s delete=%w", err.Error(), deleteErr) + } + + return ProvisionedTeam{}, err + } + + return ProvisionedTeam{ + ID: team.ID, + Name: team.Name, + Email: team.Email, + Slug: team.Slug, + IsBlocked: team.IsBlocked, + BlockedReason: team.BlockedReason, + }, nil +} + +func (s *Service) BootstrapTeam(ctx context.Context, name string, email string) (ProvisionedTeam, error) { + team, err := s.authDB.Write.CreateTeam(ctx, authqueries.CreateTeamParams{ + Name: name, + Tier: baseTierID, + Email: email, + IsBlocked: false, + BlockedReason: nil, + }) + if err != nil { + return ProvisionedTeam{}, fmt.Errorf("create team: %w", err) + } + + req := teamprovision.TeamBillingProvisionRequestedV1{ + TeamID: team.ID, + TeamName: team.Name, + TeamEmail: team.Email, + CreatorUserID: uuid.Nil, + Reason: teamprovision.ReasonAdditionalTeam, + } + if err := s.billing.ProvisionTeam(ctx, req); err != nil { + rollbackCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), teamProvisionRollbackTimeout) + defer cancel() + + if deleteErr := s.authDB.Write.DeleteTeamByID(rollbackCtx, team.ID); deleteErr != nil { + return ProvisionedTeam{}, fmt.Errorf("delete team after provisioning failure: provision=%s delete=%w", err.Error(), deleteErr) + } + + return ProvisionedTeam{}, err + } + + return ProvisionedTeam{ + ID: team.ID, + Name: team.Name, + Email: team.Email, + Slug: team.Slug, + IsBlocked: team.IsBlocked, + BlockedReason: team.BlockedReason, + }, nil +} + +func validateTeamCreationAllowed(ctx context.Context, authTxDB *authqueries.Queries, ownerUserID uuid.UUID) error { + teams, err := authTxDB.GetTeamsWithUsersTeamsWithTierForUpdate(ctx, ownerUserID) + if err != nil { + return fmt.Errorf("query user teams for limit check: %w", err) + } + + hasProTier := false + for _, row := range teams { + if row.Tier != baseTierID { + hasProTier = true + } + if row.IsBanned { + return &internalteamprovision.ProvisionError{ + StatusCode: http.StatusBadRequest, + Message: "You're unable to create a team right now. Please contact support if this persists.", + } + } + } + + if hasProTier { + if len(teams) >= maxTeamsPerUserWithProTier { + return &internalteamprovision.ProvisionError{ + StatusCode: http.StatusBadRequest, + Message: fmt.Sprintf("You can't create more than %d teams", maxTeamsPerUserWithProTier), + } + } + } else { + if len(teams) >= maxTeamsPerUser { + return &internalteamprovision.ProvisionError{ + StatusCode: http.StatusBadRequest, + Message: fmt.Sprintf( + "You can't create more than %d teams, you can upgrade to Pro tier to create up to %d teams", + maxTeamsPerUser, + maxTeamsPerUserWithProTier, + ), + } + } + } + + return nil +} diff --git a/packages/dashboard-api/internal/provisioning/team_test.go b/packages/dashboard-api/internal/provisioning/team_test.go new file mode 100644 index 0000000000..e265980595 --- /dev/null +++ b/packages/dashboard-api/internal/provisioning/team_test.go @@ -0,0 +1,96 @@ +package provisioning + +import ( + "errors" + "net/http" + "sync" + "testing" + "time" + + "github.com/e2b-dev/infra/packages/dashboard-api/internal/identity" + internalteamprovision "github.com/e2b-dev/infra/packages/dashboard-api/internal/teamprovision" + "github.com/e2b-dev/infra/packages/db/pkg/testutils" +) + +func TestCreateTeam_RecentUserCreatesUnblockedTeam(t *testing.T) { + t.Parallel() + + testDB := testutils.SetupDatabase(t) + ctx := t.Context() + userID := createTestUserAt(t, testDB, time.Now().Add(-time.Hour)) + + svc := New(testDB.AuthDB, testIdentityProvider{}, &fakeTeamProvisionSink{}, identity.Registry{testIssuer: testIdentityProvider{}}) + + team, err := svc.CreateTeam(ctx, userID, "Acme") + if err != nil { + t.Fatalf("expected team creation to succeed for recent user, got %v", err) + } + if team.IsBlocked { + t.Fatal("expected recent user team to remain unblocked") + } + if team.BlockedReason != nil { + t.Fatalf("expected nil blocked reason, got %v", team.BlockedReason) + } +} + +func TestCreateTeam_ConcurrentRequestsRespectLocalPolicyWithZeroMemberships(t *testing.T) { + t.Parallel() + + testDB := testutils.SetupDatabase(t) + ctx := t.Context() + userID := createTestUser(t, testDB) + + existingTeam, err := testDB.AuthDB.Write.GetDefaultTeamByUserID(ctx, userID) + if err != nil { + t.Fatalf("expected default team: %v", err) + } + if err := testDB.AuthDB.Write.DeleteTeamByID(ctx, existingTeam.ID); err != nil { + t.Fatalf("failed to remove default team: %v", err) + } + + svc := New(testDB.AuthDB, testIdentityProvider{}, &fakeTeamProvisionSink{}, identity.Registry{testIssuer: testIdentityProvider{}}) + + var wg sync.WaitGroup + results := make(chan error, 4) + + for _, name := range []string{"Acme-1", "Acme-2", "Acme-3", "Acme-4"} { + wg.Add(1) + go func(teamName string) { + defer wg.Done() + _, err := svc.CreateTeam(ctx, userID, teamName) + results <- err + }(name) + } + + wg.Wait() + close(results) + + var successCount int + var badRequestCount int + for err := range results { + if err == nil { + successCount++ + + continue + } + + var provisionErr *internalteamprovision.ProvisionError + if !errors.As(err, &provisionErr) { + t.Fatalf("expected provisioning error, got %T: %v", err, err) + } + if provisionErr.StatusCode == http.StatusBadRequest { + badRequestCount++ + + continue + } + + t.Fatalf("expected bad request or success, got %d", provisionErr.StatusCode) + } + + if successCount != maxTeamsPerUser { + t.Fatalf("expected %d successes, got %d", maxTeamsPerUser, successCount) + } + if badRequestCount != 1 { + t.Fatalf("expected one bad request, got %d", badRequestCount) + } +} diff --git a/packages/dashboard-api/main.go b/packages/dashboard-api/main.go index 8f6874e35c..ebf926753b 100644 --- a/packages/dashboard-api/main.go +++ b/packages/dashboard-api/main.go @@ -28,9 +28,9 @@ import ( "github.com/e2b-dev/infra/packages/dashboard-api/internal/api" "github.com/e2b-dev/infra/packages/dashboard-api/internal/cfg" "github.com/e2b-dev/infra/packages/dashboard-api/internal/handlers" + "github.com/e2b-dev/infra/packages/dashboard-api/internal/identity" dashboardmiddleware "github.com/e2b-dev/infra/packages/dashboard-api/internal/middleware" internalteamprovision "github.com/e2b-dev/infra/packages/dashboard-api/internal/teamprovision" - "github.com/e2b-dev/infra/packages/dashboard-api/internal/userprofile" sqlcdb "github.com/e2b-dev/infra/packages/db/client" authdb "github.com/e2b-dev/infra/packages/db/pkg/auth" "github.com/e2b-dev/infra/packages/db/pkg/pool" @@ -210,7 +210,7 @@ func run() int { return 1 } - userProfiles, err := userprofile.NewOryProvider(userprofile.OryConfig{ + idp, err := identity.NewOryProvider(identity.OryConfig{ HTTPClient: authClient, SDKURL: config.OrySDKURL, Token: config.OryProjectAPIToken, @@ -223,7 +223,7 @@ func run() int { return 1 } - apiStore := handlers.NewAPIStore(config, db, authDB, clickhouseClient, authService, teamProvisionSink, userProfiles) + apiStore := handlers.NewAPIStore(config, db, authDB, clickhouseClient, authService, teamProvisionSink, idp) swagger, err := api.GetSwagger() if err != nil {