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 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 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..c3cb8389bc --- /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 + }, + "picture": { + "type": "string", + "format": "uri", + "title": "Profile picture URL", + "maxLength": 2048 + } + }, + "required": [ + "email" + ], + "additionalProperties": false + } + } +} 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/api/api.gen.go b/packages/dashboard-api/internal/api/api.gen.go index 023f1f24e2..3563a3d7a0 100644 --- a/packages/dashboard-api/internal/api/api.gen.go +++ b/packages/dashboard-api/internal/api/api.gen.go @@ -78,6 +78,14 @@ type AdminAuthProviderProfilesResponse struct { Profiles []AdminAuthProviderProfile `json:"profiles"` } +// 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"` +} + // AdminTeamBootstrapRequest defines model for AdminTeamBootstrapRequest. type AdminTeamBootstrapRequest struct { // Email Billing/contact email for the team. @@ -248,11 +256,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. @@ -409,6 +420,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 @@ -432,6 +446,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) @@ -557,6 +574,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) { @@ -1011,6 +1043,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) @@ -1032,63 +1065,65 @@ 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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", + "LWt9Jdz+cC+0ftRwa/1g111/W/8/AAD//w==", } // decodeSpec returns the embedded OpenAPI spec as raw JSON bytes, 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..b323106710 --- /dev/null +++ b/packages/dashboard-api/internal/api/route_conflict_test.go @@ -0,0 +1,56 @@ +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) { + t.Parallel() + + 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) { + t.Parallel() + + req := httptest.NewRequestWithContext( + t.Context(), + 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()) + }) + } +} diff --git a/packages/dashboard-api/internal/cfg/model.go b/packages/dashboard-api/internal/cfg/model.go index 28ba384853..e7bc269251 100644 --- a/packages/dashboard-api/internal/cfg/model.go +++ b/packages/dashboard-api/internal/cfg/model.go @@ -2,11 +2,14 @@ package cfg import ( "errors" + "fmt" "reflect" + "strings" "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 +29,11 @@ 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"` + OryIssuerURL string `env:"ORY_ISSUER_URL"` } func Parse() (Config, error) { @@ -35,6 +43,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 +61,45 @@ 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 } + +// 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 + } + + 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 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 3a66419b48..e1ee0530dc 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,162 @@ 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 TestParseUserProfileProviderOryRequiresOryEnv(t *testing.T) { + tests := []struct { + name string + mode string + sdkURL string + token string + issuer string + wantErrSubstr string + }{ + { + name: "ory mode without sdk url errors", + mode: "ory", + token: "pat", + issuer: "https://ory.example.test", + wantErrSubstr: "ORY_SDK_URL", + }, + { + name: "ory mode without token errors", + mode: "ory", + sdkURL: "https://ory.example.test", + issuer: "https://ory.example.test", + wantErrSubstr: "ORY_PROJECT_API_TOKEN", + }, + { + name: "ory mode without sdk url or issuer errors on sdk url", + mode: "ory", + token: "pat", + wantErrSubstr: "ORY_SDK_URL", + }, + { + name: "fallback mode applies same requirements", + mode: "supabase-ory-fallback", + token: "pat", + issuer: "https://ory.example.test", + 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("ORY_ISSUER_URL", tt.issuer) + + _, err := Parse() + require.Error(t, err) + require.Contains(t, err.Error(), tt.wantErrSubstr) + }) + } +} + +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("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.Equal(t, "https://ory.example.test", config.OryIssuerURL) + require.Empty(t, config.AuthProvider.JWT) +} + +func TestParseUserProfileProviderOryIssuerRequiredWhenAuthProviderEmpty(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") + + _, 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://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 TestParseUserProfileProviderOryIssuerOverrideWithoutAuthProvider(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") + 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") +} diff --git a/packages/dashboard-api/internal/handlers/admin_users_bootstrap.go b/packages/dashboard-api/internal/handlers/admin_users_bootstrap.go index 2ac63bc5fa..d18b872c5f 100644 --- a/packages/dashboard-api/internal/handlers/admin_users_bootstrap.go +++ b/packages/dashboard-api/internal/handlers/admin_users_bootstrap.go @@ -1,19 +1,26 @@ package handlers import ( + "errors" + "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" ) +// 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") - team, err := s.bootstrapUser(ctx, userId) + team, err := s.bootstrapSupabaseUser(ctx, userId) if err != nil { s.handleProvisioningError(ctx, c, "bootstrap user", err) @@ -25,3 +32,45 @@ func (s *APIStore) PostAdminUsersUserIdBootstrap(c *gin.Context, userId api.User Slug: team.Slug, }) } + +// 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") + + 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 + } + + oidcIssuer := strings.TrimSpace(body.OidcIssuer) + oidcUserID := strings.TrimSpace(body.OidcUserId) + oidcUserEmail := strings.TrimSpace(string(body.OidcUserEmail)) + 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, + }) + 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/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/handlers/team_handlers_test.go b/packages/dashboard-api/internal/handlers/team_handlers_test.go index 55c6643c93..4c7f94fdab 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"}`), + name: "profile full name first word", + 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,378 @@ 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{ + OIDCIssuer: "https://ory.example.test", + 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: input.OIDCIssuer, + 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 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{ + OIDCIssuer: "https://ory.example.test", + 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: input.OIDCIssuer, + 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_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{} + store.PostAdminUsersBootstrap(ginCtx) + + if recorder.Code != http.StatusBadRequest { + t.Fatalf("expected status 400 for blank oidc_user_id, got %d", recorder.Code) + } +} + +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() + + 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() @@ -573,6 +937,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 +985,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 +1026,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 +1039,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/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/handlers/utils_team_provisioning.go b/packages/dashboard-api/internal/handlers/utils_team_provisioning.go index b977f0a6d5..6d3f527044 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" ) @@ -38,18 +37,111 @@ 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 { + OIDCIssuer string + 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", } } - if err != nil { - return provisionedTeam{}, fmt.Errorf("get auth user: %w", err) + + return bootstrapUserProfile{ + UserID: userID, + Email: profile.Email, + DefaultTeamName: defaultTeamNameFromProfile(profile), + }, nil +} + +func (s *APIStore) bootstrapOIDCUser(ctx context.Context, input oidcUserBootstrapInput) (provisionedTeam, error) { + if err := s.requireConfiguredOIDCIssuer(input.OIDCIssuer); err != nil { + return provisionedTeam{}, err + } + + profile := bootstrapUserProfile{ + UserID: uuid.New(), + Email: input.OIDCUserEmail, + DefaultTeamName: defaultTeamNameFromOIDCUserName(input.OIDCUserName), + } + + return s.bootstrapUserWithIdentity(ctx, profile, &bootstrapUserIdentity{ + Issuer: input.OIDCIssuer, + Subject: input.OIDCUserID, + }) +} + +// 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. 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, + Message: "oidc_issuer is not a configured auth provider", + } +} +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 +150,47 @@ func (s *APIStore) bootstrapUser(ctx context.Context, userID uuid.UUID) (provisi _ = tx.Rollback(ctx) }() - if err := authTxDB.UpsertPublicUser(ctx, authUser.ID); 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 { + canonicalUserID, err := authTxDB.UpsertPublicIdentity(ctx, authqueries.UpsertPublicIdentityParams{ + OidcIss: identity.Issuer, + OidcSub: identity.Subject, + 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. - 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 +200,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 +218,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 +230,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 +246,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) @@ -300,53 +422,22 @@ func validateTeamCreationAllowed(ctx context.Context, authTxDB *authqueries.Quer return nil } -func defaultTeamNameFromAuthUser(authUser supabasequeries.AuthUser) string { - metadata := rawUserMetadata(authUser.RawUserMetaData) - - 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), +func defaultTeamNameFromProfile(profile userprofile.Profile) string { + baseName := userprofile.FirstNonEmpty( + 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 "" +func defaultTeamNameFromOIDCUserName(name *string) string { + if name == nil || strings.TrimSpace(*name) == "" { + return "Default Team" } - value, ok := metadata[key].(string) - if !ok { - return "" - } - - return strings.TrimSpace(value) + return capitalizeFirstLetter(firstWord(*name)) + "'s Default Team" } func firstWord(value string) string { @@ -364,16 +455,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/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..2db14a120c --- /dev/null +++ b/packages/dashboard-api/internal/userprofile/ory.go @@ -0,0 +1,305 @@ +package userprofile + +import ( + "context" + "errors" + "fmt" + "io" + "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) +} + +// 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 { + 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, resp, err := p.identities.ListIdentitiesExecute( + p.identities.ListIdentities(ctx). + CredentialsIdentifier(normalized). + IncludeCredential([]string{"oidc"}), + ) + closeOryResponse(resp) + 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, resp, err := p.identities.ListIdentitiesExecute( + p.identities.ListIdentities(ctx). + Ids(page). + PageSize(int64(len(page))). + IncludeCredential([]string{"oidc"}), + ) + closeOryResponse(resp) + 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 +} + +// profileFromOryIdentity reads the standardized E2B identity schema traits: +// 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 { + traits, _ := identity.Traits.(map[string]any) + + return Profile{ + UserID: userID, + 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 new file mode 100644 index 0000000000..6499f207bc --- /dev/null +++ b/packages/dashboard-api/internal/userprofile/ory_test.go @@ -0,0 +1,109 @@ +package userprofile + +import ( + "reflect" + "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 + credentials *map[string]ory.IdentityCredentials + wantName string + wantEmail string + wantPicture string + wantProviders []string + }{ + { + name: "all three standardized traits", + traits: map[string]any{ + "email": "ada@example.com", + "name": "ada lovelace", + "picture": "https://example.com/ada.jpg", + }, + wantName: "ada lovelace", + wantEmail: "ada@example.com", + wantPicture: "https://example.com/ada.jpg", + }, + { + name: "providers from oidc config", + traits: map[string]any{ + "email": "grace@example.com", + "name": "grace hopper", + }, + 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: "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, + }, + { + name: "non-string trait values are ignored", + traits: map[string]any{ + "email": 42, + "name": map[string]any{"first": "barbara"}, + "picture": nil, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + 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) + } + if got.Name != tt.wantName { + t.Fatalf("Name = %q, want %q", got.Name, tt.wantName) + } + if 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) + } + 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 715719f053..7e941586b4 100644 --- a/packages/dashboard-api/internal/userprofile/provider.go +++ b/packages/dashboard-api/internal/userprofile/provider.go @@ -2,16 +2,45 @@ package userprofile import ( "context" + "fmt" "github.com/google/uuid" ) type Profile struct { - UserID uuid.UUID - Email string + UserID uuid.UUID + Email string + Name string + ProfilePictureURL string + Providers []string } 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/internal/userprofile/supabase.go b/packages/dashboard-api/internal/userprofile/supabase.go index e445f8338f..2d23807990 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,116 @@ func (p *supabaseProvider) FindProfilesByEmail(ctx context.Context, email string } func profileFromAuthUser(user supabasequeries.AuthUser) Profile { + userMetadata := rawUserMetadata(user.RawUserMetaData) + appMetadata := rawUserMetadata(user.RawAppMetaData) + return Profile{ - UserID: user.ID, - Email: user.Email, + 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"), + 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 + } + + 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 trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + } + + return "" } func uniqueUUIDs(ids []uuid.UUID) []uuid.UUID { 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) + } + }) + } +} diff --git a/packages/dashboard-api/main.go b/packages/dashboard-api/main.go index e544a7bab2..e918823a9c 100644 --- a/packages/dashboard-api/main.go +++ b/packages/dashboard-api/main.go @@ -30,6 +30,7 @@ import ( "github.com/e2b-dev/infra/packages/dashboard-api/internal/handlers" dashboardmiddleware "github.com/e2b-dev/infra/packages/dashboard-api/internal/middleware" 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" @@ -206,7 +207,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 { @@ -393,3 +401,27 @@ 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() { + // 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.Write, + }) + 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/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/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[]); 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; 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, diff --git a/spec/openapi-dashboard.yml b/spec/openapi-dashboard.yml index 6b8afc27c1..53928d6bc4 100644 --- a/spec/openapi-dashboard.yml +++ b/spec/openapi-dashboard.yml @@ -223,6 +223,26 @@ components: type: string format: email + 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 + oidc_user_email: + type: string + format: email + oidc_user_name: + type: string + nullable: true + AdminTeamBootstrapRequest: type: object required: @@ -520,12 +540,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: @@ -869,6 +901,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/teams/bootstrap: post: summary: Bootstrap team