From ba11b1af8575e54916693971bfe4331574390c48 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Fri, 22 May 2026 18:35:07 -0700 Subject: [PATCH 01/23] feat(dashboard-api): add OIDC user bootstrap endpoint --- .../dashboard-api/internal/api/api.gen.go | 140 +++++++------ .../handlers/admin_users_bootstrap.go | 36 +++- .../internal/handlers/team_handlers_test.go | 124 +++++++++--- .../handlers/utils_team_provisioning.go | 185 +++++++++++++----- .../internal/userprofile/provider.go | 1 + .../internal/userprofile/supabase.go | 53 +++++ spec/openapi-dashboard.yml | 43 ++++ 7 files changed, 443 insertions(+), 139 deletions(-) diff --git a/packages/dashboard-api/internal/api/api.gen.go b/packages/dashboard-api/internal/api/api.gen.go index 94b4a35bcb..ce3446e379 100644 --- a/packages/dashboard-api/internal/api/api.gen.go +++ b/packages/dashboard-api/internal/api/api.gen.go @@ -78,6 +78,13 @@ type AdminAuthProviderProfilesResponse struct { Profiles []AdminAuthProviderProfile `json:"profiles"` } +// AdminAuthProviderUserBootstrapRequest defines model for AdminAuthProviderUserBootstrapRequest. +type AdminAuthProviderUserBootstrapRequest struct { + OidcUserEmail openapi_types.Email `json:"oidc_user_email"` + OidcUserId string `json:"oidc_user_id"` + OidcUserName *string `json:"oidc_user_name"` +} + // BuildInfo defines model for BuildInfo. type BuildInfo struct { // CreatedAt Build creation timestamp in RFC3339 format. @@ -394,6 +401,9 @@ type PostAdminUserProfilesByEmailJSONRequestBody = AdminAuthProviderProfilesLook // PostAdminUserProfilesResolveJSONRequestBody defines body for PostAdminUserProfilesResolve for application/json ContentType. type PostAdminUserProfilesResolveJSONRequestBody = AdminAuthProviderProfilesResolveRequest +// PostAdminUsersBootstrapJSONRequestBody defines body for PostAdminUsersBootstrap for application/json ContentType. +type PostAdminUsersBootstrapJSONRequestBody = AdminAuthProviderUserBootstrapRequest + // PostTeamsJSONRequestBody defines body for PostTeams for application/json ContentType. type PostTeamsJSONRequestBody = CreateTeamRequest @@ -414,6 +424,9 @@ type ServerInterface interface { // Get user profile // (GET /admin/user-profiles/{userId}) GetAdminUserProfilesUserId(c *gin.Context, userId UserId) + // Bootstrap auth provider user + // (POST /admin/users/bootstrap) + PostAdminUsersBootstrap(c *gin.Context) // Bootstrap user // (POST /admin/users/{userId}/bootstrap) PostAdminUsersUserIdBootstrap(c *gin.Context, userId UserId) @@ -524,6 +537,21 @@ func (siw *ServerInterfaceWrapper) GetAdminUserProfilesUserId(c *gin.Context) { siw.Handler.GetAdminUserProfilesUserId(c, userId) } +// PostAdminUsersBootstrap operation middleware +func (siw *ServerInterfaceWrapper) PostAdminUsersBootstrap(c *gin.Context) { + + c.Set(string(AdminTokenAuthScopes), []string{}) + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.PostAdminUsersBootstrap(c) +} + // PostAdminUsersUserIdBootstrap operation middleware func (siw *ServerInterfaceWrapper) PostAdminUsersUserIdBootstrap(c *gin.Context) { @@ -977,6 +1005,7 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options router.POST(options.BaseURL+"/admin/user-profiles/by-email", wrapper.PostAdminUserProfilesByEmail) router.POST(options.BaseURL+"/admin/user-profiles/resolve", wrapper.PostAdminUserProfilesResolve) router.GET(options.BaseURL+"/admin/user-profiles/:userId", wrapper.GetAdminUserProfilesUserId) + router.POST(options.BaseURL+"/admin/users/bootstrap", wrapper.PostAdminUsersBootstrap) router.POST(options.BaseURL+"/admin/users/:userId/bootstrap", wrapper.PostAdminUsersUserIdBootstrap) router.GET(options.BaseURL+"/builds", wrapper.GetBuilds) router.GET(options.BaseURL+"/builds/statuses", wrapper.GetBuildsStatuses) @@ -998,61 +1027,62 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options // const string: with thousands of chunks the chained `+` fold is several // times slower for the Go compiler than parsing a slice literal. var swaggerSpec = []string{ - "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", - "87fxoPHQwK3xwK27vl7/PwAA//8=", + "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", } // decodeSpec returns the embedded OpenAPI spec as raw JSON bytes, diff --git a/packages/dashboard-api/internal/handlers/admin_users_bootstrap.go b/packages/dashboard-api/internal/handlers/admin_users_bootstrap.go index 2ac63bc5fa..34f8f72877 100644 --- a/packages/dashboard-api/internal/handlers/admin_users_bootstrap.go +++ b/packages/dashboard-api/internal/handlers/admin_users_bootstrap.go @@ -1,11 +1,14 @@ package handlers import ( + "fmt" "net/http" + "strings" "github.com/gin-gonic/gin" "github.com/e2b-dev/infra/packages/dashboard-api/internal/api" + "github.com/e2b-dev/infra/packages/shared/pkg/ginutils" "github.com/e2b-dev/infra/packages/shared/pkg/telemetry" ) @@ -13,7 +16,7 @@ func (s *APIStore) PostAdminUsersUserIdBootstrap(c *gin.Context, userId api.User ctx := c.Request.Context() telemetry.ReportEvent(ctx, "bootstrap user") - team, err := s.bootstrapUser(ctx, userId) + team, err := s.bootstrapSupabaseUser(ctx, userId) if err != nil { s.handleProvisioningError(ctx, c, "bootstrap user", err) @@ -25,3 +28,34 @@ func (s *APIStore) PostAdminUsersUserIdBootstrap(c *gin.Context, userId api.User Slug: team.Slug, }) } + +func (s *APIStore) PostAdminUsersBootstrap(c *gin.Context) { + ctx := c.Request.Context() + telemetry.ReportEvent(ctx, "bootstrap auth provider user") + + body, err := ginutils.ParseBody[api.AdminAuthProviderUserBootstrapRequest](ctx, c) + if err != nil { + telemetry.ReportErrorByCode(ctx, http.StatusBadRequest, "bootstrap auth provider user failed", fmt.Errorf("parse bootstrap auth provider user request: %w", err)) + s.sendAPIStoreError(c, http.StatusBadRequest, "Invalid request body") + + return + } + + oidcUserID := strings.TrimSpace(body.OidcUserId) + oidcUserEmail := strings.TrimSpace(string(body.OidcUserEmail)) + team, err := s.bootstrapOIDCUser(ctx, oidcUserBootstrapInput{ + OIDCUserID: oidcUserID, + OIDCUserEmail: oidcUserEmail, + OIDCUserName: body.OidcUserName, + }) + if err != nil { + s.handleProvisioningError(ctx, c, "bootstrap auth provider user", err) + + return + } + + c.JSON(http.StatusOK, api.TeamResolveResponse{ + Id: team.ID, + Slug: team.Slug, + }) +} diff --git a/packages/dashboard-api/internal/handlers/team_handlers_test.go b/packages/dashboard-api/internal/handlers/team_handlers_test.go index 55c6643c93..b701a185a3 100644 --- a/packages/dashboard-api/internal/handlers/team_handlers_test.go +++ b/packages/dashboard-api/internal/handlers/team_handlers_test.go @@ -16,11 +16,12 @@ import ( "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" 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" - supabasequeries "github.com/e2b-dev/infra/packages/db/pkg/supabase/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" @@ -384,51 +385,41 @@ func handlerTestUserEmail(userID uuid.UUID) string { return "user-" + userID.String() + "@example.com" } -func TestDefaultTeamNameFromAuthUser(t *testing.T) { +func TestDefaultTeamNameFromProfile(t *testing.T) { t.Parallel() tests := []struct { - name string - authUser supabasequeries.AuthUser - want string + name string + profile userprofile.Profile + want string }{ { - name: "first name", - authUser: supabasequeries.AuthUser{ - Email: "fallback@example.com", - RawUserMetaData: []byte(`{"first_name":"ada","username":"fallback"}`), + name: "profile name", + profile: userprofile.Profile{ + Email: "fallback@example.com", + Name: "ada", }, want: "Ada's Default Team", }, { name: "full name first word", - authUser: supabasequeries.AuthUser{ - Email: "fallback@example.com", - RawUserMetaData: []byte(`{"full_name":"grace hopper"}`), + profile: userprofile.Profile{ + Email: "fallback@example.com", + Name: "grace hopper", }, want: "Grace's Default Team", }, - { - name: "username", - authUser: supabasequeries.AuthUser{ - Email: "fallback@example.com", - RawUserMetaData: []byte(`{"username":"linus"}`), - }, - want: "Linus's Default Team", - }, { name: "email prefix", - authUser: supabasequeries.AuthUser{ + profile: userprofile.Profile{ Email: "barbara@example.com", }, want: "Barbara's Default Team", }, { - name: "fallback", - authUser: supabasequeries.AuthUser{ - RawUserMetaData: []byte(`{`), - }, - want: "User's Default Team", + name: "fallback", + profile: userprofile.Profile{}, + want: "User's Default Team", }, } @@ -436,9 +427,9 @@ func TestDefaultTeamNameFromAuthUser(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - got := defaultTeamNameFromAuthUser(tt.authUser) + got := defaultTeamNameFromProfile(tt.profile) if got != tt.want { - t.Fatalf("defaultTeamNameFromAuthUser() = %q, want %q", got, tt.want) + t.Fatalf("defaultTeamNameFromProfile() = %q, want %q", got, tt.want) } }) } @@ -501,6 +492,7 @@ WHERE id = $1 authDB: testDB.AuthDB, supabaseDB: testDB.SupabaseDB, teamProvisionSink: sink, + userProfiles: userprofile.NewSupabaseProvider(testDB.SupabaseDB), } store.PostAdminUsersUserIdBootstrap(ginCtx, userID) @@ -540,6 +532,73 @@ WHERE id = $1 } } +func TestBootstrapAuthProviderUser_CreatesIdentityAndDefaultTeam(t *testing.T) { + t.Parallel() + + testDB := testutils.SetupDatabase(t) + ctx := t.Context() + sink := &fakeTeamProvisionSink{} + + store := &APIStore{ + config: cfg.Config{ + 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, + supabaseDB: testDB.SupabaseDB, + teamProvisionSink: sink, + } + + input := oidcUserBootstrapInput{ + OIDCUserID: uuid.NewString(), + OIDCUserEmail: "ada@example.test", + OIDCUserName: nil, + } + + 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: "https://ory.example.test", + 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) + } +} + func TestPostUsersBootstrap_ProvisioningFailureKeepsCreatedDefaultTeam(t *testing.T) { t.Parallel() @@ -573,6 +632,7 @@ func TestPostUsersBootstrap_ProvisioningFailureKeepsCreatedDefaultTeam(t *testin authDB: testDB.AuthDB, supabaseDB: testDB.SupabaseDB, teamProvisionSink: sink, + userProfiles: userprofile.NewSupabaseProvider(testDB.SupabaseDB), } store.PostAdminUsersUserIdBootstrap(ginCtx, userID) @@ -620,6 +680,7 @@ func TestPostUsersBootstrap_UnknownUserReturnsNotFound(t *testing.T) { authDB: testDB.AuthDB, supabaseDB: testDB.SupabaseDB, teamProvisionSink: sink, + userProfiles: userprofile.NewSupabaseProvider(testDB.SupabaseDB), } store.PostAdminUsersUserIdBootstrap(ginCtx, userID) @@ -660,6 +721,11 @@ func TestBootstrapUser_ConcurrentRequestsCreateSingleDefaultTeam(t *testing.T) { authDB: testDB.AuthDB, supabaseDB: testDB.SupabaseDB, teamProvisionSink: sink, + userProfiles: userprofile.NewSupabaseProvider(testDB.SupabaseDB), + } + profile, err := store.bootstrapUserProfileFromSupabase(ctx, userID) + if err != nil { + t.Fatalf("failed to resolve bootstrap profile: %v", err) } var wg sync.WaitGroup @@ -668,7 +734,7 @@ func TestBootstrapUser_ConcurrentRequestsCreateSingleDefaultTeam(t *testing.T) { for range 2 { wg.Go(func() { - team, err := store.bootstrapUser(ctx, userID) + team, err := store.bootstrapUser(ctx, profile) if err != nil { errs <- err diff --git a/packages/dashboard-api/internal/handlers/utils_team_provisioning.go b/packages/dashboard-api/internal/handlers/utils_team_provisioning.go index 1fe9cf97db..54f85b6e68 100644 --- a/packages/dashboard-api/internal/handlers/utils_team_provisioning.go +++ b/packages/dashboard-api/internal/handlers/utils_team_provisioning.go @@ -2,7 +2,6 @@ package handlers import ( "context" - "encoding/json" "errors" "fmt" "net/http" @@ -15,9 +14,9 @@ import ( "go.opentelemetry.io/otel/attribute" 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" - supabasequeries "github.com/e2b-dev/infra/packages/db/pkg/supabase/queries" "github.com/e2b-dev/infra/packages/shared/pkg/teamprovision" "github.com/e2b-dev/infra/packages/shared/pkg/telemetry" ) @@ -29,6 +28,8 @@ const ( teamProvisionRollbackTimeout = 5 * time.Second ) +var oidcUserNamespace = uuid.MustParse("2f4c7cc1-b0e5-4ec8-b8ee-6f0d7a8c23f0") + type provisionedTeam struct { ID uuid.UUID Name string @@ -38,18 +39,117 @@ type provisionedTeam struct { BlockedReason *string } -func (s *APIStore) bootstrapUser(ctx context.Context, userID uuid.UUID) (provisionedTeam, error) { - authUser, err := s.supabaseDB.Write.GetAuthUserByID(ctx, userID) - if dberrors.IsNotFoundError(err) { - return provisionedTeam{}, &internalteamprovision.ProvisionError{ +type bootstrapUserProfile struct { + UserID uuid.UUID + Email string + DefaultTeamName string +} + +type bootstrapUserIdentity struct { + Issuer string + Subject string +} + +type oidcUserBootstrapInput struct { + OIDCUserID string + OIDCUserEmail string + OIDCUserName *string +} + +func (s *APIStore) bootstrapSupabaseUser(ctx context.Context, userID uuid.UUID) (provisionedTeam, error) { + profile, err := s.bootstrapUserProfileFromSupabase(ctx, userID) + if err != nil { + return provisionedTeam{}, err + } + + return s.bootstrapUser(ctx, profile) +} + +func (s *APIStore) bootstrapUserProfileFromSupabase(ctx context.Context, userID uuid.UUID) (bootstrapUserProfile, error) { + profiles, err := s.userProfiles.GetProfilesByUserID(ctx, []uuid.UUID{userID}) + if err != nil { + return bootstrapUserProfile{}, fmt.Errorf("get user profile: %w", err) + } + + profile, ok := profiles[userID] + if !ok { + return bootstrapUserProfile{}, &internalteamprovision.ProvisionError{ StatusCode: http.StatusNotFound, Message: "User not found", } } + + return bootstrapUserProfile{ + UserID: userID, + Email: profile.Email, + DefaultTeamName: defaultTeamNameFromProfile(profile), + }, nil +} + +func (s *APIStore) bootstrapOIDCUser(ctx context.Context, input oidcUserBootstrapInput) (provisionedTeam, error) { + issuer, err := s.oidcIssuer() if err != nil { - return provisionedTeam{}, fmt.Errorf("get auth user: %w", err) + return provisionedTeam{}, err + } + userID, err := s.resolveOIDCUserID(ctx, issuer, input.OIDCUserID) + if err != nil { + return provisionedTeam{}, err + } + + profile := bootstrapUserProfile{ + UserID: userID, + Email: input.OIDCUserEmail, + DefaultTeamName: defaultTeamNameFromOIDCUserName(input.OIDCUserName), + } + + return s.bootstrapUserWithIdentity(ctx, profile, &bootstrapUserIdentity{ + Issuer: issuer, + Subject: input.OIDCUserID, + }) +} + +func (s *APIStore) oidcIssuer() (string, error) { + if len(s.config.AuthProvider.JWT) != 1 { + return "", &internalteamprovision.ProvisionError{ + StatusCode: http.StatusBadRequest, + Message: "Expected exactly one OIDC auth provider issuer", + } } + issuer := strings.TrimSpace(s.config.AuthProvider.JWT[0].Issuer.URL) + if issuer == "" { + return "", &internalteamprovision.ProvisionError{ + StatusCode: http.StatusBadRequest, + Message: "OIDC auth provider issuer is not configured", + } + } + + return issuer, nil +} + +func (s *APIStore) resolveOIDCUserID(ctx context.Context, issuer, subject string) (uuid.UUID, error) { + row, err := s.authDB.Read.GetUserIdentity(ctx, authqueries.GetUserIdentityParams{ + OidcIss: issuer, + OidcSub: subject, + }) + if err == nil { + return row.UserID, nil + } + if !dberrors.IsNotFoundError(err) { + return uuid.Nil, fmt.Errorf("get user identity: %w", err) + } + + if parsed, err := uuid.Parse(subject); err == nil { + return parsed, nil + } + + return uuid.NewSHA1(oidcUserNamespace, []byte(issuer+"\x00"+subject)), nil +} +func (s *APIStore) bootstrapUser(ctx context.Context, profile bootstrapUserProfile) (provisionedTeam, error) { + return s.bootstrapUserWithIdentity(ctx, profile, nil) +} + +func (s *APIStore) bootstrapUserWithIdentity(ctx context.Context, profile bootstrapUserProfile, identity *bootstrapUserIdentity) (provisionedTeam, error) { authTxDB, tx, err := s.authDB.WithTx(ctx) if err != nil { return provisionedTeam{}, fmt.Errorf("start transaction: %w", err) @@ -58,16 +158,25 @@ func (s *APIStore) bootstrapUser(ctx context.Context, userID uuid.UUID) (provisi _ = tx.Rollback(ctx) }() - if err := authTxDB.UpsertPublicUser(ctx, authUser.ID); err != nil { + if err := authTxDB.UpsertPublicUser(ctx, profile.UserID); err != nil { return provisionedTeam{}, fmt.Errorf("upsert public user: %w", err) } + if identity != nil { + if err := authTxDB.UpsertPublicIdentity(ctx, authqueries.UpsertPublicIdentityParams{ + OidcIss: identity.Issuer, + OidcSub: identity.Subject, + UserID: profile.UserID, + }); err != nil { + return provisionedTeam{}, fmt.Errorf("upsert public identity: %w", err) + } + } // Serialize bootstrap for a user even when they have no team memberships yet. - if _, err := authTxDB.LockPublicUserForUpdate(ctx, authUser.ID); err != nil { + if _, err := authTxDB.LockPublicUserForUpdate(ctx, profile.UserID); err != nil { return provisionedTeam{}, fmt.Errorf("lock public user: %w", err) } - existingTeam, err := authTxDB.GetDefaultTeamByUserID(ctx, userID) + 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) @@ -77,7 +186,7 @@ func (s *APIStore) bootstrapUser(ctx context.Context, userID uuid.UUID) (provisi TeamID: existingTeam.ID, TeamName: existingTeam.Name, TeamEmail: existingTeam.Email, - CreatorUserID: userID, + CreatorUserID: profile.UserID, Reason: teamprovision.ReasonDefaultSignupTeam, } _ = s.teamProvisionSink.ProvisionTeam(ctx, req) @@ -95,11 +204,10 @@ func (s *APIStore) bootstrapUser(ctx context.Context, userID uuid.UUID) (provisi return provisionedTeam{}, fmt.Errorf("get default team: %w", err) } - defaultTeamName := defaultTeamNameFromAuthUser(authUser) team, err := authTxDB.CreateTeam(ctx, authqueries.CreateTeamParams{ - Name: defaultTeamName, + Name: profile.DefaultTeamName, Tier: baseTierID, - Email: authUser.Email, + Email: profile.Email, IsBlocked: false, BlockedReason: nil, }) @@ -108,7 +216,7 @@ func (s *APIStore) bootstrapUser(ctx context.Context, userID uuid.UUID) (provisi } if err := authTxDB.CreateTeamMembership(ctx, authqueries.CreateTeamMembershipParams{ - UserID: userID, + UserID: profile.UserID, TeamID: team.ID, IsDefault: true, AddedBy: nil, @@ -124,7 +232,7 @@ func (s *APIStore) bootstrapUser(ctx context.Context, userID uuid.UUID) (provisi TeamID: team.ID, TeamName: team.Name, TeamEmail: team.Email, - CreatorUserID: userID, + CreatorUserID: profile.UserID, Reason: teamprovision.ReasonDefaultSignupTeam, } _ = s.teamProvisionSink.ProvisionTeam(ctx, req) @@ -260,53 +368,22 @@ func validateTeamCreationAllowed(ctx context.Context, authTxDB *authqueries.Quer return nil } -func defaultTeamNameFromAuthUser(authUser supabasequeries.AuthUser) string { - metadata := rawUserMetadata(authUser.RawUserMetaData) - +func defaultTeamNameFromProfile(profile userprofile.Profile) string { baseName := firstNonEmpty( - firstWord(metadataString(metadata, "first_name")), - firstWord(metadataString(metadata, "firstName")), - firstWord(metadataString(metadata, "given_name")), - firstWord(metadataString(metadata, "givenName")), - firstWord(metadataString(metadata, "name")), - firstWord(metadataString(metadata, "full_name")), - firstWord(metadataString(metadata, "fullName")), - metadataString(metadata, "username"), - metadataString(metadata, "user_name"), - metadataString(metadata, "userName"), - metadataString(metadata, "preferred_username"), - metadataString(metadata, "preferredUsername"), - emailPrefix(authUser.Email), + firstWord(profile.Name), + emailPrefix(profile.Email), "User", ) return capitalizeFirstLetter(baseName) + "'s Default Team" } -func rawUserMetadata(raw []byte) map[string]any { - if len(raw) == 0 { - return nil - } - - var metadata map[string]any - if err := json.Unmarshal(raw, &metadata); err != nil { - return nil - } - - return metadata -} - -func metadataString(metadata map[string]any, key string) string { - if metadata == nil { - return "" - } - - value, ok := metadata[key].(string) - if !ok { - return "" +func defaultTeamNameFromOIDCUserName(name *string) string { + if name == nil || strings.TrimSpace(*name) == "" { + return "Default Team" } - return strings.TrimSpace(value) + return capitalizeFirstLetter(firstWord(*name)) + "'s Default Team" } func firstWord(value string) string { diff --git a/packages/dashboard-api/internal/userprofile/provider.go b/packages/dashboard-api/internal/userprofile/provider.go index 715719f053..8c072f9b84 100644 --- a/packages/dashboard-api/internal/userprofile/provider.go +++ b/packages/dashboard-api/internal/userprofile/provider.go @@ -9,6 +9,7 @@ import ( type Profile struct { UserID uuid.UUID Email string + Name string } type Provider interface { diff --git a/packages/dashboard-api/internal/userprofile/supabase.go b/packages/dashboard-api/internal/userprofile/supabase.go index e445f8338f..b7e5168c66 100644 --- a/packages/dashboard-api/internal/userprofile/supabase.go +++ b/packages/dashboard-api/internal/userprofile/supabase.go @@ -2,6 +2,7 @@ package userprofile import ( "context" + "encoding/json" "strings" "github.com/google/uuid" @@ -61,10 +62,62 @@ func (p *supabaseProvider) FindProfilesByEmail(ctx context.Context, email string } func profileFromAuthUser(user supabasequeries.AuthUser) Profile { + metadata := rawUserMetadata(user.RawUserMetaData) + return Profile{ UserID: user.ID, Email: user.Email, + Name: firstNonEmpty( + metadataString(metadata, "first_name"), + metadataString(metadata, "firstName"), + metadataString(metadata, "given_name"), + metadataString(metadata, "givenName"), + metadataString(metadata, "name"), + metadataString(metadata, "full_name"), + metadataString(metadata, "fullName"), + metadataString(metadata, "username"), + metadataString(metadata, "user_name"), + metadataString(metadata, "userName"), + metadataString(metadata, "preferred_username"), + metadataString(metadata, "preferredUsername"), + ), + } +} + +func rawUserMetadata(raw []byte) map[string]any { + if len(raw) == 0 { + return nil + } + + var metadata map[string]any + if err := json.Unmarshal(raw, &metadata); err != nil { + return nil + } + + return metadata +} + +func metadataString(metadata map[string]any, key string) string { + if metadata == nil { + return "" + } + + value, ok := metadata[key].(string) + if !ok { + return "" } + + return strings.TrimSpace(value) +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + + return "" } func uniqueUUIDs(ids []uuid.UUID) []uuid.UUID { diff --git a/spec/openapi-dashboard.yml b/spec/openapi-dashboard.yml index be8c2102e1..a8d86bfef0 100644 --- a/spec/openapi-dashboard.yml +++ b/spec/openapi-dashboard.yml @@ -217,6 +217,23 @@ components: type: string format: email + AdminAuthProviderUserBootstrapRequest: + type: object + required: + - oidc_user_id + - oidc_user_email + - oidc_user_name + properties: + oidc_user_id: + type: string + minLength: 1 + oidc_user_email: + type: string + format: email + oidc_user_name: + type: string + nullable: true + BuildStatus: type: string description: Build status mapped for dashboard clients. @@ -848,6 +865,32 @@ paths: "500": $ref: "#/components/responses/500" + /admin/users/bootstrap: + post: + summary: Bootstrap auth provider user + tags: [teams] + security: + - AdminTokenAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AdminAuthProviderUserBootstrapRequest" + responses: + "200": + description: Successfully bootstrapped user. + content: + application/json: + schema: + $ref: "#/components/schemas/TeamResolveResponse" + "400": + $ref: "#/components/responses/400" + "401": + $ref: "#/components/responses/401" + "500": + $ref: "#/components/responses/500" + /admin/user-profiles/resolve: post: summary: Resolve user profiles From f1194de3daa100300026c2376427754b4f541b16 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Fri, 22 May 2026 18:45:01 -0700 Subject: [PATCH 02/23] fix(dashboard-api): generate internal ids for OIDC bootstrap --- .../internal/handlers/utils_team_provisioning.go | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/dashboard-api/internal/handlers/utils_team_provisioning.go b/packages/dashboard-api/internal/handlers/utils_team_provisioning.go index 54f85b6e68..cc0d049214 100644 --- a/packages/dashboard-api/internal/handlers/utils_team_provisioning.go +++ b/packages/dashboard-api/internal/handlers/utils_team_provisioning.go @@ -28,8 +28,6 @@ const ( teamProvisionRollbackTimeout = 5 * time.Second ) -var oidcUserNamespace = uuid.MustParse("2f4c7cc1-b0e5-4ec8-b8ee-6f0d7a8c23f0") - type provisionedTeam struct { ID uuid.UUID Name string @@ -138,11 +136,7 @@ func (s *APIStore) resolveOIDCUserID(ctx context.Context, issuer, subject string return uuid.Nil, fmt.Errorf("get user identity: %w", err) } - if parsed, err := uuid.Parse(subject); err == nil { - return parsed, nil - } - - return uuid.NewSHA1(oidcUserNamespace, []byte(issuer+"\x00"+subject)), nil + return uuid.New(), nil } func (s *APIStore) bootstrapUser(ctx context.Context, profile bootstrapUserProfile) (provisionedTeam, error) { From 145d6bc82991217efe63cc6dbc0d4dae6b0a4f6b Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Sat, 23 May 2026 23:24:12 -0700 Subject: [PATCH 03/23] fix(dashboard-api): keep profile name separate from team naming --- .../internal/handlers/team_handlers_test.go | 2 +- .../internal/userprofile/supabase.go | 39 ++++++++----- .../internal/userprofile/supabase_test.go | 58 +++++++++++++++++++ 3 files changed, 84 insertions(+), 15 deletions(-) create mode 100644 packages/dashboard-api/internal/userprofile/supabase_test.go diff --git a/packages/dashboard-api/internal/handlers/team_handlers_test.go b/packages/dashboard-api/internal/handlers/team_handlers_test.go index b701a185a3..518f6abc59 100644 --- a/packages/dashboard-api/internal/handlers/team_handlers_test.go +++ b/packages/dashboard-api/internal/handlers/team_handlers_test.go @@ -402,7 +402,7 @@ func TestDefaultTeamNameFromProfile(t *testing.T) { want: "Ada's Default Team", }, { - name: "full name first word", + name: "profile full name first word", profile: userprofile.Profile{ Email: "fallback@example.com", Name: "grace hopper", diff --git a/packages/dashboard-api/internal/userprofile/supabase.go b/packages/dashboard-api/internal/userprofile/supabase.go index b7e5168c66..945103f58b 100644 --- a/packages/dashboard-api/internal/userprofile/supabase.go +++ b/packages/dashboard-api/internal/userprofile/supabase.go @@ -67,23 +67,34 @@ func profileFromAuthUser(user supabasequeries.AuthUser) Profile { return Profile{ UserID: user.ID, Email: user.Email, - Name: firstNonEmpty( - metadataString(metadata, "first_name"), - metadataString(metadata, "firstName"), - metadataString(metadata, "given_name"), - metadataString(metadata, "givenName"), - metadataString(metadata, "name"), - metadataString(metadata, "full_name"), - metadataString(metadata, "fullName"), - metadataString(metadata, "username"), - metadataString(metadata, "user_name"), - metadataString(metadata, "userName"), - metadataString(metadata, "preferred_username"), - metadataString(metadata, "preferredUsername"), - ), + Name: displayNameFromMetadata(metadata), } } +func displayNameFromMetadata(metadata map[string]any) string { + firstName := firstNonEmpty( + metadataString(metadata, "first_name"), + metadataString(metadata, "firstName"), + metadataString(metadata, "given_name"), + metadataString(metadata, "givenName"), + ) + lastName := firstNonEmpty( + metadataString(metadata, "last_name"), + metadataString(metadata, "lastName"), + metadataString(metadata, "family_name"), + metadataString(metadata, "familyName"), + ) + if firstName != "" || lastName != "" { + return strings.TrimSpace(strings.Join([]string{firstName, lastName}, " ")) + } + + return firstNonEmpty( + metadataString(metadata, "name"), + metadataString(metadata, "full_name"), + metadataString(metadata, "fullName"), + ) +} + func rawUserMetadata(raw []byte) map[string]any { if len(raw) == 0 { return nil diff --git a/packages/dashboard-api/internal/userprofile/supabase_test.go b/packages/dashboard-api/internal/userprofile/supabase_test.go new file mode 100644 index 0000000000..277c9fb903 --- /dev/null +++ b/packages/dashboard-api/internal/userprofile/supabase_test.go @@ -0,0 +1,58 @@ +package userprofile + +import ( + "testing" + + "github.com/google/uuid" + + supabasequeries "github.com/e2b-dev/infra/packages/db/pkg/supabase/queries" +) + +func TestProfileFromAuthUserNamePrecedence(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + user supabasequeries.AuthUser + want string + }{ + { + name: "first and last name", + user: supabasequeries.AuthUser{ + ID: uuid.New(), + Email: "fallback@example.com", + RawUserMetaData: []byte(`{"first_name":"ada","last_name":"lovelace","username":"fallback user"}`), + }, + want: "ada lovelace", + }, + { + name: "full name fallback", + user: supabasequeries.AuthUser{ + ID: uuid.New(), + Email: "fallback@example.com", + RawUserMetaData: []byte(`{"full_name":"grace hopper"}`), + }, + want: "grace hopper", + }, + { + name: "username is not profile name", + user: supabasequeries.AuthUser{ + ID: uuid.New(), + Email: "fallback@example.com", + RawUserMetaData: []byte(`{"username":"john doe"}`), + }, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := profileFromAuthUser(tt.user) + if got.Name != tt.want { + t.Fatalf("profileFromAuthUser().Name = %q, want %q", got.Name, tt.want) + } + }) + } +} From 6be72e7802e47f792634c78620970961534f4fcb Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Sun, 24 May 2026 20:35:09 -0700 Subject: [PATCH 04/23] fix(dashboard-api): serialize OIDC bootstrap identity claim - Change UpsertPublicIdentity to return the canonical user_id and stop overwriting it on conflict so concurrent bootstraps converge on a single (issuer, subject) -> user_id mapping. - Move identity resolution inside the bootstrap transaction; clean up the orphan candidate public.users row when a concurrent request wins the race for a fresh identity. - Reject blank oidc_user_id / oidc_user_email in PostAdminUsersBootstrap so empty strings can't share a single placeholder identity. - Export userprofile.FirstNonEmpty and drop the duplicate helper in handlers. --- .../handlers/admin_users_bootstrap.go | 7 ++ .../internal/handlers/team_handlers_test.go | 119 ++++++++++++++++++ .../handlers/utils_team_provisioning.go | 63 +++++----- .../internal/userprofile/supabase.go | 12 +- .../queries/upsert_public_identity.sql.go | 12 +- .../upsert_public_identity.sql | 6 +- 6 files changed, 170 insertions(+), 49 deletions(-) diff --git a/packages/dashboard-api/internal/handlers/admin_users_bootstrap.go b/packages/dashboard-api/internal/handlers/admin_users_bootstrap.go index 34f8f72877..8d64962e42 100644 --- a/packages/dashboard-api/internal/handlers/admin_users_bootstrap.go +++ b/packages/dashboard-api/internal/handlers/admin_users_bootstrap.go @@ -43,6 +43,13 @@ func (s *APIStore) PostAdminUsersBootstrap(c *gin.Context) { oidcUserID := strings.TrimSpace(body.OidcUserId) oidcUserEmail := strings.TrimSpace(string(body.OidcUserEmail)) + if oidcUserID == "" || oidcUserEmail == "" { + telemetry.ReportErrorByCode(ctx, http.StatusBadRequest, "bootstrap auth provider user failed", fmt.Errorf("oidc_user_id and oidc_user_email must be non-empty")) + s.sendAPIStoreError(c, http.StatusBadRequest, "oidc_user_id and oidc_user_email must be non-empty") + + return + } + team, err := s.bootstrapOIDCUser(ctx, oidcUserBootstrapInput{ OIDCUserID: oidcUserID, OIDCUserEmail: oidcUserEmail, diff --git a/packages/dashboard-api/internal/handlers/team_handlers_test.go b/packages/dashboard-api/internal/handlers/team_handlers_test.go index 518f6abc59..8509d5b6d7 100644 --- a/packages/dashboard-api/internal/handlers/team_handlers_test.go +++ b/packages/dashboard-api/internal/handlers/team_handlers_test.go @@ -599,6 +599,125 @@ func TestBootstrapAuthProviderUser_CreatesIdentityAndDefaultTeam(t *testing.T) { } } +func TestBootstrapOIDCUser_ConcurrentRequestsSingleIdentityAndTeam(t *testing.T) { + t.Parallel() + + testDB := testutils.SetupDatabase(t) + ctx := t.Context() + sink := &fakeTeamProvisionSink{} + + store := &APIStore{ + config: cfg.Config{ + 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, + supabaseDB: testDB.SupabaseDB, + teamProvisionSink: sink, + } + + input := oidcUserBootstrapInput{ + 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: "https://ory.example.test", + 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() + + recorder := httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(recorder) + ginCtx.Request = httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/", strings.NewReader(`{"oidc_user_id":" ","oidc_user_email":"ada@example.test","oidc_user_name":null}`)) + ginCtx.Request.Header.Set("Content-Type", "application/json") + + store := &APIStore{} + store.PostAdminUsersBootstrap(ginCtx) + + if recorder.Code != http.StatusBadRequest { + t.Fatalf("expected status 400 for blank oidc_user_id, got %d", recorder.Code) + } +} + func TestPostUsersBootstrap_ProvisioningFailureKeepsCreatedDefaultTeam(t *testing.T) { t.Parallel() diff --git a/packages/dashboard-api/internal/handlers/utils_team_provisioning.go b/packages/dashboard-api/internal/handlers/utils_team_provisioning.go index cc0d049214..4cf588f199 100644 --- a/packages/dashboard-api/internal/handlers/utils_team_provisioning.go +++ b/packages/dashboard-api/internal/handlers/utils_team_provisioning.go @@ -89,13 +89,9 @@ func (s *APIStore) bootstrapOIDCUser(ctx context.Context, input oidcUserBootstra if err != nil { return provisionedTeam{}, err } - userID, err := s.resolveOIDCUserID(ctx, issuer, input.OIDCUserID) - if err != nil { - return provisionedTeam{}, err - } profile := bootstrapUserProfile{ - UserID: userID, + UserID: uuid.New(), Email: input.OIDCUserEmail, DefaultTeamName: defaultTeamNameFromOIDCUserName(input.OIDCUserName), } @@ -124,21 +120,6 @@ func (s *APIStore) oidcIssuer() (string, error) { return issuer, nil } -func (s *APIStore) resolveOIDCUserID(ctx context.Context, issuer, subject string) (uuid.UUID, error) { - row, err := s.authDB.Read.GetUserIdentity(ctx, authqueries.GetUserIdentityParams{ - OidcIss: issuer, - OidcSub: subject, - }) - if err == nil { - return row.UserID, nil - } - if !dberrors.IsNotFoundError(err) { - return uuid.Nil, fmt.Errorf("get user identity: %w", err) - } - - return uuid.New(), nil -} - func (s *APIStore) bootstrapUser(ctx context.Context, profile bootstrapUserProfile) (provisionedTeam, error) { return s.bootstrapUserWithIdentity(ctx, profile, nil) } @@ -152,17 +133,39 @@ func (s *APIStore) bootstrapUserWithIdentity(ctx context.Context, profile bootst _ = tx.Rollback(ctx) }() - if err := authTxDB.UpsertPublicUser(ctx, profile.UserID); err != nil { + 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 { - if err := authTxDB.UpsertPublicIdentity(ctx, authqueries.UpsertPublicIdentityParams{ + canonicalUserID, err := authTxDB.UpsertPublicIdentity(ctx, authqueries.UpsertPublicIdentityParams{ OidcIss: identity.Issuer, OidcSub: identity.Subject, - UserID: profile.UserID, - }); err != nil { + 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. @@ -363,7 +366,7 @@ func validateTeamCreationAllowed(ctx context.Context, authTxDB *authqueries.Quer } func defaultTeamNameFromProfile(profile userprofile.Profile) string { - baseName := firstNonEmpty( + baseName := userprofile.FirstNonEmpty( firstWord(profile.Name), emailPrefix(profile.Email), "User", @@ -395,16 +398,6 @@ func emailPrefix(email string) string { return prefix } -func firstNonEmpty(values ...string) string { - for _, value := range values { - if strings.TrimSpace(value) != "" { - return strings.TrimSpace(value) - } - } - - return "" -} - func capitalizeFirstLetter(value string) string { runes := []rune(strings.TrimSpace(value)) if len(runes) == 0 { diff --git a/packages/dashboard-api/internal/userprofile/supabase.go b/packages/dashboard-api/internal/userprofile/supabase.go index 945103f58b..82e641184c 100644 --- a/packages/dashboard-api/internal/userprofile/supabase.go +++ b/packages/dashboard-api/internal/userprofile/supabase.go @@ -72,13 +72,13 @@ func profileFromAuthUser(user supabasequeries.AuthUser) Profile { } func displayNameFromMetadata(metadata map[string]any) string { - firstName := firstNonEmpty( + firstName := FirstNonEmpty( metadataString(metadata, "first_name"), metadataString(metadata, "firstName"), metadataString(metadata, "given_name"), metadataString(metadata, "givenName"), ) - lastName := firstNonEmpty( + lastName := FirstNonEmpty( metadataString(metadata, "last_name"), metadataString(metadata, "lastName"), metadataString(metadata, "family_name"), @@ -88,7 +88,7 @@ func displayNameFromMetadata(metadata map[string]any) string { return strings.TrimSpace(strings.Join([]string{firstName, lastName}, " ")) } - return firstNonEmpty( + return FirstNonEmpty( metadataString(metadata, "name"), metadataString(metadata, "full_name"), metadataString(metadata, "fullName"), @@ -121,10 +121,10 @@ func metadataString(metadata map[string]any, key string) string { return strings.TrimSpace(value) } -func firstNonEmpty(values ...string) string { +func FirstNonEmpty(values ...string) string { for _, value := range values { - if strings.TrimSpace(value) != "" { - return strings.TrimSpace(value) + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed } } diff --git a/packages/db/pkg/auth/queries/upsert_public_identity.sql.go b/packages/db/pkg/auth/queries/upsert_public_identity.sql.go index fb1100dad2..15304e7ed4 100644 --- a/packages/db/pkg/auth/queries/upsert_public_identity.sql.go +++ b/packages/db/pkg/auth/queries/upsert_public_identity.sql.go @@ -11,13 +11,13 @@ import ( "github.com/google/uuid" ) -const upsertPublicIdentity = `-- name: UpsertPublicIdentity :exec +const upsertPublicIdentity = `-- name: UpsertPublicIdentity :one INSERT INTO public.user_identities (oidc_iss, oidc_sub, user_id) VALUES ($1::text, $2::text, $3::uuid) ON CONFLICT (oidc_iss, oidc_sub) DO UPDATE SET - user_id = EXCLUDED.user_id, updated_at = now() +RETURNING user_id ` type UpsertPublicIdentityParams struct { @@ -26,7 +26,9 @@ type UpsertPublicIdentityParams struct { UserID uuid.UUID } -func (q *Queries) UpsertPublicIdentity(ctx context.Context, arg UpsertPublicIdentityParams) error { - _, err := q.db.Exec(ctx, upsertPublicIdentity, arg.OidcIss, arg.OidcSub, arg.UserID) - return err +func (q *Queries) UpsertPublicIdentity(ctx context.Context, arg UpsertPublicIdentityParams) (uuid.UUID, error) { + row := q.db.QueryRow(ctx, upsertPublicIdentity, arg.OidcIss, arg.OidcSub, arg.UserID) + var user_id uuid.UUID + err := row.Scan(&user_id) + return user_id, err } diff --git a/packages/db/pkg/auth/sql_queries/user_identities/upsert_public_identity.sql b/packages/db/pkg/auth/sql_queries/user_identities/upsert_public_identity.sql index ccd1819161..1ad36a18fc 100644 --- a/packages/db/pkg/auth/sql_queries/user_identities/upsert_public_identity.sql +++ b/packages/db/pkg/auth/sql_queries/user_identities/upsert_public_identity.sql @@ -1,7 +1,7 @@ --- name: UpsertPublicIdentity :exec +-- name: UpsertPublicIdentity :one INSERT INTO public.user_identities (oidc_iss, oidc_sub, user_id) VALUES (sqlc.arg(oidc_iss)::text, sqlc.arg(oidc_sub)::text, sqlc.arg(user_id)::uuid) ON CONFLICT (oidc_iss, oidc_sub) DO UPDATE SET - user_id = EXCLUDED.user_id, - updated_at = now(); + updated_at = now() +RETURNING user_id; From c0511c85d26dcb46b357e41a33411387d0ac8b87 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Sun, 24 May 2026 20:42:26 -0700 Subject: [PATCH 05/23] fix(dashboard-api): satisfy perfsprint lint in bootstrap handler Use errors.New for the static empty-input error instead of fmt.Errorf. --- .../dashboard-api/internal/handlers/admin_users_bootstrap.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/dashboard-api/internal/handlers/admin_users_bootstrap.go b/packages/dashboard-api/internal/handlers/admin_users_bootstrap.go index 8d64962e42..4b759206cf 100644 --- a/packages/dashboard-api/internal/handlers/admin_users_bootstrap.go +++ b/packages/dashboard-api/internal/handlers/admin_users_bootstrap.go @@ -1,6 +1,7 @@ package handlers import ( + "errors" "fmt" "net/http" "strings" @@ -44,7 +45,7 @@ func (s *APIStore) PostAdminUsersBootstrap(c *gin.Context) { oidcUserID := strings.TrimSpace(body.OidcUserId) oidcUserEmail := strings.TrimSpace(string(body.OidcUserEmail)) if oidcUserID == "" || oidcUserEmail == "" { - telemetry.ReportErrorByCode(ctx, http.StatusBadRequest, "bootstrap auth provider user failed", fmt.Errorf("oidc_user_id and oidc_user_email must be non-empty")) + telemetry.ReportErrorByCode(ctx, http.StatusBadRequest, "bootstrap auth provider user failed", errors.New("oidc_user_id and oidc_user_email must be non-empty")) s.sendAPIStoreError(c, http.StatusBadRequest, "oidc_user_id and oidc_user_email must be non-empty") return From 993a8424c2a159d7e4d2b0a41ea388c57524c989 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Sun, 24 May 2026 20:43:50 -0700 Subject: [PATCH 06/23] docs(dashboard-api): clarify Supabase vs OIDC bootstrap handlers Document that PostAdminUsersUserIdBootstrap is the legacy Supabase-only path and PostAdminUsersBootstrap is the entry point for generic OIDC provider deployments. --- .../dashboard-api/internal/handlers/admin_users_bootstrap.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/dashboard-api/internal/handlers/admin_users_bootstrap.go b/packages/dashboard-api/internal/handlers/admin_users_bootstrap.go index 4b759206cf..0b4c34dc83 100644 --- a/packages/dashboard-api/internal/handlers/admin_users_bootstrap.go +++ b/packages/dashboard-api/internal/handlers/admin_users_bootstrap.go @@ -13,6 +13,9 @@ import ( "github.com/e2b-dev/infra/packages/shared/pkg/telemetry" ) +// PostAdminUsersUserIdBootstrap is the legacy bootstrap path; effectively +// deprecated and only used by Supabase-backed deployments. New OIDC-based +// dashboard setups should use PostAdminUsersBootstrap. func (s *APIStore) PostAdminUsersUserIdBootstrap(c *gin.Context, userId api.UserId) { ctx := c.Request.Context() telemetry.ReportEvent(ctx, "bootstrap user") @@ -30,6 +33,8 @@ func (s *APIStore) PostAdminUsersUserIdBootstrap(c *gin.Context, userId api.User }) } +// PostAdminUsersBootstrap is the new bootstrap entry point for dashboards +// using a generic OIDC provider. func (s *APIStore) PostAdminUsersBootstrap(c *gin.Context) { ctx := c.Request.Context() telemetry.ReportEvent(ctx, "bootstrap auth provider user") From 04c74aa6bd9333696c84dd70e7b48b7110d5e12b Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Sun, 24 May 2026 20:54:39 -0700 Subject: [PATCH 07/23] fix(dashboard-api): make oidc_user_name optional in bootstrap schema The OapiRequestValidator middleware enforces the OpenAPI required list, so the previous schema rejected bootstrap calls that omitted oidc_user_name even though the handler explicitly supports a missing or blank name by defaulting to "Default Team". --- .../dashboard-api/internal/api/api.gen.go | 114 +++++++++--------- spec/openapi-dashboard.yml | 1 - 2 files changed, 57 insertions(+), 58 deletions(-) diff --git a/packages/dashboard-api/internal/api/api.gen.go b/packages/dashboard-api/internal/api/api.gen.go index ce3446e379..d49fda3b55 100644 --- a/packages/dashboard-api/internal/api/api.gen.go +++ b/packages/dashboard-api/internal/api/api.gen.go @@ -82,7 +82,7 @@ type AdminAuthProviderProfilesResponse struct { type AdminAuthProviderUserBootstrapRequest struct { OidcUserEmail openapi_types.Email `json:"oidc_user_email"` OidcUserId string `json:"oidc_user_id"` - OidcUserName *string `json:"oidc_user_name"` + OidcUserName *string `json:"oidc_user_name,omitempty"` } // BuildInfo defines model for BuildInfo. @@ -1027,62 +1027,62 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options // const string: with thousands of chunks the chained `+` fold is several // times slower for the Go compiler than parsing a slice literal. var swaggerSpec = []string{ - "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", } // decodeSpec returns the embedded OpenAPI spec as raw JSON bytes, diff --git a/spec/openapi-dashboard.yml b/spec/openapi-dashboard.yml index a8d86bfef0..828bb03181 100644 --- a/spec/openapi-dashboard.yml +++ b/spec/openapi-dashboard.yml @@ -222,7 +222,6 @@ components: required: - oidc_user_id - oidc_user_email - - oidc_user_name properties: oidc_user_id: type: string From f21a49f79c105ea5f75cbb69c28f22bef02ab054 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Mon, 25 May 2026 20:01:27 -0700 Subject: [PATCH 08/23] feat(dashboard-api): add ory user profile provider Lets s.userProfiles resolve members and email lookups against an Ory identity API in addition to Supabase auth.users. USER_PROFILE_PROVIDER selects supabase, ory, or supabase-ory-fallback (dual provider that prefers supabase and falls back to ory for ids missing locally). The ory provider maps internal user UUIDs to Ory subject ids via two new sqlc queries against public.user_identities (by user_ids and by subjects), scoped to the configured oidc_iss so a future multi-issuer deployment cannot cross-resolve. --- packages/dashboard-api/go.mod | 2 + packages/dashboard-api/go.sum | 4 + packages/dashboard-api/internal/cfg/model.go | 31 ++ .../dashboard-api/internal/cfg/model_test.go | 106 +++++++ .../dashboard-api/internal/handlers/store.go | 13 +- .../internal/userprofile/dual.go | 72 +++++ .../internal/userprofile/dual_test.go | 198 +++++++++++++ .../internal/userprofile/mode.go | 31 ++ .../dashboard-api/internal/userprofile/ory.go | 270 ++++++++++++++++++ .../internal/userprofile/ory_test.go | 95 ++++++ .../internal/userprofile/provider.go | 26 ++ packages/dashboard-api/main.go | 31 +- .../get_user_identities_by_subjects.sql.go | 50 ++++ .../get_user_identities_by_user_ids.sql.go | 50 ++++ .../get_user_identities_by_subjects.sql | 5 + .../get_user_identities_by_user_ids.sql | 5 + 16 files changed, 986 insertions(+), 3 deletions(-) create mode 100644 packages/dashboard-api/internal/userprofile/dual.go create mode 100644 packages/dashboard-api/internal/userprofile/dual_test.go create mode 100644 packages/dashboard-api/internal/userprofile/mode.go create mode 100644 packages/dashboard-api/internal/userprofile/ory.go create mode 100644 packages/dashboard-api/internal/userprofile/ory_test.go create mode 100644 packages/db/pkg/auth/queries/get_user_identities_by_subjects.sql.go create mode 100644 packages/db/pkg/auth/queries/get_user_identities_by_user_ids.sql.go create mode 100644 packages/db/pkg/auth/sql_queries/user_identities/get_user_identities_by_subjects.sql create mode 100644 packages/db/pkg/auth/sql_queries/user_identities/get_user_identities_by_user_ids.sql diff --git a/packages/dashboard-api/go.mod b/packages/dashboard-api/go.mod index 63485f94ea..50759b34ff 100644 --- a/packages/dashboard-api/go.mod +++ b/packages/dashboard-api/go.mod @@ -24,6 +24,7 @@ require ( github.com/jackc/pgx/v5 v5.9.2 github.com/oapi-codegen/gin-middleware v1.0.2 github.com/oapi-codegen/runtime v1.4.0 + github.com/ory/client-go v1.22.42 github.com/stretchr/testify v1.11.1 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 go.opentelemetry.io/otel v1.43.0 @@ -160,6 +161,7 @@ require ( golang.org/x/crypto v0.50.0 // indirect golang.org/x/mod v0.35.0 // indirect golang.org/x/net v0.53.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.43.0 // indirect golang.org/x/text v0.36.0 // indirect diff --git a/packages/dashboard-api/go.sum b/packages/dashboard-api/go.sum index d757af45f4..5c3537e709 100644 --- a/packages/dashboard-api/go.sum +++ b/packages/dashboard-api/go.sum @@ -239,6 +239,8 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/ory/client-go v1.22.42 h1:uH3IWR1RjP9XCcekTD+SFGp6sZLEwvVTEH0DDqT9Rm4= +github.com/ory/client-go v1.22.42/go.mod h1:G1f+5+m/PJVvl40bsRn0QuyVIcXe7EHiWeM7iWpIDjw= github.com/paulmach/orb v0.11.1 h1:3koVegMC4X/WeiXYz9iswopaTwMem53NzTJuTF20JzU= github.com/paulmach/orb v0.11.1/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU= github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY= @@ -397,6 +399,8 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/packages/dashboard-api/internal/cfg/model.go b/packages/dashboard-api/internal/cfg/model.go index 28ba384853..f74a2d9435 100644 --- a/packages/dashboard-api/internal/cfg/model.go +++ b/packages/dashboard-api/internal/cfg/model.go @@ -2,11 +2,13 @@ package cfg import ( "errors" + "fmt" "reflect" "github.com/caarlos0/env/v11" "github.com/e2b-dev/infra/packages/auth/pkg/auth" + "github.com/e2b-dev/infra/packages/dashboard-api/internal/userprofile" ) type Config struct { @@ -26,6 +28,10 @@ type Config struct { BillingServerURL string `env:"BILLING_SERVER_URL"` BillingServerAPIToken string `env:"BILLING_SERVER_API_TOKEN"` + + UserProfileProvider userprofile.Mode `env:"USER_PROFILE_PROVIDER" envDefault:"supabase"` + OrySDKURL string `env:"ORY_SDK_URL"` + OryProjectAPIToken string `env:"ORY_PROJECT_API_TOKEN,unset"` } func Parse() (Config, error) { @@ -35,6 +41,9 @@ func Parse() (Config, error) { reflect.TypeFor[auth.ProviderConfig](): func(v string) (any, error) { return auth.ParseProviderConfig(v) }, + reflect.TypeFor[userprofile.Mode](): func(v string) (any, error) { + return userprofile.ParseMode(v) + }, }, }) @@ -50,5 +59,27 @@ func Parse() (Config, error) { err = errors.New("at least one of REDIS_URL or REDIS_CLUSTER_URL must be set") } + if err == nil { + err = validateUserProfileProvider(config) + } + return config, err } + +func validateUserProfileProvider(config Config) error { + if !config.UserProfileProvider.RequiresOry() { + return nil + } + + if config.OrySDKURL == "" { + return errors.New("ORY_SDK_URL is required when USER_PROFILE_PROVIDER uses ory") + } + if config.OryProjectAPIToken == "" { + return errors.New("ORY_PROJECT_API_TOKEN is required when USER_PROFILE_PROVIDER uses ory") + } + if len(config.AuthProvider.JWT) != 1 { + return fmt.Errorf("AUTH_PROVIDER_CONFIG must declare exactly one jwt issuer when USER_PROFILE_PROVIDER uses ory (got %d)", len(config.AuthProvider.JWT)) + } + + return nil +} diff --git a/packages/dashboard-api/internal/cfg/model_test.go b/packages/dashboard-api/internal/cfg/model_test.go index 3a66419b48..3898798b3b 100644 --- a/packages/dashboard-api/internal/cfg/model_test.go +++ b/packages/dashboard-api/internal/cfg/model_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/require" "github.com/e2b-dev/infra/packages/auth/pkg/auth/oidc" + "github.com/e2b-dev/infra/packages/dashboard-api/internal/userprofile" ) func TestParseAuthProviderConfig(t *testing.T) { @@ -52,3 +53,108 @@ func TestParseAuthProviderConfigLegacy(t *testing.T) { require.NotNil(t, config.AuthProvider.Legacy) require.Equal(t, []string{"secret-1", "secret-2"}, config.AuthProvider.Legacy.HMAC.Secrets) } + +func TestParseUserProfileProviderDefaultsToSupabase(t *testing.T) { + t.Setenv("POSTGRES_CONNECTION_STRING", "postgres://example") + t.Setenv("ADMIN_TOKEN", "admin-token") + t.Setenv("REDIS_URL", "redis://example") + + config, err := Parse() + require.NoError(t, err) + require.Equal(t, userprofile.ModeSupabase, config.UserProfileProvider) +} + +func TestParseUserProfileProviderOryRequiresEnvAndIssuer(t *testing.T) { + tests := []struct { + name string + mode string + sdkURL string + token string + authConfig string + wantErrSubstr string + }{ + { + name: "ory mode without sdk url errors", + mode: "ory", + token: "pat", + authConfig: oryJWTConfigJSON, + wantErrSubstr: "ORY_SDK_URL", + }, + { + name: "ory mode without token errors", + mode: "ory", + sdkURL: "https://ory.example.test", + authConfig: oryJWTConfigJSON, + wantErrSubstr: "ORY_PROJECT_API_TOKEN", + }, + { + name: "ory mode without jwt issuer errors", + mode: "ory", + sdkURL: "https://ory.example.test", + token: "pat", + wantErrSubstr: "AUTH_PROVIDER_CONFIG must declare exactly one jwt issuer", + }, + { + name: "fallback mode applies same requirements", + mode: "supabase-ory-fallback", + token: "pat", + authConfig: oryJWTConfigJSON, + wantErrSubstr: "ORY_SDK_URL", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("POSTGRES_CONNECTION_STRING", "postgres://example") + t.Setenv("ADMIN_TOKEN", "admin-token") + t.Setenv("REDIS_URL", "redis://example") + t.Setenv("USER_PROFILE_PROVIDER", tt.mode) + t.Setenv("ORY_SDK_URL", tt.sdkURL) + t.Setenv("ORY_PROJECT_API_TOKEN", tt.token) + t.Setenv("AUTH_PROVIDER_CONFIG", tt.authConfig) + + _, err := Parse() + require.Error(t, err) + require.Contains(t, err.Error(), tt.wantErrSubstr) + }) + } +} + +func TestParseUserProfileProviderOryHappyPath(t *testing.T) { + t.Setenv("POSTGRES_CONNECTION_STRING", "postgres://example") + t.Setenv("ADMIN_TOKEN", "admin-token") + t.Setenv("REDIS_URL", "redis://example") + t.Setenv("USER_PROFILE_PROVIDER", "supabase-ory-fallback") + t.Setenv("ORY_SDK_URL", "https://ory.example.test") + t.Setenv("ORY_PROJECT_API_TOKEN", "pat") + t.Setenv("AUTH_PROVIDER_CONFIG", oryJWTConfigJSON) + + config, err := Parse() + require.NoError(t, err) + require.Equal(t, userprofile.ModeSupabaseOryFallback, config.UserProfileProvider) + require.Equal(t, "https://ory.example.test", config.OrySDKURL) + require.Equal(t, "pat", config.OryProjectAPIToken) + require.Len(t, config.AuthProvider.JWT, 1) +} + +func TestParseUserProfileProviderInvalidModeErrors(t *testing.T) { + t.Setenv("POSTGRES_CONNECTION_STRING", "postgres://example") + t.Setenv("ADMIN_TOKEN", "admin-token") + t.Setenv("REDIS_URL", "redis://example") + t.Setenv("USER_PROFILE_PROVIDER", "totally-invalid") + + _, err := Parse() + require.Error(t, err) + require.Contains(t, err.Error(), "invalid user profile provider") +} + +const oryJWTConfigJSON = `{ + "jwt": [ + { + "issuer": { + "url": "https://ory.example.test", + "audiences": ["dashboard-api"] + } + } + ] + }` diff --git a/packages/dashboard-api/internal/handlers/store.go b/packages/dashboard-api/internal/handlers/store.go index a4f9cba944..9b3b0caf34 100644 --- a/packages/dashboard-api/internal/handlers/store.go +++ b/packages/dashboard-api/internal/handlers/store.go @@ -33,7 +33,16 @@ type APIStore struct { userProfiles userprofile.Provider } -func NewAPIStore(config cfg.Config, db *sqlcdb.Client, authDB *authdb.Client, supabaseDB *supabasedb.Client, ch clickhouse.Clickhouse, authService sharedauth.Service, teamProvisionSink internalteamprovision.TeamProvisionSink) *APIStore { +func NewAPIStore( + config cfg.Config, + db *sqlcdb.Client, + authDB *authdb.Client, + supabaseDB *supabasedb.Client, + ch clickhouse.Clickhouse, + authService sharedauth.Service, + teamProvisionSink internalteamprovision.TeamProvisionSink, + userProfiles userprofile.Provider, +) *APIStore { return &APIStore{ config: config, db: db, @@ -42,7 +51,7 @@ func NewAPIStore(config cfg.Config, db *sqlcdb.Client, authDB *authdb.Client, su clickhouse: ch, authService: authService, teamProvisionSink: teamProvisionSink, - userProfiles: userprofile.NewSupabaseProvider(supabaseDB), + userProfiles: userProfiles, } } diff --git a/packages/dashboard-api/internal/userprofile/dual.go b/packages/dashboard-api/internal/userprofile/dual.go new file mode 100644 index 0000000000..a4732c8376 --- /dev/null +++ b/packages/dashboard-api/internal/userprofile/dual.go @@ -0,0 +1,72 @@ +package userprofile + +import ( + "context" + "maps" + + "github.com/google/uuid" +) + +type dualProvider struct { + primary Provider + secondary Provider +} + +var _ Provider = (*dualProvider)(nil) + +func newDualProvider(primary, secondary Provider) *dualProvider { + return &dualProvider{primary: primary, secondary: secondary} +} + +func (p *dualProvider) GetProfilesByUserID(ctx context.Context, userIDs []uuid.UUID) (map[uuid.UUID]Profile, error) { + primary, err := p.primary.GetProfilesByUserID(ctx, userIDs) + if err != nil { + return nil, err + } + + missing := make([]uuid.UUID, 0, len(userIDs)) + for _, id := range userIDs { + if _, ok := primary[id]; ok { + continue + } + missing = append(missing, id) + } + if len(missing) == 0 { + return primary, nil + } + + secondary, err := p.secondary.GetProfilesByUserID(ctx, missing) + if err != nil { + return nil, err + } + + maps.Copy(primary, secondary) + + return primary, nil +} + +func (p *dualProvider) FindProfilesByEmail(ctx context.Context, email string) ([]Profile, error) { + primary, err := p.primary.FindProfilesByEmail(ctx, email) + if err != nil { + return nil, err + } + + secondary, err := p.secondary.FindProfilesByEmail(ctx, email) + if err != nil { + return nil, err + } + + seen := make(map[uuid.UUID]struct{}, len(primary)+len(secondary)) + merged := make([]Profile, 0, len(primary)+len(secondary)) + for _, source := range [][]Profile{primary, secondary} { + for _, profile := range source { + if _, ok := seen[profile.UserID]; ok { + continue + } + seen[profile.UserID] = struct{}{} + merged = append(merged, profile) + } + } + + return merged, nil +} diff --git a/packages/dashboard-api/internal/userprofile/dual_test.go b/packages/dashboard-api/internal/userprofile/dual_test.go new file mode 100644 index 0000000000..bc983bb4ee --- /dev/null +++ b/packages/dashboard-api/internal/userprofile/dual_test.go @@ -0,0 +1,198 @@ +package userprofile + +import ( + "context" + "errors" + "testing" + + "github.com/google/uuid" +) + +type fakeProvider struct { + byID map[uuid.UUID]Profile + byEmail map[string][]Profile + err error + calls int +} + +func (f *fakeProvider) GetProfilesByUserID(_ context.Context, userIDs []uuid.UUID) (map[uuid.UUID]Profile, error) { + f.calls++ + if f.err != nil { + return nil, f.err + } + + result := make(map[uuid.UUID]Profile) + for _, id := range userIDs { + if profile, ok := f.byID[id]; ok { + result[id] = profile + } + } + + return result, nil +} + +func (f *fakeProvider) FindProfilesByEmail(_ context.Context, email string) ([]Profile, error) { + f.calls++ + if f.err != nil { + return nil, f.err + } + + return f.byEmail[email], nil +} + +func TestDualProvider_GetProfilesByUserID_PrefersPrimaryFallsBackToSecondary(t *testing.T) { + t.Parallel() + + primaryOnly := uuid.New() + bothShared := uuid.New() + secondaryOnly := uuid.New() + missing := uuid.New() + + primary := &fakeProvider{ + byID: map[uuid.UUID]Profile{ + primaryOnly: {UserID: primaryOnly, Email: "primary-only@example.com"}, + bothShared: {UserID: bothShared, Email: "primary-wins@example.com"}, + }, + } + secondary := &fakeProvider{ + byID: map[uuid.UUID]Profile{ + bothShared: {UserID: bothShared, Email: "secondary-loses@example.com"}, + secondaryOnly: {UserID: secondaryOnly, Email: "secondary-only@example.com"}, + }, + } + + dual := newDualProvider(primary, secondary) + got, err := dual.GetProfilesByUserID(t.Context(), []uuid.UUID{primaryOnly, bothShared, secondaryOnly, missing}) + if err != nil { + t.Fatalf("GetProfilesByUserID() error = %v", err) + } + + if len(got) != 3 { + t.Fatalf("got %d profiles, want 3: %+v", len(got), got) + } + if got[primaryOnly].Email != "primary-only@example.com" { + t.Fatalf("primaryOnly email = %q, want %q", got[primaryOnly].Email, "primary-only@example.com") + } + if got[bothShared].Email != "primary-wins@example.com" { + t.Fatalf("bothShared email = %q, want primary to win", got[bothShared].Email) + } + if got[secondaryOnly].Email != "secondary-only@example.com" { + t.Fatalf("secondaryOnly email = %q, want %q", got[secondaryOnly].Email, "secondary-only@example.com") + } + if _, ok := got[missing]; ok { + t.Fatalf("expected missing user to be absent, got %+v", got[missing]) + } +} + +func TestDualProvider_GetProfilesByUserID_SkipsSecondaryWhenPrimaryFullyResolves(t *testing.T) { + t.Parallel() + + id := uuid.New() + primary := &fakeProvider{byID: map[uuid.UUID]Profile{id: {UserID: id, Email: "p@example.com"}}} + secondary := &fakeProvider{} + + dual := newDualProvider(primary, secondary) + if _, err := dual.GetProfilesByUserID(t.Context(), []uuid.UUID{id}); err != nil { + t.Fatalf("GetProfilesByUserID() error = %v", err) + } + if secondary.calls != 0 { + t.Fatalf("secondary calls = %d, want 0", secondary.calls) + } +} + +func TestDualProvider_GetProfilesByUserID_PropagatesPrimaryError(t *testing.T) { + t.Parallel() + + primary := &fakeProvider{err: errors.New("primary boom")} + secondary := &fakeProvider{} + + dual := newDualProvider(primary, secondary) + _, err := dual.GetProfilesByUserID(t.Context(), []uuid.UUID{uuid.New()}) + if err == nil || err.Error() != "primary boom" { + t.Fatalf("expected primary error, got %v", err) + } +} + +func TestDualProvider_FindProfilesByEmail_DedupesByUserIDPrimaryWins(t *testing.T) { + t.Parallel() + + sharedID := uuid.New() + onlyInSecondaryID := uuid.New() + + primary := &fakeProvider{byEmail: map[string][]Profile{ + "shared@example.com": {{UserID: sharedID, Email: "shared@example.com", Name: "primary-name"}}, + }} + secondary := &fakeProvider{byEmail: map[string][]Profile{ + "shared@example.com": { + {UserID: sharedID, Email: "shared@example.com", Name: "secondary-name-loses"}, + {UserID: onlyInSecondaryID, Email: "shared@example.com", Name: "secondary-only"}, + }, + }} + + dual := newDualProvider(primary, secondary) + got, err := dual.FindProfilesByEmail(t.Context(), "shared@example.com") + if err != nil { + t.Fatalf("FindProfilesByEmail() error = %v", err) + } + + if len(got) != 2 { + t.Fatalf("got %d profiles, want 2: %+v", len(got), got) + } + + byID := make(map[uuid.UUID]Profile, len(got)) + for _, profile := range got { + byID[profile.UserID] = profile + } + if byID[sharedID].Name != "primary-name" { + t.Fatalf("shared id name = %q, want primary-name", byID[sharedID].Name) + } + if byID[onlyInSecondaryID].Name != "secondary-only" { + t.Fatalf("secondary-only name = %q, want secondary-only", byID[onlyInSecondaryID].Name) + } +} + +func TestNewProvider_FactorySelection(t *testing.T) { + t.Parallel() + + supa := &fakeProvider{} + ory := &fakeProvider{} + + tests := []struct { + name string + mode Mode + supa Provider + ory Provider + wantErr bool + wantDual bool + }{ + {name: "supabase mode returns supabase", mode: ModeSupabase, supa: supa, ory: ory}, + {name: "ory mode returns ory", mode: ModeOry, supa: supa, ory: ory}, + {name: "fallback mode returns dual", mode: ModeSupabaseOryFallback, supa: supa, ory: ory, wantDual: true}, + {name: "supabase mode requires supa", mode: ModeSupabase, supa: nil, ory: ory, wantErr: true}, + {name: "ory mode requires ory", mode: ModeOry, supa: supa, ory: nil, wantErr: true}, + {name: "fallback requires both", mode: ModeSupabaseOryFallback, supa: supa, ory: nil, wantErr: true}, + {name: "unknown mode errors", mode: Mode("nope"), supa: supa, ory: ory, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := NewProvider(tt.mode, tt.supa, tt.ory) + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + + return + } + if err != nil { + t.Fatalf("NewProvider() error = %v", err) + } + _, isDual := got.(*dualProvider) + if isDual != tt.wantDual { + t.Fatalf("got dual = %v, want %v", isDual, tt.wantDual) + } + }) + } +} diff --git a/packages/dashboard-api/internal/userprofile/mode.go b/packages/dashboard-api/internal/userprofile/mode.go new file mode 100644 index 0000000000..5773d5e73d --- /dev/null +++ b/packages/dashboard-api/internal/userprofile/mode.go @@ -0,0 +1,31 @@ +package userprofile + +import ( + "fmt" + "strings" +) + +type Mode string + +const ( + ModeSupabase Mode = "supabase" + ModeOry Mode = "ory" + ModeSupabaseOryFallback Mode = "supabase-ory-fallback" +) + +func ParseMode(value string) (Mode, error) { + switch Mode(strings.TrimSpace(value)) { + case ModeSupabase: + return ModeSupabase, nil + case ModeOry: + return ModeOry, nil + case ModeSupabaseOryFallback: + return ModeSupabaseOryFallback, nil + default: + return "", fmt.Errorf("invalid user profile provider %q (want one of %q, %q, %q)", value, ModeSupabase, ModeOry, ModeSupabaseOryFallback) + } +} + +func (m Mode) RequiresOry() bool { + return m == ModeOry || m == ModeSupabaseOryFallback +} diff --git a/packages/dashboard-api/internal/userprofile/ory.go b/packages/dashboard-api/internal/userprofile/ory.go new file mode 100644 index 0000000000..b56a82ab2c --- /dev/null +++ b/packages/dashboard-api/internal/userprofile/ory.go @@ -0,0 +1,270 @@ +package userprofile + +import ( + "context" + "errors" + "fmt" + "maps" + "net/http" + "slices" + "strings" + + "github.com/google/uuid" + ory "github.com/ory/client-go" + + authqueries "github.com/e2b-dev/infra/packages/db/pkg/auth/queries" +) + +// matches the page cap used by dashboard.full-stack's oryAuthAdmin. +const oryListPageSize = 1000 + +type oryProvider struct { + identities ory.IdentityAPI + resolver identityResolver + issuer string +} + +type identityResolver interface { + GetUserIdentitiesByUserIDs(ctx context.Context, arg authqueries.GetUserIdentitiesByUserIDsParams) ([]authqueries.GetUserIdentitiesByUserIDsRow, error) + GetUserIdentitiesBySubjects(ctx context.Context, arg authqueries.GetUserIdentitiesBySubjectsParams) ([]authqueries.GetUserIdentitiesBySubjectsRow, error) +} + +var _ Provider = (*oryProvider)(nil) + +type OryConfig struct { + HTTPClient *http.Client + SDKURL string + Token string + Issuer string + Resolver identityResolver +} + +func NewOryProvider(config OryConfig) (Provider, error) { + sdkURL := strings.TrimRight(strings.TrimSpace(config.SDKURL), "/") + token := strings.TrimSpace(config.Token) + issuer := strings.TrimSpace(config.Issuer) + + switch { + case config.HTTPClient == nil: + return nil, errors.New("ory http client is required") + case sdkURL == "": + return nil, errors.New("ory sdk url is required") + case token == "": + return nil, errors.New("ory api token is required") + case issuer == "": + return nil, errors.New("ory issuer is required") + case config.Resolver == nil: + return nil, errors.New("ory identity resolver is required") + } + + return &oryProvider{ + identities: newOryIdentityAPI(config.HTTPClient, sdkURL, token), + resolver: config.Resolver, + issuer: issuer, + }, nil +} + +func newOryIdentityAPI(httpClient *http.Client, sdkURL, token string) ory.IdentityAPI { + clientCopy := *httpClient + base := clientCopy.Transport + if base == nil { + base = http.DefaultTransport + } + clientCopy.Transport = &oryBearerTransport{token: token, base: base} + + cfg := ory.NewConfiguration() + cfg.Servers = ory.ServerConfigurations{{URL: sdkURL}} + cfg.HTTPClient = &clientCopy + + return ory.NewAPIClient(cfg).IdentityAPI +} + +// injects the PAT instead of threading context.WithValue(ory.ContextAccessToken, ...) per call. +type oryBearerTransport struct { + token string + base http.RoundTripper +} + +func (t *oryBearerTransport) RoundTrip(req *http.Request) (*http.Response, error) { + cloned := req.Clone(req.Context()) + cloned.Header.Set("Authorization", "Bearer "+t.token) + + return t.base.RoundTrip(cloned) +} + +func (p *oryProvider) GetProfilesByUserID(ctx context.Context, userIDs []uuid.UUID) (map[uuid.UUID]Profile, error) { + unique := uniqueUUIDs(userIDs) + if len(unique) == 0 { + return map[uuid.UUID]Profile{}, nil + } + + userIDBySubject, err := p.subjectsForUserIDs(ctx, unique) + if err != nil { + return nil, err + } + if len(userIDBySubject) == 0 { + return map[uuid.UUID]Profile{}, nil + } + + identities, err := p.listIdentitiesByIDs(ctx, slices.Collect(maps.Keys(userIDBySubject))) + if err != nil { + return nil, err + } + + profiles := make(map[uuid.UUID]Profile, len(identities)) + for _, identity := range identities { + if userID, ok := userIDBySubject[identity.Id]; ok { + profiles[userID] = profileFromOryIdentity(userID, identity) + } + } + + return profiles, nil +} + +func (p *oryProvider) FindProfilesByEmail(ctx context.Context, email string) ([]Profile, error) { + normalized := strings.TrimSpace(email) + if normalized == "" { + return []Profile{}, nil + } + + identities, _, err := p.identities.ListIdentitiesExecute( + p.identities.ListIdentities(ctx).CredentialsIdentifier(normalized), + ) + if err != nil { + return nil, fmt.Errorf("ory list identities by credentials identifier: %w", err) + } + if len(identities) == 0 { + return []Profile{}, nil + } + + userIDBySubject, err := p.userIDsForSubjects(ctx, identitySubjects(identities)) + if err != nil { + return nil, err + } + + profiles := make([]Profile, 0, len(identities)) + for _, identity := range identities { + userID, ok := userIDBySubject[identity.Id] + if !ok { + continue + } + profiles = append(profiles, profileFromOryIdentity(userID, identity)) + } + + return profiles, nil +} + +func (p *oryProvider) subjectsForUserIDs(ctx context.Context, userIDs []uuid.UUID) (map[string]uuid.UUID, error) { + rows, err := p.resolver.GetUserIdentitiesByUserIDs(ctx, authqueries.GetUserIdentitiesByUserIDsParams{ + OidcIss: p.issuer, + UserIds: userIDs, + }) + if err != nil { + return nil, fmt.Errorf("lookup ory subjects: %w", err) + } + + userIDBySubject := make(map[string]uuid.UUID, len(rows)) + for _, row := range rows { + userIDBySubject[row.OidcSub] = row.UserID + } + + return userIDBySubject, nil +} + +func (p *oryProvider) userIDsForSubjects(ctx context.Context, subjects []string) (map[string]uuid.UUID, error) { + rows, err := p.resolver.GetUserIdentitiesBySubjects(ctx, authqueries.GetUserIdentitiesBySubjectsParams{ + OidcIss: p.issuer, + OidcSubs: subjects, + }) + if err != nil { + return nil, fmt.Errorf("lookup user ids by ory subjects: %w", err) + } + + userIDBySubject := make(map[string]uuid.UUID, len(rows)) + for _, row := range rows { + userIDBySubject[row.OidcSub] = row.UserID + } + + return userIDBySubject, nil +} + +func (p *oryProvider) listIdentitiesByIDs(ctx context.Context, ids []string) ([]ory.Identity, error) { + identities := make([]ory.Identity, 0, len(ids)) + for page := range slices.Chunk(ids, oryListPageSize) { + batch, _, err := p.identities.ListIdentitiesExecute( + p.identities.ListIdentities(ctx).Ids(page).PageSize(int64(len(page))), + ) + if err != nil { + return nil, fmt.Errorf("ory list identities: %w", err) + } + identities = append(identities, batch...) + } + + return identities, nil +} + +func identitySubjects(identities []ory.Identity) []string { + subjects := make([]string, 0, len(identities)) + for _, identity := range identities { + subjects = append(subjects, identity.Id) + } + + return subjects +} + +func profileFromOryIdentity(userID uuid.UUID, identity ory.Identity) Profile { + traits, _ := identity.Traits.(map[string]any) + + return Profile{ + UserID: userID, + Email: metadataString(traits, "email"), + Name: oryDisplayName(traits), + } +} + +// mirrors dashboard.full-stack/src/core/server/auth/ory/identity.ts readDisplayName. +func oryDisplayName(traits map[string]any) string { + if name := metadataString(traits, "name"); name != "" { + return name + } + if name := nestedNameTrait(traits); name != "" { + return name + } + if name := splitNameTraits(traits); name != "" { + return name + } + + return FirstNonEmpty( + metadataString(traits, "full_name"), + metadataString(traits, "fullName"), + ) +} + +func nestedNameTrait(traits map[string]any) string { + nested, ok := traits["name"].(map[string]any) + if !ok { + return "" + } + + return strings.TrimSpace(metadataString(nested, "first") + " " + metadataString(nested, "last")) +} + +func splitNameTraits(traits map[string]any) string { + first := FirstNonEmpty( + metadataString(traits, "first_name"), + metadataString(traits, "firstName"), + metadataString(traits, "given_name"), + metadataString(traits, "givenName"), + ) + last := FirstNonEmpty( + metadataString(traits, "last_name"), + metadataString(traits, "lastName"), + metadataString(traits, "family_name"), + metadataString(traits, "familyName"), + ) + if first == "" && last == "" { + return "" + } + + return strings.TrimSpace(first + " " + last) +} diff --git a/packages/dashboard-api/internal/userprofile/ory_test.go b/packages/dashboard-api/internal/userprofile/ory_test.go new file mode 100644 index 0000000000..70b68fc3b1 --- /dev/null +++ b/packages/dashboard-api/internal/userprofile/ory_test.go @@ -0,0 +1,95 @@ +package userprofile + +import ( + "testing" + + "github.com/google/uuid" + ory "github.com/ory/client-go" +) + +func TestProfileFromOryIdentity(t *testing.T) { + t.Parallel() + + userID := uuid.New() + + tests := []struct { + name string + traits any + wantName string + wantEmail string + }{ + { + name: "flat name and email", + traits: map[string]any{ + "email": "ada@example.com", + "name": "ada lovelace", + }, + wantName: "ada lovelace", + wantEmail: "ada@example.com", + }, + { + name: "nested first and last name", + traits: map[string]any{ + "email": "grace@example.com", + "name": map[string]any{ + "first": "grace", + "last": "hopper", + }, + }, + wantName: "grace hopper", + wantEmail: "grace@example.com", + }, + { + name: "first name only nested", + traits: map[string]any{ + "name": map[string]any{ + "first": "barbara", + }, + }, + wantName: "barbara", + wantEmail: "", + }, + { + name: "given/family fallback", + traits: map[string]any{ + "given_name": "marie", + "family_name": "curie", + "email": "marie@example.com", + }, + wantName: "marie curie", + wantEmail: "marie@example.com", + }, + { + name: "full name fallback when no flat name and no nested", + traits: map[string]any{ + "full_name": "alan turing", + }, + wantName: "alan turing", + wantEmail: "", + }, + { + name: "missing traits returns zero values", + traits: nil, + wantName: "", + wantEmail: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + identity := ory.Identity{Id: uuid.NewString(), Traits: tt.traits} + got := profileFromOryIdentity(userID, identity) + if got.UserID != userID { + t.Fatalf("profileFromOryIdentity().UserID = %s, want %s", got.UserID, userID) + } + if got.Name != tt.wantName { + t.Fatalf("profileFromOryIdentity().Name = %q, want %q", got.Name, tt.wantName) + } + if got.Email != tt.wantEmail { + t.Fatalf("profileFromOryIdentity().Email = %q, want %q", got.Email, tt.wantEmail) + } + }) + } +} diff --git a/packages/dashboard-api/internal/userprofile/provider.go b/packages/dashboard-api/internal/userprofile/provider.go index 8c072f9b84..7566b26370 100644 --- a/packages/dashboard-api/internal/userprofile/provider.go +++ b/packages/dashboard-api/internal/userprofile/provider.go @@ -2,6 +2,7 @@ package userprofile import ( "context" + "fmt" "github.com/google/uuid" ) @@ -16,3 +17,28 @@ type Provider interface { GetProfilesByUserID(ctx context.Context, userIDs []uuid.UUID) (map[uuid.UUID]Profile, error) FindProfilesByEmail(ctx context.Context, email string) ([]Profile, error) } + +func NewProvider(mode Mode, supa Provider, ory Provider) (Provider, error) { + switch mode { + case ModeSupabase: + if supa == nil { + return nil, fmt.Errorf("mode %q requires a supabase provider", mode) + } + + return supa, nil + case ModeOry: + if ory == nil { + return nil, fmt.Errorf("mode %q requires an ory provider", mode) + } + + return ory, nil + case ModeSupabaseOryFallback: + if supa == nil || ory == nil { + return nil, fmt.Errorf("mode %q requires both supabase and ory providers", mode) + } + + return newDualProvider(supa, ory), nil + default: + return nil, fmt.Errorf("unknown user profile provider mode %q", mode) + } +} diff --git a/packages/dashboard-api/main.go b/packages/dashboard-api/main.go index a4ad8e1d81..7c3b76669d 100644 --- a/packages/dashboard-api/main.go +++ b/packages/dashboard-api/main.go @@ -29,6 +29,7 @@ import ( "github.com/e2b-dev/infra/packages/dashboard-api/internal/cfg" "github.com/e2b-dev/infra/packages/dashboard-api/internal/handlers" 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" @@ -205,7 +206,14 @@ func run() int { return 1 } - apiStore := handlers.NewAPIStore(config, db, authDB, supabaseDB, clickhouseClient, authService, teamProvisionSink) + userProfiles, err := buildUserProfileProvider(config, supabaseDB, authDB, authClient) + if err != nil { + l.Error(ctx, "Initializing user profile provider", zap.Error(err)) + + return 1 + } + + apiStore := handlers.NewAPIStore(config, db, authDB, supabaseDB, clickhouseClient, authService, teamProvisionSink, userProfiles) swagger, err := api.GetSwagger() if err != nil { @@ -390,3 +398,24 @@ func shutdownService(ctx context.Context, s *http.Server) error { return nil } + +func buildUserProfileProvider(config cfg.Config, supabaseDB *supabasedb.Client, authDB *authdb.Client, httpClient *http.Client) (userprofile.Provider, error) { + supaProvider := userprofile.NewSupabaseProvider(supabaseDB) + + var oryProvider userprofile.Provider + if config.UserProfileProvider.RequiresOry() { + provider, err := userprofile.NewOryProvider(userprofile.OryConfig{ + HTTPClient: httpClient, + SDKURL: config.OrySDKURL, + Token: config.OryProjectAPIToken, + Issuer: config.AuthProvider.JWT[0].Issuer.URL, + Resolver: authDB.Read, + }) + if err != nil { + return nil, fmt.Errorf("build ory user profile provider: %w", err) + } + oryProvider = provider + } + + return userprofile.NewProvider(config.UserProfileProvider, supaProvider, oryProvider) +} diff --git a/packages/db/pkg/auth/queries/get_user_identities_by_subjects.sql.go b/packages/db/pkg/auth/queries/get_user_identities_by_subjects.sql.go new file mode 100644 index 0000000000..3f0e4bee7f --- /dev/null +++ b/packages/db/pkg/auth/queries/get_user_identities_by_subjects.sql.go @@ -0,0 +1,50 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: get_user_identities_by_subjects.sql + +package authqueries + +import ( + "context" + + "github.com/google/uuid" +) + +const getUserIdentitiesBySubjects = `-- name: GetUserIdentitiesBySubjects :many +SELECT oidc_iss, oidc_sub, user_id +FROM public.user_identities +WHERE oidc_iss = $1::text + AND oidc_sub = ANY($2::text[]) +` + +type GetUserIdentitiesBySubjectsParams struct { + OidcIss string + OidcSubs []string +} + +type GetUserIdentitiesBySubjectsRow struct { + OidcIss string + OidcSub string + UserID uuid.UUID +} + +func (q *Queries) GetUserIdentitiesBySubjects(ctx context.Context, arg GetUserIdentitiesBySubjectsParams) ([]GetUserIdentitiesBySubjectsRow, error) { + rows, err := q.db.Query(ctx, getUserIdentitiesBySubjects, arg.OidcIss, arg.OidcSubs) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetUserIdentitiesBySubjectsRow + for rows.Next() { + var i GetUserIdentitiesBySubjectsRow + if err := rows.Scan(&i.OidcIss, &i.OidcSub, &i.UserID); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/packages/db/pkg/auth/queries/get_user_identities_by_user_ids.sql.go b/packages/db/pkg/auth/queries/get_user_identities_by_user_ids.sql.go new file mode 100644 index 0000000000..026a263f3a --- /dev/null +++ b/packages/db/pkg/auth/queries/get_user_identities_by_user_ids.sql.go @@ -0,0 +1,50 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: get_user_identities_by_user_ids.sql + +package authqueries + +import ( + "context" + + "github.com/google/uuid" +) + +const getUserIdentitiesByUserIDs = `-- name: GetUserIdentitiesByUserIDs :many +SELECT oidc_iss, oidc_sub, user_id +FROM public.user_identities +WHERE oidc_iss = $1::text + AND user_id = ANY($2::uuid[]) +` + +type GetUserIdentitiesByUserIDsParams struct { + OidcIss string + UserIds []uuid.UUID +} + +type GetUserIdentitiesByUserIDsRow struct { + OidcIss string + OidcSub string + UserID uuid.UUID +} + +func (q *Queries) GetUserIdentitiesByUserIDs(ctx context.Context, arg GetUserIdentitiesByUserIDsParams) ([]GetUserIdentitiesByUserIDsRow, error) { + rows, err := q.db.Query(ctx, getUserIdentitiesByUserIDs, arg.OidcIss, arg.UserIds) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetUserIdentitiesByUserIDsRow + for rows.Next() { + var i GetUserIdentitiesByUserIDsRow + if err := rows.Scan(&i.OidcIss, &i.OidcSub, &i.UserID); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/packages/db/pkg/auth/sql_queries/user_identities/get_user_identities_by_subjects.sql b/packages/db/pkg/auth/sql_queries/user_identities/get_user_identities_by_subjects.sql new file mode 100644 index 0000000000..c4a6a7d97b --- /dev/null +++ b/packages/db/pkg/auth/sql_queries/user_identities/get_user_identities_by_subjects.sql @@ -0,0 +1,5 @@ +-- name: GetUserIdentitiesBySubjects :many +SELECT oidc_iss, oidc_sub, user_id +FROM public.user_identities +WHERE oidc_iss = sqlc.arg(oidc_iss)::text + AND oidc_sub = ANY(sqlc.arg(oidc_subs)::text[]); diff --git a/packages/db/pkg/auth/sql_queries/user_identities/get_user_identities_by_user_ids.sql b/packages/db/pkg/auth/sql_queries/user_identities/get_user_identities_by_user_ids.sql new file mode 100644 index 0000000000..5fd613337f --- /dev/null +++ b/packages/db/pkg/auth/sql_queries/user_identities/get_user_identities_by_user_ids.sql @@ -0,0 +1,5 @@ +-- name: GetUserIdentitiesByUserIDs :many +SELECT oidc_iss, oidc_sub, user_id +FROM public.user_identities +WHERE oidc_iss = sqlc.arg(oidc_iss)::text + AND user_id = ANY(sqlc.arg(user_ids)::uuid[]); From 2a4264e9c85bc608d49de00b66aa296fccf759db Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Mon, 25 May 2026 20:03:07 -0700 Subject: [PATCH 09/23] feat(dashboard-api): require oidc_issuer in bootstrap and allow-list it The bootstrap endpoint previously assumed exactly one configured OIDC issuer and silently planted the resulting identity under that single iss. The caller now passes oidc_issuer in the request body and the handler checks it against the configured AuthProvider.JWT list, rejecting unknown issuers with 400. This both unblocks multi-issuer deployments and prevents an admin-token holder from planting an identity under an arbitrary iss. --- .../dashboard-api/internal/api/api.gen.go | 85 ++++++------- .../handlers/admin_users_bootstrap.go | 8 +- .../internal/handlers/team_handlers_test.go | 118 +++++++++++++++++- .../handlers/utils_team_provisioning.go | 30 +++-- spec/openapi-dashboard.yml | 4 + 5 files changed, 181 insertions(+), 64 deletions(-) diff --git a/packages/dashboard-api/internal/api/api.gen.go b/packages/dashboard-api/internal/api/api.gen.go index d49fda3b55..c74877dd29 100644 --- a/packages/dashboard-api/internal/api/api.gen.go +++ b/packages/dashboard-api/internal/api/api.gen.go @@ -80,6 +80,7 @@ type AdminAuthProviderProfilesResponse struct { // AdminAuthProviderUserBootstrapRequest defines model for AdminAuthProviderUserBootstrapRequest. type AdminAuthProviderUserBootstrapRequest struct { + OidcIssuer string `json:"oidc_issuer"` OidcUserEmail openapi_types.Email `json:"oidc_user_email"` OidcUserId string `json:"oidc_user_id"` OidcUserName *string `json:"oidc_user_name,omitempty"` @@ -1041,48 +1042,48 @@ var swaggerSpec = []string{ "WUlTteP751DqOfAb4E6wawdCjarDNFX29AWURzyzmldhE2cFcEkM9iDHJGuA1jwJGW6F+Es76soPY7Pf", "INHIOkxzQpXmTzm7ISnwU87mJIOevZtMfVCPEU5TDkKgOWe5tsmE0TlZlBxShEu5RIVdXtkpLbMMz9Qe", "xhZboUCn6VMJXAVgH95MtdXXgiy18LA3qwvF+4btpSM+M/a9LDTvL0BZ4sx4305SDKviQQFZTqj7c0R0", - "FpS12Jor7Xbb/BR2xOg4qxPmraBrg3C/0yjKLwTwKWNSSI6LTm0wkibXSiLXoyES1yaZrCkn9DPQhVzW", - "9REabk7CuyGr2+C7sV3cIjkkDR3LntA5a3Nsw8zDQCyoZyE9QJ06kuQgJM4LFZ6efTx6+/btP2sBqZdT", - "iiXsqMEhWc0JJWLZux/LiwyGdowRmSO3WOf2g+5MaUCE4iebEerfEYdMZyWSIbkkwmQlmgJ8g4neQQc5", - "Du7tbcJ01JIJnWZsl5GYSV9ACLwIpMQfMclKDig3A9CPJVCbSSEi0Lc5Jhmk32LE5BL4DyIAfVN0fhtx", - "DmwgssJQQ8Ger01aOyF67uUQQoYlPsdFAanCAUqxWM4Y5ilKMqJkpdNCqvKHS5PoKHLjyPCq6CiTBETd", - "ZVRKqlGg3GXbVF4Ydrcs0QweKq8chJopD7gADAfRJz4TIbtPthTL8dUDtRSketlQ9YDCrTzqLxVIhgos", - "hHE6gNQMVyXQh5cuXRlhKTwp+YGSKWVmrMvRt5OiZrJBX7e4zm1tpVtkswosITf7OVjlqXvSkUjU9joU", - "LzSJCbF1dHpxxEoaMO+j0wuUMG7KcPXiQtSsaPz9XdRfw4ijI+0sVUbRGYW4uCDHty6QePP+fdwfWGxw", - "q9cIMXlsqjFfa7XU5u66CrpFALex4KGaHsK8lr+Xb6sO1JaUnmCSjUHn1QhjxoUiQG/SX4ELYjLIkf62", - "9bgoZxlJaj/NGMsA62yZ4/zLbJNbjZE2t6LAP2hQPB0TJJM4Oybi+zn5Azq26WCqtspNUpSjNgy5WweV", - "SleOZ7twm8q4ES1Y4TXA0RDFCAQbwIVhHI7GVFBX4OQesbdZdARRPU7RXR7c28IGPV21Q5BSp4xp28+p", - "35Agf8Cmn1NRzBcy7XV3eyF8mZJLO+3QhftWxUINRuq33Sge4yLyrsDDrGR/3h1M7DU51XIhsX0CnMll", - "t1o7SflU5pjucMCpwhla6nVQsoTkO+IgykwO09dHWD3U+Jne/QyRGxR3X5l+bdx6mm0LDgKorO/lL0dP", - "jnejng3G3Qe40cOINwqo7llr+3TmlXFXJhoymy+QM74KOUHzy3084P6bf4S81LlZ4QwSxtOek2qj6K/1", - "siG4YOxTlD5u6MOlD2/XcZQ2DoHew6caqeaxHBMaMG4sAJkfFZQ4NEQnOZ7PSaLwjHUCThRmR8A3rymp", - "j0ivzHveEXYYO+9wnV9Jbg21zuUPLJCdNNphCsmKYvtN9KR7u0VvS8fb2Ky5QfixJMlSKbJOlDW7QaOu", - "bRw3LmArWdfgXFN/A7Aha65uaAL2laaQTlehPGJQVMN5xeASvpLccTwNnjpEHLsb9HaSEXKbrlJdTawz", - "0i8+0Rfh6AGjw9aaToYiVrd0F23+FqWLtpGiFPa6fEwpSQ0N0XNRpO0EPif0tEbQfvwYKX3s7jhOSSJL", - "Dhc8G5ey9NL8QBE6Th6L1pbgOysXFwK4YiFQZ8pY8h3SM8BiZDJ/n3rBw+14iimFNFwrIGJquOj6uccJ", - "xKbrZtAinQQ/m9GPrc1O+4ojSYxnHqv/2PWl6ImVS2uTVZdcTcbxBiia3tCKa8gtbsgrkMXSpOQcqLQh", - "HoiRta1qpovDTU115HR1GrZLPl1JsvM5n1jJxcjqUo5vz0LVq+49fg1UkoKjN31/k7w4KNUeiVWb16j2", - "IupTa2+RBufjTzrvmYYrM2rZNk3KdCApOZGrc7Um2M6TnNCv7Dvom2xNjQrRloBTbRS2k+q/O3rgjh5Z", - "yRsX5F+gK6/1e/ApYA7crTfTf310GjPB+bW0C2n2tKPRw6qll1IWmwsr/gfIVEN2dLzXIvG8LPAMC9gf", - "w64b3M2xG/FmBFXVakqDrcWUboi9OpdE6qbPD2+m6NjfPR6enkRxdONKydHe7v7unr7pL4DigkQH0dvd", - "vd095b6wXGrVTrBS2aQUwHdcI8Nkttrxh0zBTGChQKkLNCrBjk6ZkFrZCnGuGWO6+mC9o70amrJ09Wg9", - "U9s02qybeLfNwI0evTeP2M413J4SavUyV8DzMstWpssM5VgmS0IXyGli17S67XUR4DmaqEFVF+DQ2P1a", - "Q1v/WDWo7hWig8u2P7i8Wl/FkSjzHPNVdBAZjZj2K8cKmq2Q71zBC6Gr2Gqd6EqtH8Sh7RjdEoY2Rn9u", - "GG40WL06CFphp68dfFYPTfRtAbo704i2VuQtIAC6X6CNuQvXKFh/h+UyzF81ZGL7CxUDL945WQG9UlT8", - "ArKBiEFAiMnMdemN9D/Ct/U9l+sJ9hI+s+MJVUWGAOUlW0BqW/NfJai87JsNzJqlGsBssN0CmPM02yPN", - "uJs63l6e33lMYLwsZfeod+aT566jwyeL22ks+NbfOh45z7c0jZ3h3ssaP96+8/WkkAp0xw1HNfZFtwIv", - "CNXds4bgJ3U59n2YobFvH4bYUK56eRVMOi+vlCa78m81KZxCtyJ7IqR+n8xKsWYG9kHdDib1Fy37DeK8", - "arq7n2GI54Beq9NwNPw2Wgt/Ym977KkQrtWh2Ye+O4eN9TD+pr5v7H7wewb06fcmtgRcChKTTOw+JYjs", - "q3hDY9+9YsBZMXbhzbRS9YHMNG1FTwiRjbawAE4+1Ru+hAeNEbVnuj5K/zQRrvg9ufNX5esJ900kXTz7", - "ovm5m2UbT7a1seqC/kmNrNkdM9rQXO/BT1N7oKk5QXKHEmdrHoDW3PydiEVeU0tnWjEC4SzTkYrpEMHV", - "m8s2tUAzyBhdCCRZjH4QuUTmMg5hqgxe39CheYYXu1HcBre+tnlKe27fDY1GpOZOs/6M6VMYNP3oCMSW", - "Fe2BDCvuSY4rhTx+4aX9qsQrKLLYG2X79YcXWV95OGaMZjSPHRm5/n/9JqHDaejfBcImu3HfrHCfz/ib", - "sO+Ry1WMbnBGUiwJXfhvS+iOQmS6h7r9RXUzsd0B6D+w8eIKNv7SoIayP9/Rtw0i3fWDhpHDTC8278xH", - "WNbmE1gyWQYcnHqsIfTVfbBlewT5+OnxHWS7Fe2ZHWSgr2wIuqWe8gz+8U+bkBuhDztfB/BJrXGzK2mo", - "gdz2gT4M60/oKzf7VEfHZ9o1WFns/kRTq7SYe8VvHQA+InKe4v4u8AGfUY5yvx2xNKClO8vrwnsxDu1P", - "m7Eepg2Bb+UAGz0GKWRg3otqYvpYP2+j+p6tBg7b8QMuB98NwJBDzm5eKBBfFbjOtCBH4cu+YTuxFYvh", - "uohKWdxnLV2Zwy9jCiFyCYQj/cBVQAmdM10Zsa9adyQ5dpljR8wTHsGdLzqPPodb3L++ckmLhQZO/NvX", - "WiT2+V3j66zmSrj5KUpoPDRwazxw666v1v8PAAD//w==", + "FpS12Jor7Xbb/BR2xOg4qxPmraBrg3C/0yjKLwTwKWNSSI6LTm0wkibXRIgSdAyXE/oZ6EIu65Kt1KBH", + "K/ldjwZUfZLJsUbvYc7NuyEb3ZBSnaONzdsMhCSp4+ATOmdtadkQ9TAQR+pZSA9QJ5YkOQiJ80KFtmcf", + "j96+ffvPWjDrpZZiCTtqcEhyc0KJWPbux/Iig6EdY0TmyC3Wuf2gK1T6EKHYy2aT+nfEIdMZjWRILokw", + "GY2mAN9gonfQAZIzlfY2YTpqiYhOUbbLZsykLyAEXgTS6Y+YZCUHlJsB6McSqM3CEBHo2xyTDNJvMWJy", + "CfwHEYC+KTq/jThDNvBZYaihYM/XJq2dED33cgghwxKf46KAVOEApVgsZwzzFCUZUbLSKSVVucelSZIU", + "uXFkeFV0lEkCou5uKiXVKFCutm0qLwy7W5Z3Bg+kVw5CzZQHXACGg+gTn4mQ3adiiuX4yoNaClK9bKjy", + "QOFWHvWXGSRDBRbCOB1AaoarMOiDT5e9jLAUnpT8QMmUMjPW5ffbSVEz2aCvW1znti7TLbJZBZaQm/0c", + "rBDVPelIJGp7HYo1msSE2Do6vThiJQ2Y99HpBUoYNyW8emEialZD/v4u6q9/xNGRdpYqG+mMYFyUkONb", + "F1a8ef8+7g8zNrjVa4SYPDaVnK+1Omxzd11B3SL421jwUE0PYV7L38u3VUNqS0pPMInKoPNqhDHjQhGg", + "N+mvwAUx2edIf9t6XJSzjCS1n2aMZYB1ps1x/mW2ya3GSJtbUeAfNCiejgmSSZwdE/H9nPwBHdt0MFVb", + "5SYpylEbhtytg0qlK8ezXbhNZdyIFqzwGuBoiGIEgg3gwjAOR2MqqCtwco9I3Cw6gqgep+guHu5tYYOe", + "rtohSKlTxrTt59RvSJA/YNPPqSjmC5n2uru9EL5Muaadduiif6vaoQYj9dtuFI9xEXlX4GFWsj/vDhYF", + "NDnVciGxfQKcyWW3WjtJ+VTmmO5wwKnCGVrqdVCyhOQ74iDKTA7T10dYPdT4md79DJEbFHdft35t3Jia", + "bQsOAqis7+UvVk+Od6OeDcbdJbjRw4g3CqjuaGv7dOaVcVcmGjKbL5Azvgo5QfPLfTzg/pt/hLzUuVnh", + "DBLG056TauPCQOtlQ3DB2KcofdzQh0sf3q7jKG0cAr2HTzVSzWM5JjRg3FgAMj8qKHFoiE5yPJ+TROEZ", + "6wScKMyOgG9eU1IfkV6Z97xf7DB23uE6v5LcGmqdyx9YIDtptMMUkhXF9pvoSfd2i96WjrexWXP78GNJ", + "kqVSZJ0oa3aDRl3bOG5c3layrsG5pv4GYEPWXN3uBOwrTSGdrkJ5xKCohvOKwSV8XbnjeBo8dYg4drfv", + "7SQj5DZd3bqaWGekX3yiL8LRA0aHrTWdDEWsbuku2vwNTBdtI0Up7FX7mFKSGhqi56JI2wl8TuhpjaD9", + "+DFS+tjdj5ySRJYcLng2LmXppfmBInScPBatLcF3Vi4uBHDFQqDOlLHkO6RngMXIZP4+9YKH2/EUUwpp", + "uFZAxNRw0fVzjxOITcfOoEU6CX42ox9bm532FUeSGM88Vv+x62nREyuX1iarLrmajOMNUDS9oRXXkFvc", + "kFcgi6VJyTlQaUM8ECNrW9VMF4ebmurI6eo0bJd8upJk53M+sZKLkdWlHN+ehapX3Xv8GqgkBUdv+v4m", + "eXFQqj0SqzavUe1F1KfW3iINzsefdN4zDVdm1LJtmpTpQFJyIlfnak2wXSs5oV/Zd9C34JoaFaItAafa", + "KGwX1n939MAdPbKSNy7Iv0BXXut36FPAHLhbb6b/+ug0ZoLza2kX0uxpR6OHVUsvpSw2F1b8D5Cphuzo", + "eK9F4nlZ4BkWsD+GXTe4m2M34s0IqqrVlAZbiyndEHt1LonUDaMf3kzRsb97PDw9ieLoxpWSo73d/d09", + "fe9fAMUFiQ6it7t7u3vKfWG51KqdYKWySSmA77gmiMlsteMPmYKZwEKBUhdoVIIdnTIhtbIV4lwjx3T1", + "wXpHezU0Zenq0fqttmnSWTfxbhuJG/19bx6xFWy4tSXUJmaugOdllq1MhxrKsUyWhC6Q08SuaZPb6yLA", + "czRRg6oOwqGx+7VmuP6xalDdK0QHl21/cHm1voojUeY55qvoIDIaMa1bjhU0WyHfx4IXQlex1TrRlVo/", + "iEPbbbolDG2M/tww3GjOenUQtMJOXzv4rB6a6NsCdHemiW2tyFtAAHS/QBtzF67JsP7+y2WYv2rIxPYm", + "KgZevHOyAnqlqPgFZAMRg4AQk5nr8Bvpf4RvCXwu1xPsQ3xmxxOqigwByku2gNS29b9KUHnZN5ufNUs1", + "gNlguwUw52m2R5pxN3W8vTy/85jAeFnK7lHvzCfPXUeHTxa301jwjcF1PHKeb2kaO8O90zV+vH1f7Ekh", + "FeiOG45q7EtyBV4QqrtnDcFP6nLsuzRDY98+DLGhXPXyKph0Xl4pTXbl32pSOIVuRfZESP0umpVizQzs", + "g7odTOovafYbxHnVdHc/wxDPAb1Wp+Fo+G20Fv7E3vbYUyFcq0OzD313DhvrYfxNfd/Y/eD3DOjT701s", + "CbgUJCaZ2H1KENnX+IbGvnvFgLNi7MKbaaXqA5lp2oqeECIbbWEBnHyqN3wJDxojas90fZT+aSJc8Xty", + "56/K1xPum0i6ePZF83M3yzaebGtj1QX9kxpZsztmtKG53oOfpvZAU3OC5A4lztY8AK25+TsRi7ymls60", + "YgTCWaYjFdMhgqu3nm1qgWaQMboQSLIY/SByicxlHMJUGby+oUPzDC92o7gNbn1t85T23L4bGo1IzZ1m", + "/RnTpzBo+tERiC0r2gMZVtyTHFcKefzCS/tViVdQZLE3yvbLES+yvvJwzBjNaB47MnL9//pNQofT0L8L", + "hE1247534T698Tdh30GXqxjd4IykWBK68N+l0B2FyHQPdfuL6mZiuwPQf5zjxRVs/KVBDWV/vqNvG0S6", + "6wcNI4eZXmzemQ+4rM3ns2SyDDg49VhD6Kv72Mv2CPLx0+M7yHYr2jM7yEBf2RB0Sz3lGfzjnzYhN0If", + "dr4O4JNa42ZX0lADue0DfRjWn9BXbvapjo7PtGuwstj9iaZWaTH3it86AHxE5DzF/V3g4z+jHOV+O2Jp", + "QEt3lteF92Ic2p82Yz1MGwLfygE2egxSyMC8F9XE9LF+3kb1PVsNHLbjB1wOvhuAIYec3bxQIL4qcJ1p", + "QY7Cl33DdmIrFsN1EZWyuE9iujKHX8YUQuQSCEf6gauAEjpnujJiX7XuSHLsMseOmCc8gjtfdB59Dre4", + "f33lkhYLDZz4t6+1SOzzu8aXXc2VcPMzltB4aODWeODWXV+t/x8AAP//", } // decodeSpec returns the embedded OpenAPI spec as raw JSON bytes, diff --git a/packages/dashboard-api/internal/handlers/admin_users_bootstrap.go b/packages/dashboard-api/internal/handlers/admin_users_bootstrap.go index 0b4c34dc83..d18b872c5f 100644 --- a/packages/dashboard-api/internal/handlers/admin_users_bootstrap.go +++ b/packages/dashboard-api/internal/handlers/admin_users_bootstrap.go @@ -47,16 +47,18 @@ func (s *APIStore) PostAdminUsersBootstrap(c *gin.Context) { return } + oidcIssuer := strings.TrimSpace(body.OidcIssuer) oidcUserID := strings.TrimSpace(body.OidcUserId) oidcUserEmail := strings.TrimSpace(string(body.OidcUserEmail)) - if oidcUserID == "" || oidcUserEmail == "" { - telemetry.ReportErrorByCode(ctx, http.StatusBadRequest, "bootstrap auth provider user failed", errors.New("oidc_user_id and oidc_user_email must be non-empty")) - s.sendAPIStoreError(c, http.StatusBadRequest, "oidc_user_id and oidc_user_email must be non-empty") + if oidcIssuer == "" || oidcUserID == "" || oidcUserEmail == "" { + telemetry.ReportErrorByCode(ctx, http.StatusBadRequest, "bootstrap auth provider user failed", errors.New("oidc_issuer, oidc_user_id and oidc_user_email must be non-empty")) + s.sendAPIStoreError(c, http.StatusBadRequest, "oidc_issuer, oidc_user_id and oidc_user_email must be non-empty") return } team, err := s.bootstrapOIDCUser(ctx, oidcUserBootstrapInput{ + OIDCIssuer: oidcIssuer, OIDCUserID: oidcUserID, OIDCUserEmail: oidcUserEmail, OIDCUserName: body.OidcUserName, diff --git a/packages/dashboard-api/internal/handlers/team_handlers_test.go b/packages/dashboard-api/internal/handlers/team_handlers_test.go index 8509d5b6d7..e968811e81 100644 --- a/packages/dashboard-api/internal/handlers/team_handlers_test.go +++ b/packages/dashboard-api/internal/handlers/team_handlers_test.go @@ -559,6 +559,7 @@ func TestBootstrapAuthProviderUser_CreatesIdentityAndDefaultTeam(t *testing.T) { } input := oidcUserBootstrapInput{ + OIDCIssuer: "https://ory.example.test", OIDCUserID: uuid.NewString(), OIDCUserEmail: "ada@example.test", OIDCUserName: nil, @@ -570,7 +571,7 @@ func TestBootstrapAuthProviderUser_CreatesIdentityAndDefaultTeam(t *testing.T) { } userIdentity, err := testDB.AuthDB.Read.GetUserIdentity(ctx, authqueries.GetUserIdentityParams{ - OidcIss: "https://ory.example.test", + OidcIss: input.OIDCIssuer, OidcSub: input.OIDCUserID, }) if err != nil { @@ -626,6 +627,7 @@ func TestBootstrapOIDCUser_ConcurrentRequestsSingleIdentityAndTeam(t *testing.T) } input := oidcUserBootstrapInput{ + OIDCIssuer: "https://ory.example.test", OIDCUserID: uuid.NewString(), OIDCUserEmail: "ada@example.test", OIDCUserName: nil, @@ -673,7 +675,7 @@ func TestBootstrapOIDCUser_ConcurrentRequestsSingleIdentityAndTeam(t *testing.T) } userIdentity, err := testDB.AuthDB.Read.GetUserIdentity(ctx, authqueries.GetUserIdentityParams{ - OidcIss: "https://ory.example.test", + OidcIss: input.OIDCIssuer, OidcSub: input.OIDCUserID, }) if err != nil { @@ -707,7 +709,7 @@ func TestPostAdminUsersBootstrap_EmptyOIDCUserIDReturnsBadRequest(t *testing.T) recorder := httptest.NewRecorder() ginCtx, _ := gin.CreateTestContext(recorder) - ginCtx.Request = httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/", strings.NewReader(`{"oidc_user_id":" ","oidc_user_email":"ada@example.test","oidc_user_name":null}`)) + ginCtx.Request = httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/", strings.NewReader(`{"oidc_issuer":"https://ory.example.test","oidc_user_id":" ","oidc_user_email":"ada@example.test","oidc_user_name":null}`)) ginCtx.Request.Header.Set("Content-Type", "application/json") store := &APIStore{} @@ -718,6 +720,116 @@ func TestPostAdminUsersBootstrap_EmptyOIDCUserIDReturnsBadRequest(t *testing.T) } } +func TestBootstrapOIDCUser_UnknownIssuerReturnsBadRequest(t *testing.T) { + t.Parallel() + + testDB := testutils.SetupDatabase(t) + ctx := t.Context() + sink := &fakeTeamProvisionSink{} + + store := &APIStore{ + config: cfg.Config{ + AuthProvider: auth.ProviderConfig{ + JWT: []oidc.Config{ + {Issuer: oidc.Issuer{URL: "https://ory.example.test"}}, + }, + }, + }, + db: testDB.SqlcClient, + authDB: testDB.AuthDB, + supabaseDB: testDB.SupabaseDB, + teamProvisionSink: sink, + } + + _, 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 TestBootstrapOIDCUser_MultipleConfiguredIssuersIsolatesIdentities(t *testing.T) { + t.Parallel() + + testDB := testutils.SetupDatabase(t) + ctx := t.Context() + sink := &fakeTeamProvisionSink{} + + const issuerA = "https://ory-a.example.test" + const issuerB = "https://ory-b.example.test" + + store := &APIStore{ + config: cfg.Config{ + AuthProvider: auth.ProviderConfig{ + JWT: []oidc.Config{ + {Issuer: oidc.Issuer{URL: issuerA}}, + {Issuer: oidc.Issuer{URL: issuerB}}, + }, + }, + }, + db: testDB.SqlcClient, + authDB: testDB.AuthDB, + supabaseDB: testDB.SupabaseDB, + teamProvisionSink: sink, + } + + sharedSubject := uuid.NewString() + + teamA, err := store.bootstrapOIDCUser(ctx, oidcUserBootstrapInput{ + OIDCIssuer: issuerA, + OIDCUserID: sharedSubject, + OIDCUserEmail: "ada-a@example.test", + OIDCUserName: nil, + }) + if err != nil { + t.Fatalf("issuer A bootstrap failed: %v", err) + } + + teamB, err := store.bootstrapOIDCUser(ctx, oidcUserBootstrapInput{ + OIDCIssuer: issuerB, + OIDCUserID: sharedSubject, + OIDCUserEmail: "ada-b@example.test", + OIDCUserName: nil, + }) + if err != nil { + t.Fatalf("issuer B bootstrap failed: %v", err) + } + + if teamA.ID == teamB.ID { + t.Fatalf("expected distinct teams for same subject under different issuers, both got %s", teamA.ID) + } + + identityA, err := testDB.AuthDB.Read.GetUserIdentity(ctx, authqueries.GetUserIdentityParams{ + OidcIss: issuerA, + OidcSub: sharedSubject, + }) + if err != nil { + t.Fatalf("expected identity under issuer A: %v", err) + } + identityB, err := testDB.AuthDB.Read.GetUserIdentity(ctx, authqueries.GetUserIdentityParams{ + OidcIss: issuerB, + OidcSub: sharedSubject, + }) + if err != nil { + t.Fatalf("expected identity under issuer B: %v", err) + } + if identityA.UserID == identityB.UserID { + t.Fatalf("expected distinct user ids for same subject under different issuers, both got %s", identityA.UserID) + } +} + func TestPostUsersBootstrap_ProvisioningFailureKeepsCreatedDefaultTeam(t *testing.T) { t.Parallel() diff --git a/packages/dashboard-api/internal/handlers/utils_team_provisioning.go b/packages/dashboard-api/internal/handlers/utils_team_provisioning.go index 4cf588f199..96453595d6 100644 --- a/packages/dashboard-api/internal/handlers/utils_team_provisioning.go +++ b/packages/dashboard-api/internal/handlers/utils_team_provisioning.go @@ -49,6 +49,7 @@ type bootstrapUserIdentity struct { } type oidcUserBootstrapInput struct { + OIDCIssuer string OIDCUserID string OIDCUserEmail string OIDCUserName *string @@ -85,8 +86,7 @@ func (s *APIStore) bootstrapUserProfileFromSupabase(ctx context.Context, userID } func (s *APIStore) bootstrapOIDCUser(ctx context.Context, input oidcUserBootstrapInput) (provisionedTeam, error) { - issuer, err := s.oidcIssuer() - if err != nil { + if err := s.requireConfiguredOIDCIssuer(input.OIDCIssuer); err != nil { return provisionedTeam{}, err } @@ -97,27 +97,25 @@ func (s *APIStore) bootstrapOIDCUser(ctx context.Context, input oidcUserBootstra } return s.bootstrapUserWithIdentity(ctx, profile, &bootstrapUserIdentity{ - Issuer: issuer, + Issuer: input.OIDCIssuer, Subject: input.OIDCUserID, }) } -func (s *APIStore) oidcIssuer() (string, error) { - if len(s.config.AuthProvider.JWT) != 1 { - return "", &internalteamprovision.ProvisionError{ - StatusCode: http.StatusBadRequest, - Message: "Expected exactly one OIDC auth provider issuer", - } - } - issuer := strings.TrimSpace(s.config.AuthProvider.JWT[0].Issuer.URL) - if issuer == "" { - return "", &internalteamprovision.ProvisionError{ - StatusCode: http.StatusBadRequest, - Message: "OIDC auth provider issuer is not configured", +// requireConfiguredOIDCIssuer rejects bootstrap requests whose issuer is not in +// the configured provider list. Without this an admin-token holder could plant +// an identity under any arbitrary iss string. +func (s *APIStore) requireConfiguredOIDCIssuer(issuer string) error { + for _, jwt := range s.config.AuthProvider.JWT { + if strings.TrimSpace(jwt.Issuer.URL) == issuer { + return nil } } - return issuer, nil + return &internalteamprovision.ProvisionError{ + StatusCode: http.StatusBadRequest, + Message: "oidc_issuer is not a configured auth provider", + } } func (s *APIStore) bootstrapUser(ctx context.Context, profile bootstrapUserProfile) (provisionedTeam, error) { diff --git a/spec/openapi-dashboard.yml b/spec/openapi-dashboard.yml index 828bb03181..627b84190c 100644 --- a/spec/openapi-dashboard.yml +++ b/spec/openapi-dashboard.yml @@ -220,9 +220,13 @@ components: AdminAuthProviderUserBootstrapRequest: type: object required: + - oidc_issuer - oidc_user_id - oidc_user_email properties: + oidc_issuer: + type: string + minLength: 1 oidc_user_id: type: string minLength: 1 From cf55a5a980cd4675a4c1a23902c3cea80ae99979 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Mon, 25 May 2026 20:25:38 -0700 Subject: [PATCH 10/23] refactor(dashboard-api): decouple ory userprofile issuer from auth provider The ory userprofile provider previously borrowed its issuer from config.AuthProvider.JWT[0].Issuer.URL and required exactly one configured jwt issuer. That conflated two unrelated concerns: which issuers we accept incoming JWTs from (a verification concern) and which oidc_iss value we use to scope public.user_identities lookups (a property of the ory tenant deployment). Introduce ORY_ISSUER_URL as an explicit config field. Profile resolution now works regardless of how many - or zero - jwt issuers are configured, and is compatible with the multi-issuer bootstrap allow-list added in 2a4264e9c. --- packages/dashboard-api/internal/cfg/model.go | 6 ++-- .../dashboard-api/internal/cfg/model_test.go | 34 +++++++------------ packages/dashboard-api/main.go | 2 +- 3 files changed, 16 insertions(+), 26 deletions(-) diff --git a/packages/dashboard-api/internal/cfg/model.go b/packages/dashboard-api/internal/cfg/model.go index f74a2d9435..0ef7e865a4 100644 --- a/packages/dashboard-api/internal/cfg/model.go +++ b/packages/dashboard-api/internal/cfg/model.go @@ -2,7 +2,6 @@ package cfg import ( "errors" - "fmt" "reflect" "github.com/caarlos0/env/v11" @@ -32,6 +31,7 @@ type Config struct { UserProfileProvider userprofile.Mode `env:"USER_PROFILE_PROVIDER" envDefault:"supabase"` OrySDKURL string `env:"ORY_SDK_URL"` OryProjectAPIToken string `env:"ORY_PROJECT_API_TOKEN,unset"` + OryIssuerURL string `env:"ORY_ISSUER_URL"` } func Parse() (Config, error) { @@ -77,8 +77,8 @@ func validateUserProfileProvider(config Config) error { if config.OryProjectAPIToken == "" { return errors.New("ORY_PROJECT_API_TOKEN is required when USER_PROFILE_PROVIDER uses ory") } - if len(config.AuthProvider.JWT) != 1 { - return fmt.Errorf("AUTH_PROVIDER_CONFIG must declare exactly one jwt issuer when USER_PROFILE_PROVIDER uses ory (got %d)", len(config.AuthProvider.JWT)) + if config.OryIssuerURL == "" { + return errors.New("ORY_ISSUER_URL is required when USER_PROFILE_PROVIDER uses ory") } return nil diff --git a/packages/dashboard-api/internal/cfg/model_test.go b/packages/dashboard-api/internal/cfg/model_test.go index 3898798b3b..3efcb4f116 100644 --- a/packages/dashboard-api/internal/cfg/model_test.go +++ b/packages/dashboard-api/internal/cfg/model_test.go @@ -64,41 +64,41 @@ func TestParseUserProfileProviderDefaultsToSupabase(t *testing.T) { require.Equal(t, userprofile.ModeSupabase, config.UserProfileProvider) } -func TestParseUserProfileProviderOryRequiresEnvAndIssuer(t *testing.T) { +func TestParseUserProfileProviderOryRequiresOryEnv(t *testing.T) { tests := []struct { name string mode string sdkURL string token string - authConfig string + issuer string wantErrSubstr string }{ { name: "ory mode without sdk url errors", mode: "ory", token: "pat", - authConfig: oryJWTConfigJSON, + issuer: "https://ory.example.test", wantErrSubstr: "ORY_SDK_URL", }, { name: "ory mode without token errors", mode: "ory", sdkURL: "https://ory.example.test", - authConfig: oryJWTConfigJSON, + issuer: "https://ory.example.test", wantErrSubstr: "ORY_PROJECT_API_TOKEN", }, { - name: "ory mode without jwt issuer errors", + name: "ory mode without issuer errors", mode: "ory", sdkURL: "https://ory.example.test", token: "pat", - wantErrSubstr: "AUTH_PROVIDER_CONFIG must declare exactly one jwt issuer", + wantErrSubstr: "ORY_ISSUER_URL", }, { name: "fallback mode applies same requirements", mode: "supabase-ory-fallback", token: "pat", - authConfig: oryJWTConfigJSON, + issuer: "https://ory.example.test", wantErrSubstr: "ORY_SDK_URL", }, } @@ -111,7 +111,7 @@ func TestParseUserProfileProviderOryRequiresEnvAndIssuer(t *testing.T) { t.Setenv("USER_PROFILE_PROVIDER", tt.mode) t.Setenv("ORY_SDK_URL", tt.sdkURL) t.Setenv("ORY_PROJECT_API_TOKEN", tt.token) - t.Setenv("AUTH_PROVIDER_CONFIG", tt.authConfig) + t.Setenv("ORY_ISSUER_URL", tt.issuer) _, err := Parse() require.Error(t, err) @@ -120,21 +120,22 @@ func TestParseUserProfileProviderOryRequiresEnvAndIssuer(t *testing.T) { } } -func TestParseUserProfileProviderOryHappyPath(t *testing.T) { +func TestParseUserProfileProviderOryHappyPathIsIndependentOfAuthProvider(t *testing.T) { t.Setenv("POSTGRES_CONNECTION_STRING", "postgres://example") t.Setenv("ADMIN_TOKEN", "admin-token") t.Setenv("REDIS_URL", "redis://example") t.Setenv("USER_PROFILE_PROVIDER", "supabase-ory-fallback") t.Setenv("ORY_SDK_URL", "https://ory.example.test") t.Setenv("ORY_PROJECT_API_TOKEN", "pat") - t.Setenv("AUTH_PROVIDER_CONFIG", oryJWTConfigJSON) + t.Setenv("ORY_ISSUER_URL", "https://ory.example.test") config, err := Parse() require.NoError(t, err) require.Equal(t, userprofile.ModeSupabaseOryFallback, config.UserProfileProvider) require.Equal(t, "https://ory.example.test", config.OrySDKURL) require.Equal(t, "pat", config.OryProjectAPIToken) - require.Len(t, config.AuthProvider.JWT, 1) + require.Equal(t, "https://ory.example.test", config.OryIssuerURL) + require.Empty(t, config.AuthProvider.JWT) } func TestParseUserProfileProviderInvalidModeErrors(t *testing.T) { @@ -147,14 +148,3 @@ func TestParseUserProfileProviderInvalidModeErrors(t *testing.T) { require.Error(t, err) require.Contains(t, err.Error(), "invalid user profile provider") } - -const oryJWTConfigJSON = `{ - "jwt": [ - { - "issuer": { - "url": "https://ory.example.test", - "audiences": ["dashboard-api"] - } - } - ] - }` diff --git a/packages/dashboard-api/main.go b/packages/dashboard-api/main.go index 7c3b76669d..9cb010a827 100644 --- a/packages/dashboard-api/main.go +++ b/packages/dashboard-api/main.go @@ -408,7 +408,7 @@ func buildUserProfileProvider(config cfg.Config, supabaseDB *supabasedb.Client, HTTPClient: httpClient, SDKURL: config.OrySDKURL, Token: config.OryProjectAPIToken, - Issuer: config.AuthProvider.JWT[0].Issuer.URL, + Issuer: config.OryIssuerURL, Resolver: authDB.Read, }) if err != nil { From f16a246d07c9c61de5771db5b97d1b1978266aa7 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Mon, 25 May 2026 20:27:43 -0700 Subject: [PATCH 11/23] feat(dashboard-api): default ORY_ISSUER_URL to ORY_SDK_URL when unset For the common deployments (default Ory Network and single-endpoint self-hosted setups) the issuer is the same string as the admin SDK URL. Default the issuer to the SDK URL so those callers only need to configure one knob. Custom-domain Ory Network and split Hydra/Kratos deployments can still override ORY_ISSUER_URL explicitly. --- packages/dashboard-api/internal/cfg/model.go | 4 +++ .../dashboard-api/internal/cfg/model_test.go | 33 +++++++++++++++++-- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/packages/dashboard-api/internal/cfg/model.go b/packages/dashboard-api/internal/cfg/model.go index 0ef7e865a4..cd430ca1ad 100644 --- a/packages/dashboard-api/internal/cfg/model.go +++ b/packages/dashboard-api/internal/cfg/model.go @@ -55,6 +55,10 @@ func Parse() (Config, error) { config.SupabaseDBConnectionString = config.PostgresConnectionString } + if config.OryIssuerURL == "" { + config.OryIssuerURL = config.OrySDKURL + } + if err == nil && config.RedisURL == "" && config.RedisClusterURL == "" { err = errors.New("at least one of REDIS_URL or REDIS_CLUSTER_URL must be set") } diff --git a/packages/dashboard-api/internal/cfg/model_test.go b/packages/dashboard-api/internal/cfg/model_test.go index 3efcb4f116..7b4493996b 100644 --- a/packages/dashboard-api/internal/cfg/model_test.go +++ b/packages/dashboard-api/internal/cfg/model_test.go @@ -88,11 +88,10 @@ func TestParseUserProfileProviderOryRequiresOryEnv(t *testing.T) { wantErrSubstr: "ORY_PROJECT_API_TOKEN", }, { - name: "ory mode without issuer errors", + name: "ory mode without sdk url or issuer errors on sdk url", mode: "ory", - sdkURL: "https://ory.example.test", token: "pat", - wantErrSubstr: "ORY_ISSUER_URL", + wantErrSubstr: "ORY_SDK_URL", }, { name: "fallback mode applies same requirements", @@ -138,6 +137,34 @@ func TestParseUserProfileProviderOryHappyPathIsIndependentOfAuthProvider(t *test require.Empty(t, config.AuthProvider.JWT) } +func TestParseUserProfileProviderOryIssuerDefaultsToSDKURL(t *testing.T) { + t.Setenv("POSTGRES_CONNECTION_STRING", "postgres://example") + t.Setenv("ADMIN_TOKEN", "admin-token") + t.Setenv("REDIS_URL", "redis://example") + t.Setenv("USER_PROFILE_PROVIDER", "ory") + t.Setenv("ORY_SDK_URL", "https://tenant.projects.oryapis.com") + t.Setenv("ORY_PROJECT_API_TOKEN", "pat") + + config, err := Parse() + require.NoError(t, err) + require.Equal(t, "https://tenant.projects.oryapis.com", config.OryIssuerURL) +} + +func TestParseUserProfileProviderOryIssuerOverrideForCustomDomain(t *testing.T) { + t.Setenv("POSTGRES_CONNECTION_STRING", "postgres://example") + t.Setenv("ADMIN_TOKEN", "admin-token") + t.Setenv("REDIS_URL", "redis://example") + t.Setenv("USER_PROFILE_PROVIDER", "ory") + t.Setenv("ORY_SDK_URL", "https://tenant.projects.oryapis.com") + t.Setenv("ORY_PROJECT_API_TOKEN", "pat") + t.Setenv("ORY_ISSUER_URL", "https://auth.mycompany.com") + + config, err := Parse() + require.NoError(t, err) + require.Equal(t, "https://tenant.projects.oryapis.com", config.OrySDKURL) + require.Equal(t, "https://auth.mycompany.com", config.OryIssuerURL) +} + func TestParseUserProfileProviderInvalidModeErrors(t *testing.T) { t.Setenv("POSTGRES_CONNECTION_STRING", "postgres://example") t.Setenv("ADMIN_TOKEN", "admin-token") From bcd9b523740b1de8aa8bfd29a2eaf0dc2c7c5f62 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Tue, 26 May 2026 13:11:55 -0700 Subject: [PATCH 12/23] fix(dashboard-api): align ORY_ISSUER_URL with auth-provider issuer The Ory profile provider filters public.user_identities by oidc_iss, which gets stored at bootstrap as the auth-provider issuer URL. The SDK admin URL (e.g. https://tenant.projects.oryapis.com) is not the OIDC iss claim, so defaulting to it silently strands every user once the deployment uses a custom issuer URL. - Drop the silent ORY_ISSUER_URL = ORY_SDK_URL fallback. - When AUTH_PROVIDER_CONFIG has exactly one JWT issuer, default ORY_ISSUER_URL from it. - When AUTH_PROVIDER_CONFIG has any JWT entries, reject an ORY_ISSUER_URL that doesn't match any of them so the mismatch surfaces at startup instead of as missing profile lookups. --- packages/dashboard-api/internal/cfg/model.go | 28 +++++++++--- .../dashboard-api/internal/cfg/model_test.go | 43 +++++++++++++++++-- 2 files changed, 62 insertions(+), 9 deletions(-) diff --git a/packages/dashboard-api/internal/cfg/model.go b/packages/dashboard-api/internal/cfg/model.go index cd430ca1ad..60db7187e5 100644 --- a/packages/dashboard-api/internal/cfg/model.go +++ b/packages/dashboard-api/internal/cfg/model.go @@ -2,7 +2,9 @@ package cfg import ( "errors" + "fmt" "reflect" + "strings" "github.com/caarlos0/env/v11" @@ -55,22 +57,22 @@ func Parse() (Config, error) { config.SupabaseDBConnectionString = config.PostgresConnectionString } - if config.OryIssuerURL == "" { - config.OryIssuerURL = config.OrySDKURL - } - if err == nil && config.RedisURL == "" && config.RedisClusterURL == "" { err = errors.New("at least one of REDIS_URL or REDIS_CLUSTER_URL must be set") } if err == nil { - err = validateUserProfileProvider(config) + err = validateUserProfileProvider(&config) } return config, err } -func validateUserProfileProvider(config Config) error { +// validateUserProfileProvider enforces Ory-specific env requirements and keeps +// ORY_ISSUER_URL aligned with the configured auth-provider issuer. The Ory +// profile provider filters public.user_identities by oidc_iss, so a mismatch +// against the iss claim stored at bootstrap silently strands every user. +func validateUserProfileProvider(config *Config) error { if !config.UserProfileProvider.RequiresOry() { return nil } @@ -81,9 +83,23 @@ func validateUserProfileProvider(config Config) error { if config.OryProjectAPIToken == "" { return errors.New("ORY_PROJECT_API_TOKEN is required when USER_PROFILE_PROVIDER uses ory") } + + if config.OryIssuerURL == "" && len(config.AuthProvider.JWT) == 1 { + config.OryIssuerURL = strings.TrimSpace(config.AuthProvider.JWT[0].Issuer.URL) + } if config.OryIssuerURL == "" { return errors.New("ORY_ISSUER_URL is required when USER_PROFILE_PROVIDER uses ory") } + if len(config.AuthProvider.JWT) > 0 { + for _, jwt := range config.AuthProvider.JWT { + if strings.TrimSpace(jwt.Issuer.URL) == config.OryIssuerURL { + return nil + } + } + + return fmt.Errorf("ORY_ISSUER_URL %q does not match any AUTH_PROVIDER_CONFIG.jwt[].issuer.url; identities stored at bootstrap would be invisible to the Ory profile provider", config.OryIssuerURL) + } + return nil } diff --git a/packages/dashboard-api/internal/cfg/model_test.go b/packages/dashboard-api/internal/cfg/model_test.go index 7b4493996b..e1ee0530dc 100644 --- a/packages/dashboard-api/internal/cfg/model_test.go +++ b/packages/dashboard-api/internal/cfg/model_test.go @@ -137,7 +137,7 @@ func TestParseUserProfileProviderOryHappyPathIsIndependentOfAuthProvider(t *test require.Empty(t, config.AuthProvider.JWT) } -func TestParseUserProfileProviderOryIssuerDefaultsToSDKURL(t *testing.T) { +func TestParseUserProfileProviderOryIssuerRequiredWhenAuthProviderEmpty(t *testing.T) { t.Setenv("POSTGRES_CONNECTION_STRING", "postgres://example") t.Setenv("ADMIN_TOKEN", "admin-token") t.Setenv("REDIS_URL", "redis://example") @@ -145,12 +145,49 @@ func TestParseUserProfileProviderOryIssuerDefaultsToSDKURL(t *testing.T) { t.Setenv("ORY_SDK_URL", "https://tenant.projects.oryapis.com") t.Setenv("ORY_PROJECT_API_TOKEN", "pat") + _, err := Parse() + require.Error(t, err) + require.Contains(t, err.Error(), "ORY_ISSUER_URL") +} + +func TestParseUserProfileProviderOryIssuerDefaultsFromSingleAuthProviderJWT(t *testing.T) { + t.Setenv("POSTGRES_CONNECTION_STRING", "postgres://example") + t.Setenv("ADMIN_TOKEN", "admin-token") + t.Setenv("REDIS_URL", "redis://example") + t.Setenv("USER_PROFILE_PROVIDER", "ory") + t.Setenv("ORY_SDK_URL", "https://tenant.projects.oryapis.com") + t.Setenv("ORY_PROJECT_API_TOKEN", "pat") + t.Setenv("AUTH_PROVIDER_CONFIG", `{ + "jwt": [ + {"issuer": {"url": "https://auth.mycompany.com", "audiences": ["dashboard-api"]}} + ] + }`) + config, err := Parse() require.NoError(t, err) - require.Equal(t, "https://tenant.projects.oryapis.com", config.OryIssuerURL) + require.Equal(t, "https://auth.mycompany.com", config.OryIssuerURL) +} + +func TestParseUserProfileProviderOryIssuerRejectsMismatchAgainstAuthProvider(t *testing.T) { + t.Setenv("POSTGRES_CONNECTION_STRING", "postgres://example") + t.Setenv("ADMIN_TOKEN", "admin-token") + t.Setenv("REDIS_URL", "redis://example") + t.Setenv("USER_PROFILE_PROVIDER", "ory") + t.Setenv("ORY_SDK_URL", "https://tenant.projects.oryapis.com") + t.Setenv("ORY_PROJECT_API_TOKEN", "pat") + t.Setenv("ORY_ISSUER_URL", "https://tenant.projects.oryapis.com") + t.Setenv("AUTH_PROVIDER_CONFIG", `{ + "jwt": [ + {"issuer": {"url": "https://auth.mycompany.com", "audiences": ["dashboard-api"]}} + ] + }`) + + _, err := Parse() + require.Error(t, err) + require.Contains(t, err.Error(), "does not match any AUTH_PROVIDER_CONFIG") } -func TestParseUserProfileProviderOryIssuerOverrideForCustomDomain(t *testing.T) { +func TestParseUserProfileProviderOryIssuerOverrideWithoutAuthProvider(t *testing.T) { t.Setenv("POSTGRES_CONNECTION_STRING", "postgres://example") t.Setenv("ADMIN_TOKEN", "admin-token") t.Setenv("REDIS_URL", "redis://example") From 7a4f6eaf6430b9a61fb1c04ee194aa0dda2e864b Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Tue, 26 May 2026 13:15:51 -0700 Subject: [PATCH 13/23] fix(dashboard-api): close ory response bodies and align lint nits - Capture and drain *http.Response from Ory ListIdentitiesExecute so the connection is released (bodyclose). - Realign USER_PROFILE_PROVIDER env tag (tagalign). - Update local-dev seed to the new UpsertPublicIdentity :one signature after merging main. --- packages/dashboard-api/internal/cfg/model.go | 2 +- .../dashboard-api/internal/userprofile/ory.go | 17 +++++++++++++++-- packages/local-dev/seed-local-database.go | 2 +- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/packages/dashboard-api/internal/cfg/model.go b/packages/dashboard-api/internal/cfg/model.go index 60db7187e5..e7bc269251 100644 --- a/packages/dashboard-api/internal/cfg/model.go +++ b/packages/dashboard-api/internal/cfg/model.go @@ -30,7 +30,7 @@ type Config struct { BillingServerURL string `env:"BILLING_SERVER_URL"` BillingServerAPIToken string `env:"BILLING_SERVER_API_TOKEN"` - UserProfileProvider userprofile.Mode `env:"USER_PROFILE_PROVIDER" envDefault:"supabase"` + UserProfileProvider userprofile.Mode `env:"USER_PROFILE_PROVIDER" envDefault:"supabase"` OrySDKURL string `env:"ORY_SDK_URL"` OryProjectAPIToken string `env:"ORY_PROJECT_API_TOKEN,unset"` OryIssuerURL string `env:"ORY_ISSUER_URL"` diff --git a/packages/dashboard-api/internal/userprofile/ory.go b/packages/dashboard-api/internal/userprofile/ory.go index b56a82ab2c..fe793f0f08 100644 --- a/packages/dashboard-api/internal/userprofile/ory.go +++ b/packages/dashboard-api/internal/userprofile/ory.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "io" "maps" "net/http" "slices" @@ -92,6 +93,16 @@ func (t *oryBearerTransport) RoundTrip(req *http.Request) (*http.Response, error return t.base.RoundTrip(cloned) } +// ory's generated client returns the raw *http.Response alongside the parsed +// body, so callers must close it (even on error) to release the connection. +func closeOryResponse(resp *http.Response) { + if resp == nil || resp.Body == nil { + return + } + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() +} + func (p *oryProvider) GetProfilesByUserID(ctx context.Context, userIDs []uuid.UUID) (map[uuid.UUID]Profile, error) { unique := uniqueUUIDs(userIDs) if len(unique) == 0 { @@ -127,9 +138,10 @@ func (p *oryProvider) FindProfilesByEmail(ctx context.Context, email string) ([] return []Profile{}, nil } - identities, _, err := p.identities.ListIdentitiesExecute( + identities, resp, err := p.identities.ListIdentitiesExecute( p.identities.ListIdentities(ctx).CredentialsIdentifier(normalized), ) + closeOryResponse(resp) if err != nil { return nil, fmt.Errorf("ory list identities by credentials identifier: %w", err) } @@ -191,9 +203,10 @@ func (p *oryProvider) userIDsForSubjects(ctx context.Context, subjects []string) func (p *oryProvider) listIdentitiesByIDs(ctx context.Context, ids []string) ([]ory.Identity, error) { identities := make([]ory.Identity, 0, len(ids)) for page := range slices.Chunk(ids, oryListPageSize) { - batch, _, err := p.identities.ListIdentitiesExecute( + batch, resp, err := p.identities.ListIdentitiesExecute( p.identities.ListIdentities(ctx).Ids(page).PageSize(int64(len(page))), ) + closeOryResponse(resp) if err != nil { return nil, fmt.Errorf("ory list identities: %w", err) } diff --git a/packages/local-dev/seed-local-database.go b/packages/local-dev/seed-local-database.go index c717b9e8aa..8d9eb4b85e 100644 --- a/packages/local-dev/seed-local-database.go +++ b/packages/local-dev/seed-local-database.go @@ -120,7 +120,7 @@ func upsertTeamAPIKey(ctx context.Context, db *authdb.Client, teamID uuid.UUID, } func upsertUserIdentity(ctx context.Context, db *authdb.Client, oidcIssuer, oidcSubject string) error { - if err := db.Write.UpsertPublicIdentity(ctx, authqueries.UpsertPublicIdentityParams{ + if _, err := db.Write.UpsertPublicIdentity(ctx, authqueries.UpsertPublicIdentityParams{ OidcIss: oidcIssuer, OidcSub: oidcSubject, UserID: userID, From 912eb1a40a06db024f79021718dedc9f6d7ce7fe Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Tue, 26 May 2026 13:28:58 -0700 Subject: [PATCH 14/23] feat(iac): wire ORY/USER_PROFILE_PROVIDER env into dashboard-api job Plumbs the four new dashboard-api configuration env vars through the .env.gcp -> Makefile -> root terraform -> nomad module -> job module chain so that make plan-only-jobs/dashboard-api picks them up. - Add USER_PROFILE_PROVIDER, ORY_SDK_URL, ORY_ISSUER_URL as plain terraform variables. - Add ORY_PROJECT_API_TOKEN as a Google Secret Manager secret with a placeholder value + ignore_changes so operators set it out-of-band. - Pass them into module.dashboard_api and inject into the nomad job env only when non-empty so the binary keeps its supabase default. --- .env.gcp.template | 13 +++++++++++++ iac/modules/job-dashboard-api/main.tf | 11 +++++++++-- iac/modules/job-dashboard-api/variables.tf | 21 +++++++++++++++++++++ iac/provider-gcp/Makefile | 3 +++ iac/provider-gcp/init/outputs.tf | 4 ++++ iac/provider-gcp/init/secrets.tf | 20 ++++++++++++++++++++ iac/provider-gcp/main.tf | 4 ++++ iac/provider-gcp/nomad/main.tf | 8 ++++++++ iac/provider-gcp/nomad/variables.tf | 19 +++++++++++++++++++ iac/provider-gcp/variables.tf | 18 ++++++++++++++++++ 10 files changed, 119 insertions(+), 2 deletions(-) diff --git a/.env.gcp.template b/.env.gcp.template index fd507c1217..d1c8145929 100644 --- a/.env.gcp.template +++ b/.env.gcp.template @@ -124,6 +124,19 @@ PERSISTENT_VOLUME_TYPES= # Client proxy OIDC issuer for edge gRPC autoresume auth. CLIENT_PROXY_OIDC_ISSUER_URL= +# Dashboard API user profile resolver (default: supabase) +# One of: supabase, ory, supabase-ory-fallback +USER_PROFILE_PROVIDER= +# Ory Network admin SDK URL (required when USER_PROFILE_PROVIDER uses ory) +# e.g. https://.projects.oryapis.com +ORY_SDK_URL= +# Ory OIDC issuer URL (must match an AUTH_PROVIDER_CONFIG.jwt[*].issuer.url; +# defaults to the configured single JWT issuer if unset). Not the SDK URL. +ORY_ISSUER_URL= +# Ory Project API token: set the secret value out-of-band via Google Secret +# Manager on `ory-project-api-token` (the secret resource is managed +# here with a placeholder so terraform doesn't overwrite operator updates). + # Sandbox firewall: comma-separated CIDRs to allow through the private-range deny list # ALLOW_SANDBOX_INTERNAL_CIDRS= diff --git a/iac/modules/job-dashboard-api/main.tf b/iac/modules/job-dashboard-api/main.tf index 152a6e99a4..9a0cd7da0b 100644 --- a/iac/modules/job-dashboard-api/main.tf +++ b/iac/modules/job-dashboard-api/main.tf @@ -1,5 +1,12 @@ locals { - base_env = { + ory_env = merge( + var.user_profile_provider != "" ? { USER_PROFILE_PROVIDER = var.user_profile_provider } : {}, + var.ory_sdk_url != "" ? { ORY_SDK_URL = var.ory_sdk_url } : {}, + var.ory_issuer_url != "" ? { ORY_ISSUER_URL = var.ory_issuer_url } : {}, + var.ory_project_api_token != "" ? { ORY_PROJECT_API_TOKEN = var.ory_project_api_token } : {}, + ) + + base_env = merge({ GIN_MODE = "release" ENVIRONMENT = var.environment ADMIN_TOKEN = var.admin_token @@ -19,7 +26,7 @@ locals { BILLING_SERVER_API_TOKEN = var.billing_server_api_token OTEL_COLLECTOR_GRPC_ENDPOINT = "localhost:${var.otel_collector_grpc_port}" LOGS_COLLECTOR_ADDRESS = "http://localhost:${var.logs_proxy_port.port}" - } + }, local.ory_env) } resource "nomad_job" "dashboard_api" { diff --git a/iac/modules/job-dashboard-api/variables.tf b/iac/modules/job-dashboard-api/variables.tf index a668cc67c8..e6c2a8d145 100644 --- a/iac/modules/job-dashboard-api/variables.tf +++ b/iac/modules/job-dashboard-api/variables.tf @@ -103,6 +103,27 @@ variable "billing_server_api_token" { default = "" } +variable "user_profile_provider" { + type = string + default = "" +} + +variable "ory_sdk_url" { + type = string + default = "" +} + +variable "ory_issuer_url" { + type = string + default = "" +} + +variable "ory_project_api_token" { + type = string + sensitive = true + default = "" +} + variable "logs_proxy_port" { type = object({ name = string diff --git a/iac/provider-gcp/Makefile b/iac/provider-gcp/Makefile index 44febc0947..042189bc0e 100644 --- a/iac/provider-gcp/Makefile +++ b/iac/provider-gcp/Makefile @@ -93,6 +93,9 @@ tf_vars := \ $(call tfvar, AUTH_DB_MAX_OPEN_CONNECTIONS) \ $(call tfvar, AUTH_DB_MIN_IDLE_CONNECTIONS) \ $(call tfvar, CLIENT_PROXY_OIDC_ISSUER_URL) \ + $(call tfvar, USER_PROFILE_PROVIDER) \ + $(call tfvar, ORY_SDK_URL) \ + $(call tfvar, ORY_ISSUER_URL) \ $(call tfvar, GCS_GRPC_CONNECTION_POOL_SIZE) \ $(call tfvar, ADDITIONAL_API_PATHS_HANDLED_BY_INGRESS) \ $(call tfvar, ANYWHERE_CACHE_ENABLED) diff --git a/iac/provider-gcp/init/outputs.tf b/iac/provider-gcp/init/outputs.tf index 4c62a1f908..6a7e5ef61f 100644 --- a/iac/provider-gcp/init/outputs.tf +++ b/iac/provider-gcp/init/outputs.tf @@ -66,6 +66,10 @@ output "posthog_api_key_secret_name" { value = google_secret_manager_secret_version.posthog_api_key.secret } +output "ory_project_api_token_secret_name" { + value = google_secret_manager_secret_version.ory_project_api_token.secret +} + output "supabase_jwt_secret_name" { value = google_secret_manager_secret_version.supabase_jwt_secrets.secret } diff --git a/iac/provider-gcp/init/secrets.tf b/iac/provider-gcp/init/secrets.tf index 0750024627..ef588f4fd3 100644 --- a/iac/provider-gcp/init/secrets.tf +++ b/iac/provider-gcp/init/secrets.tf @@ -259,6 +259,26 @@ resource "google_secret_manager_secret_version" "posthog_api_key" { } +resource "google_secret_manager_secret" "ory_project_api_token" { + secret_id = "${var.prefix}ory-project-api-token" + + replication { + auto {} + } + + depends_on = [time_sleep.secrets_api_wait_60_seconds] +} + +resource "google_secret_manager_secret_version" "ory_project_api_token" { + secret = google_secret_manager_secret.ory_project_api_token.name + secret_data = " " + + lifecycle { + ignore_changes = [secret_data] + } +} + + resource "google_secret_manager_secret" "redis_cluster_url" { secret_id = "${var.prefix}redis-cluster-url" diff --git a/iac/provider-gcp/main.tf b/iac/provider-gcp/main.tf index 28d3cdc869..747fa95db3 100644 --- a/iac/provider-gcp/main.tf +++ b/iac/provider-gcp/main.tf @@ -297,6 +297,10 @@ module "nomad" { dashboard_api_count = var.dashboard_api_count dashboard_api_admin_token_secret_name = module.init.dashboard_api_admin_token_secret_name supabase_db_connection_string_secret_version = module.init.supabase_db_connection_string_secret_version + user_profile_provider = var.user_profile_provider + ory_sdk_url = var.ory_sdk_url + ory_issuer_url = var.ory_issuer_url + ory_project_api_token_secret_name = module.init.ory_project_api_token_secret_name # Docker reverse proxy docker_reverse_proxy_port = var.docker_reverse_proxy_port diff --git a/iac/provider-gcp/nomad/main.tf b/iac/provider-gcp/nomad/main.tf index a14515a95b..c73105f47c 100644 --- a/iac/provider-gcp/nomad/main.tf +++ b/iac/provider-gcp/nomad/main.tf @@ -55,6 +55,10 @@ data "google_secret_manager_secret_version" "dashboard_api_admin_token" { secret = var.dashboard_api_admin_token_secret_name } +data "google_secret_manager_secret_version" "ory_project_api_token" { + secret = var.ory_project_api_token_secret_name +} + data "google_secret_manager_secret_version" "supabase_db_connection_string" { secret = var.supabase_db_connection_string_secret_version.secret } @@ -181,6 +185,10 @@ module "dashboard_api" { redis_tls_ca_base64 = trimspace(data.google_secret_manager_secret_version.redis_tls_ca_base64.secret_data) billing_server_url = local.dashboard_api_billing_server_url billing_server_api_token = local.dashboard_api_billing_server_api_token + user_profile_provider = var.user_profile_provider + ory_sdk_url = var.ory_sdk_url + ory_issuer_url = var.ory_issuer_url + ory_project_api_token = trimspace(data.google_secret_manager_secret_version.ory_project_api_token.secret_data) otel_collector_grpc_port = var.otel_collector_grpc_port logs_proxy_port = var.logs_proxy_port diff --git a/iac/provider-gcp/nomad/variables.tf b/iac/provider-gcp/nomad/variables.tf index 0ea622c902..a1924892ec 100644 --- a/iac/provider-gcp/nomad/variables.tf +++ b/iac/provider-gcp/nomad/variables.tf @@ -109,6 +109,25 @@ variable "dashboard_api_admin_token_secret_name" { type = string } +variable "user_profile_provider" { + type = string + default = "" +} + +variable "ory_sdk_url" { + type = string + default = "" +} + +variable "ory_issuer_url" { + type = string + default = "" +} + +variable "ory_project_api_token_secret_name" { + type = string +} + variable "sandbox_access_token_hash_seed" { type = string } diff --git a/iac/provider-gcp/variables.tf b/iac/provider-gcp/variables.tf index 3978b95258..0245a48aa7 100644 --- a/iac/provider-gcp/variables.tf +++ b/iac/provider-gcp/variables.tf @@ -243,6 +243,24 @@ variable "auth_provider_config" { default = null } +variable "user_profile_provider" { + type = string + default = "" + description = "Source for dashboard-api user profile lookups. One of: supabase, ory, supabase-ory-fallback. Empty leaves the binary default (supabase)." +} + +variable "ory_sdk_url" { + type = string + default = "" + description = "Ory Network admin SDK URL (e.g. https://.projects.oryapis.com). Required when user_profile_provider uses ory." +} + +variable "ory_issuer_url" { + type = string + default = "" + description = "Ory OIDC issuer URL used to namespace public.user_identities. Must match one of auth_provider_config.jwt[*].issuer.url; defaults to the single configured JWT issuer if unset." +} + variable "ingress_port" { type = object({ name = string From 54b77973ebeabc0c11432a50a98939865c7a0174 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Tue, 26 May 2026 14:11:05 -0700 Subject: [PATCH 15/23] fix(dashboard-api): align bootstrap issuer check with Ory resolver cfg permits Ory user-profile mode with ORY_ISSUER_URL set and no AUTH_PROVIDER_CONFIG.jwt entries, but requireConfiguredOIDCIssuer only consulted JWT issuers, so the configured issuer was rejected with 400 at bootstrap time. Additionally, in Ory user-profile mode the resolver only queries public.user_identities for OidcIss = OryIssuerURL, so identities created under any other configured JWT issuer would be stranded. When Ory mode is on, restrict bootstrap to ORY_ISSUER_URL only; otherwise allow any configured JWT issuer plus ORY_ISSUER_URL when set. --- .../internal/handlers/team_handlers_test.go | 74 +++++++++++++++++++ .../handlers/utils_team_provisioning.go | 21 +++++- 2 files changed, 94 insertions(+), 1 deletion(-) diff --git a/packages/dashboard-api/internal/handlers/team_handlers_test.go b/packages/dashboard-api/internal/handlers/team_handlers_test.go index e968811e81..4c7f94fdab 100644 --- a/packages/dashboard-api/internal/handlers/team_handlers_test.go +++ b/packages/dashboard-api/internal/handlers/team_handlers_test.go @@ -720,6 +720,80 @@ 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, + supabaseDB: testDB.SupabaseDB, + teamProvisionSink: sink, + } + + 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{ + UserProfileProvider: userprofile.ModeOry, + OryIssuerURL: oryIssuer, + AuthProvider: auth.ProviderConfig{ + JWT: []oidc.Config{ + {Issuer: oidc.Issuer{URL: otherIssuer}}, + }, + }, + }, + db: testDB.SqlcClient, + authDB: testDB.AuthDB, + supabaseDB: testDB.SupabaseDB, + teamProvisionSink: sink, + } + + _, 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() diff --git a/packages/dashboard-api/internal/handlers/utils_team_provisioning.go b/packages/dashboard-api/internal/handlers/utils_team_provisioning.go index 96453595d6..b6d084e6b9 100644 --- a/packages/dashboard-api/internal/handlers/utils_team_provisioning.go +++ b/packages/dashboard-api/internal/handlers/utils_team_provisioning.go @@ -104,13 +104,32 @@ func (s *APIStore) bootstrapOIDCUser(ctx context.Context, input oidcUserBootstra // requireConfiguredOIDCIssuer rejects bootstrap requests whose issuer is not in // the configured provider list. Without this an admin-token holder could plant -// an identity under any arbitrary iss string. +// an identity under any arbitrary iss string. When the user-profile provider +// requires Ory, only ORY_ISSUER_URL is accepted: the Ory resolver looks up +// public.user_identities by exactly that issuer, so any other configured JWT +// issuer would create rows that profile/membership lookups never read. func (s *APIStore) requireConfiguredOIDCIssuer(issuer string) error { + oryIssuer := strings.TrimSpace(s.config.OryIssuerURL) + + if s.config.UserProfileProvider.RequiresOry() { + if oryIssuer != "" && oryIssuer == issuer { + return nil + } + + return &internalteamprovision.ProvisionError{ + StatusCode: http.StatusBadRequest, + Message: "oidc_issuer must equal the configured ORY_ISSUER_URL", + } + } + for _, jwt := range s.config.AuthProvider.JWT { if strings.TrimSpace(jwt.Issuer.URL) == issuer { return nil } } + if oryIssuer != "" && oryIssuer == issuer { + return nil + } return &internalteamprovision.ProvisionError{ StatusCode: http.StatusBadRequest, From 9a38969182d589435813619bf54d65bc7ef580ae Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Tue, 26 May 2026 14:19:45 -0700 Subject: [PATCH 16/23] fix(dashboard-api): resolve Ory identities on the primary The Ory profile provider's identity resolver reads public.user_identities to translate between user_id and OIDC subject. Routing that through authDB.Read uses the read replica when AUTH_DB_READ_REPLICA_CONNECTION_STRING is set, but the OIDC bootstrap path writes those rows on the primary inside a transaction, so a profile lookup immediately after bootstrap can race replication lag and return no profile. Pin identity resolution to the primary; the wider profile/team queries keep using the replica. --- packages/dashboard-api/main.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/dashboard-api/main.go b/packages/dashboard-api/main.go index 9cb010a827..0bca1a0ba1 100644 --- a/packages/dashboard-api/main.go +++ b/packages/dashboard-api/main.go @@ -404,12 +404,15 @@ func buildUserProfileProvider(config cfg.Config, supabaseDB *supabasedb.Client, var oryProvider userprofile.Provider if config.UserProfileProvider.RequiresOry() { + // identity rows are written on the primary inside the bootstrap tx; + // reading them from the read replica races replication lag, so resolve + // (issuer, subject) <-> user_id mappings on the primary. provider, err := userprofile.NewOryProvider(userprofile.OryConfig{ HTTPClient: httpClient, SDKURL: config.OrySDKURL, Token: config.OryProjectAPIToken, Issuer: config.OryIssuerURL, - Resolver: authDB.Read, + Resolver: authDB.Write, }) if err != nil { return nil, fmt.Errorf("build ory user profile provider: %w", err) From cd142a9857a786d9b7c64595dbd28adfad80c42c Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Tue, 26 May 2026 14:37:42 -0700 Subject: [PATCH 17/23] feat(dashboard-api): resolve profile_picture_url from ory traits The Ory project standardizes on flat traits regardless of upstream OIDC provider: name, email, profile_picture_url. Extend Profile with ProfilePictureURL and read it from traits.profile_picture_url. Drop the old multi-shape name fallback chain (nested first/last, given/family, full_name) now that the trait schema is committed. --- .../dashboard-api/internal/userprofile/ory.go | 58 +++------------ .../internal/userprofile/ory_test.go | 71 +++++++------------ .../internal/userprofile/provider.go | 7 +- 3 files changed, 38 insertions(+), 98 deletions(-) diff --git a/packages/dashboard-api/internal/userprofile/ory.go b/packages/dashboard-api/internal/userprofile/ory.go index fe793f0f08..094d769333 100644 --- a/packages/dashboard-api/internal/userprofile/ory.go +++ b/packages/dashboard-api/internal/userprofile/ory.go @@ -225,59 +225,17 @@ func identitySubjects(identities []ory.Identity) []string { return subjects } +// profileFromOryIdentity reads the standardized E2B identity schema traits: +// name, email, profile_picture_url. The Ory project is configured to populate +// these from OIDC provider claims (e.g. Google profile scope, GitHub user +// scope) so the underlying upstream is transparent here. func profileFromOryIdentity(userID uuid.UUID, identity ory.Identity) Profile { traits, _ := identity.Traits.(map[string]any) return Profile{ - UserID: userID, - Email: metadataString(traits, "email"), - Name: oryDisplayName(traits), + UserID: userID, + Email: metadataString(traits, "email"), + Name: metadataString(traits, "name"), + ProfilePictureURL: metadataString(traits, "profile_picture_url"), } } - -// mirrors dashboard.full-stack/src/core/server/auth/ory/identity.ts readDisplayName. -func oryDisplayName(traits map[string]any) string { - if name := metadataString(traits, "name"); name != "" { - return name - } - if name := nestedNameTrait(traits); name != "" { - return name - } - if name := splitNameTraits(traits); name != "" { - return name - } - - return FirstNonEmpty( - metadataString(traits, "full_name"), - metadataString(traits, "fullName"), - ) -} - -func nestedNameTrait(traits map[string]any) string { - nested, ok := traits["name"].(map[string]any) - if !ok { - return "" - } - - return strings.TrimSpace(metadataString(nested, "first") + " " + metadataString(nested, "last")) -} - -func splitNameTraits(traits map[string]any) string { - first := FirstNonEmpty( - metadataString(traits, "first_name"), - metadataString(traits, "firstName"), - metadataString(traits, "given_name"), - metadataString(traits, "givenName"), - ) - last := FirstNonEmpty( - metadataString(traits, "last_name"), - metadataString(traits, "lastName"), - metadataString(traits, "family_name"), - metadataString(traits, "familyName"), - ) - if first == "" && last == "" { - return "" - } - - return strings.TrimSpace(first + " " + last) -} diff --git a/packages/dashboard-api/internal/userprofile/ory_test.go b/packages/dashboard-api/internal/userprofile/ory_test.go index 70b68fc3b1..b815d0e85d 100644 --- a/packages/dashboard-api/internal/userprofile/ory_test.go +++ b/packages/dashboard-api/internal/userprofile/ory_test.go @@ -13,65 +13,43 @@ func TestProfileFromOryIdentity(t *testing.T) { userID := uuid.New() tests := []struct { - name string - traits any - wantName string - wantEmail string + name string + traits any + wantName string + wantEmail string + wantPicture string }{ { - name: "flat name and email", + name: "all three standardized traits", traits: map[string]any{ - "email": "ada@example.com", - "name": "ada lovelace", + "email": "ada@example.com", + "name": "ada lovelace", + "profile_picture_url": "https://example.com/ada.jpg", }, - wantName: "ada lovelace", - wantEmail: "ada@example.com", + wantName: "ada lovelace", + wantEmail: "ada@example.com", + wantPicture: "https://example.com/ada.jpg", }, { - name: "nested first and last name", + name: "missing picture is empty", traits: map[string]any{ "email": "grace@example.com", - "name": map[string]any{ - "first": "grace", - "last": "hopper", - }, + "name": "grace hopper", }, wantName: "grace hopper", wantEmail: "grace@example.com", }, { - name: "first name only nested", - traits: map[string]any{ - "name": map[string]any{ - "first": "barbara", - }, - }, - wantName: "barbara", - wantEmail: "", + name: "nil traits returns zero values", + traits: nil, }, { - name: "given/family fallback", + name: "non-string trait values are ignored", traits: map[string]any{ - "given_name": "marie", - "family_name": "curie", - "email": "marie@example.com", + "email": 42, + "name": map[string]any{"first": "barbara"}, + "profile_picture_url": nil, }, - wantName: "marie curie", - wantEmail: "marie@example.com", - }, - { - name: "full name fallback when no flat name and no nested", - traits: map[string]any{ - "full_name": "alan turing", - }, - wantName: "alan turing", - wantEmail: "", - }, - { - name: "missing traits returns zero values", - traits: nil, - wantName: "", - wantEmail: "", }, } @@ -82,13 +60,16 @@ func TestProfileFromOryIdentity(t *testing.T) { identity := ory.Identity{Id: uuid.NewString(), Traits: tt.traits} got := profileFromOryIdentity(userID, identity) if got.UserID != userID { - t.Fatalf("profileFromOryIdentity().UserID = %s, want %s", got.UserID, userID) + t.Fatalf("UserID = %s, want %s", got.UserID, userID) } if got.Name != tt.wantName { - t.Fatalf("profileFromOryIdentity().Name = %q, want %q", got.Name, tt.wantName) + t.Fatalf("Name = %q, want %q", got.Name, tt.wantName) } if got.Email != tt.wantEmail { - t.Fatalf("profileFromOryIdentity().Email = %q, want %q", got.Email, tt.wantEmail) + t.Fatalf("Email = %q, want %q", got.Email, tt.wantEmail) + } + if got.ProfilePictureURL != tt.wantPicture { + t.Fatalf("ProfilePictureURL = %q, want %q", got.ProfilePictureURL, tt.wantPicture) } }) } diff --git a/packages/dashboard-api/internal/userprofile/provider.go b/packages/dashboard-api/internal/userprofile/provider.go index 7566b26370..c24f2b690d 100644 --- a/packages/dashboard-api/internal/userprofile/provider.go +++ b/packages/dashboard-api/internal/userprofile/provider.go @@ -8,9 +8,10 @@ import ( ) type Profile struct { - UserID uuid.UUID - Email string - Name string + UserID uuid.UUID + Email string + Name string + ProfilePictureURL string } type Provider interface { From 0c0ff6afbd1b4adc2cc3aa66c5363b8bf9f13921 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Tue, 26 May 2026 14:47:21 -0700 Subject: [PATCH 18/23] docs(dashboard-api): add Ory Kratos identity schema fixture Commits the v1 trait schema applied to the Ory project so the contract between profileFromOryIdentity and Ory is visible in-repo. Adds profile_picture_url alongside email and name to match what the resolver reads. Pointer comment added on profileFromOryIdentity so the consuming code links to the schema. --- .../fixtures/ory/identity.v1.schema.json | 63 +++++++++++++++++++ .../dashboard-api/internal/userprofile/ory.go | 4 +- 2 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 packages/dashboard-api/fixtures/ory/identity.v1.schema.json diff --git a/packages/dashboard-api/fixtures/ory/identity.v1.schema.json b/packages/dashboard-api/fixtures/ory/identity.v1.schema.json new file mode 100644 index 0000000000..12d7e6ac4b --- /dev/null +++ b/packages/dashboard-api/fixtures/ory/identity.v1.schema.json @@ -0,0 +1,63 @@ +{ + "$id": "https://schemas.e2b.dev/presets/kratos/identity.v1.schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Person", + "type": "object", + "properties": { + "traits": { + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email", + "title": "E-Mail", + "ory.sh/kratos": { + "credentials": { + "password": { + "identifier": true + }, + "webauthn": { + "identifier": true + }, + "totp": { + "account_name": true + }, + "code": { + "identifier": true, + "via": "email" + }, + "passkey": { + "display_name": true + } + }, + "recovery": { + "via": "email" + }, + "verification": { + "via": "email" + }, + "organizations": { + "matcher": "email_domain" + } + }, + "maxLength": 320 + }, + "name": { + "type": "string", + "title": "Name", + "maxLength": 320 + }, + "profile_picture_url": { + "type": "string", + "format": "uri", + "title": "Profile picture URL", + "maxLength": 2048 + } + }, + "required": [ + "email" + ], + "additionalProperties": false + } + } +} diff --git a/packages/dashboard-api/internal/userprofile/ory.go b/packages/dashboard-api/internal/userprofile/ory.go index 094d769333..4c14657f50 100644 --- a/packages/dashboard-api/internal/userprofile/ory.go +++ b/packages/dashboard-api/internal/userprofile/ory.go @@ -228,7 +228,9 @@ func identitySubjects(identities []ory.Identity) []string { // profileFromOryIdentity reads the standardized E2B identity schema traits: // name, email, profile_picture_url. The Ory project is configured to populate // these from OIDC provider claims (e.g. Google profile scope, GitHub user -// scope) so the underlying upstream is transparent here. +// scope) so the underlying upstream is transparent here. See +// packages/dashboard-api/fixtures/ory/identity.v1.schema.json for the canonical +// trait shape uploaded to Ory. func profileFromOryIdentity(userID uuid.UUID, identity ory.Identity) Profile { traits, _ := identity.Traits.(map[string]any) From 9fb896eb50049960e0a8780783d9b8443a9ade42 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Tue, 26 May 2026 14:57:06 -0700 Subject: [PATCH 19/23] refactor(dashboard-api): rename ory profile picture trait to picture Align with the OIDC standard claim name (picture). The internal Go field stays ProfilePictureURL for descriptiveness; only the Ory trait key, fixture schema, and test cases change. --- .../fixtures/ory/identity.v1.schema.json | 2 +- packages/dashboard-api/internal/userprofile/ory.go | 8 ++++---- .../dashboard-api/internal/userprofile/ory_test.go | 12 ++++++------ 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/dashboard-api/fixtures/ory/identity.v1.schema.json b/packages/dashboard-api/fixtures/ory/identity.v1.schema.json index 12d7e6ac4b..c3cb8389bc 100644 --- a/packages/dashboard-api/fixtures/ory/identity.v1.schema.json +++ b/packages/dashboard-api/fixtures/ory/identity.v1.schema.json @@ -47,7 +47,7 @@ "title": "Name", "maxLength": 320 }, - "profile_picture_url": { + "picture": { "type": "string", "format": "uri", "title": "Profile picture URL", diff --git a/packages/dashboard-api/internal/userprofile/ory.go b/packages/dashboard-api/internal/userprofile/ory.go index 4c14657f50..0f50acdb27 100644 --- a/packages/dashboard-api/internal/userprofile/ory.go +++ b/packages/dashboard-api/internal/userprofile/ory.go @@ -226,9 +226,9 @@ func identitySubjects(identities []ory.Identity) []string { } // profileFromOryIdentity reads the standardized E2B identity schema traits: -// name, email, profile_picture_url. The Ory project is configured to populate -// these from OIDC provider claims (e.g. Google profile scope, GitHub user -// scope) so the underlying upstream is transparent here. See +// name, email, picture. The Ory project is configured to populate these from +// OIDC provider claims (e.g. Google profile scope, GitHub user scope) so the +// underlying upstream is transparent here. See // packages/dashboard-api/fixtures/ory/identity.v1.schema.json for the canonical // trait shape uploaded to Ory. func profileFromOryIdentity(userID uuid.UUID, identity ory.Identity) Profile { @@ -238,6 +238,6 @@ func profileFromOryIdentity(userID uuid.UUID, identity ory.Identity) Profile { UserID: userID, Email: metadataString(traits, "email"), Name: metadataString(traits, "name"), - ProfilePictureURL: metadataString(traits, "profile_picture_url"), + ProfilePictureURL: metadataString(traits, "picture"), } } diff --git a/packages/dashboard-api/internal/userprofile/ory_test.go b/packages/dashboard-api/internal/userprofile/ory_test.go index b815d0e85d..a91e281c9b 100644 --- a/packages/dashboard-api/internal/userprofile/ory_test.go +++ b/packages/dashboard-api/internal/userprofile/ory_test.go @@ -22,9 +22,9 @@ func TestProfileFromOryIdentity(t *testing.T) { { name: "all three standardized traits", traits: map[string]any{ - "email": "ada@example.com", - "name": "ada lovelace", - "profile_picture_url": "https://example.com/ada.jpg", + "email": "ada@example.com", + "name": "ada lovelace", + "picture": "https://example.com/ada.jpg", }, wantName: "ada lovelace", wantEmail: "ada@example.com", @@ -46,9 +46,9 @@ func TestProfileFromOryIdentity(t *testing.T) { { name: "non-string trait values are ignored", traits: map[string]any{ - "email": 42, - "name": map[string]any{"first": "barbara"}, - "profile_picture_url": nil, + "email": 42, + "name": map[string]any{"first": "barbara"}, + "picture": nil, }, }, } From de7e68269ceb9e508f76a4030afcbb14aaa01a0e Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Tue, 26 May 2026 15:22:06 -0700 Subject: [PATCH 20/23] feat(dashboard-api): expose name, picture, providers on team members Move user-profile enrichment out of the dashboard and onto dashboard-api so list-members is a single backend call instead of a per-member Ory admin fanout. - Extend userprofile.Profile with Providers []string. - Ory provider now requests credentials via include_credential=oidc and reads upstream providers from credentials.oidc.config.providers[] (with a fallback to identifier prefixes). - Supabase provider reads providers from raw_app_meta_data for parity. - TeamMember API schema gains name, profilePictureUrl, providers. - Handler projects the new profile fields through. --- .../dashboard-api/internal/api/api.gen.go | 64 +++++++++--------- .../internal/handlers/team_members.go | 12 ++++ .../dashboard-api/internal/userprofile/ory.go | 66 ++++++++++++++++++- .../internal/userprofile/ory_test.go | 53 ++++++++++++--- .../internal/userprofile/provider.go | 1 + .../internal/userprofile/supabase.go | 51 ++++++++++++-- spec/openapi-dashboard.yml | 12 ++++ 7 files changed, 213 insertions(+), 46 deletions(-) diff --git a/packages/dashboard-api/internal/api/api.gen.go b/packages/dashboard-api/internal/api/api.gen.go index c74877dd29..ddda087660 100644 --- a/packages/dashboard-api/internal/api/api.gen.go +++ b/packages/dashboard-api/internal/api/api.gen.go @@ -247,11 +247,14 @@ type SandboxRecord struct { // TeamMember defines model for TeamMember. type TeamMember struct { - AddedBy *openapi_types.UUID `json:"addedBy,omitempty"` - CreatedAt *time.Time `json:"createdAt"` - Email string `json:"email"` - Id openapi_types.UUID `json:"id"` - IsDefault bool `json:"isDefault"` + AddedBy *openapi_types.UUID `json:"addedBy,omitempty"` + CreatedAt *time.Time `json:"createdAt"` + Email string `json:"email"` + Id openapi_types.UUID `json:"id"` + IsDefault bool `json:"isDefault"` + Name *string `json:"name,omitempty"` + ProfilePictureUrl *string `json:"profilePictureUrl,omitempty"` + Providers []string `json:"providers"` } // TeamMembersResponse defines model for TeamMembersResponse. @@ -1059,31 +1062,32 @@ var swaggerSpec = []string{ "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", + "fbX+fwAAAP//", } // decodeSpec returns the embedded OpenAPI spec as raw JSON bytes, diff --git a/packages/dashboard-api/internal/handlers/team_members.go b/packages/dashboard-api/internal/handlers/team_members.go index 64f5e19f9c..e993292362 100644 --- a/packages/dashboard-api/internal/handlers/team_members.go +++ b/packages/dashboard-api/internal/handlers/team_members.go @@ -62,6 +62,18 @@ func (s *APIStore) GetTeamsTeamIDMembers(c *gin.Context, teamID api.TeamID) { Email: profile.Email, IsDefault: row.IsDefault, AddedBy: row.AddedBy, + Providers: profile.Providers, + } + if member.Providers == nil { + member.Providers = []string{} + } + if profile.Name != "" { + name := profile.Name + member.Name = &name + } + if profile.ProfilePictureURL != "" { + url := profile.ProfilePictureURL + member.ProfilePictureUrl = &url } if row.CreatedAt.Valid { diff --git a/packages/dashboard-api/internal/userprofile/ory.go b/packages/dashboard-api/internal/userprofile/ory.go index 0f50acdb27..2db14a120c 100644 --- a/packages/dashboard-api/internal/userprofile/ory.go +++ b/packages/dashboard-api/internal/userprofile/ory.go @@ -139,7 +139,9 @@ func (p *oryProvider) FindProfilesByEmail(ctx context.Context, email string) ([] } identities, resp, err := p.identities.ListIdentitiesExecute( - p.identities.ListIdentities(ctx).CredentialsIdentifier(normalized), + p.identities.ListIdentities(ctx). + CredentialsIdentifier(normalized). + IncludeCredential([]string{"oidc"}), ) closeOryResponse(resp) if err != nil { @@ -204,7 +206,10 @@ func (p *oryProvider) listIdentitiesByIDs(ctx context.Context, ids []string) ([] identities := make([]ory.Identity, 0, len(ids)) for page := range slices.Chunk(ids, oryListPageSize) { batch, resp, err := p.identities.ListIdentitiesExecute( - p.identities.ListIdentities(ctx).Ids(page).PageSize(int64(len(page))), + p.identities.ListIdentities(ctx). + Ids(page). + PageSize(int64(len(page))). + IncludeCredential([]string{"oidc"}), ) closeOryResponse(resp) if err != nil { @@ -239,5 +244,62 @@ func profileFromOryIdentity(userID uuid.UUID, identity ory.Identity) Profile { Email: metadataString(traits, "email"), Name: metadataString(traits, "name"), ProfilePictureURL: metadataString(traits, "picture"), + Providers: oryLinkedProviders(identity), } } + +// oryLinkedProviders extracts the upstream OIDC providers (e.g. "google", +// "github") linked to an Ory identity. The list lives at +// credentials.oidc.config.providers[].provider; if the Console didn't expose +// config (depends on include_credential), fall back to parsing the +// "provider:subject" prefix from credentials.oidc.identifiers. +func oryLinkedProviders(identity ory.Identity) []string { + if identity.Credentials == nil { + return nil + } + oidc, ok := (*identity.Credentials)["oidc"] + if !ok { + return nil + } + + seen := make(map[string]struct{}, 4) + providers := make([]string, 0, 4) + add := func(p string) { + p = strings.TrimSpace(p) + if p == "" { + return + } + if _, dup := seen[p]; dup { + return + } + seen[p] = struct{}{} + providers = append(providers, p) + } + + if entries, ok := oidc.Config["providers"].([]any); ok { + for _, entry := range entries { + obj, ok := entry.(map[string]any) + if !ok { + continue + } + if name, ok := obj["provider"].(string); ok { + add(name) + } + } + } + + if len(providers) == 0 { + for _, identifier := range oidc.Identifiers { + provider, _, found := strings.Cut(identifier, ":") + if found { + add(provider) + } + } + } + + if len(providers) == 0 { + return nil + } + + return providers +} diff --git a/packages/dashboard-api/internal/userprofile/ory_test.go b/packages/dashboard-api/internal/userprofile/ory_test.go index a91e281c9b..6499f207bc 100644 --- a/packages/dashboard-api/internal/userprofile/ory_test.go +++ b/packages/dashboard-api/internal/userprofile/ory_test.go @@ -1,6 +1,7 @@ package userprofile import ( + "reflect" "testing" "github.com/google/uuid" @@ -13,11 +14,13 @@ func TestProfileFromOryIdentity(t *testing.T) { userID := uuid.New() tests := []struct { - name string - traits any - wantName string - wantEmail string - wantPicture string + name string + traits any + credentials *map[string]ory.IdentityCredentials + wantName string + wantEmail string + wantPicture string + wantProviders []string }{ { name: "all three standardized traits", @@ -31,16 +34,43 @@ func TestProfileFromOryIdentity(t *testing.T) { wantPicture: "https://example.com/ada.jpg", }, { - name: "missing picture is empty", + name: "providers from oidc config", traits: map[string]any{ "email": "grace@example.com", "name": "grace hopper", }, - wantName: "grace hopper", - wantEmail: "grace@example.com", + credentials: &map[string]ory.IdentityCredentials{ + "oidc": { + Config: map[string]any{ + "providers": []any{ + map[string]any{"provider": "google"}, + map[string]any{"provider": "github"}, + }, + }, + Identifiers: []string{"google:111", "github:222"}, + }, + }, + wantName: "grace hopper", + wantEmail: "grace@example.com", + wantProviders: []string{"google", "github"}, + }, + { + name: "providers fallback from identifiers when config missing", + credentials: &map[string]ory.IdentityCredentials{ + "oidc": { + Identifiers: []string{"google:111", "github:222", "google:111"}, + }, + }, + wantProviders: []string{"google", "github"}, }, { - name: "nil traits returns zero values", + name: "providers ignored when only password credential", + credentials: &map[string]ory.IdentityCredentials{ + "password": {Identifiers: []string{"ada@example.com"}}, + }, + }, + { + name: "nil traits and credentials returns zero values", traits: nil, }, { @@ -57,7 +87,7 @@ func TestProfileFromOryIdentity(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - identity := ory.Identity{Id: uuid.NewString(), Traits: tt.traits} + identity := ory.Identity{Id: uuid.NewString(), Traits: tt.traits, Credentials: tt.credentials} got := profileFromOryIdentity(userID, identity) if got.UserID != userID { t.Fatalf("UserID = %s, want %s", got.UserID, userID) @@ -71,6 +101,9 @@ func TestProfileFromOryIdentity(t *testing.T) { if got.ProfilePictureURL != tt.wantPicture { t.Fatalf("ProfilePictureURL = %q, want %q", got.ProfilePictureURL, tt.wantPicture) } + if !reflect.DeepEqual(got.Providers, tt.wantProviders) { + t.Fatalf("Providers = %v, want %v", got.Providers, tt.wantProviders) + } }) } } diff --git a/packages/dashboard-api/internal/userprofile/provider.go b/packages/dashboard-api/internal/userprofile/provider.go index c24f2b690d..7e941586b4 100644 --- a/packages/dashboard-api/internal/userprofile/provider.go +++ b/packages/dashboard-api/internal/userprofile/provider.go @@ -12,6 +12,7 @@ type Profile struct { Email string Name string ProfilePictureURL string + Providers []string } type Provider interface { diff --git a/packages/dashboard-api/internal/userprofile/supabase.go b/packages/dashboard-api/internal/userprofile/supabase.go index 82e641184c..2d23807990 100644 --- a/packages/dashboard-api/internal/userprofile/supabase.go +++ b/packages/dashboard-api/internal/userprofile/supabase.go @@ -62,15 +62,58 @@ func (p *supabaseProvider) FindProfilesByEmail(ctx context.Context, email string } func profileFromAuthUser(user supabasequeries.AuthUser) Profile { - metadata := rawUserMetadata(user.RawUserMetaData) + userMetadata := rawUserMetadata(user.RawUserMetaData) + appMetadata := rawUserMetadata(user.RawAppMetaData) return Profile{ - UserID: user.ID, - Email: user.Email, - Name: displayNameFromMetadata(metadata), + UserID: user.ID, + Email: user.Email, + Name: displayNameFromMetadata(userMetadata), + ProfilePictureURL: FirstNonEmpty(metadataString(userMetadata, "picture"), metadataString(userMetadata, "avatar_url")), + Providers: supabaseLinkedProviders(appMetadata), } } +// supabaseLinkedProviders mirrors the way Supabase records linked OAuth +// providers under raw_app_meta_data: a `providers` array plus an `provider` +// scalar for the most recently used one. +func supabaseLinkedProviders(appMetadata map[string]any) []string { + if appMetadata == nil { + return nil + } + + seen := make(map[string]struct{}, 4) + providers := make([]string, 0, 4) + add := func(p string) { + p = strings.TrimSpace(p) + if p == "" { + return + } + if _, dup := seen[p]; dup { + return + } + seen[p] = struct{}{} + providers = append(providers, p) + } + + if list, ok := appMetadata["providers"].([]any); ok { + for _, entry := range list { + if name, ok := entry.(string); ok { + add(name) + } + } + } + if name, ok := appMetadata["provider"].(string); ok { + add(name) + } + + if len(providers) == 0 { + return nil + } + + return providers +} + func displayNameFromMetadata(metadata map[string]any) string { firstName := FirstNonEmpty( metadataString(metadata, "first_name"), diff --git a/spec/openapi-dashboard.yml b/spec/openapi-dashboard.yml index 627b84190c..00318ad13f 100644 --- a/spec/openapi-dashboard.yml +++ b/spec/openapi-dashboard.yml @@ -519,12 +519,24 @@ components: - email - isDefault - createdAt + - providers properties: id: type: string format: uuid email: type: string + name: + type: string + nullable: true + profilePictureUrl: + type: string + format: uri + nullable: true + providers: + type: array + items: + type: string isDefault: type: boolean addedBy: From 5304e8c845810cb784a70244c2d9525d30aa09d1 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Thu, 28 May 2026 12:28:10 -0700 Subject: [PATCH 21/23] fix(auth): silence per-scheme misses during OR-of-AND negotiation The shared authenticator logged "authorization header is missing" at WARN on every per-scheme miss, which fires on each request in Ory mode because the Supabase schemes are evaluated first and never match. Demote to a span event so the trace still records which schemes were skipped, but no log line is emitted. The 401 status stamp is preserved with an inline note explaining why ErrorHandler depends on it. --- packages/auth/pkg/auth/middleware.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/auth/pkg/auth/middleware.go b/packages/auth/pkg/auth/middleware.go index b88a914d95..8dda377e57 100644 --- a/packages/auth/pkg/auth/middleware.go +++ b/packages/auth/pkg/auth/middleware.go @@ -67,12 +67,13 @@ func (a *commonAuthenticator[T]) getHeaderKeysFromRequest(req *http.Request) (st func (a *commonAuthenticator[T]) Authenticate(ctx context.Context, ginCtx *gin.Context, input *openapi3filter.AuthenticationInput) error { key, err := a.getHeaderKeysFromRequest(input.RequestValidationInput.Request) if err != nil { - telemetry.ReportError(ctx, - "authorization header is missing", - err, - attribute.String("error.message", a.errorMessage), + telemetry.ReportEvent(ctx, "auth scheme skipped", + attribute.String("auth.scheme", a.schemeName), + attribute.String("auth.reason", err.Error()), ) + // stamp 401 so the ErrorHandler's max(writer, 400) resolves to 401 + // when every security group fails. without this, auth failures become 400s. ginCtx.Status(http.StatusUnauthorized) return err From 1b4afae9c8f0f30416b3b3069f49e47ced4c59ac Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Thu, 28 May 2026 13:51:39 -0700 Subject: [PATCH 22/23] test(dashboard-api): guard sibling static + param admin/users routes POST /admin/users/bootstrap (static) and POST /admin/users/:userId/bootstrap (parameterized) coexist at the same path level. gin's router currently handles this correctly, but a future upgrade or router fork swap could regress it. The test asserts no-panic at registration and that both URLs resolve to their intended handler. --- .../internal/api/route_conflict_test.go | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 packages/dashboard-api/internal/api/route_conflict_test.go diff --git a/packages/dashboard-api/internal/api/route_conflict_test.go b/packages/dashboard-api/internal/api/route_conflict_test.go new file mode 100644 index 0000000000..44a83261bd --- /dev/null +++ b/packages/dashboard-api/internal/api/route_conflict_test.go @@ -0,0 +1,48 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestStaticAndParamSiblingsCoexist guards against gin's router rejecting or +// misrouting the sibling pair POST /admin/users/bootstrap and +// POST /admin/users/:userId/bootstrap. The pair was flagged as a potential +// httprouter conflict; this verifies gin handles it correctly. +func TestStaticAndParamSiblingsCoexist(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + + require.NotPanics(t, func() { + r.POST("/admin/users/bootstrap", func(c *gin.Context) { + c.String(http.StatusOK, "static") + }) + r.POST("/admin/users/:userId/bootstrap", func(c *gin.Context) { + c.String(http.StatusOK, "param:"+c.Param("userId")) + }) + }, "gin must accept sibling static and parameter segments at the same level") + + cases := []struct { + path string + want string + }{ + {"/admin/users/bootstrap", "static"}, + {"/admin/users/abc-123/bootstrap", "param:abc-123"}, + } + + for _, tc := range cases { + t.Run(tc.path, func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, tc.path, nil) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, tc.want, rec.Body.String()) + }) + } +} From ad40cb0783dd3558188eac04c5b885937df3442d Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Thu, 28 May 2026 13:55:47 -0700 Subject: [PATCH 23/23] test(dashboard-api): satisfy golangci-lint in route conflict test Drop forbidden gin.SetMode call (CI sets GIN_MODE=test), switch to httptest.NewRequestWithContext, and add t.Parallel() at both levels. --- .../internal/api/route_conflict_test.go | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/dashboard-api/internal/api/route_conflict_test.go b/packages/dashboard-api/internal/api/route_conflict_test.go index 44a83261bd..b323106710 100644 --- a/packages/dashboard-api/internal/api/route_conflict_test.go +++ b/packages/dashboard-api/internal/api/route_conflict_test.go @@ -15,7 +15,8 @@ import ( // POST /admin/users/:userId/bootstrap. The pair was flagged as a potential // httprouter conflict; this verifies gin handles it correctly. func TestStaticAndParamSiblingsCoexist(t *testing.T) { - gin.SetMode(gin.TestMode) + t.Parallel() + r := gin.New() require.NotPanics(t, func() { @@ -37,7 +38,14 @@ func TestStaticAndParamSiblingsCoexist(t *testing.T) { for _, tc := range cases { t.Run(tc.path, func(t *testing.T) { - req := httptest.NewRequest(http.MethodPost, tc.path, nil) + t.Parallel() + + req := httptest.NewRequestWithContext( + t.Context(), + http.MethodPost, + tc.path, + nil, + ) rec := httptest.NewRecorder() r.ServeHTTP(rec, req)