Skip to content

Commit 866f389

Browse files
refactor(dashboard-api): send creator context with team provisioning (#2505)
* feat(dashboard-api): send creator context with team provisioning Populate creator_user_id and resolve creator_context (signup IP, user agent, auth method) from auth.users.raw_app_meta_data inside the HTTP provisioning sink so the receiving service no longer needs its own Supabase auth lookup. - Extend GetAuthUserByID to return raw_app_meta_data and add the column to the auth.users schema/test migration so sqlc can generate it. - Drop the deprecated owner_user_id field from the shared request struct now that the receiver accepts creator_user_id. - Resolution lives inside HTTPProvisionSink so the noop sink stays free of any Supabase load. Made-with: Cursor * fix(dashboard-api): broaden oauth detection and bound creator context lookup - Treat any non-"email" supabase provider as social so apple/gitlab/microsoft signups are no longer misclassified as Password. - Cap the auth.users lookup with a 2s deadline so a slow query cannot eat into the team provisioning HTTP budget. Made-with: Cursor
1 parent ed90bd3 commit 866f389

13 files changed

Lines changed: 168 additions & 47 deletions

File tree

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

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -130,11 +130,11 @@ func (s *APIStore) bootstrapUser(ctx context.Context, userID uuid.UUID) (provisi
130130
}
131131

132132
req := teamprovision.TeamBillingProvisionRequestedV1{
133-
TeamID: existingTeam.ID,
134-
TeamName: existingTeam.Name,
135-
TeamEmail: existingTeam.Email,
136-
OwnerUserID: userID,
137-
Reason: teamprovision.ReasonDefaultSignupTeam,
133+
TeamID: existingTeam.ID,
134+
TeamName: existingTeam.Name,
135+
TeamEmail: existingTeam.Email,
136+
CreatorUserID: userID,
137+
Reason: teamprovision.ReasonDefaultSignupTeam,
138138
}
139139
_ = s.teamProvisionSink.ProvisionTeam(ctx, req)
140140

@@ -176,11 +176,11 @@ func (s *APIStore) bootstrapUser(ctx context.Context, userID uuid.UUID) (provisi
176176
}
177177

178178
req := teamprovision.TeamBillingProvisionRequestedV1{
179-
TeamID: team.ID,
180-
TeamName: team.Name,
181-
TeamEmail: team.Email,
182-
OwnerUserID: userID,
183-
Reason: teamprovision.ReasonDefaultSignupTeam,
179+
TeamID: team.ID,
180+
TeamName: team.Name,
181+
TeamEmail: team.Email,
182+
CreatorUserID: userID,
183+
Reason: teamprovision.ReasonDefaultSignupTeam,
184184
}
185185
_ = s.teamProvisionSink.ProvisionTeam(ctx, req)
186186

@@ -249,11 +249,11 @@ func (s *APIStore) createTeam(ctx context.Context, userID uuid.UUID, name string
249249
}
250250

251251
req := teamprovision.TeamBillingProvisionRequestedV1{
252-
TeamID: team.ID,
253-
TeamName: team.Name,
254-
TeamEmail: team.Email,
255-
OwnerUserID: userID,
256-
Reason: teamprovision.ReasonAdditionalTeam,
252+
TeamID: team.ID,
253+
TeamName: team.Name,
254+
TeamEmail: team.Email,
255+
CreatorUserID: userID,
256+
Reason: teamprovision.ReasonAdditionalTeam,
257257
}
258258
if err := s.teamProvisionSink.ProvisionTeam(ctx, req); err != nil {
259259
rollbackCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), teamProvisionRollbackTimeout)
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package teamprovision
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
8+
"github.com/google/uuid"
9+
10+
"github.com/e2b-dev/infra/packages/db/pkg/dberrors"
11+
supabasedb "github.com/e2b-dev/infra/packages/db/pkg/supabase"
12+
sharedteamprovision "github.com/e2b-dev/infra/packages/shared/pkg/teamprovision"
13+
)
14+
15+
const (
16+
// supabase uses "email" for password signups; everything else (google,
17+
// github, apple, gitlab, ...) is treated as a social provider.
18+
emailAuthProvider = "email"
19+
20+
signupIPMetadataKey = "signup_ip"
21+
signupUserAgentMetadataKey = "signup_user_agent"
22+
providersMetadataKey = "providers"
23+
)
24+
25+
// resolveCreatorContext reads signup IP/UA and auth provider from
26+
// auth.users.raw_app_meta_data, which Supabase populates for every signup flow.
27+
// Returns nil when the user cannot be found so callers can keep going without
28+
// the optional context.
29+
func resolveCreatorContext(ctx context.Context, supabaseDB *supabasedb.Client, userID uuid.UUID) (*sharedteamprovision.CreatorContextV1, error) {
30+
authUser, err := supabaseDB.Write.GetAuthUserByID(ctx, userID)
31+
if err != nil {
32+
if dberrors.IsNotFoundError(err) {
33+
return nil, nil
34+
}
35+
36+
return nil, fmt.Errorf("get auth user: %w", err)
37+
}
38+
39+
metadata := map[string]any{}
40+
if len(authUser.RawAppMetaData) > 0 {
41+
if err := json.Unmarshal(authUser.RawAppMetaData, &metadata); err != nil {
42+
return nil, fmt.Errorf("decode raw_app_meta_data: %w", err)
43+
}
44+
}
45+
46+
authMethod := sharedteamprovision.AuthMethodPassword
47+
if hasOAuthProvider(metadata) {
48+
authMethod = sharedteamprovision.AuthMethodSocial
49+
}
50+
51+
return &sharedteamprovision.CreatorContextV1{
52+
IPAddress: stringFromMetadata(metadata, signupIPMetadataKey),
53+
UserAgent: stringFromMetadata(metadata, signupUserAgentMetadataKey),
54+
AuthMethod: authMethod,
55+
}, nil
56+
}
57+
58+
func hasOAuthProvider(metadata map[string]any) bool {
59+
rawProviders, ok := metadata[providersMetadataKey].([]any)
60+
if !ok {
61+
return false
62+
}
63+
64+
for _, p := range rawProviders {
65+
name, ok := p.(string)
66+
if ok && name != "" && name != emailAuthProvider {
67+
return true
68+
}
69+
}
70+
71+
return false
72+
}
73+
74+
func stringFromMetadata(metadata map[string]any, key string) string {
75+
if value, ok := metadata[key].(string); ok {
76+
return value
77+
}
78+
79+
return ""
80+
}

