diff --git a/packages/dashboard-api/internal/handlers/sandbox_record.go b/packages/dashboard-api/internal/handlers/sandbox_record.go index 1f018eef26..189315c407 100644 --- a/packages/dashboard-api/internal/handlers/sandbox_record.go +++ b/packages/dashboard-api/internal/handlers/sandbox_record.go @@ -60,8 +60,10 @@ func (s *APIStore) GetSandboxesSandboxIDRecord(c *gin.Context, sandboxID api.San // fixed retention window ago. retentionExpired := row.StoppedAt != nil && time.Since(*row.StoppedAt) > monitoringRetention - // Events retention comes from the team's limits (tier + addons) - eventsRetentionDays := min(team.Limits.EventsTTLDays, events.MaxEventsTTLDays) + eventsRetentionDays := events.DefaultEventsTTLDays + if team.Limits != nil { + eventsRetentionDays = min(team.Limits.EventsTTLDays, events.MaxEventsTTLDays) + } eventsRetention := time.Duration(eventsRetentionDays) * 24 * time.Hour eventsRetentionExpired := row.StoppedAt != nil && time.Since(*row.StoppedAt) > eventsRetention diff --git a/packages/dashboard-api/internal/handlers/sso_test.go b/packages/dashboard-api/internal/handlers/sso_test.go new file mode 100644 index 0000000000..2f0d12b96c --- /dev/null +++ b/packages/dashboard-api/internal/handlers/sso_test.go @@ -0,0 +1,296 @@ +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" + 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}}, + } + + 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}}, + } + + 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}}, + } + + 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()}}, + } + + _, 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{}} + 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}}, + } + 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/team_handlers_test.go b/packages/dashboard-api/internal/handlers/team_handlers_test.go index 436e9b563d..4edcb09760 100644 --- a/packages/dashboard-api/internal/handlers/team_handlers_test.go +++ b/packages/dashboard-api/internal/handlers/team_handlers_test.go @@ -409,6 +409,14 @@ func (handlerTestUserProfiles) GetTeamCreatorContext(context.Context, uuid.UUID) return nil, nil } +func (handlerTestUserProfiles) GetIdentityOrganizationID(context.Context, string) (uuid.UUID, error) { + return uuid.Nil, nil +} + +func (handlerTestUserProfiles) GetUserOrganizationID(context.Context, uuid.UUID) (uuid.UUID, error) { + return uuid.Nil, nil +} + func (handlerTestUserProfiles) SetIdentityExternalID(context.Context, string, uuid.UUID) error { return nil } diff --git a/packages/dashboard-api/internal/handlers/team_members.go b/packages/dashboard-api/internal/handlers/team_members.go index 09147b9003..9c2ef5255f 100644 --- a/packages/dashboard-api/internal/handlers/team_members.go +++ b/packages/dashboard-api/internal/handlers/team_members.go @@ -87,6 +87,41 @@ 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() + if s.userProfiles == nil { + logger.L().Error(ctx, "user profile provider is not configured", logger.WithUserID(inviteeUserID.String())) + s.sendAPIStoreError(c, http.StatusInternalServerError, "Failed to add team member") + + return true + } + + userOrgID, err := s.userProfiles.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 userOrgID != *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") @@ -128,6 +163,11 @@ func (s *APIStore) PostTeamsTeamIDMembers(c *gin.Context, teamID api.TeamID) { } user := profiles[0] + + if s.rejectInviteOutsideSSOOrg(c, user.UserID) { + return + } + if err := s.authDB.Write.UpsertPublicUser(ctx, user.UserID); err != nil { logger.L().Error(ctx, "failed to create public user anchor", zap.Error(err), logger.WithUserID(user.UserID.String())) s.sendAPIStoreError(c, http.StatusInternalServerError, "Failed to add team member") diff --git a/packages/dashboard-api/internal/handlers/utils_team_provisioning.go b/packages/dashboard-api/internal/handlers/utils_team_provisioning.go index 32e679e3b2..57f62178a5 100644 --- a/packages/dashboard-api/internal/handlers/utils_team_provisioning.go +++ b/packages/dashboard-api/internal/handlers/utils_team_provisioning.go @@ -83,7 +83,8 @@ func (s *APIStore) resolveProfile(ctx context.Context, userID uuid.UUID) (userpr } func (s *APIStore) bootstrapOIDCUser(ctx context.Context, input oidcUserBootstrapInput) (provisionedTeam, error) { - if err := s.requireConfiguredOIDCIssuer(input.OIDCIssuer); err != nil { + idp, err := s.providerForIssuer(input.OIDCIssuer) + if err != nil { return provisionedTeam{}, err } @@ -94,32 +95,41 @@ func (s *APIStore) bootstrapOIDCUser(ctx context.Context, input oidcUserBootstra CreatorContext: creatorContextFromSignupMetadata(input.SignupIP, input.SignupUserAgent, teamprovision.AuthMethodSocial), } - return s.bootstrapUserWithIdentity(ctx, profile, &bootstrapUserIdentity{ + return s.bootstrapUserWithIdentity(ctx, idp, profile, &bootstrapUserIdentity{ Issuer: input.OIDCIssuer, Subject: input.OIDCUserID, }) } -// requireConfiguredOIDCIssuer rejects bootstrap requests whose issuer is not the -// configured Ory issuer. -func (s *APIStore) requireConfiguredOIDCIssuer(issuer string) error { - oryIssuer := strings.TrimSpace(s.config.OryIssuerURL) - - if oryIssuer != "" && oryIssuer == issuer { - return nil +// 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) { + if s.userProfiles == nil || strings.TrimSpace(issuer) != strings.TrimSpace(s.config.OryIssuerURL) { + return nil, &internalteamprovision.ProvisionError{ + StatusCode: http.StatusBadRequest, + Message: "oidc_issuer does not match a configured identity provider", + } } - return &internalteamprovision.ProvisionError{ - StatusCode: http.StatusBadRequest, - Message: "oidc_issuer must equal the configured ORY_ISSUER_URL", - } + return s.userProfiles, nil } func (s *APIStore) bootstrapUser(ctx context.Context, profile bootstrapUserProfile) (provisionedTeam, error) { - return s.bootstrapUserWithIdentity(ctx, profile, nil) + return s.bootstrapUserWithIdentity(ctx, s.userProfiles, profile, nil) } -func (s *APIStore) bootstrapUserWithIdentity(ctx context.Context, profile bootstrapUserProfile, identity *bootstrapUserIdentity) (provisionedTeam, error) { +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) @@ -191,7 +201,7 @@ func (s *APIStore) bootstrapUserWithIdentity(ctx context.Context, profile bootst // 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, identity, profile.UserID); err != nil { + if err := s.setOIDCIdentityExternalID(ctx, idp, identity, profile.UserID); err != nil { return provisionedTeam{}, err } @@ -208,6 +218,25 @@ func (s *APIStore) bootstrapUserWithIdentity(ctx context.Context, profile bootst 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, @@ -251,7 +280,7 @@ func (s *APIStore) bootstrapUserWithIdentity(ctx context.Context, profile bootst // 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, identity, profile.UserID); err != nil { + if err := s.setOIDCIdentityExternalID(ctx, idp, identity, profile.UserID); err != nil { return provisionedTeam{}, err } @@ -267,19 +296,85 @@ func (s *APIStore) bootstrapUserWithIdentity(ctx context.Context, profile bootst // 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, identity *bootstrapUserIdentity, userID uuid.UUID) error { +func (s *APIStore) setOIDCIdentityExternalID(ctx context.Context, idp userprofile.Provider, identity *bootstrapUserIdentity, userID uuid.UUID) error { if identity == nil { return nil } - if err := s.userProfiles.SetIdentityExternalID(ctx, identity.Subject, userID); err != 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.CreateTeamMembershipIfMissing(ctx, authqueries.CreateTeamMembershipIfMissingParams{ + 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 { + if s.userProfiles == nil { + return errors.New("user profile provider is not configured") + } + + orgID, err := s.userProfiles.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 diff --git a/packages/dashboard-api/internal/userprofile/ory.go b/packages/dashboard-api/internal/userprofile/ory.go index 4494c61184..43900d0b15 100644 --- a/packages/dashboard-api/internal/userprofile/ory.go +++ b/packages/dashboard-api/internal/userprofile/ory.go @@ -179,6 +179,58 @@ func (p *oryProvider) GetTeamCreatorContext(ctx context.Context, userID uuid.UUI return creatorContextFromOryIdentity(identities[0]), nil } +// GetIdentityOrganizationID returns the identity's organization_id — a +// first-class Ory field (not a trait) set when the identity signed in through an +// organization's SSO connection — or uuid.Nil when it belongs to no organization. +func (p *oryProvider) GetIdentityOrganizationID(ctx context.Context, subject string) (uuid.UUID, error) { + subject = strings.TrimSpace(subject) + if subject == "" { + return uuid.Nil, errors.New("ory identity subject is required") + } + + identity, resp, err := p.identities.GetIdentityExecute( + p.identities.GetIdentity(p.authCtx(ctx), subject), + ) + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } + if err != nil { + return uuid.Nil, fmt.Errorf("ory get identity: %w", err) + } + + orgID := strings.TrimSpace(identity.GetOrganizationId()) + if orgID == "" { + return uuid.Nil, nil + } + + orgUUID, err := uuid.Parse(orgID) + if err != nil { + return uuid.Nil, fmt.Errorf("parse ory organization_id %q: %w", orgID, err) + } + + return orgUUID, nil +} + +func (p *oryProvider) GetUserOrganizationID(ctx context.Context, userID uuid.UUID) (uuid.UUID, error) { + if userID == uuid.Nil { + return uuid.Nil, nil + } + + userIDBySubject, err := p.subjectsForUserIDs(ctx, []uuid.UUID{userID}) + if err != nil { + return uuid.Nil, err + } + if len(userIDBySubject) > 1 { + return uuid.Nil, fmt.Errorf("multiple ory identity mappings for user %s", userID) + } + + for subject := range userIDBySubject { + return p.GetIdentityOrganizationID(ctx, subject) + } + + return uuid.Nil, nil +} + func (p *oryProvider) SetIdentityExternalID(ctx context.Context, subject string, externalID uuid.UUID) error { subject = strings.TrimSpace(subject) if subject == "" { diff --git a/packages/dashboard-api/internal/userprofile/ory_test.go b/packages/dashboard-api/internal/userprofile/ory_test.go index 6afdc19f60..9da84e35ff 100644 --- a/packages/dashboard-api/internal/userprofile/ory_test.go +++ b/packages/dashboard-api/internal/userprofile/ory_test.go @@ -247,3 +247,76 @@ func TestCreatorContextFromOryIdentityUsesMetadataAdmin(t *testing.T) { t.Fatalf("AuthMethod = %q, want %q", got.AuthMethod, sharedteamprovision.AuthMethodSocial) } } + +type multiSubjectResolver struct { + stubIdentityResolver + + userID uuid.UUID + subjects []string +} + +func (r multiSubjectResolver) GetUserIdentitiesByUserIDs(context.Context, authqueries.GetUserIdentitiesByUserIDsParams) ([]authqueries.GetUserIdentitiesByUserIDsRow, error) { + rows := make([]authqueries.GetUserIdentitiesByUserIDsRow, 0, len(r.subjects)) + for _, subject := range r.subjects { + rows = append(rows, authqueries.GetUserIdentitiesByUserIDsRow{OidcSub: subject, UserID: r.userID}) + } + + return rows, nil +} + +func TestOryProvider_GetUserOrganizationID(t *testing.T) { + t.Parallel() + + userID := uuid.New() + orgID := uuid.New() + subject := uuid.NewString() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + body := `{"id":"` + subject + `","schema_id":"default","schema_url":"","state":"active","traits":{},"organization_id":"` + orgID.String() + `"}` + _, _ = w.Write([]byte(body)) + })) + defer server.Close() + + provider, err := NewOryProvider(OryConfig{ + HTTPClient: server.Client(), + SDKURL: server.URL, + Token: "test-token", + Issuer: "https://ory.example.test", + Resolver: multiSubjectResolver{userID: userID, subjects: []string{subject}}, + }) + if err != nil { + t.Fatalf("failed to build ory provider: %v", err) + } + + got, err := provider.GetUserOrganizationID(t.Context(), userID) + if err != nil { + t.Fatalf("GetUserOrganizationID returned error: %v", err) + } + if got != orgID { + t.Fatalf("expected organization %s, got %s", orgID, got) + } +} + +func TestOryProvider_GetUserOrganizationIDRejectsMultipleLinkedIdentities(t *testing.T) { + t.Parallel() + + userID := uuid.New() + subjectA := uuid.NewString() + subjectB := uuid.NewString() + + provider, err := NewOryProvider(OryConfig{ + HTTPClient: http.DefaultClient, + SDKURL: "https://ory.example.test", + Token: "test-token", + Issuer: "https://ory.example.test", + Resolver: multiSubjectResolver{userID: userID, subjects: []string{subjectA, subjectB}}, + }) + if err != nil { + t.Fatalf("failed to build ory provider: %v", err) + } + + if _, err := provider.GetUserOrganizationID(t.Context(), userID); err == nil { + t.Fatal("expected multiple linked identities to return an error") + } +} diff --git a/packages/dashboard-api/internal/userprofile/provider.go b/packages/dashboard-api/internal/userprofile/provider.go index a4becb001f..c3670d39c2 100644 --- a/packages/dashboard-api/internal/userprofile/provider.go +++ b/packages/dashboard-api/internal/userprofile/provider.go @@ -25,11 +25,9 @@ type Provider interface { GetProfilesByUserID(ctx context.Context, userIDs []uuid.UUID) (map[uuid.UUID]Profile, error) FindProfilesByEmail(ctx context.Context, email string) ([]Profile, error) GetTeamCreatorContext(ctx context.Context, userID uuid.UUID) (*sharedteamprovision.CreatorContextV1, error) - // SetIdentityExternalID stores the canonical user UUID on the external - // identity (Ory external_id) so the IdP can back-reference our user. + GetIdentityOrganizationID(ctx context.Context, subject string) (uuid.UUID, error) + GetUserOrganizationID(ctx context.Context, userID uuid.UUID) (uuid.UUID, error) SetIdentityExternalID(ctx context.Context, subject string, externalID uuid.UUID) error - // PrepareDeleteUser resolves the external identity references for the - // given user so they can be removed after the database rows are gone. PrepareDeleteUser(ctx context.Context, userID uuid.UUID) (DeleteUserHandle, error) } diff --git a/packages/db/migrations/20260706120000_add_teams_sso_organization_id.sql b/packages/db/migrations/20260706120000_add_teams_sso_organization_id.sql new file mode 100644 index 0000000000..42278861df --- /dev/null +++ b/packages/db/migrations/20260706120000_add_teams_sso_organization_id.sql @@ -0,0 +1,23 @@ +-- +goose Up +-- +goose NO TRANSACTION + +ALTER TABLE teams + ADD COLUMN IF NOT EXISTS sso_organization_id UUID, + -- Members of the org are added to this team automatically on first SSO + -- sign-in. When false, the team belongs to the org but access is granted by + -- inviting org-domain accounts. + ADD COLUMN IF NOT EXISTS sso_auto_join BOOLEAN NOT NULL DEFAULT false; + +-- Non-unique: one SSO organization can map to multiple teams. +CREATE INDEX CONCURRENTLY IF NOT EXISTS teams_sso_organization_id_idx + ON teams (sso_organization_id) + WHERE sso_organization_id IS NOT NULL; + +-- +goose Down +-- +goose NO TRANSACTION + +DROP INDEX CONCURRENTLY IF EXISTS teams_sso_organization_id_idx; + +ALTER TABLE teams + DROP COLUMN IF EXISTS sso_organization_id, + DROP COLUMN IF EXISTS sso_auto_join; diff --git a/packages/db/pkg/auth/queries/get_team.sql.go b/packages/db/pkg/auth/queries/get_team.sql.go index fa97b7ad5b..931cb3b611 100644 --- a/packages/db/pkg/auth/queries/get_team.sql.go +++ b/packages/db/pkg/auth/queries/get_team.sql.go @@ -11,8 +11,56 @@ import ( "github.com/google/uuid" ) +const getAutoJoinTeamsBySSOOrganizationID = `-- name: GetAutoJoinTeamsBySSOOrganizationID :many +SELECT t.id, t.created_at, t.is_blocked, t.name, t.tier, t.email, t.is_banned, t.blocked_reason, t.cluster_id, t.sandbox_scheduling_labels, t.sso_organization_id, t.sso_auto_join, t.slug +FROM "public"."teams" t +WHERE t.sso_organization_id = $1::uuid + AND t.sso_auto_join = true + AND t.is_blocked = false + AND t.is_banned = false +ORDER BY t.created_at ASC +` + +type GetAutoJoinTeamsBySSOOrganizationIDRow struct { + Team Team +} + +func (q *Queries) GetAutoJoinTeamsBySSOOrganizationID(ctx context.Context, ssoOrganizationID uuid.UUID) ([]GetAutoJoinTeamsBySSOOrganizationIDRow, error) { + rows, err := q.db.Query(ctx, getAutoJoinTeamsBySSOOrganizationID, ssoOrganizationID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetAutoJoinTeamsBySSOOrganizationIDRow + for rows.Next() { + var i GetAutoJoinTeamsBySSOOrganizationIDRow + if err := rows.Scan( + &i.Team.ID, + &i.Team.CreatedAt, + &i.Team.IsBlocked, + &i.Team.Name, + &i.Team.Tier, + &i.Team.Email, + &i.Team.IsBanned, + &i.Team.BlockedReason, + &i.Team.ClusterID, + &i.Team.SandboxSchedulingLabels, + &i.Team.SsoOrganizationID, + &i.Team.SsoAutoJoin, + &i.Team.Slug, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getTeamWithTierByAPIKey = `-- name: GetTeamWithTierByAPIKey :one -SELECT t.id, t.created_at, t.is_blocked, t.name, t.tier, t.email, t.is_banned, t.blocked_reason, t.cluster_id, t.sandbox_scheduling_labels, t.slug, tl.id, tl.max_length_hours, tl.concurrent_sandboxes, tl.concurrent_template_builds, tl.max_vcpu, tl.max_ram_mb, tl.disk_mb, tl.events_ttl_days FROM "public"."team_api_keys" tak +SELECT t.id, t.created_at, t.is_blocked, t.name, t.tier, t.email, t.is_banned, t.blocked_reason, t.cluster_id, t.sandbox_scheduling_labels, t.sso_organization_id, t.sso_auto_join, t.slug, tl.id, tl.max_length_hours, tl.concurrent_sandboxes, tl.concurrent_template_builds, tl.max_vcpu, tl.max_ram_mb, tl.disk_mb, tl.events_ttl_days FROM "public"."team_api_keys" tak JOIN "public"."teams" t ON tak.team_id = t.id JOIN "public"."team_limits" tl on tl.id = t.id WHERE tak.team_id = t.id @@ -38,6 +86,8 @@ func (q *Queries) GetTeamWithTierByAPIKey(ctx context.Context, apiKeyHash string &i.Team.BlockedReason, &i.Team.ClusterID, &i.Team.SandboxSchedulingLabels, + &i.Team.SsoOrganizationID, + &i.Team.SsoAutoJoin, &i.Team.Slug, &i.TeamLimit.ID, &i.TeamLimit.MaxLengthHours, @@ -52,7 +102,7 @@ func (q *Queries) GetTeamWithTierByAPIKey(ctx context.Context, apiKeyHash string } const getTeamWithTierByTeamAndUser = `-- name: GetTeamWithTierByTeamAndUser :one -SELECT t.id, t.created_at, t.is_blocked, t.name, t.tier, t.email, t.is_banned, t.blocked_reason, t.cluster_id, t.sandbox_scheduling_labels, t.slug, tl.id, tl.max_length_hours, tl.concurrent_sandboxes, tl.concurrent_template_builds, tl.max_vcpu, tl.max_ram_mb, tl.disk_mb, tl.events_ttl_days +SELECT t.id, t.created_at, t.is_blocked, t.name, t.tier, t.email, t.is_banned, t.blocked_reason, t.cluster_id, t.sandbox_scheduling_labels, t.sso_organization_id, t.sso_auto_join, t.slug, tl.id, tl.max_length_hours, tl.concurrent_sandboxes, tl.concurrent_template_builds, tl.max_vcpu, tl.max_ram_mb, tl.disk_mb, tl.events_ttl_days FROM "public"."teams" t JOIN "public"."users_teams" ut ON ut.team_id = t.id JOIN "public"."team_limits" tl on tl.id = t.id @@ -83,6 +133,8 @@ func (q *Queries) GetTeamWithTierByTeamAndUser(ctx context.Context, arg GetTeamW &i.Team.BlockedReason, &i.Team.ClusterID, &i.Team.SandboxSchedulingLabels, + &i.Team.SsoOrganizationID, + &i.Team.SsoAutoJoin, &i.Team.Slug, &i.TeamLimit.ID, &i.TeamLimit.MaxLengthHours, @@ -97,7 +149,7 @@ func (q *Queries) GetTeamWithTierByTeamAndUser(ctx context.Context, arg GetTeamW } const getTeamWithTierByTeamID = `-- name: GetTeamWithTierByTeamID :one -SELECT t.id, t.created_at, t.is_blocked, t.name, t.tier, t.email, t.is_banned, t.blocked_reason, t.cluster_id, t.sandbox_scheduling_labels, t.slug, tl.id, tl.max_length_hours, tl.concurrent_sandboxes, tl.concurrent_template_builds, tl.max_vcpu, tl.max_ram_mb, tl.disk_mb, tl.events_ttl_days +SELECT t.id, t.created_at, t.is_blocked, t.name, t.tier, t.email, t.is_banned, t.blocked_reason, t.cluster_id, t.sandbox_scheduling_labels, t.sso_organization_id, t.sso_auto_join, t.slug, tl.id, tl.max_length_hours, tl.concurrent_sandboxes, tl.concurrent_template_builds, tl.max_vcpu, tl.max_ram_mb, tl.disk_mb, tl.events_ttl_days FROM "public"."teams" t JOIN "public"."team_limits" tl on tl.id = t.id WHERE t.id = $1 @@ -122,6 +174,8 @@ func (q *Queries) GetTeamWithTierByTeamID(ctx context.Context, id uuid.UUID) (Ge &i.Team.BlockedReason, &i.Team.ClusterID, &i.Team.SandboxSchedulingLabels, + &i.Team.SsoOrganizationID, + &i.Team.SsoAutoJoin, &i.Team.Slug, &i.TeamLimit.ID, &i.TeamLimit.MaxLengthHours, @@ -136,7 +190,7 @@ func (q *Queries) GetTeamWithTierByTeamID(ctx context.Context, id uuid.UUID) (Ge } const getTeamsWithUsersTeamsWithTier = `-- name: GetTeamsWithUsersTeamsWithTier :many -SELECT t.id, t.created_at, t.is_blocked, t.name, t.tier, t.email, t.is_banned, t.blocked_reason, t.cluster_id, t.sandbox_scheduling_labels, t.slug, ut.is_default, tl.id, tl.max_length_hours, tl.concurrent_sandboxes, tl.concurrent_template_builds, tl.max_vcpu, tl.max_ram_mb, tl.disk_mb, tl.events_ttl_days +SELECT t.id, t.created_at, t.is_blocked, t.name, t.tier, t.email, t.is_banned, t.blocked_reason, t.cluster_id, t.sandbox_scheduling_labels, t.sso_organization_id, t.sso_auto_join, t.slug, ut.is_default, tl.id, tl.max_length_hours, tl.concurrent_sandboxes, tl.concurrent_template_builds, tl.max_vcpu, tl.max_ram_mb, tl.disk_mb, tl.events_ttl_days FROM "public"."teams" t JOIN "public"."users_teams" ut ON ut.team_id = t.id JOIN "public"."team_limits" tl on tl.id = t.id @@ -169,6 +223,8 @@ func (q *Queries) GetTeamsWithUsersTeamsWithTier(ctx context.Context, userID uui &i.Team.BlockedReason, &i.Team.ClusterID, &i.Team.SandboxSchedulingLabels, + &i.Team.SsoOrganizationID, + &i.Team.SsoAutoJoin, &i.Team.Slug, &i.IsDefault, &i.TeamLimit.ID, diff --git a/packages/db/pkg/auth/queries/models.go b/packages/db/pkg/auth/queries/models.go index 6d2706a285..0caf3f6c8a 100644 --- a/packages/db/pkg/auth/queries/models.go +++ b/packages/db/pkg/auth/queries/models.go @@ -21,6 +21,8 @@ type Team struct { BlockedReason *string ClusterID *uuid.UUID SandboxSchedulingLabels []string + SsoOrganizationID *uuid.UUID + SsoAutoJoin bool Slug string } diff --git a/packages/db/pkg/auth/queries/team_lifecycle.sql.go b/packages/db/pkg/auth/queries/team_lifecycle.sql.go index f9328f2af2..a27e014256 100644 --- a/packages/db/pkg/auth/queries/team_lifecycle.sql.go +++ b/packages/db/pkg/auth/queries/team_lifecycle.sql.go @@ -7,6 +7,7 @@ package authqueries import ( "context" + "time" "github.com/google/uuid" ) @@ -42,7 +43,21 @@ type CreateTeamParams struct { BlockedReason *string } -func (q *Queries) CreateTeam(ctx context.Context, arg CreateTeamParams) (Team, error) { +type CreateTeamRow struct { + ID uuid.UUID + CreatedAt time.Time + IsBlocked bool + Name string + Tier string + Email string + IsBanned bool + BlockedReason *string + ClusterID *uuid.UUID + SandboxSchedulingLabels []string + Slug string +} + +func (q *Queries) CreateTeam(ctx context.Context, arg CreateTeamParams) (CreateTeamRow, error) { row := q.db.QueryRow(ctx, createTeam, arg.Name, arg.Tier, @@ -50,7 +65,7 @@ func (q *Queries) CreateTeam(ctx context.Context, arg CreateTeamParams) (Team, e arg.IsBlocked, arg.BlockedReason, ) - var i Team + var i CreateTeamRow err := row.Scan( &i.ID, &i.CreatedAt, @@ -94,6 +109,34 @@ func (q *Queries) CreateTeamMembership(ctx context.Context, arg CreateTeamMember return err } +const createTeamMembershipIfMissing = `-- name: CreateTeamMembershipIfMissing :exec +INSERT INTO public.users_teams (user_id, team_id, is_default, added_by) +VALUES ( + $1::uuid, + $2::uuid, + $3::boolean, + $4::uuid +) +ON CONFLICT (user_id, team_id) DO NOTHING +` + +type CreateTeamMembershipIfMissingParams struct { + UserID uuid.UUID + TeamID uuid.UUID + IsDefault bool + AddedBy *uuid.UUID +} + +func (q *Queries) CreateTeamMembershipIfMissing(ctx context.Context, arg CreateTeamMembershipIfMissingParams) error { + _, err := q.db.Exec(ctx, createTeamMembershipIfMissing, + arg.UserID, + arg.TeamID, + arg.IsDefault, + arg.AddedBy, + ) + return err +} + const deleteTeamByID = `-- name: DeleteTeamByID :exec DELETE FROM public.teams WHERE id = $1::uuid @@ -123,9 +166,23 @@ WHERE ut.user_id = $1::uuid AND ut.is_default = true ` -func (q *Queries) GetDefaultTeamByUserID(ctx context.Context, userID uuid.UUID) (Team, error) { +type GetDefaultTeamByUserIDRow struct { + ID uuid.UUID + CreatedAt time.Time + IsBlocked bool + Name string + Tier string + Email string + IsBanned bool + BlockedReason *string + ClusterID *uuid.UUID + SandboxSchedulingLabels []string + Slug string +} + +func (q *Queries) GetDefaultTeamByUserID(ctx context.Context, userID uuid.UUID) (GetDefaultTeamByUserIDRow, error) { row := q.db.QueryRow(ctx, getDefaultTeamByUserID, userID) - var i Team + var i GetDefaultTeamByUserIDRow err := row.Scan( &i.ID, &i.CreatedAt, diff --git a/packages/db/pkg/auth/queries/teams_usersteams_join.sql.go b/packages/db/pkg/auth/queries/teams_usersteams_join.sql.go index 94b1ec69a6..5750d1cb4f 100644 --- a/packages/db/pkg/auth/queries/teams_usersteams_join.sql.go +++ b/packages/db/pkg/auth/queries/teams_usersteams_join.sql.go @@ -12,7 +12,7 @@ import ( ) const getTeamsWithUsersTeams = `-- name: GetTeamsWithUsersTeams :many -SELECT t.id, t.created_at, t.is_blocked, t.name, t.tier, t.email, t.is_banned, t.blocked_reason, t.cluster_id, t.sandbox_scheduling_labels, t.slug, ut.is_default +SELECT t.id, t.created_at, t.is_blocked, t.name, t.tier, t.email, t.is_banned, t.blocked_reason, t.cluster_id, t.sandbox_scheduling_labels, t.sso_organization_id, t.sso_auto_join, t.slug, ut.is_default FROM "public"."teams" t JOIN "public"."users_teams" ut ON ut.team_id = t.id WHERE ut.user_id = $1 @@ -43,6 +43,8 @@ func (q *Queries) GetTeamsWithUsersTeams(ctx context.Context, userID uuid.UUID) &i.Team.BlockedReason, &i.Team.ClusterID, &i.Team.SandboxSchedulingLabels, + &i.Team.SsoOrganizationID, + &i.Team.SsoAutoJoin, &i.Team.Slug, &i.IsDefault, ); err != nil { diff --git a/packages/db/pkg/auth/sql_queries/teams/get_team.sql b/packages/db/pkg/auth/sql_queries/teams/get_team.sql index 2f941c8b06..59998bbe2c 100644 --- a/packages/db/pkg/auth/sql_queries/teams/get_team.sql +++ b/packages/db/pkg/auth/sql_queries/teams/get_team.sql @@ -18,6 +18,15 @@ FROM "public"."teams" t JOIN "public"."team_limits" tl on tl.id = t.id WHERE t.id = $1; +-- name: GetAutoJoinTeamsBySSOOrganizationID :many +SELECT sqlc.embed(t) +FROM "public"."teams" t +WHERE t.sso_organization_id = sqlc.arg(sso_organization_id)::uuid + AND t.sso_auto_join = true + AND t.is_blocked = false + AND t.is_banned = false +ORDER BY t.created_at ASC; + -- name: GetTeamsWithUsersTeamsWithTier :many SELECT sqlc.embed(t), ut.is_default, sqlc.embed(tl) FROM "public"."teams" t diff --git a/packages/db/pkg/auth/sql_queries/teams/team_lifecycle.sql b/packages/db/pkg/auth/sql_queries/teams/team_lifecycle.sql index 26f7094156..806f2755d0 100644 --- a/packages/db/pkg/auth/sql_queries/teams/team_lifecycle.sql +++ b/packages/db/pkg/auth/sql_queries/teams/team_lifecycle.sql @@ -29,6 +29,16 @@ VALUES ( sqlc.narg(added_by)::uuid ); +-- name: CreateTeamMembershipIfMissing :exec +INSERT INTO public.users_teams (user_id, team_id, is_default, added_by) +VALUES ( + sqlc.arg(user_id)::uuid, + sqlc.arg(team_id)::uuid, + sqlc.arg(is_default)::boolean, + sqlc.narg(added_by)::uuid +) +ON CONFLICT (user_id, team_id) DO NOTHING; + -- name: GetDefaultTeamByUserID :one SELECT t.id, diff --git a/packages/db/pkg/dashboard/queries/get_dashboard_teams_with_users_teams_with_tier.sql.go b/packages/db/pkg/dashboard/queries/get_dashboard_teams_with_users_teams_with_tier.sql.go index aa5802c607..131d97a98a 100644 --- a/packages/db/pkg/dashboard/queries/get_dashboard_teams_with_users_teams_with_tier.sql.go +++ b/packages/db/pkg/dashboard/queries/get_dashboard_teams_with_users_teams_with_tier.sql.go @@ -12,7 +12,7 @@ import ( ) const getDashboardTeamsWithUsersTeamsWithTier = `-- name: GetDashboardTeamsWithUsersTeamsWithTier :many -SELECT t.id, t.created_at, t.is_blocked, t.name, t.tier, t.email, t.is_banned, t.blocked_reason, t.cluster_id, t.sandbox_scheduling_labels, t.slug, t.profile_picture_url, ut.is_default, tl.id, tl.max_length_hours, tl.concurrent_sandboxes, tl.concurrent_template_builds, tl.max_vcpu, tl.max_ram_mb, tl.disk_mb, tl.events_ttl_days +SELECT t.id, t.created_at, t.is_blocked, t.name, t.tier, t.email, t.is_banned, t.blocked_reason, t.cluster_id, t.sandbox_scheduling_labels, t.sso_organization_id, t.sso_auto_join, t.slug, t.profile_picture_url, ut.is_default, tl.id, tl.max_length_hours, tl.concurrent_sandboxes, tl.concurrent_template_builds, tl.max_vcpu, tl.max_ram_mb, tl.disk_mb, tl.events_ttl_days FROM "public"."teams" t JOIN "public"."users_teams" ut ON ut.team_id = t.id JOIN "public"."team_limits" tl ON tl.id = t.id @@ -45,6 +45,8 @@ func (q *Queries) GetDashboardTeamsWithUsersTeamsWithTier(ctx context.Context, u &i.Team.BlockedReason, &i.Team.ClusterID, &i.Team.SandboxSchedulingLabels, + &i.Team.SsoOrganizationID, + &i.Team.SsoAutoJoin, &i.Team.Slug, &i.Team.ProfilePictureUrl, &i.IsDefault, diff --git a/packages/db/pkg/dashboard/queries/models.go b/packages/db/pkg/dashboard/queries/models.go index b53150f793..4ff7630eb0 100644 --- a/packages/db/pkg/dashboard/queries/models.go +++ b/packages/db/pkg/dashboard/queries/models.go @@ -21,6 +21,8 @@ type Team struct { BlockedReason *string ClusterID *uuid.UUID SandboxSchedulingLabels []string + SsoOrganizationID *uuid.UUID + SsoAutoJoin bool Slug string ProfilePictureUrl *string } diff --git a/packages/db/pkg/testutils/queries/models.go b/packages/db/pkg/testutils/queries/models.go index b46f20b219..9fecdc27a4 100644 --- a/packages/db/pkg/testutils/queries/models.go +++ b/packages/db/pkg/testutils/queries/models.go @@ -191,6 +191,8 @@ type Team struct { BlockedReason pgtype.Text ClusterID *uuid.UUID SandboxSchedulingLabels []string + SsoOrganizationID *uuid.UUID + SsoAutoJoin bool Slug string }