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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions packages/dashboard-api/internal/handlers/sandbox_record.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
296 changes: 296 additions & 0 deletions packages/dashboard-api/internal/handlers/sso_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
40 changes: 40 additions & 0 deletions packages/dashboard-api/internal/handlers/team_members.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Comment thread
ben-fornefeld marked this conversation as resolved.
Comment thread
ben-fornefeld marked this conversation as resolved.
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")
Expand Down Expand Up @@ -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")
Expand Down
Loading
Loading