packages/dashboard-api/internal/teamprovision/factory.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66

77
"go.uber.org/zap"
88

9+
supabasedb "github.com/e2b-dev/infra/packages/db/pkg/supabase"
910
"github.com/e2b-dev/infra/packages/shared/pkg/logger"
1011
)
1112

@@ -14,7 +15,7 @@ var (
1415
ErrMissingAPIToken = errors.New("billing server api token is required when billing http team provision sink is enabled")
1516
)
1617

17-
func NewProvisionSink(ctx context.Context, enabled bool, baseURL, apiToken string) (TeamProvisionSink, error) {
18+
func NewProvisionSink(ctx context.Context, enabled bool, baseURL, apiToken string, supabaseDB *supabasedb.Client) (TeamProvisionSink, error) {
1819
if !enabled {
1920
logger.L().Info(ctx, "team provision sink configured",
2021
zap.String("sink", "noop"),
@@ -38,5 +39,5 @@ func NewProvisionSink(ctx context.Context, enabled bool, baseURL, apiToken strin
3839
zap.String("base_url", baseURL),
3940
)
4041

41-
return NewHTTPProvisionSink(baseURL, apiToken), nil
42+
return NewHTTPProvisionSink(baseURL, apiToken, supabaseDB), nil
4243
}

packages/dashboard-api/internal/teamprovision/http_sink.go

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"go.opentelemetry.io/otel/attribute"
1717
"go.uber.org/zap"
1818

19+
supabasedb "github.com/e2b-dev/infra/packages/db/pkg/supabase"
1920
"github.com/e2b-dev/infra/packages/shared/pkg/logger"
2021
sharedteamprovision "github.com/e2b-dev/infra/packages/shared/pkg/teamprovision"
2122
"github.com/e2b-dev/infra/packages/shared/pkg/telemetry"
@@ -32,13 +33,16 @@ const (
3233
provisionBackoffMultiplier = 2.0
3334
// Error responses only need enough body to extract a short API message without buffering large upstream payloads.
3435
provisionErrorMessageReadLimit = 2 * 1024
36+
// short cap so a slow auth.users lookup can't eat into the provisioning timeout.
37+
creatorContextResolveTimeout = 2 * time.Second
3538
)
3639

3740
type HTTPProvisionSink struct {
38-
baseURL string
39-
apiToken string
40-
client *retryablehttp.Client
41-
timeout time.Duration
41+
baseURL string
42+
apiToken string
43+
client *retryablehttp.Client
44+
timeout time.Duration
45+
supabaseDB *supabasedb.Client
4246
}
4347

4448
var _ TeamProvisionSink = (*HTTPProvisionSink)(nil)
@@ -47,12 +51,13 @@ type errorResponse struct {
4751
Message string `json:"message"`
4852
}
4953

50-
func NewHTTPProvisionSink(baseURL, apiToken string) *HTTPProvisionSink {
54+
func NewHTTPProvisionSink(baseURL, apiToken string, supabaseDB *supabasedb.Client) *HTTPProvisionSink {
5155
return &HTTPProvisionSink{
52-
baseURL: strings.TrimRight(baseURL, "/"),
53-
apiToken: apiToken,
54-
client: newRetryableProvisionClient(defaultProvisionAttemptTimeout),
55-
timeout: defaultProvisionTimeout,
56+
baseURL: strings.TrimRight(baseURL, "/"),
57+
apiToken: apiToken,
58+
client: newRetryableProvisionClient(defaultProvisionAttemptTimeout),
59+
timeout: defaultProvisionTimeout,
60+
supabaseDB: supabaseDB,
5661
}
5762
}
5863

@@ -75,6 +80,20 @@ func (s *HTTPProvisionSink) ProvisionTeam(ctx context.Context, req sharedteampro
7580
return err
7681
}
7782

83+
if s.supabaseDB != nil && req.CreatorContext == nil {
84+
resolveCtx, cancel := context.WithTimeout(ctx, creatorContextResolveTimeout)
85+
creatorContext, resolveErr := resolveCreatorContext(resolveCtx, s.supabaseDB, req.CreatorUserID)
86+
cancel()
87+
if resolveErr != nil {
88+
// creator context is best-effort; keep going without it
89+
logger.L().Warn(ctx, "failed to resolve creator context for team provisioning",
90+
append(provisionLogFields(req, provisionSinkHTTP), zap.Error(resolveErr))...,
91+
)
92+
} else {
93+
req.CreatorContext = creatorContext
94+
}
95+
}
96+
7897
body, err := json.Marshal(req)
7998
if err != nil {
8099
telemetry.ReportCriticalError(ctx, "marshal billing provisioning request", err, baseAttrs...)

packages/dashboard-api/internal/teamprovision/http_sink_test.go

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ func TestHTTPProvisionSink_ReturnsJSONErrorMessage(t *testing.T) {
2424
}))
2525
defer server.Close()
2626

27-
sink := NewHTTPProvisionSink(server.URL, "token")
27+
sink := NewHTTPProvisionSink(server.URL, "token", nil)
2828
err := sink.ProvisionTeam(t.Context(), testProvisionRequest())
2929
require.Error(t, err)
3030

@@ -52,7 +52,7 @@ func TestHTTPProvisionSink_RetriesRetryableResponsesAndSucceeds(t *testing.T) {
5252
}))
5353
defer server.Close()
5454

55-
sink := NewHTTPProvisionSink(server.URL, "token")
55+
sink := NewHTTPProvisionSink(server.URL, "token", nil)
5656
sink.client.RetryWaitMin = time.Millisecond
5757
sink.client.RetryWaitMax = time.Millisecond
5858
err := sink.ProvisionTeam(t.Context(), testProvisionRequest())
@@ -74,7 +74,7 @@ func TestHTTPProvisionSink_RetriesRequestTimeoutWithinOverallBudget(t *testing.T
7474
}))
7575
defer server.Close()
7676

