Skip to content

Commit 71d2e87

Browse files
committed
feat(dashboard-api): map Ory SSO organizations to E2B teams
Rebased onto main (#3133 external_id-after-commit) and squashed the SSO work. Enterprise SSO users land in their organization's teams instead of a personal team, with per-team control over automatic vs invite-only access: - teams.sso_organization_id + teams.sso_auto_join columns - bootstrap: SSO identities join their org's auto-join teams (idempotent, never default, no billing). The Kratos org lookup runs before the transaction so it isn't held under the per-user lock, and external_id is backfilled post-commit like main's other paths. Fails closed only when the user would end up with no team at all — a returning member (existing memberships, no default team) is never blocked, even if the org has no auto-join team. - create-team is blocked for SSO users - invites to an SSO team are allowed, but the invitee's Ory identity must belong to the team's organization (compared as UUIDs, not raw strings)
1 parent 7dae471 commit 71d2e87

19 files changed

Lines changed: 771 additions & 30 deletions
Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
1+
package handlers
2+
3+
import (
4+
"context"
5+
"errors"
6+
"net/http"
7+
"net/http/httptest"
8+
"strings"
9+
"testing"
10+
"time"
11+
12+
"github.com/gin-gonic/gin"
13+
"github.com/google/uuid"
14+
15+
"github.com/e2b-dev/infra/packages/auth/pkg/auth"
16+
authtypes "github.com/e2b-dev/infra/packages/auth/pkg/types"
17+
"github.com/e2b-dev/infra/packages/dashboard-api/internal/cfg"
18+
internalteamprovision "github.com/e2b-dev/infra/packages/dashboard-api/internal/teamprovision"
19+
"github.com/e2b-dev/infra/packages/dashboard-api/internal/userprofile"
20+
authqueries "github.com/e2b-dev/infra/packages/db/pkg/auth/queries"
21+
"github.com/e2b-dev/infra/packages/db/pkg/testutils"
22+
)
23+
24+
// ssoUserProfiles is a Provider whose SSO-organization lookups are configurable,
25+
// so tests can simulate identities that belong to an Ory organization.
26+
type ssoUserProfiles struct {
27+
handlerTestUserProfiles
28+
29+
orgBySubject map[string]uuid.UUID
30+
orgByUser map[uuid.UUID]uuid.UUID
31+
}
32+
33+
func (p ssoUserProfiles) GetIdentityOrganizationID(_ context.Context, subject string) (uuid.UUID, error) {
34+
return p.orgBySubject[subject], nil
35+
}
36+
37+
func (p ssoUserProfiles) GetUserOrganizationID(_ context.Context, userID uuid.UUID) (uuid.UUID, error) {
38+
return p.orgByUser[userID], nil
39+
}
40+
41+
func setTeamSSOOrg(t *testing.T, db *testutils.Database, teamID, orgID uuid.UUID, autoJoin bool, createdAt time.Time) {
42+
t.Helper()
43+
44+
if err := db.SqlcClient.TestsRawSQL(t.Context(),
45+
"UPDATE public.teams SET sso_organization_id = $1, sso_auto_join = $2, created_at = $3 WHERE id = $4",
46+
orgID, autoJoin, createdAt, teamID,
47+
); err != nil {
48+
t.Fatalf("failed to set team sso org: %v", err)
49+
}
50+
}
51+
52+
func TestBootstrapOIDCUser_SSOJoinsMappedTeams(t *testing.T) {
53+
t.Parallel()
54+
55+
testDB := testutils.SetupDatabase(t)
56+
ctx := t.Context()
57+
sink := &fakeTeamProvisionSink{}
58+
59+
orgID := uuid.New()
60+
subject := uuid.NewString()
61+
62+
// teamOlder is given an earlier created_at, so it is returned as the landing team.
63+
teamNewer := testutils.CreateTestTeam(t, testDB)
64+
teamOlder := testutils.CreateTestTeam(t, testDB)
65+
setTeamSSOOrg(t, testDB, teamNewer, orgID, true, time.Now().Add(-1*time.Hour))
66+
setTeamSSOOrg(t, testDB, teamOlder, orgID, true, time.Now().Add(-2*time.Hour))
67+
68+
store := &APIStore{
69+
config: cfg.Config{OryIssuerURL: "https://ory.example.test"},
70+
db: testDB.SqlcClient,
71+
authDB: testDB.AuthDB,
72+
teamProvisionSink: sink,
73+
userProfiles: ssoUserProfiles{orgBySubject: map[string]uuid.UUID{subject: orgID}},
74+
idps: userprofile.Registry{"https://ory.example.test": ssoUserProfiles{orgBySubject: map[string]uuid.UUID{subject: orgID}}},
75+
}
76+
77+
input := oidcUserBootstrapInput{
78+
OIDCIssuer: "https://ory.example.test",
79+
OIDCUserID: subject,
80+
OIDCUserEmail: "ada@example.test",
81+
}
82+
83+
team, err := store.bootstrapOIDCUser(ctx, input)
84+
if err != nil {
85+
t.Fatalf("expected sso bootstrap to succeed: %v", err)
86+
}
87+
if team.ID != teamOlder {
88+
t.Fatalf("expected landing team %s (earliest created), got %s", teamOlder, team.ID)
89+
}
90+
91+
userIdentity, err := testDB.AuthDB.Read.GetUserIdentity(ctx, authqueries.GetUserIdentityParams{
92+
OidcIss: input.OIDCIssuer,
93+
OidcSub: input.OIDCUserID,
94+
})
95+
if err != nil {
96+
t.Fatalf("expected user identity to be created: %v", err)
97+
}
98+
99+
// SSO members never get a default team; selection is not pinned.
100+
if _, err := testDB.AuthDB.Read.GetDefaultTeamByUserID(ctx, userIdentity.UserID); err == nil {
101+
t.Fatal("expected no default team for an SSO member")
102+
}
103+
104+
memberships, err := testDB.AuthDB.Read.GetTeamsWithUsersTeams(ctx, userIdentity.UserID)
105+
if err != nil {
106+
t.Fatalf("failed to read memberships: %v", err)
107+
}
108+
if len(memberships) != 2 {
109+
t.Fatalf("expected membership in both mapped teams, got %d", len(memberships))
110+
}
111+
joined := map[uuid.UUID]bool{}
112+
for _, m := range memberships {
113+
if m.IsDefault {
114+
t.Fatalf("expected no default SSO membership, but %s is default", m.Team.ID)
115+
}
116+
joined[m.Team.ID] = true
117+
}
118+
if !joined[teamOlder] || !joined[teamNewer] {
119+
t.Fatalf("expected membership in both %s and %s, got %v", teamOlder, teamNewer, joined)
120+
}
121+
122+
if len(sink.requests) != 0 {
123+
t.Fatalf("expected no billing provisioning for SSO teams, got %d", len(sink.requests))
124+
}
125+
}
126+
127+
func TestBootstrapOIDCUser_SSOFailsClosedWhenNoTeamMapped(t *testing.T) {
128+
t.Parallel()
129+
130+
testDB := testutils.SetupDatabase(t)
131+
ctx := t.Context()
132+
sink := &fakeTeamProvisionSink{}
133+
134+
orgID := uuid.New()
135+
subject := uuid.NewString()
136+
137+
store := &APIStore{
138+
config: cfg.Config{OryIssuerURL: "https://ory.example.test"},
139+
db: testDB.SqlcClient,
140+
authDB: testDB.AuthDB,
141+
teamProvisionSink: sink,
142+
userProfiles: ssoUserProfiles{orgBySubject: map[string]uuid.UUID{subject: orgID}},
143+
idps: userprofile.Registry{"https://ory.example.test": ssoUserProfiles{orgBySubject: map[string]uuid.UUID{subject: orgID}}},
144+
}
145+
146+
input := oidcUserBootstrapInput{
147+
OIDCIssuer: "https://ory.example.test",
148+
OIDCUserID: subject,
149+
OIDCUserEmail: "grace@example.test",
150+
}
151+
152+
_, err := store.bootstrapOIDCUser(ctx, input)
153+
if err == nil {
154+
t.Fatal("expected fail-closed error when organization maps to no team")
155+
}
156+
157+
var provErr *internalteamprovision.ProvisionError
158+
if !errors.As(err, &provErr) || provErr.StatusCode != http.StatusForbidden {
159+
t.Fatalf("expected 403 ProvisionError, got %v", err)
160+
}
161+
162+
// The transaction must roll back: no identity or personal team is left behind.
163+
if _, err := testDB.AuthDB.Read.GetUserIdentity(ctx, authqueries.GetUserIdentityParams{
164+
OidcIss: input.OIDCIssuer,
165+
OidcSub: input.OIDCUserID,
166+
}); err == nil {
167+
t.Fatal("expected no user identity after fail-closed bootstrap")
168+
}
169+
170+
if len(sink.requests) != 0 {
171+
t.Fatalf("expected no billing provisioning, got %d", len(sink.requests))
172+
}
173+
}
174+
175+
func TestBootstrapOIDCUser_SSOSkipsManualTeams(t *testing.T) {
176+
t.Parallel()
177+
178+
testDB := testutils.SetupDatabase(t)
179+
ctx := t.Context()
180+
sink := &fakeTeamProvisionSink{}
181+
182+
orgID := uuid.New()
183+
subject := uuid.NewString()
184+
185+
// The org's only team is manual (sso_auto_join = false): it must not be
186+
// auto-joined, so bootstrap fails closed rather than enrolling the user.
187+
manualTeam := testutils.CreateTestTeam(t, testDB)
188+
setTeamSSOOrg(t, testDB, manualTeam, orgID, false, time.Now().Add(-1*time.Hour))
189+
190+
store := &APIStore{
191+
config: cfg.Config{OryIssuerURL: "https://ory.example.test"},
192+
db: testDB.SqlcClient,
193+
authDB: testDB.AuthDB,
194+
teamProvisionSink: sink,
195+
userProfiles: ssoUserProfiles{orgBySubject: map[string]uuid.UUID{subject: orgID}},
196+
idps: userprofile.Registry{"https://ory.example.test": ssoUserProfiles{orgBySubject: map[string]uuid.UUID{subject: orgID}}},
197+
}
198+
199+
input := oidcUserBootstrapInput{
200+
OIDCIssuer: "https://ory.example.test",
201+
OIDCUserID: subject,
202+
OIDCUserEmail: "manual@example.test",
203+
}
204+
205+
_, err := store.bootstrapOIDCUser(ctx, input)
206+
var provErr *internalteamprovision.ProvisionError
207+
if !errors.As(err, &provErr) || provErr.StatusCode != http.StatusForbidden {
208+
t.Fatalf("expected 403 fail-closed when the org has only manual teams, got %v", err)
209+
}
210+
}
211+
212+
func TestCreateTeam_SSOUserRejected(t *testing.T) {
213+
t.Parallel()
214+
215+
ctx := t.Context()
216+
userID := uuid.New()
217+
218+
store := &APIStore{
219+
userProfiles: ssoUserProfiles{orgByUser: map[uuid.UUID]uuid.UUID{userID: uuid.New()}},
220+
idps: userprofile.Registry{"https://ory.example.test": ssoUserProfiles{orgByUser: map[uuid.UUID]uuid.UUID{userID: uuid.New()}}},
221+
}
222+
223+
_, err := store.createTeam(ctx, userID, "My Team")
224+
if err == nil {
225+
t.Fatal("expected SSO user to be blocked from creating a team")
226+
}
227+
228+
var provErr *internalteamprovision.ProvisionError
229+
if !errors.As(err, &provErr) || provErr.StatusCode != http.StatusForbidden {
230+
t.Fatalf("expected 403 ProvisionError, got %v", err)
231+
}
232+
}
233+
234+
func TestPostTeamsTeamIDMembers_RejectsInviteOutsideSSOOrg(t *testing.T) {
235+
t.Parallel()
236+
237+
ctx := t.Context()
238+
teamID := uuid.New()
239+
orgID := uuid.New()
240+
actingUserID := uuid.New()
241+
inviteeID := uuid.New()
242+
243+
recorder := httptest.NewRecorder()
244+
ginCtx, _ := gin.CreateTestContext(recorder)
245+
auth.SetUserIDForTest(t, ginCtx, actingUserID)
246+
auth.SetTeamInfoForTest(t, ginCtx, &authtypes.Team{
247+
Team: &authqueries.Team{ID: teamID, SsoOrganizationID: &orgID},
248+
})
249+
ginCtx.Request = httptest.NewRequestWithContext(ctx, http.MethodPost, "/", strings.NewReader(`{"email":"`+handlerTestUserEmail(inviteeID)+`"}`))
250+
ginCtx.Request.Header.Set("Content-Type", "application/json")
251+
252+
// invitee belongs to no org (orgByUser empty) → outside the team's org.
253+
store := &APIStore{userProfiles: ssoUserProfiles{}, idps: userprofile.Registry{"https://ory.example.test": ssoUserProfiles{}}}
254+
store.PostTeamsTeamIDMembers(ginCtx, teamID)
255+
256+
if recorder.Code != http.StatusForbidden {
257+
t.Fatalf("expected 403 for an invite outside the SSO org, got %d: %s", recorder.Code, recorder.Body.String())
258+
}
259+
}
260+
261+
func TestPostTeamsTeamIDMembers_AllowsInviteFromSSOOrg(t *testing.T) {
262+
t.Parallel()
263+
264+
testDB := testutils.SetupDatabase(t)
265+
ctx := t.Context()
266+
267+
orgID := uuid.New()
268+
teamID := testutils.CreateTestTeam(t, testDB)
269+
actingUserID := createHandlerTestUser(t, testDB)
270+
inviteeID := uuid.New()
271+
272+
recorder := httptest.NewRecorder()
273+
ginCtx, _ := gin.CreateTestContext(recorder)
274+
auth.SetUserIDForTest(t, ginCtx, actingUserID)
275+
auth.SetTeamInfoForTest(t, ginCtx, &authtypes.Team{
276+
Team: &authqueries.Team{ID: teamID, SsoOrganizationID: &orgID},
277+
})
278+
ginCtx.Request = httptest.NewRequestWithContext(ctx, http.MethodPost, "/", strings.NewReader(`{"email":"`+handlerTestUserEmail(inviteeID)+`"}`))
279+
ginCtx.Request.Header.Set("Content-Type", "application/json")
280+
281+
// invitee belongs to the same org as the team → allowed.
282+
store := &APIStore{
283+
db: testDB.SqlcClient,
284+
authDB: testDB.AuthDB,
285+
authService: noopAuthService{},
286+
userProfiles: ssoUserProfiles{orgByUser: map[uuid.UUID]uuid.UUID{inviteeID: orgID}},
287+
idps: userprofile.Registry{"https://ory.example.test": ssoUserProfiles{orgByUser: map[uuid.UUID]uuid.UUID{inviteeID: orgID}}},
288+
}
289+
store.PostTeamsTeamIDMembers(ginCtx, teamID)
290+
291+
if ginCtx.Writer.Status() != http.StatusCreated {
292+
t.Fatalf("expected 201 for an in-org invite, got %d: %s", ginCtx.Writer.Status(), recorder.Body.String())
293+
}
294+
295+
memberships, err := testDB.AuthDB.Read.GetTeamsWithUsersTeams(ctx, inviteeID)
296+
if err != nil {
297+
t.Fatalf("failed to read memberships: %v", err)
298+
}
299+
if len(memberships) != 1 || memberships[0].Team.ID != teamID {
300+
t.Fatalf("expected invitee to be a member of %s, got %v", teamID, memberships)
301+
}
302+
}

packages/dashboard-api/internal/handlers/store.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ type APIStore struct {
2929
authService sharedauth.Service
3030
teamProvisionSink internalteamprovision.TeamProvisionSink
3131
userProfiles userprofile.Provider
32+
idps userprofile.Registry
3233
}
3334

3435
func NewAPIStore(
@@ -48,6 +49,7 @@ func NewAPIStore(
4849
authService: authService,
4950
teamProvisionSink: teamProvisionSink,
5051
userProfiles: userProfiles,
52+
idps: userprofile.Registry{config.OryIssuerURL: userProfiles},
5153
}
5254
}
5355

0 commit comments

Comments
 (0)