77-
sink := NewHTTPProvisionSink(server.URL, "token")
77+
sink := NewHTTPProvisionSink(server.URL, "token", nil)
7878
sink.timeout = 80 * time.Millisecond
7979
sink.client.HTTPClient.Timeout = 25 * time.Millisecond
8080
sink.client.RetryWaitMin = time.Millisecond
@@ -87,10 +87,10 @@ func TestHTTPProvisionSink_RetriesRequestTimeoutWithinOverallBudget(t *testing.T
8787

8888
func testProvisionRequest() sharedteamprovision.TeamBillingProvisionRequestedV1 {
8989
return sharedteamprovision.TeamBillingProvisionRequestedV1{
90-
TeamID: uuid.New(),
91-
TeamName: "Acme",
92-
TeamEmail: "acme@example.com",
93-
OwnerUserID: uuid.New(),
94-
Reason: sharedteamprovision.ReasonAdditionalTeam,
90+
TeamID: uuid.New(),
91+
TeamName: "Acme",
92+
TeamEmail: "acme@example.com",
93+
CreatorUserID: uuid.New(),
94+
Reason: sharedteamprovision.ReasonAdditionalTeam,
9595
}
9696
}

packages/dashboard-api/internal/teamprovision/sink.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ func (e *ProvisionError) Unwrap() error {
4646
func provisionLogFields(req sharedteamprovision.TeamBillingProvisionRequestedV1, sink string) []zap.Field {
4747
return []zap.Field{
4848
logger.WithTeamID(req.TeamID.String()),
49-
logger.WithUserID(req.OwnerUserID.String()),
49+
logger.WithUserID(req.CreatorUserID.String()),
5050
zap.String("team.provision.reason", req.Reason),
5151
zap.String("team.provision.sink", sink),
5252
}
@@ -55,7 +55,7 @@ func provisionLogFields(req sharedteamprovision.TeamBillingProvisionRequestedV1,
5555
func provisionTelemetryAttrs(req sharedteamprovision.TeamBillingProvisionRequestedV1, sink string, attrs ...attribute.KeyValue) []attribute.KeyValue {
5656
base := []attribute.KeyValue{
5757
telemetry.WithTeamID(req.TeamID.String()),
58-
telemetry.WithUserID(req.OwnerUserID.String()),
58+
telemetry.WithUserID(req.CreatorUserID.String()),
5959
attribute.String("team.provision.reason", req.Reason),
6060
attribute.String("team.provision.sink", sink),
6161
}

packages/dashboard-api/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@ func run() int {
195195
config.EnableBillingHTTPTeamProvisionSink,
196196
config.BillingServerURL,
197197
config.BillingServerAPIToken,
198+
supabaseDB,
198199
)
199200
if err != nil {
200201
l.Error(ctx, "initializing team provision sink", zap.Error(err))

packages/db/migrations/20000101000000_auth.sql

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ CREATE TABLE auth.users (
1717
id uuid NOT NULL DEFAULT gen_random_uuid(),
1818
email text NOT NULL,
1919
created_at timestamptz NOT NULL DEFAULT now(),
20+
raw_app_meta_data jsonb,
2021
PRIMARY KEY (id)
2122
);
2223
-- +goose StatementEnd

packages/db/pkg/supabase/queries/get_auth_user.sql.go

Lines changed: 7 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/db/pkg/supabase/queries/models.go

Lines changed: 4 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)