diff --git a/CLAUDE.md b/CLAUDE.md index 798ec1b76d..f483ea931c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -110,7 +110,7 @@ Client → Client-Proxy → API (REST) ⟷ PostgreSQL **Dashboard API (`packages/dashboard-api/`)** - REST backend for the web dashboard, not the SDK - Entry point: `main.go` -- Spec: `spec/openapi-dashboard.yml`; legacy team management, template tags, builds, admin bootstrap, `/v1/management` +- Spec: `spec/openapi-dashboard.yml`; team reads, template tags, builds, admin access, `/v1/management` - Talks to Postgres and ClickHouse; never to orchestrators (see `docs/ARCHITECTURE.md`) - Port: 3010 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4e71463459..5f130b2c55 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -307,12 +307,11 @@ below). ### Dashboard API (`packages/dashboard-api`) A separate REST service (port 3010, spec `spec/openapi-dashboard.yml`) consumed by the web -dashboard, not the SDK: legacy team management/provisioning, template tags, build listings, admin -bootstrap. Its admin routes accept either a shared admin token or a short-lived service JWT -verified against the configured admin JWKS. The `disable-legacy-team-mutations` LaunchDarkly flag -rejects legacy lifecycle writes with 412 after authentication; it leaves reads and the workspace -API's management projection writes available. Team-scoped template and build read routes accept either dashboard user auth or -team API key auth (`X-API-Key`). Its workspace-agnostic `/v1/management` operations are defined in the +dashboard, not the SDK: team and template reads, template tags, build listings, and admin access +operations. Its admin routes accept either a shared admin token or a short-lived service JWT +verified against the configured admin JWKS. Workspace API management projection writes remain +available. Team-scoped template and build read routes accept either dashboard user auth or team +API key auth (`X-API-Key`). Its workspace-agnostic `/v1/management` operations are defined in the same dashboard OpenAPI contract and registered on the existing router. Their `AdminJWTAuth` OpenAPI security scheme accepts only short-lived service JWTs verified against the workspace-api `/.well-known/jwks.json` endpoint, with accepted signing methods derived from each JWK's required diff --git a/packages/dashboard-api/internal/api/api.gen.go b/packages/dashboard-api/internal/api/api.gen.go index 702cec4342..d35465ac32 100644 --- a/packages/dashboard-api/internal/api/api.gen.go +++ b/packages/dashboard-api/internal/api/api.gen.go @@ -138,11 +138,6 @@ func (e GetTemplatesTemplateIDTagsGroupsParamsSort) Valid() bool { } } -// AddTeamMemberRequest defines model for AddTeamMemberRequest. -type AddTeamMemberRequest struct { - Email openapi_types.Email `json:"email"` -} - // AdminAuthProviderProfile defines model for AdminAuthProviderProfile. type AdminAuthProviderProfile struct { // Email Email address from the configured auth provider. @@ -167,16 +162,6 @@ 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"` - SignupIp *string `json:"signup_ip,omitempty"` - SignupUserAgent *string `json:"signup_user_agent,omitempty"` -} - // AdminClusterCreateRequest defines model for AdminClusterCreateRequest. type AdminClusterCreateRequest struct { AuthOrgId *string `json:"auth_org_id,omitempty"` @@ -201,23 +186,6 @@ type AdminTeamBlockRequest struct { Reason string `json:"reason"` } -// AdminTeamBootstrapRequest defines model for AdminTeamBootstrapRequest. -type AdminTeamBootstrapRequest struct { - // Email Billing/contact email for the team. - Email openapi_types.Email `json:"email"` - - // Name Team name. - Name string `json:"name"` -} - -// AdminTeamClusterAssignmentRequest defines model for AdminTeamClusterAssignmentRequest. -type AdminTeamClusterAssignmentRequest struct { - ClusterId openapi_types.UUID `json:"cluster_id"` - - // PreserveExisting Assign only when the team is unassigned or already assigned to cluster_id. - PreserveExisting *bool `json:"preserve_existing,omitempty"` -} - // AdminTeamClusterAssignmentResponse defines model for AdminTeamClusterAssignmentResponse. type AdminTeamClusterAssignmentResponse struct { ClusterId openapi_types.UUID `json:"cluster_id"` @@ -276,11 +244,6 @@ type BuildsStatusesResponse struct { // CPUCount CPU cores for the sandbox type CPUCount = int64 -// CreateTeamRequest defines model for CreateTeamRequest. -type CreateTeamRequest struct { - Name string `json:"name"` -} - // DefaultTemplate defines model for DefaultTemplate. type DefaultTemplate struct { Aliases []DefaultTemplateAlias `json:"aliases"` @@ -674,17 +637,6 @@ type TemplateTagsCountResponse struct { Total int64 `json:"total"` } -// UpdateTeamRequest defines model for UpdateTeamRequest. -type UpdateTeamRequest struct { - Name *string `json:"name,omitempty"` -} - -// UpdateTeamResponse defines model for UpdateTeamResponse. -type UpdateTeamResponse struct { - Id openapi_types.UUID `json:"id"` - Name string `json:"name"` -} - // UserTeam defines model for UserTeam. type UserTeam struct { BlockedReason *string `json:"blockedReason"` @@ -815,18 +767,12 @@ type N404 = Error // N409 defines model for 409. type N409 = Error -// N412 defines model for 412. -type N412 = Error - // N500 defines model for 500. type N500 = Error // N501 defines model for 501. type N501 = Error -// N502 defines model for 502. -type N502 = Error - // GetBuildsParams defines parameters for GetBuilds. type GetBuildsParams struct { // BuildIdOrTemplate Optional filter by build identifier, template identifier, or template alias. @@ -914,33 +860,15 @@ type GetTemplatesTemplateIDTagsTagAssignmentsParams struct { // PostAdminClustersJSONRequestBody defines body for PostAdminClusters for application/json ContentType. type PostAdminClustersJSONRequestBody = AdminClusterCreateRequest -// PostAdminTeamsBootstrapJSONRequestBody defines body for PostAdminTeamsBootstrap for application/json ContentType. -type PostAdminTeamsBootstrapJSONRequestBody = AdminTeamBootstrapRequest - // PutAdminTeamsTeamIDBlockJSONRequestBody defines body for PutAdminTeamsTeamIDBlock for application/json ContentType. type PutAdminTeamsTeamIDBlockJSONRequestBody = AdminTeamBlockRequest -// PutAdminTeamsTeamIDClusterJSONRequestBody defines body for PutAdminTeamsTeamIDCluster for application/json ContentType. -type PutAdminTeamsTeamIDClusterJSONRequestBody = AdminTeamClusterAssignmentRequest - // PostAdminUserProfilesByEmailJSONRequestBody defines body for PostAdminUserProfilesByEmail for application/json ContentType. type PostAdminUserProfilesByEmailJSONRequestBody = AdminAuthProviderProfilesLookupEmailRequest // 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 - -// PatchTeamsTeamIDJSONRequestBody defines body for PatchTeamsTeamID for application/json ContentType. -type PatchTeamsTeamIDJSONRequestBody = UpdateTeamRequest - -// PostTeamsTeamIDMembersJSONRequestBody defines body for PostTeamsTeamIDMembers for application/json ContentType. -type PostTeamsTeamIDMembersJSONRequestBody = AddTeamMemberRequest - // ManagementRegisterClusterJSONRequestBody defines body for ManagementRegisterCluster for application/json ContentType. type ManagementRegisterClusterJSONRequestBody = ManagementClusterRegistrationRequest @@ -1183,9 +1111,6 @@ type ServerInterface interface { // DeleteAdminClustersClusterID Delete an unreferenced cluster // (DELETE /admin/clusters/{clusterID}) DeleteAdminClustersClusterID(c *gin.Context, clusterID ClusterID) - // PostAdminTeamsBootstrap Bootstrap team - // (POST /admin/teams/bootstrap) - PostAdminTeamsBootstrap(c *gin.Context) // DeleteAdminTeamsTeamIDBan Unban a team // (DELETE /admin/teams/{teamID}/ban) DeleteAdminTeamsTeamIDBan(c *gin.Context, teamID TeamID) @@ -1201,12 +1126,6 @@ type ServerInterface interface { // GetAdminTeamsTeamIDCluster Get a team's assigned cluster // (GET /admin/teams/{teamID}/cluster) GetAdminTeamsTeamIDCluster(c *gin.Context, teamID TeamID) - // PutAdminTeamsTeamIDCluster Assign a cluster to a team - // (PUT /admin/teams/{teamID}/cluster) - PutAdminTeamsTeamIDCluster(c *gin.Context, teamID TeamID) - // DeleteAdminTeamsTeamIDClusterClusterID Detach a cluster from a team - // (DELETE /admin/teams/{teamID}/cluster/{clusterID}) - DeleteAdminTeamsTeamIDClusterClusterID(c *gin.Context, teamID TeamID, clusterID ClusterID) // PostAdminUserProfilesByEmail Lookup user profiles by email // (POST /admin/user-profiles/by-email) PostAdminUserProfilesByEmail(c *gin.Context) @@ -1216,12 +1135,6 @@ type ServerInterface interface { // GetAdminUserProfilesUserId User profile // (GET /admin/user-profiles/{userId}) GetAdminUserProfilesUserId(c *gin.Context, userId UserId) - // PostAdminUsersBootstrap Bootstrap auth provider user - // (POST /admin/users/bootstrap) - PostAdminUsersBootstrap(c *gin.Context) - // DeleteAdminUsersUserId Delete user - // (DELETE /admin/users/{userId}) - DeleteAdminUsersUserId(c *gin.Context, userId UserId) // GetBuilds List team builds // (GET /builds) GetBuilds(c *gin.Context, params GetBuildsParams) @@ -1240,27 +1153,15 @@ type ServerInterface interface { // GetTeams List user teams // (GET /teams) GetTeams(c *gin.Context) - // PostTeams Create team - // (POST /teams) - PostTeams(c *gin.Context) // GetTeamsResolve Resolve team identity // (GET /teams/resolve) GetTeamsResolve(c *gin.Context, params GetTeamsResolveParams) - // PatchTeamsTeamID Update team - // (PATCH /teams/{teamID}) - PatchTeamsTeamID(c *gin.Context, teamID TeamID) // GetTeamsTeamIDLimits Get team limits // (GET /teams/{teamID}/limits) GetTeamsTeamIDLimits(c *gin.Context, teamID TeamID) // GetTeamsTeamIDMembers List team members // (GET /teams/{teamID}/members) GetTeamsTeamIDMembers(c *gin.Context, teamID TeamID) - // PostTeamsTeamIDMembers Add team member - // (POST /teams/{teamID}/members) - PostTeamsTeamIDMembers(c *gin.Context, teamID TeamID) - // DeleteTeamsTeamIDMembersUserId Remove team member - // (DELETE /teams/{teamID}/members/{userId}) - DeleteTeamsTeamIDMembersUserId(c *gin.Context, teamID TeamID, userId UserId) // GetTeamsTeamIDStatus Get team access status // (GET /teams/{teamID}/status) GetTeamsTeamIDStatus(c *gin.Context, teamID TeamID) @@ -1364,19 +1265,6 @@ func (siw *ServerInterfaceWrapper) DeleteAdminClustersClusterID(c *gin.Context) siw.Handler.DeleteAdminClustersClusterID(c, clusterID) } -// PostAdminTeamsBootstrap operation middleware -func (siw *ServerInterfaceWrapper) PostAdminTeamsBootstrap(c *gin.Context) { - - for _, middleware := range siw.HandlerMiddlewares { - middleware(c) - if c.IsAborted() { - return - } - } - - siw.Handler.PostAdminTeamsBootstrap(c) -} - // DeleteAdminTeamsTeamIDBan operation middleware func (siw *ServerInterfaceWrapper) DeleteAdminTeamsTeamIDBan(c *gin.Context) { @@ -1502,65 +1390,6 @@ func (siw *ServerInterfaceWrapper) GetAdminTeamsTeamIDCluster(c *gin.Context) { siw.Handler.GetAdminTeamsTeamIDCluster(c, teamID) } -// PutAdminTeamsTeamIDCluster operation middleware -func (siw *ServerInterfaceWrapper) PutAdminTeamsTeamIDCluster(c *gin.Context) { - - var err error - _ = err - - // ------------- Path parameter "teamID" ------------- - var teamID TeamID - - err = runtime.BindStyledParameterWithOptions("simple", "teamID", c.Param("teamID"), &teamID, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "uuid", ValueIsUnescaped: true}) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter teamID: %w", err), http.StatusBadRequest) - return - } - - for _, middleware := range siw.HandlerMiddlewares { - middleware(c) - if c.IsAborted() { - return - } - } - - siw.Handler.PutAdminTeamsTeamIDCluster(c, teamID) -} - -// DeleteAdminTeamsTeamIDClusterClusterID operation middleware -func (siw *ServerInterfaceWrapper) DeleteAdminTeamsTeamIDClusterClusterID(c *gin.Context) { - - var err error - _ = err - - // ------------- Path parameter "teamID" ------------- - var teamID TeamID - - err = runtime.BindStyledParameterWithOptions("simple", "teamID", c.Param("teamID"), &teamID, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "uuid", ValueIsUnescaped: true}) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter teamID: %w", err), http.StatusBadRequest) - return - } - - // ------------- Path parameter "clusterID" ------------- - var clusterID ClusterID - - err = runtime.BindStyledParameterWithOptions("simple", "clusterID", c.Param("clusterID"), &clusterID, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "uuid", ValueIsUnescaped: true}) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter clusterID: %w", err), http.StatusBadRequest) - return - } - - for _, middleware := range siw.HandlerMiddlewares { - middleware(c) - if c.IsAborted() { - return - } - } - - siw.Handler.DeleteAdminTeamsTeamIDClusterClusterID(c, teamID, clusterID) -} - // PostAdminUserProfilesByEmail operation middleware func (siw *ServerInterfaceWrapper) PostAdminUserProfilesByEmail(c *gin.Context) { @@ -1612,44 +1441,6 @@ func (siw *ServerInterfaceWrapper) GetAdminUserProfilesUserId(c *gin.Context) { siw.Handler.GetAdminUserProfilesUserId(c, userId) } -// PostAdminUsersBootstrap operation middleware -func (siw *ServerInterfaceWrapper) PostAdminUsersBootstrap(c *gin.Context) { - - for _, middleware := range siw.HandlerMiddlewares { - middleware(c) - if c.IsAborted() { - return - } - } - - siw.Handler.PostAdminUsersBootstrap(c) -} - -// DeleteAdminUsersUserId operation middleware -func (siw *ServerInterfaceWrapper) DeleteAdminUsersUserId(c *gin.Context) { - - var err error - _ = err - - // ------------- Path parameter "userId" ------------- - var userId UserId - - err = runtime.BindStyledParameterWithOptions("simple", "userId", c.Param("userId"), &userId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "uuid", ValueIsUnescaped: true}) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter userId: %w", err), http.StatusBadRequest) - return - } - - for _, middleware := range siw.HandlerMiddlewares { - middleware(c) - if c.IsAborted() { - return - } - } - - siw.Handler.DeleteAdminUsersUserId(c, userId) -} - // GetBuilds operation middleware func (siw *ServerInterfaceWrapper) GetBuilds(c *gin.Context) { @@ -1804,19 +1595,6 @@ func (siw *ServerInterfaceWrapper) GetTeams(c *gin.Context) { siw.Handler.GetTeams(c) } -// PostTeams operation middleware -func (siw *ServerInterfaceWrapper) PostTeams(c *gin.Context) { - - for _, middleware := range siw.HandlerMiddlewares { - middleware(c) - if c.IsAborted() { - return - } - } - - siw.Handler.PostTeams(c) -} - // GetTeamsResolve operation middleware func (siw *ServerInterfaceWrapper) GetTeamsResolve(c *gin.Context) { @@ -1844,31 +1622,6 @@ func (siw *ServerInterfaceWrapper) GetTeamsResolve(c *gin.Context) { siw.Handler.GetTeamsResolve(c, params) } -// PatchTeamsTeamID operation middleware -func (siw *ServerInterfaceWrapper) PatchTeamsTeamID(c *gin.Context) { - - var err error - _ = err - - // ------------- Path parameter "teamID" ------------- - var teamID TeamID - - err = runtime.BindStyledParameterWithOptions("simple", "teamID", c.Param("teamID"), &teamID, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "uuid", ValueIsUnescaped: true}) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter teamID: %w", err), http.StatusBadRequest) - return - } - - for _, middleware := range siw.HandlerMiddlewares { - middleware(c) - if c.IsAborted() { - return - } - } - - siw.Handler.PatchTeamsTeamID(c, teamID) -} - // GetTeamsTeamIDLimits operation middleware func (siw *ServerInterfaceWrapper) GetTeamsTeamIDLimits(c *gin.Context) { @@ -1919,65 +1672,6 @@ func (siw *ServerInterfaceWrapper) GetTeamsTeamIDMembers(c *gin.Context) { siw.Handler.GetTeamsTeamIDMembers(c, teamID) } -// PostTeamsTeamIDMembers operation middleware -func (siw *ServerInterfaceWrapper) PostTeamsTeamIDMembers(c *gin.Context) { - - var err error - _ = err - - // ------------- Path parameter "teamID" ------------- - var teamID TeamID - - err = runtime.BindStyledParameterWithOptions("simple", "teamID", c.Param("teamID"), &teamID, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "uuid", ValueIsUnescaped: true}) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter teamID: %w", err), http.StatusBadRequest) - return - } - - for _, middleware := range siw.HandlerMiddlewares { - middleware(c) - if c.IsAborted() { - return - } - } - - siw.Handler.PostTeamsTeamIDMembers(c, teamID) -} - -// DeleteTeamsTeamIDMembersUserId operation middleware -func (siw *ServerInterfaceWrapper) DeleteTeamsTeamIDMembersUserId(c *gin.Context) { - - var err error - _ = err - - // ------------- Path parameter "teamID" ------------- - var teamID TeamID - - err = runtime.BindStyledParameterWithOptions("simple", "teamID", c.Param("teamID"), &teamID, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "uuid", ValueIsUnescaped: true}) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter teamID: %w", err), http.StatusBadRequest) - return - } - - // ------------- Path parameter "userId" ------------- - var userId UserId - - err = runtime.BindStyledParameterWithOptions("simple", "userId", c.Param("userId"), &userId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "uuid", ValueIsUnescaped: true}) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter userId: %w", err), http.StatusBadRequest) - return - } - - for _, middleware := range siw.HandlerMiddlewares { - middleware(c) - if c.IsAborted() { - return - } - } - - siw.Handler.DeleteTeamsTeamIDMembersUserId(c, teamID, userId) -} - // GetTeamsTeamIDStatus operation middleware func (siw *ServerInterfaceWrapper) GetTeamsTeamIDStatus(c *gin.Context) { @@ -2592,15 +2286,10 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options router.GET(options.BaseURL+"/builds/:build_id", wrapper.GetBuildsBuildId) router.GET(options.BaseURL+"/sandboxes/:sandboxID/record", wrapper.GetSandboxesSandboxIDRecord) router.GET(options.BaseURL+"/teams", wrapper.GetTeams) - router.POST(options.BaseURL+"/teams", wrapper.PostTeams) - router.POST(options.BaseURL+"/admin/users/bootstrap", wrapper.PostAdminUsersBootstrap) - router.POST(options.BaseURL+"/admin/teams/bootstrap", wrapper.PostAdminTeamsBootstrap) router.POST(options.BaseURL+"/admin/clusters", wrapper.PostAdminClusters) router.DELETE(options.BaseURL+"/admin/clusters/:clusterID", wrapper.DeleteAdminClustersClusterID) router.GET(options.BaseURL+"/v1/management/clusters/:clusterID/destroy-readiness", wrapper.ManagementClusterDestroyReadiness) router.GET(options.BaseURL+"/admin/teams/:teamID/cluster", wrapper.GetAdminTeamsTeamIDCluster) - router.PUT(options.BaseURL+"/admin/teams/:teamID/cluster", wrapper.PutAdminTeamsTeamIDCluster) - router.DELETE(options.BaseURL+"/admin/teams/:teamID/cluster/:clusterID", wrapper.DeleteAdminTeamsTeamIDClusterClusterID) router.DELETE(options.BaseURL+"/admin/teams/:teamID/ban", wrapper.DeleteAdminTeamsTeamIDBan) router.PUT(options.BaseURL+"/admin/teams/:teamID/ban", wrapper.PutAdminTeamsTeamIDBan) router.DELETE(options.BaseURL+"/admin/teams/:teamID/block", wrapper.DeleteAdminTeamsTeamIDBlock) @@ -2608,14 +2297,10 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options router.POST(options.BaseURL+"/admin/user-profiles/resolve", wrapper.PostAdminUserProfilesResolve) router.POST(options.BaseURL+"/admin/user-profiles/by-email", wrapper.PostAdminUserProfilesByEmail) router.GET(options.BaseURL+"/admin/user-profiles/:userId", wrapper.GetAdminUserProfilesUserId) - router.DELETE(options.BaseURL+"/admin/users/:userId", wrapper.DeleteAdminUsersUserId) router.GET(options.BaseURL+"/teams/resolve", wrapper.GetTeamsResolve) - router.PATCH(options.BaseURL+"/teams/:teamID", wrapper.PatchTeamsTeamID) router.GET(options.BaseURL+"/teams/:teamID/status", wrapper.GetTeamsTeamIDStatus) router.GET(options.BaseURL+"/teams/:teamID/limits", wrapper.GetTeamsTeamIDLimits) router.GET(options.BaseURL+"/teams/:teamID/members", wrapper.GetTeamsTeamIDMembers) - router.POST(options.BaseURL+"/teams/:teamID/members", wrapper.PostTeamsTeamIDMembers) - router.DELETE(options.BaseURL+"/teams/:teamID/members/:userId", wrapper.DeleteTeamsTeamIDMembersUserId) router.GET(options.BaseURL+"/templates", wrapper.GetTemplates) router.GET(options.BaseURL+"/templates/defaults", wrapper.GetTemplatesDefaults) router.GET(options.BaseURL+"/templates/:templateID", wrapper.GetTemplatesTemplateID) @@ -2639,166 +2324,152 @@ 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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", - "293/DwAA//8=", + "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", + "CBTO69vUtS2iH929ssYP4Qz85teb/z8A", } // decodeSpec returns the embedded OpenAPI spec as raw JSON bytes, diff --git a/packages/dashboard-api/internal/cfg/model.go b/packages/dashboard-api/internal/cfg/model.go index 9860e7be5d..26b3c66f73 100644 --- a/packages/dashboard-api/internal/cfg/model.go +++ b/packages/dashboard-api/internal/cfg/model.go @@ -26,9 +26,6 @@ type Config struct { RedisTLSEnabled bool `env:"REDIS_TLS_ENABLED"` RedisPassword string `env:"REDIS_PASSWORD"` - BillingServerURL string `env:"BILLING_SERVER_URL"` - BillingServerAPIToken string `env:"BILLING_SERVER_API_TOKEN"` - OrySDKURL string `env:"ORY_SDK_URL"` OryProjectAPIToken string `env:"ORY_PROJECT_API_TOKEN,unset"` diff --git a/packages/dashboard-api/internal/handlers/admin_team_cluster.go b/packages/dashboard-api/internal/handlers/admin_team_cluster.go index b3a4c28592..d205fc17eb 100644 --- a/packages/dashboard-api/internal/handlers/admin_team_cluster.go +++ b/packages/dashboard-api/internal/handlers/admin_team_cluster.go @@ -155,16 +155,6 @@ func (s *APIStore) GetAdminTeamsTeamIDCluster(c *gin.Context, teamID api.TeamID) c.JSON(http.StatusOK, api.AdminTeamClusterAssignmentResponse{ClusterId: *clusterID}) } -func (s *APIStore) PutAdminTeamsTeamIDCluster(c *gin.Context, teamID api.TeamID) { - body, err := ginutils.ParseBody[api.AdminTeamClusterAssignmentRequest](c.Request.Context(), c) - if err != nil { - s.sendAPIStoreError(c, http.StatusBadRequest, fmt.Sprintf("Error when parsing request: %s", err)) - - return - } - s.assignTeamCluster(c, teamID, body.ClusterId, body.PreserveExisting != nil && *body.PreserveExisting) -} - func (s *APIStore) assignTeamCluster(c *gin.Context, teamID, clusterID uuid.UUID, preserveExisting bool) { if clusterID == uuid.Nil { s.sendAPIStoreError(c, http.StatusBadRequest, "cluster_id is required") @@ -217,7 +207,20 @@ func (s *APIStore) assignTeamCluster(c *gin.Context, teamID, clusterID uuid.UUID c.Status(http.StatusNoContent) } -func (s *APIStore) DeleteAdminTeamsTeamIDClusterClusterID( +func trimmedOptional(value *string) *string { + if value == nil { + return nil + } + + trimmed := strings.TrimSpace(*value) + if trimmed == "" { + return nil + } + + return &trimmed +} + +func (s *APIStore) detachTeamCluster( c *gin.Context, teamID api.TeamID, clusterID api.ClusterID, @@ -257,16 +260,3 @@ func (s *APIStore) DeleteAdminTeamsTeamIDClusterClusterID( c.Status(http.StatusNoContent) } - -func trimmedOptional(value *string) *string { - if value == nil { - return nil - } - - trimmed := strings.TrimSpace(*value) - if trimmed == "" { - return nil - } - - return &trimmed -} diff --git a/packages/dashboard-api/internal/handlers/admin_team_cluster_test.go b/packages/dashboard-api/internal/handlers/admin_team_cluster_test.go index 558260f592..63ad4bf5d3 100644 --- a/packages/dashboard-api/internal/handlers/admin_team_cluster_test.go +++ b/packages/dashboard-api/internal/handlers/admin_team_cluster_test.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "encoding/json" - "errors" "net/http" "net/http/httptest" "testing" @@ -340,611 +339,6 @@ func TestDeleteClusterReleasesOnlyDeletedTemplateReferences(t *testing.T) { } } -func TestPutAdminTeamsTeamIDClusterAssignsExistingCluster(t *testing.T) { - t.Parallel() - - db := testutils.SetupDatabase(t) - ctx := t.Context() - teamID := createClusterAssignmentTestTeam(t, db) - clusterID := uuid.New() - require.NoError(t, db.SqlcClient.TestsRawSQL(ctx, - `INSERT INTO public.clusters (id, name, endpoint, endpoint_tls, token) VALUES ($1, 'managed', 'api.example.test:5008', true, 'token')`, - clusterID, - )) - - authService := &recordingCacheAuthService{} - store := &APIStore{db: db.SqlcClient, authService: authService} - response := callAssignCluster(t, store, teamID, api.AdminTeamClusterAssignmentRequest{ClusterId: clusterID}) - require.Equal(t, http.StatusNoContent, response.Code, response.Body.String()) - require.Equal(t, []uuid.UUID{teamID}, authService.invalidated) - - var assignedClusterID uuid.UUID - require.NoError(t, db.SqlcClient.TestsRawSQLQuery(ctx, - `SELECT cluster_id FROM public.teams WHERE id = $1`, - func(rows pgx.Rows) error { - require.True(t, rows.Next()) - - return rows.Scan(&assignedClusterID) - }, - teamID, - )) - require.Equal(t, clusterID, assignedClusterID) -} - -func TestPutAdminTeamsTeamIDClusterRejectsNonEnterpriseNewAssignmentWithoutMutation(t *testing.T) { - t.Parallel() - - db := testutils.SetupDatabase(t) - ctx := t.Context() - teamID := createClusterAssignmentTestTeam(t, db) - clusterID := uuid.New() - require.NoError(t, db.SqlcClient.TestsRawSQL(ctx, - `INSERT INTO public.clusters (id, name, endpoint, endpoint_tls, token) VALUES ($1, 'managed', 'api.example.test:5008', true, 'token')`, - clusterID, - )) - require.NoError(t, db.SqlcClient.TestsRawSQL(ctx, - `UPDATE public.teams SET tier = 'cluster_assignment_test' WHERE id = $1`, - teamID, - )) - - authService := &recordingCacheAuthService{} - store := &APIStore{db: db.SqlcClient, authService: authService} - response := callAssignCluster(t, store, teamID, api.AdminTeamClusterAssignmentRequest{ClusterId: clusterID}) - require.Equal(t, http.StatusConflict, response.Code, response.Body.String()) - require.Contains(t, response.Body.String(), enterpriseClusterAssignmentPolicyMessage) - require.Empty(t, authService.invalidated) - - var assignedClusterID *uuid.UUID - require.NoError(t, db.SqlcClient.TestsRawSQLQuery(ctx, - `SELECT cluster_id FROM public.teams WHERE id = $1`, - func(rows pgx.Rows) error { - require.True(t, rows.Next()) - - return rows.Scan(&assignedClusterID) - }, - teamID, - )) - require.Nil(t, assignedClusterID) -} - -func TestPutAdminTeamsTeamIDClusterRejectsNonEnterpriseReplacementWithoutMutation(t *testing.T) { - t.Parallel() - - db := testutils.SetupDatabase(t) - ctx := t.Context() - teamID := createClusterAssignmentTestTeam(t, db) - existingClusterID := uuid.New() - replacementClusterID := uuid.New() - require.NoError(t, db.SqlcClient.TestsRawSQL(ctx, - `INSERT INTO public.clusters (id, name, endpoint, endpoint_tls, token) VALUES ($1, 'managed', 'api.example.test:5008', true, 'token'), ($2, 'replacement', 'replacement.example.test:5008', true, 'token')`, - existingClusterID, - replacementClusterID, - )) - require.NoError(t, db.SqlcClient.TestsRawSQL(ctx, - `UPDATE public.teams SET tier = 'cluster_assignment_test', cluster_id = $1 WHERE id = $2`, - existingClusterID, - teamID, - )) - - authService := &recordingCacheAuthService{} - store := &APIStore{db: db.SqlcClient, authService: authService} - response := callAssignCluster(t, store, teamID, api.AdminTeamClusterAssignmentRequest{ClusterId: replacementClusterID}) - require.Equal(t, http.StatusConflict, response.Code, response.Body.String()) - require.Contains(t, response.Body.String(), enterpriseClusterAssignmentPolicyMessage) - require.Empty(t, authService.invalidated) - - var assignedClusterID uuid.UUID - require.NoError(t, db.SqlcClient.TestsRawSQLQuery(ctx, - `SELECT cluster_id FROM public.teams WHERE id = $1`, - func(rows pgx.Rows) error { - require.True(t, rows.Next()) - - return rows.Scan(&assignedClusterID) - }, - teamID, - )) - require.Equal(t, existingClusterID, assignedClusterID) -} - -func TestPutAdminTeamsTeamIDClusterReplaysSameAssignmentAfterTierDowngrade(t *testing.T) { - t.Parallel() - - db := testutils.SetupDatabase(t) - ctx := t.Context() - teamID := createClusterAssignmentTestTeam(t, db) - clusterID := uuid.New() - require.NoError(t, db.SqlcClient.TestsRawSQL(ctx, - `INSERT INTO public.clusters (id, name, endpoint, endpoint_tls, token) VALUES ($1, 'managed', 'api.example.test:5008', true, 'token')`, - clusterID, - )) - - authService := &recordingCacheAuthService{} - store := &APIStore{db: db.SqlcClient, authService: authService} - require.Equal(t, http.StatusNoContent, - callAssignCluster(t, store, teamID, api.AdminTeamClusterAssignmentRequest{ClusterId: clusterID}).Code) - require.NoError(t, db.SqlcClient.TestsRawSQL(ctx, - `UPDATE public.teams SET tier = 'cluster_assignment_test' WHERE id = $1`, - teamID, - )) - - preserveExisting := true - replayed := callAssignCluster(t, store, teamID, api.AdminTeamClusterAssignmentRequest{ - ClusterId: clusterID, - PreserveExisting: &preserveExisting, - }) - require.Equal(t, http.StatusNoContent, replayed.Code, replayed.Body.String()) - require.Equal(t, []uuid.UUID{teamID, teamID}, authService.invalidated) - - var assignedClusterID uuid.UUID - require.NoError(t, db.SqlcClient.TestsRawSQLQuery(ctx, - `SELECT cluster_id FROM public.teams WHERE id = $1`, - func(rows pgx.Rows) error { - require.True(t, rows.Next()) - - return rows.Scan(&assignedClusterID) - }, - teamID, - )) - require.Equal(t, clusterID, assignedClusterID) -} - -func TestPutAdminTeamsTeamIDClusterWaitsForConcurrentTierDowngradeBeforeReplacement(t *testing.T) { - t.Parallel() - - db := testutils.SetupDatabase(t) - ctx := t.Context() - teamID := createClusterAssignmentTestTeam(t, db) - existingClusterID := uuid.New() - replacementClusterID := uuid.New() - require.NoError(t, db.SqlcClient.TestsRawSQL(ctx, - `INSERT INTO public.clusters (id, name, endpoint, endpoint_tls, token) VALUES ($1, 'managed', 'api.example.test:5008', true, 'token'), ($2, 'replacement', 'replacement.example.test:5008', true, 'token')`, - existingClusterID, - replacementClusterID, - )) - require.NoError(t, db.SqlcClient.TestsRawSQL(ctx, - `UPDATE public.teams SET cluster_id = $1 WHERE id = $2`, - existingClusterID, - teamID, - )) - - _, downgradeTx, err := db.SqlcClient.WithTx(ctx) - require.NoError(t, err) - defer func() { - _ = downgradeTx.Rollback(t.Context()) - }() - _, err = downgradeTx.Exec(ctx, - `UPDATE public.teams SET tier = 'cluster_assignment_test' WHERE id = $1`, - teamID, - ) - require.NoError(t, err) - - body, err := json.Marshal(api.AdminTeamClusterAssignmentRequest{ClusterId: replacementClusterID}) - require.NoError(t, err) - httpRequest := httptest.NewRequestWithContext(ctx, http.MethodPut, - "/admin/teams/"+teamID.String()+"/cluster", bytes.NewReader(body)) - httpRequest.Header.Set("Content-Type", "application/json") - authService := &recordingCacheAuthService{} - store := &APIStore{db: db.SqlcClient, authService: authService} - responseCh := make(chan *httptest.ResponseRecorder, 1) - assignmentStarted := make(chan struct{}) - go func() { - close(assignmentStarted) - responseCh <- callAssignClusterRequest(store, teamID, httpRequest) - }() - - <-assignmentStarted - select { - case response := <-responseCh: - require.FailNow(t, "assignment returned before the tier downgrade committed", response.Body.String()) - case <-time.After(time.Second): - } - - require.NoError(t, downgradeTx.Commit(ctx)) - select { - case response := <-responseCh: - require.Equal(t, http.StatusConflict, response.Code, response.Body.String()) - require.Contains(t, response.Body.String(), enterpriseClusterAssignmentPolicyMessage) - case <-time.After(5 * time.Second): - require.FailNow(t, "assignment did not finish after the tier downgrade committed") - } - require.Empty(t, authService.invalidated) - - var assignedClusterID uuid.UUID - require.NoError(t, db.SqlcClient.TestsRawSQLQuery(ctx, - `SELECT cluster_id FROM public.teams WHERE id = $1`, - func(rows pgx.Rows) error { - require.True(t, rows.Next()) - - return rows.Scan(&assignedClusterID) - }, - teamID, - )) - require.Equal(t, existingClusterID, assignedClusterID) -} - -func TestPutAdminTeamsTeamIDClusterRetriesCacheInvalidation(t *testing.T) { - t.Parallel() - - db := testutils.SetupDatabase(t) - ctx := t.Context() - teamID := createClusterAssignmentTestTeam(t, db) - clusterID := uuid.New() - require.NoError(t, db.SqlcClient.TestsRawSQL(ctx, - `INSERT INTO public.clusters (id, name, endpoint, endpoint_tls, token) VALUES ($1, 'managed', 'api.example.test:5008', true, 'token')`, - clusterID, - )) - - authService := &configurableCacheAuthService{invalidateErr: errors.New("cache unavailable")} - store := &APIStore{db: db.SqlcClient, authService: authService} - request := api.AdminTeamClusterAssignmentRequest{ClusterId: clusterID} - - first := callAssignCluster(t, store, teamID, request) - require.Equal(t, http.StatusInternalServerError, first.Code, first.Body.String()) - require.Equal(t, []uuid.UUID{teamID}, authService.invalidated) - - var assignedClusterID uuid.UUID - require.NoError(t, db.SqlcClient.TestsRawSQLQuery(ctx, - `SELECT cluster_id FROM public.teams WHERE id = $1`, - func(rows pgx.Rows) error { - require.True(t, rows.Next()) - - return rows.Scan(&assignedClusterID) - }, - teamID, - )) - require.Equal(t, clusterID, assignedClusterID) - - second := callAssignCluster(t, store, teamID, request) - require.Equal(t, http.StatusInternalServerError, second.Code, second.Body.String()) - require.Equal(t, []uuid.UUID{teamID, teamID}, authService.invalidated) - - authService.invalidateErr = nil - third := callAssignCluster(t, store, teamID, request) - require.Equal(t, http.StatusNoContent, third.Code, third.Body.String()) - require.Equal(t, []uuid.UUID{teamID, teamID, teamID}, authService.invalidated) -} - -func TestPutAdminTeamsTeamIDClusterPreservesExistingAssignment(t *testing.T) { - t.Parallel() - - db := testutils.SetupDatabase(t) - ctx := t.Context() - teamID := createClusterAssignmentTestTeam(t, db) - managedClusterID := uuid.New() - replacementClusterID := uuid.New() - require.NoError(t, db.SqlcClient.TestsRawSQL(ctx, - `INSERT INTO public.clusters (id, name, endpoint, endpoint_tls, token) VALUES ($1, 'managed', 'managed.example.test:5008', true, 'token'), ($2, 'replacement', 'replacement.example.test:5008', true, 'token')`, - managedClusterID, - replacementClusterID, - )) - preserveExisting := true - store := &APIStore{db: db.SqlcClient, authService: &recordingCacheAuthService{}} - request := api.AdminTeamClusterAssignmentRequest{ - ClusterId: managedClusterID, - PreserveExisting: &preserveExisting, - } - require.Equal(t, http.StatusNoContent, callAssignCluster(t, store, teamID, request).Code) - require.Equal(t, http.StatusNoContent, callAssignCluster(t, store, teamID, request).Code) - require.NoError(t, db.SqlcClient.TestsRawSQL(ctx, - `UPDATE public.teams SET cluster_id = $1 WHERE id = $2`, - replacementClusterID, - teamID, - )) - - response := callAssignCluster(t, store, teamID, request) - require.Equal(t, http.StatusConflict, response.Code, response.Body.String()) - - var assignedClusterID uuid.UUID - require.NoError(t, db.SqlcClient.TestsRawSQLQuery(ctx, - `SELECT cluster_id FROM public.teams WHERE id = $1`, - func(rows pgx.Rows) error { - require.True(t, rows.Next()) - - return rows.Scan(&assignedClusterID) - }, - teamID, - )) - require.Equal(t, replacementClusterID, assignedClusterID) -} - -func TestPutAdminTeamsTeamIDClusterPreservesConcurrentReplacement(t *testing.T) { - t.Parallel() - - db := testutils.SetupDatabase(t) - ctx := t.Context() - teamID := createClusterAssignmentTestTeam(t, db) - managedClusterID := uuid.New() - replacementClusterID := uuid.New() - require.NoError(t, db.SqlcClient.TestsRawSQL(ctx, - `INSERT INTO public.clusters (id, name, endpoint, endpoint_tls, token) VALUES ($1, 'managed', 'managed.example.test:5008', true, 'token'), ($2, 'replacement', 'replacement.example.test:5008', true, 'token')`, - managedClusterID, - replacementClusterID, - )) - require.NoError(t, db.SqlcClient.TestsRawSQL(ctx, - `UPDATE public.teams SET cluster_id = $1 WHERE id = $2`, - managedClusterID, - teamID, - )) - - replacementClient, replacementTx, err := db.SqlcClient.WithTx(ctx) - require.NoError(t, err) - defer func() { - _ = replacementTx.Rollback(t.Context()) - }() - result, err := replacementClient.Dashboard.AssignTeamCluster(ctx, dashboardqueries.AssignTeamClusterParams{ - TeamID: teamID, - ClusterID: replacementClusterID, - }) - require.NoError(t, err) - require.True(t, result.Assigned) - - preserveExisting := true - store := &APIStore{db: db.SqlcClient, authService: &recordingCacheAuthService{}} - body, err := json.Marshal(api.AdminTeamClusterAssignmentRequest{ - ClusterId: managedClusterID, - PreserveExisting: &preserveExisting, - }) - require.NoError(t, err) - httpRequest := httptest.NewRequestWithContext(ctx, http.MethodPut, "/admin/teams/"+teamID.String()+"/cluster", bytes.NewReader(body)) - httpRequest.Header.Set("Content-Type", "application/json") - responseCh := make(chan *httptest.ResponseRecorder, 1) - go func() { - responseCh <- callAssignClusterRequest(store, teamID, httpRequest) - }() - - require.Eventually(t, func() bool { - var blocked bool - err := db.SqlcClient.TestsRawSQLQuery(ctx, ` - SELECT EXISTS ( - SELECT 1 - FROM pg_stat_activity - WHERE datname = current_database() - AND state = 'active' - AND wait_event_type = 'Lock' - AND query LIKE '%WITH locked_team AS MATERIALIZED%' - )`, func(rows pgx.Rows) error { - require.True(t, rows.Next()) - - return rows.Scan(&blocked) - }) - - return err == nil && blocked - }, 5*time.Second, 10*time.Millisecond) - - select { - case response := <-responseCh: - require.FailNow(t, "assignment returned before the replacement committed", response.Body.String()) - default: - } - - require.NoError(t, replacementTx.Commit(ctx)) - select { - case response := <-responseCh: - require.Equal(t, http.StatusConflict, response.Code, response.Body.String()) - case <-time.After(5 * time.Second): - require.FailNow(t, "assignment did not finish after the replacement committed") - } - - var assignedClusterID uuid.UUID - require.NoError(t, db.SqlcClient.TestsRawSQLQuery(ctx, - `SELECT cluster_id FROM public.teams WHERE id = $1`, - func(rows pgx.Rows) error { - require.True(t, rows.Next()) - - return rows.Scan(&assignedClusterID) - }, - teamID, - )) - require.Equal(t, replacementClusterID, assignedClusterID) -} - -func TestDeleteAdminTeamsTeamIDClusterClusterIDDetachesIdempotently(t *testing.T) { - t.Parallel() - - db := testutils.SetupDatabase(t) - ctx := t.Context() - teamID := createClusterAssignmentTestTeam(t, db) - clusterID := uuid.New() - require.NoError(t, db.SqlcClient.TestsRawSQL(ctx, - `INSERT INTO public.clusters (id, name, endpoint, endpoint_tls, token) VALUES ($1, 'managed', 'api.example.test:5008', true, 'token')`, - clusterID, - )) - require.NoError(t, db.SqlcClient.TestsRawSQL(ctx, - `UPDATE public.teams SET cluster_id = $1 WHERE id = $2`, - clusterID, - teamID, - )) - - authService := &recordingCacheAuthService{} - store := &APIStore{db: db.SqlcClient, authService: authService} - response := callDetachCluster(t, store, teamID, clusterID) - require.Equal(t, http.StatusNoContent, response.Code, response.Body.String()) - - replayed := callDetachCluster(t, store, teamID, clusterID) - require.Equal(t, http.StatusNoContent, replayed.Code, replayed.Body.String()) - require.Equal(t, []uuid.UUID{teamID, teamID}, authService.invalidated) - - var assignedClusterID *uuid.UUID - require.NoError(t, db.SqlcClient.TestsRawSQLQuery(ctx, - `SELECT cluster_id FROM public.teams WHERE id = $1`, - func(rows pgx.Rows) error { - require.True(t, rows.Next()) - - return rows.Scan(&assignedClusterID) - }, - teamID, - )) - require.Nil(t, assignedClusterID) -} - -func TestDeleteAdminTeamsTeamIDClusterClusterIDRetriesCacheInvalidation(t *testing.T) { - t.Parallel() - - db := testutils.SetupDatabase(t) - ctx := t.Context() - teamID := createClusterAssignmentTestTeam(t, db) - clusterID := uuid.New() - require.NoError(t, db.SqlcClient.TestsRawSQL(ctx, - `INSERT INTO public.clusters (id, name, endpoint, endpoint_tls, token) VALUES ($1, 'managed', 'api.example.test:5008', true, 'token')`, - clusterID, - )) - require.NoError(t, db.SqlcClient.TestsRawSQL(ctx, - `UPDATE public.teams SET cluster_id = $1 WHERE id = $2`, - clusterID, - teamID, - )) - - authService := &configurableCacheAuthService{invalidateErr: errors.New("cache unavailable")} - store := &APIStore{db: db.SqlcClient, authService: authService} - failed := callDetachCluster(t, store, teamID, clusterID) - require.Equal(t, http.StatusInternalServerError, failed.Code, failed.Body.String()) - - var assignedClusterID *uuid.UUID - require.NoError(t, db.SqlcClient.TestsRawSQLQuery(ctx, - `SELECT cluster_id FROM public.teams WHERE id = $1`, - func(rows pgx.Rows) error { - require.True(t, rows.Next()) - - return rows.Scan(&assignedClusterID) - }, - teamID, - )) - require.Nil(t, assignedClusterID) - - failedReplay := callDetachCluster(t, store, teamID, clusterID) - require.Equal(t, http.StatusInternalServerError, failedReplay.Code, failedReplay.Body.String()) - - authService.invalidateErr = nil - replayed := callDetachCluster(t, store, teamID, clusterID) - require.Equal(t, http.StatusNoContent, replayed.Code, replayed.Body.String()) - require.Equal(t, []uuid.UUID{teamID, teamID, teamID}, authService.invalidated) -} - -func TestDeleteAdminTeamsTeamIDClusterClusterIDPreservesReplacement(t *testing.T) { - t.Parallel() - - db := testutils.SetupDatabase(t) - ctx := t.Context() - teamID := createClusterAssignmentTestTeam(t, db) - staleClusterID := uuid.New() - replacementClusterID := uuid.New() - require.NoError(t, db.SqlcClient.TestsRawSQL(ctx, - `INSERT INTO public.clusters (id, name, endpoint, endpoint_tls, token) VALUES ($1, 'replacement', 'replacement.example.test:5008', true, 'token')`, - replacementClusterID, - )) - require.NoError(t, db.SqlcClient.TestsRawSQL(ctx, - `UPDATE public.teams SET cluster_id = $1 WHERE id = $2`, - replacementClusterID, - teamID, - )) - - authService := &recordingCacheAuthService{} - store := &APIStore{db: db.SqlcClient, authService: authService} - response := callDetachCluster(t, store, teamID, staleClusterID) - require.Equal(t, http.StatusConflict, response.Code, response.Body.String()) - require.Empty(t, authService.invalidated) - - var assignedClusterID uuid.UUID - require.NoError(t, db.SqlcClient.TestsRawSQLQuery(ctx, - `SELECT cluster_id FROM public.teams WHERE id = $1`, - func(rows pgx.Rows) error { - require.True(t, rows.Next()) - - return rows.Scan(&assignedClusterID) - }, - teamID, - )) - require.Equal(t, replacementClusterID, assignedClusterID) - - missingTeam := callDetachCluster(t, store, uuid.New(), staleClusterID) - require.Equal(t, http.StatusNotFound, missingTeam.Code, missingTeam.Body.String()) -} - -func TestDeleteAdminTeamsTeamIDClusterClusterIDWaitsForReplacement(t *testing.T) { - t.Parallel() - - db := testutils.SetupDatabase(t) - ctx := t.Context() - teamID := createClusterAssignmentTestTeam(t, db) - staleClusterID := uuid.New() - replacementClusterID := uuid.New() - require.NoError(t, db.SqlcClient.TestsRawSQL(ctx, - `INSERT INTO public.clusters (id, name, endpoint, endpoint_tls, token) VALUES ($1, 'stale', 'stale.example.test:5008', true, 'token'), ($2, 'replacement', 'replacement.example.test:5008', true, 'token')`, - staleClusterID, - replacementClusterID, - )) - require.NoError(t, db.SqlcClient.TestsRawSQL(ctx, - `UPDATE public.teams SET cluster_id = $1 WHERE id = $2`, - staleClusterID, - teamID, - )) - - replacementClient, replacementTx, err := db.SqlcClient.WithTx(ctx) - require.NoError(t, err) - defer func() { - _ = replacementTx.Rollback(t.Context()) - }() - - result, err := replacementClient.Dashboard.AssignTeamCluster(ctx, dashboardqueries.AssignTeamClusterParams{ - TeamID: teamID, - ClusterID: replacementClusterID, - }) - require.NoError(t, err) - require.True(t, result.Assigned) - - store := &APIStore{db: db.SqlcClient, authService: &recordingCacheAuthService{}} - responseCh := make(chan *httptest.ResponseRecorder, 1) - go func() { - responseCh <- callDetachCluster(t, store, teamID, staleClusterID) - }() - - require.Eventually(t, func() bool { - var blocked bool - err := db.SqlcClient.TestsRawSQLQuery(ctx, ` - SELECT EXISTS ( - SELECT 1 - FROM pg_stat_activity - WHERE datname = current_database() - AND state = 'active' - AND wait_event_type = 'Lock' - AND query LIKE '%WITH locked_team AS MATERIALIZED (%' - )`, func(rows pgx.Rows) error { - require.True(t, rows.Next()) - - return rows.Scan(&blocked) - }) - - return err == nil && blocked - }, 5*time.Second, 10*time.Millisecond) - - select { - case response := <-responseCh: - require.FailNow(t, "detach returned before replacement committed", response.Body.String()) - default: - } - - require.NoError(t, replacementTx.Commit(ctx)) - - select { - case response := <-responseCh: - require.Equal(t, http.StatusConflict, response.Code, response.Body.String()) - case <-time.After(5 * time.Second): - require.FailNow(t, "detach did not finish after replacement committed") - } - - var assignedClusterID uuid.UUID - require.NoError(t, db.SqlcClient.TestsRawSQLQuery(ctx, - `SELECT cluster_id FROM public.teams WHERE id = $1`, - func(rows pgx.Rows) error { - require.True(t, rows.Next()) - - return rows.Scan(&assignedClusterID) - }, - teamID, - )) - require.Equal(t, replacementClusterID, assignedClusterID) -} - func TestGetAdminTeamsTeamIDClusterReturnsOnlyAssignment(t *testing.T) { t.Parallel() @@ -973,25 +367,6 @@ func TestGetAdminTeamsTeamIDClusterReturnsOnlyAssignment(t *testing.T) { require.Equal(t, http.StatusNotFound, missing.Code, missing.Body.String()) } -func TestPutAdminTeamsTeamIDClusterRejectsMissingResources(t *testing.T) { - t.Parallel() - - db := testutils.SetupDatabase(t) - teamID := createClusterAssignmentTestTeam(t, db) - store := &APIStore{db: db.SqlcClient, authService: &recordingCacheAuthService{}} - - missingCluster := callAssignCluster(t, store, teamID, api.AdminTeamClusterAssignmentRequest{ClusterId: uuid.New()}) - require.Equal(t, http.StatusNotFound, missingCluster.Code, missingCluster.Body.String()) - - clusterID := uuid.New() - require.NoError(t, db.SqlcClient.TestsRawSQL(t.Context(), - `INSERT INTO public.clusters (id, name, endpoint, endpoint_tls, token) VALUES ($1, 'managed', 'api.example.test:5008', true, 'token')`, - clusterID, - )) - missingTeam := callAssignCluster(t, store, uuid.New(), api.AdminTeamClusterAssignmentRequest{ClusterId: clusterID}) - require.Equal(t, http.StatusNotFound, missingTeam.Code, missingTeam.Body.String()) -} - func callCreateCluster(t *testing.T, store *APIStore, request api.AdminClusterCreateRequest) *httptest.ResponseRecorder { t.Helper() @@ -1025,37 +400,6 @@ func callDeleteCluster(t *testing.T, store *APIStore, clusterID uuid.UUID) *http return recorder } -func callAssignCluster( - t *testing.T, - store *APIStore, - teamID uuid.UUID, - request api.AdminTeamClusterAssignmentRequest, -) *httptest.ResponseRecorder { - t.Helper() - - body, err := json.Marshal(request) - require.NoError(t, err) - - httpRequest := httptest.NewRequestWithContext(t.Context(), http.MethodPut, "/admin/teams/"+teamID.String()+"/cluster", bytes.NewReader(body)) - httpRequest.Header.Set("Content-Type", "application/json") - - return callAssignClusterRequest(store, teamID, httpRequest) -} - -func callAssignClusterRequest( - store *APIStore, - teamID uuid.UUID, - httpRequest *http.Request, -) *httptest.ResponseRecorder { - recorder := httptest.NewRecorder() - ginCtx, _ := gin.CreateTestContext(recorder) - ginCtx.Request = httpRequest - store.PutAdminTeamsTeamIDCluster(ginCtx, teamID) - ginCtx.Writer.WriteHeaderNow() - - return recorder -} - func callGetClusterAssignment( t *testing.T, store *APIStore, @@ -1077,28 +421,6 @@ func callGetClusterAssignment( return recorder } -func callDetachCluster( - t *testing.T, - store *APIStore, - teamID uuid.UUID, - clusterID uuid.UUID, -) *httptest.ResponseRecorder { - t.Helper() - - recorder := httptest.NewRecorder() - ginCtx, _ := gin.CreateTestContext(recorder) - ginCtx.Request = httptest.NewRequestWithContext( - t.Context(), - http.MethodDelete, - "/admin/teams/"+teamID.String()+"/cluster/"+clusterID.String(), - nil, - ) - store.DeleteAdminTeamsTeamIDClusterClusterID(ginCtx, teamID, clusterID) - ginCtx.Writer.WriteHeaderNow() - - return recorder -} - type configurableCacheAuthService struct { noopAuthService diff --git a/packages/dashboard-api/internal/handlers/admin_teams_bootstrap.go b/packages/dashboard-api/internal/handlers/admin_teams_bootstrap.go deleted file mode 100644 index 10e9ca3153..0000000000 --- a/packages/dashboard-api/internal/handlers/admin_teams_bootstrap.go +++ /dev/null @@ -1,43 +0,0 @@ -package handlers - -import ( - "fmt" - "net/http" - "strings" - - "github.com/gin-gonic/gin" - - "github.com/e2b-dev/infra/packages/dashboard-api/internal/api" - "github.com/e2b-dev/infra/packages/shared/pkg/ginutils" -) - -func (s *APIStore) PostAdminTeamsBootstrap(c *gin.Context) { - ctx := c.Request.Context() - - body, err := ginutils.ParseBody[api.AdminTeamBootstrapRequest](ctx, c) - if err != nil { - s.sendAPIStoreError(c, http.StatusBadRequest, fmt.Sprintf("Error when parsing request: %s", err)) - - return - } - - name := strings.TrimSpace(body.Name) - email := string(body.Email) - if name == "" || email == "" { - s.sendAPIStoreError(c, http.StatusBadRequest, "Team name and email are required") - - return - } - - team, err := s.provisioningService.BootstrapTeam(ctx, name, email) - if err != nil { - s.sendProvisioningError(ctx, c, "provision team", err) - - return - } - - c.JSON(http.StatusOK, api.TeamResolveResponse{ - Id: team.ID, - Slug: team.Slug, - }) -} diff --git a/packages/dashboard-api/internal/handlers/admin_teams_bootstrap_test.go b/packages/dashboard-api/internal/handlers/admin_teams_bootstrap_test.go deleted file mode 100644 index d07e77a358..0000000000 --- a/packages/dashboard-api/internal/handlers/admin_teams_bootstrap_test.go +++ /dev/null @@ -1,164 +0,0 @@ -package handlers - -import ( - "encoding/json" - "errors" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/gin-gonic/gin" - "github.com/google/uuid" - "github.com/jackc/pgx/v5" - - "github.com/e2b-dev/infra/packages/dashboard-api/internal/provisioning" - internalteamprovision "github.com/e2b-dev/infra/packages/dashboard-api/internal/teamprovision" - "github.com/e2b-dev/infra/packages/db/pkg/testutils" - "github.com/e2b-dev/infra/packages/shared/pkg/teamprovision" -) - -func TestPostAdminTeamsBootstrapCreatesTeam(t *testing.T) { - t.Parallel() - - testDB := testutils.SetupDatabase(t) - ctx := t.Context() - sink := &fakeTeamProvisionSink{} - - recorder := httptest.NewRecorder() - ginCtx, _ := gin.CreateTestContext(recorder) - ginCtx.Request = httptest.NewRequestWithContext(ctx, http.MethodPost, "/admin/teams/bootstrap", strings.NewReader(`{ - "name": " Bootstrap team ", - "email": "bootstrap-team@example.com" - }`)) - ginCtx.Request.Header.Set("Content-Type", "application/json") - - provisioningService := provisioning.New(testDB.AuthDB, nil, sink) - - store := &APIStore{ - provisioningService: provisioningService, - } - store.PostAdminTeamsBootstrap(ginCtx) - - if recorder.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d: %s", recorder.Code, recorder.Body.String()) - } - - var body struct { - ID uuid.UUID `json:"id"` - Slug string `json:"slug"` - } - if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - if body.ID == uuid.Nil { - t.Fatal("expected created team id") - } - if body.Slug == "" { - t.Fatal("expected created team slug") - } - - var name, email string - if err := testDB.SqlcClient.TestsRawSQLQuery(ctx, `SELECT name, email FROM public.teams WHERE id = $1`, func(rows pgx.Rows) error { - if !rows.Next() { - return pgx.ErrNoRows - } - - return rows.Scan(&name, &email) - }, body.ID); err != nil { - t.Fatalf("failed to query created team: %v", err) - } - if name != "Bootstrap team" { - t.Fatalf("expected trimmed name, got %q", name) - } - if email != "bootstrap-team@example.com" { - t.Fatalf("expected email, got %q", email) - } - - if len(sink.requests) != 1 { - t.Fatalf("expected one provisioning request, got %d", len(sink.requests)) - } - req := sink.requests[0] - if req.TeamID != body.ID || req.TeamName != name || req.TeamEmail != email { - t.Fatalf("unexpected provisioning request: %+v", req) - } - if req.CreatorUserID != uuid.Nil { - t.Fatalf("expected bootstrap creator user id, got %s", req.CreatorUserID) - } - if req.Reason != teamprovision.ReasonAdditionalTeam { - t.Fatalf("expected additional team reason, got %q", req.Reason) - } -} - -func TestPostAdminTeamsBootstrapRollsBackOnProvisioningFailure(t *testing.T) { - t.Parallel() - - testDB := testutils.SetupDatabase(t) - ctx := t.Context() - sink := &fakeTeamProvisionSink{ - err: &internalteamprovision.ProvisionError{ - StatusCode: http.StatusBadGateway, - Message: "billing unavailable", - }, - } - - recorder := httptest.NewRecorder() - ginCtx, _ := gin.CreateTestContext(recorder) - ginCtx.Request = httptest.NewRequestWithContext(ctx, http.MethodPost, "/admin/teams/bootstrap", strings.NewReader(`{ - "name": "Bootstrap team", - "email": "bootstrap-team@example.com" - }`)) - ginCtx.Request.Header.Set("Content-Type", "application/json") - - provisioningService := provisioning.New(testDB.AuthDB, nil, sink) - - store := &APIStore{ - provisioningService: provisioningService, - } - store.PostAdminTeamsBootstrap(ginCtx) - - if recorder.Code != http.StatusBadGateway { - t.Fatalf("expected status 502, got %d: %s", recorder.Code, recorder.Body.String()) - } - if len(sink.requests) != 1 { - t.Fatalf("expected one provisioning request, got %d", len(sink.requests)) - } - - var count int - if err := testDB.SqlcClient.TestsRawSQLQuery(ctx, `SELECT count(*) FROM public.teams WHERE email = $1`, func(rows pgx.Rows) error { - if !rows.Next() { - return pgx.ErrNoRows - } - - return rows.Scan(&count) - }, "bootstrap-team@example.com"); err != nil { - t.Fatalf("failed to count teams: %v", err) - } - if count != 0 { - t.Fatalf("expected rolled back team, found %d", count) - } -} - -func TestPostAdminTeamsBootstrapRejectsMissingFields(t *testing.T) { - t.Parallel() - - testDB := testutils.SetupDatabase(t) - recorder := httptest.NewRecorder() - ginCtx, _ := gin.CreateTestContext(recorder) - ginCtx.Request = httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/admin/teams/bootstrap", strings.NewReader(`{ - "name": "Bootstrap team", - "email": "" - }`)) - ginCtx.Request.Header.Set("Content-Type", "application/json") - - provisioningService := provisioning.New(testDB.AuthDB, nil, &fakeTeamProvisionSink{err: errors.New("should not provision")}) - - store := &APIStore{ - provisioningService: provisioningService, - } - store.PostAdminTeamsBootstrap(ginCtx) - - if recorder.Code != http.StatusBadRequest { - t.Fatalf("expected status 400, got %d: %s", recorder.Code, recorder.Body.String()) - } -} diff --git a/packages/dashboard-api/internal/handlers/admin_users_bootstrap.go b/packages/dashboard-api/internal/handlers/admin_users_bootstrap.go deleted file mode 100644 index a2e7993efa..0000000000 --- a/packages/dashboard-api/internal/handlers/admin_users_bootstrap.go +++ /dev/null @@ -1,65 +0,0 @@ -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/dashboard-api/internal/provisioning" - "github.com/e2b-dev/infra/packages/shared/pkg/ginutils" - "github.com/e2b-dev/infra/packages/shared/pkg/telemetry" -) - -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.provisioningService.BootstrapOIDCUser(ctx, provisioning.OIDCUserBootstrapInput{ - OIDCIssuer: oidcIssuer, - OIDCUserID: oidcUserID, - OIDCUserEmail: oidcUserEmail, - OIDCUserName: body.OidcUserName, - SignupIP: strings.TrimSpace(valueOrEmpty(body.SignupIp)), - SignupUserAgent: strings.TrimSpace(valueOrEmpty(body.SignupUserAgent)), - }) - if err != nil { - s.sendProvisioningError(ctx, c, "bootstrap auth provider user", err) - - return - } - - c.JSON(http.StatusOK, api.TeamResolveResponse{ - Id: team.ID, - Slug: team.Slug, - }) -} - -func valueOrEmpty(value *string) string { - if value == nil { - return "" - } - - return *value -} diff --git a/packages/dashboard-api/internal/handlers/admin_users_delete.go b/packages/dashboard-api/internal/handlers/admin_users_delete.go deleted file mode 100644 index b9e388a10a..0000000000 --- a/packages/dashboard-api/internal/handlers/admin_users_delete.go +++ /dev/null @@ -1,93 +0,0 @@ -package handlers - -import ( - "context" - "errors" - "fmt" - "net/http" - "time" - - "github.com/gin-gonic/gin" - "go.uber.org/zap" - - "github.com/e2b-dev/infra/packages/dashboard-api/internal/api" - "github.com/e2b-dev/infra/packages/dashboard-api/internal/identity" - "github.com/e2b-dev/infra/packages/db/pkg/dberrors" - "github.com/e2b-dev/infra/packages/shared/pkg/logger" -) - -const identityDeleteMaxRetries = 3 - -func (s *APIStore) DeleteAdminUsersUserId(c *gin.Context, userId api.UserId) { - ctx := c.Request.Context() - - handle, err := s.identityService.PrepareDeleteUser(ctx, userId) - if err != nil { - if s.abortIfNoIdentityProvider(c, err) { - return - } - - if errors.Is(err, identity.ErrUserNotFound) { - s.sendAPIStoreError(c, http.StatusNotFound, fmt.Sprintf("User %s not found or has no identity provider record", userId)) - - return - } - - logger.L().Error(ctx, "failed to prepare user deletion", zap.String("user_id", userId.String()), zap.Error(err)) - s.sendAPIStoreError(c, http.StatusInternalServerError, "Failed to resolve identity provider record for user") - - return - } - - // Done before the IdP removal so a DB failure does not orphan the identity. - if err := s.authDB.DeletePublicUser(ctx, userId); err != nil { - if dberrors.IsNotFoundError(err) { - s.sendAPIStoreError(c, http.StatusNotFound, fmt.Sprintf("User %s not found", userId)) - - return - } - - if dberrors.IsForeignKeyViolation(err) { - logger.L().Warn(ctx, "cannot delete user due to existing references", zap.String("user_id", userId.String()), zap.Error(err)) - s.sendAPIStoreError(c, http.StatusConflict, "Cannot delete user: existing references (e.g. addons) must be removed first") - - return - } - - logger.L().Error(ctx, "failed to delete public user", zap.String("user_id", userId.String()), zap.Error(err)) - s.sendAPIStoreError(c, http.StatusInternalServerError, "Failed to delete public user record") - - return - } - - // Retry since the DB rows are already gone and we must not leave the IdP identity active. - // Use a detached context so a client disconnect does not cancel the cleanup. - cleanupCtx, cleanupCancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second) - defer cleanupCancel() - - var identityErr error - for attempt := range identityDeleteMaxRetries { - identityErr = handle.Execute(cleanupCtx) - if identityErr == nil { - break - } - - logger.L().Warn(ctx, "retrying identity deletion", - zap.String("user_id", userId.String()), - zap.Int("attempt", attempt+1), - zap.Error(identityErr), - ) - - time.Sleep(time.Duration(attempt+1) * 200 * time.Millisecond) - } - - if identityErr != nil { - logger.L().Error(ctx, "failed to delete user identity provider record after retries", zap.String("user_id", userId.String()), zap.Error(identityErr)) - s.sendAPIStoreError(c, http.StatusInternalServerError, - "User DB records deleted but identity provider removal failed — the IdP identity may need manual cleanup") - - return - } - - c.Status(http.StatusNoContent) -} diff --git a/packages/dashboard-api/internal/handlers/identity_unavailable_test.go b/packages/dashboard-api/internal/handlers/identity_unavailable_test.go index a320da8513..866ad917be 100644 --- a/packages/dashboard-api/internal/handlers/identity_unavailable_test.go +++ b/packages/dashboard-api/internal/handlers/identity_unavailable_test.go @@ -1,7 +1,6 @@ package handlers import ( - "fmt" "net/http" "net/http/httptest" "testing" @@ -27,17 +26,3 @@ func TestGetAdminUserProfilesUserIdWithoutIdentityProvider(t *testing.T) { require.Equal(t, http.StatusServiceUnavailable, recorder.Code) require.JSONEq(t, `{"code":503,"message":"No identity provider is configured; this endpoint is unavailable"}`, recorder.Body.String()) } - -func TestSendProvisioningErrorWithoutIdentityProvider(t *testing.T) { - t.Parallel() - - recorder := httptest.NewRecorder() - ginCtx, _ := gin.CreateTestContext(recorder) - ginCtx.Request = httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/teams", nil) - - store := &APIStore{} - store.sendProvisioningError(t.Context(), ginCtx, "create team", fmt.Errorf("get user profile: %w", identity.ErrNoIdentityProvider)) - - require.Equal(t, http.StatusServiceUnavailable, recorder.Code) - require.Contains(t, recorder.Body.String(), "No identity provider is configured") -} diff --git a/packages/dashboard-api/internal/handlers/management_project_cluster.go b/packages/dashboard-api/internal/handlers/management_project_cluster.go index 5444e361a0..6553171d80 100644 --- a/packages/dashboard-api/internal/handlers/management_project_cluster.go +++ b/packages/dashboard-api/internal/handlers/management_project_cluster.go @@ -44,7 +44,7 @@ func (s *APIStore) ManagementAssignProjectCluster(c *gin.Context, projectID api. } func (s *APIStore) ManagementDetachProjectCluster(c *gin.Context, projectID api.ProjectID, clusterID api.ClusterID) { - s.DeleteAdminTeamsTeamIDClusterClusterID(c, projectID, clusterID) + s.detachTeamCluster(c, projectID, clusterID) } func (s *APIStore) ManagementDeleteCluster(c *gin.Context, clusterID api.ClusterID) { diff --git a/packages/dashboard-api/internal/handlers/store.go b/packages/dashboard-api/internal/handlers/store.go index 15eca3f6eb..d2d001bd09 100644 --- a/packages/dashboard-api/internal/handlers/store.go +++ b/packages/dashboard-api/internal/handlers/store.go @@ -15,8 +15,6 @@ import ( "github.com/e2b-dev/infra/packages/dashboard-api/internal/cfg" "github.com/e2b-dev/infra/packages/dashboard-api/internal/identity" "github.com/e2b-dev/infra/packages/dashboard-api/internal/management" - "github.com/e2b-dev/infra/packages/dashboard-api/internal/provisioning" - internalteamprovision "github.com/e2b-dev/infra/packages/dashboard-api/internal/teamprovision" 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/shared/pkg/apierrors" @@ -25,14 +23,13 @@ import ( var _ api.ServerInterface = (*APIStore)(nil) type APIStore struct { - config cfg.Config - db *sqlcdb.Client - authDB *authdb.Client - clickhouse clickhouse.Clickhouse - authService sharedauth.Service - identityService identity.Service - provisioningService *provisioning.Service - managementService *management.Service + config cfg.Config + db *sqlcdb.Client + authDB *authdb.Client + clickhouse clickhouse.Clickhouse + authService sharedauth.Service + identityService identity.Service + managementService *management.Service } func NewAPIStore( @@ -42,17 +39,15 @@ func NewAPIStore( ch clickhouse.Clickhouse, authService sharedauth.Service, identityService identity.Service, - teamProvisionSink internalteamprovision.TeamProvisionSink, ) *APIStore { return &APIStore{ - config: config, - db: db, - authDB: authDB, - clickhouse: ch, - authService: authService, - identityService: identityService, - provisioningService: provisioning.New(authDB, identityService, teamProvisionSink), - managementService: management.NewService(authDB, db, authService), + config: config, + db: db, + authDB: authDB, + clickhouse: ch, + authService: authService, + identityService: identityService, + managementService: management.NewService(authDB, db, authService), } } diff --git a/packages/dashboard-api/internal/handlers/team_creation.go b/packages/dashboard-api/internal/handlers/team_creation.go deleted file mode 100644 index 7a8536f235..0000000000 --- a/packages/dashboard-api/internal/handlers/team_creation.go +++ /dev/null @@ -1,52 +0,0 @@ -package handlers - -import ( - "errors" - "fmt" - "net/http" - "strings" - - "github.com/gin-gonic/gin" - "go.opentelemetry.io/otel/attribute" - - "github.com/e2b-dev/infra/packages/auth/pkg/auth" - "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" -) - -func (s *APIStore) PostTeams(c *gin.Context) { - ctx := c.Request.Context() - telemetry.ReportEvent(ctx, "create team") - - userID := auth.MustGetUserID(c) - attrs := []attribute.KeyValue{ - attribute.String("team.provision.operation", "create team"), - } - body, err := ginutils.ParseBody[api.CreateTeamRequest](ctx, c) - if err != nil { - telemetry.ReportErrorByCode(ctx, http.StatusBadRequest, "create team failed", fmt.Errorf("parse create team request: %w", err), attrs...) - s.sendAPIStoreError(c, http.StatusBadRequest, "Invalid request body") - - return - } - name := strings.TrimSpace(body.Name) - if name == "" { - telemetry.ReportErrorByCode(ctx, http.StatusBadRequest, "create team failed", errors.New("team name is required"), attrs...) - s.sendAPIStoreError(c, http.StatusBadRequest, "Team name is required") - - return - } - - team, err := s.provisioningService.CreateTeam(ctx, userID, name) - if err != nil { - s.sendProvisioningError(ctx, c, "create team", err) - - return - } - - c.JSON(http.StatusOK, api.TeamResolveResponse{ - Id: team.ID, - Slug: team.Slug, - }) -} diff --git a/packages/dashboard-api/internal/handlers/team_handlers_test.go b/packages/dashboard-api/internal/handlers/team_handlers_test.go index ff7a68282d..99a30ea844 100644 --- a/packages/dashboard-api/internal/handlers/team_handlers_test.go +++ b/packages/dashboard-api/internal/handlers/team_handlers_test.go @@ -2,60 +2,23 @@ package handlers import ( "context" - "encoding/json" "net/http" "net/http/httptest" - "strings" - "sync" "testing" - "time" "github.com/gin-gonic/gin" "github.com/google/uuid" + "github.com/stretchr/testify/require" + "github.com/e2b-dev/infra/packages/auth/pkg/auth" authtypes "github.com/e2b-dev/infra/packages/auth/pkg/types" - "github.com/e2b-dev/infra/packages/dashboard-api/internal/identity" - "github.com/e2b-dev/infra/packages/dashboard-api/internal/provisioning" - internalteamprovision "github.com/e2b-dev/infra/packages/dashboard-api/internal/teamprovision" authqueries "github.com/e2b-dev/infra/packages/db/pkg/auth/queries" "github.com/e2b-dev/infra/packages/db/pkg/testutils" - "github.com/e2b-dev/infra/packages/db/queries" - "github.com/e2b-dev/infra/packages/shared/pkg/teamprovision" ) const testBaseTier = "base_v1" -func TestParseUpdateTeamBody_NameOnly(t *testing.T) { - t.Parallel() - - body, err := parseUpdateTeamBody(strings.NewReader(`{"name":"team-a"}`)) - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - if !body.NameSet || body.Name != "team-a" { - t.Fatalf("expected name to be set to team-a, got %+v", body) - } -} - -func TestParseUpdateTeamBody_UnknownFieldRejected(t *testing.T) { - t.Parallel() - - _, err := parseUpdateTeamBody(strings.NewReader(`{"profilePictureUrl":"https://example.com/a.png"}`)) - if err == nil { - t.Fatalf("expected error for unknown field") - } -} - -func TestParseUpdateTeamBody_NameNullRejected(t *testing.T) { - t.Parallel() - - _, err := parseUpdateTeamBody(strings.NewReader(`{"name":null}`)) - if err == nil { - t.Fatalf("expected error for null name") - } -} - func TestRequireAuthedTeamMatchesPath_Success(t *testing.T) { t.Parallel() @@ -94,727 +57,21 @@ func TestRequireAuthedTeamMatchesPath_Mismatch(t *testing.T) { } } -func TestPostTeamsTeamIDMembers_DuplicateMemberReturnsBadRequest(t *testing.T) { - t.Parallel() - - testDB := testutils.SetupDatabase(t) - ctx := t.Context() - - teamID := testutils.CreateTestTeam(t, testDB) - targetUserID := createHandlerTestUser(t, testDB) - addedByUserID := createHandlerTestUser(t, testDB) - - insertHandlerTestTeamMember(t, testDB, targetUserID, teamID, false) - - recorder := httptest.NewRecorder() - ginCtx, _ := gin.CreateTestContext(recorder) - body := `{"email":"` + handlerTestUserEmail(targetUserID) + `"}` - request := httptest.NewRequestWithContext(ctx, http.MethodPost, "/", strings.NewReader(body)) - request.Header.Set("Content-Type", "application/json") - ginCtx.Request = request - - auth.SetUserIDForTest(t, ginCtx, addedByUserID) - auth.SetTeamInfoForTest(t, ginCtx, &authtypes.Team{ - Team: &authqueries.Team{ID: teamID}, - }) - - store := &APIStore{ - db: testDB.SqlcClient, - authDB: testDB.AuthDB, - identityService: handlerTestIdentityProvider{}, - } - store.PostTeamsTeamIDMembers(ginCtx, teamID) - - if recorder.Code != http.StatusBadRequest { - t.Fatalf("expected status 400, got %d", recorder.Code) - } - if !strings.Contains(recorder.Body.String(), "User is already a member of this team") { - t.Fatalf("unexpected response body: %s", recorder.Body.String()) - } -} - -func TestPostTeamsTeamIDMembers_CreatesPublicUserAnchorForInvitee(t *testing.T) { - t.Parallel() - - testDB := testutils.SetupDatabase(t) - ctx := t.Context() - - teamID := testutils.CreateTestTeam(t, testDB) - inviteeID := uuid.New() - addedByUserID := createHandlerTestUser(t, testDB) - inviteeEmail := handlerTestUserEmail(inviteeID) - - recorder := httptest.NewRecorder() - ginCtx, _ := gin.CreateTestContext(recorder) - body := `{"email":"` + inviteeEmail + `"}` - request := httptest.NewRequestWithContext(ctx, http.MethodPost, "/", strings.NewReader(body)) - request.Header.Set("Content-Type", "application/json") - ginCtx.Request = request - - auth.SetUserIDForTest(t, ginCtx, addedByUserID) - auth.SetTeamInfoForTest(t, ginCtx, &authtypes.Team{ - Team: &authqueries.Team{ID: teamID}, - }) - - store := &APIStore{ - db: testDB.SqlcClient, - authDB: testDB.AuthDB, - authService: noopAuthService{}, - identityService: handlerTestIdentityProvider{}, - } - store.PostTeamsTeamIDMembers(ginCtx, teamID) - - if ginCtx.Writer.Status() != http.StatusCreated { - t.Fatalf("expected status 201, got %d: %s", ginCtx.Writer.Status(), recorder.Body.String()) - } - - if _, err := testDB.SqlcClient.GetPublicUserID(ctx, inviteeID); err != nil { - t.Fatalf("expected public user anchor to be created: %v", err) - } - - if _, err := testDB.SqlcClient.GetTeamMemberRelation(ctx, queries.GetTeamMemberRelationParams{ - TeamID: teamID, - UserID: inviteeID, - }); err != nil { - t.Fatalf("expected invitee team member relation: %v", err) - } -} - -type ssoIdentityProvider struct { - handlerTestIdentityProvider - - orgByUser map[uuid.UUID]uuid.UUID -} - -func (p ssoIdentityProvider) UserOrganizationID(_ context.Context, userID uuid.UUID) (uuid.UUID, error) { - return p.orgByUser[userID], nil -} - -func TestPostTeamsTeamIDMembers_RejectsInviteOutsideSSOOrg(t *testing.T) { - t.Parallel() - - ctx := t.Context() - teamID := uuid.New() - orgID := uuid.New() - actingUserID := uuid.New() - inviteeID := uuid.New() - - recorder := httptest.NewRecorder() - ginCtx, _ := gin.CreateTestContext(recorder) - auth.SetUserIDForTest(t, ginCtx, actingUserID) - auth.SetTeamInfoForTest(t, ginCtx, &authtypes.Team{ - Team: &authqueries.Team{ID: teamID, SsoOrganizationID: &orgID}, - }) - ginCtx.Request = httptest.NewRequestWithContext(ctx, http.MethodPost, "/", strings.NewReader(`{"email":"`+handlerTestUserEmail(inviteeID)+`"}`)) - ginCtx.Request.Header.Set("Content-Type", "application/json") - - store := &APIStore{ - identityService: ssoIdentityProvider{}, - } - store.PostTeamsTeamIDMembers(ginCtx, teamID) - - if recorder.Code != http.StatusBadRequest { - t.Fatalf("expected 400 for an invite outside the SSO org, got %d: %s", recorder.Code, recorder.Body.String()) - } - if !strings.Contains(recorder.Body.String(), "is not part of this team's SSO organization") { - t.Fatalf("unexpected response body: %s", recorder.Body.String()) - } -} - -func TestPostTeamsTeamIDMembers_AllowsInviteFromSSOOrg(t *testing.T) { - t.Parallel() - - testDB := testutils.SetupDatabase(t) - ctx := t.Context() - - orgID := uuid.New() - teamID := testutils.CreateTestTeam(t, testDB) - actingUserID := createHandlerTestUser(t, testDB) - inviteeID := uuid.New() - - recorder := httptest.NewRecorder() - ginCtx, _ := gin.CreateTestContext(recorder) - auth.SetUserIDForTest(t, ginCtx, actingUserID) - auth.SetTeamInfoForTest(t, ginCtx, &authtypes.Team{ - Team: &authqueries.Team{ID: teamID, SsoOrganizationID: &orgID}, - }) - ginCtx.Request = httptest.NewRequestWithContext(ctx, http.MethodPost, "/", strings.NewReader(`{"email":"`+handlerTestUserEmail(inviteeID)+`"}`)) - ginCtx.Request.Header.Set("Content-Type", "application/json") - - profiles := ssoIdentityProvider{orgByUser: map[uuid.UUID]uuid.UUID{inviteeID: orgID}} - provisioningService := provisioning.New(testDB.AuthDB, profiles, &fakeTeamProvisionSink{}) - - store := &APIStore{ - db: testDB.SqlcClient, - authDB: testDB.AuthDB, - authService: noopAuthService{}, - identityService: profiles, - provisioningService: provisioningService, - } - store.PostTeamsTeamIDMembers(ginCtx, teamID) - - if ginCtx.Writer.Status() != http.StatusCreated { - t.Fatalf("expected 201 for an in-org invite, got %d: %s", ginCtx.Writer.Status(), recorder.Body.String()) - } - - memberships, err := testDB.AuthDB.GetTeamsWithUsersTeams(ctx, inviteeID) - if err != nil { - t.Fatalf("failed to read memberships: %v", err) - } - if len(memberships) != 1 || memberships[0].Team.ID != teamID { - t.Fatalf("expected invitee to be a member of %s, got %v", teamID, memberships) - } -} - -func TestDeleteTeamsTeamIDMembersUserId_NonMemberReturnsBadRequest(t *testing.T) { - t.Parallel() - - testDB := testutils.SetupDatabase(t) - ctx := t.Context() - - teamID := testutils.CreateTestTeam(t, testDB) - - recorder := httptest.NewRecorder() - ginCtx, _ := gin.CreateTestContext(recorder) - ginCtx.Request = httptest.NewRequestWithContext(ctx, http.MethodDelete, "/", nil) - auth.SetTeamInfoForTest(t, ginCtx, &authtypes.Team{ - Team: &authqueries.Team{ID: teamID}, - }) - - store := &APIStore{db: testDB.SqlcClient} - store.DeleteTeamsTeamIDMembersUserId(ginCtx, teamID, uuid.New()) - - if recorder.Code != http.StatusBadRequest { - t.Fatalf("expected status 400, got %d", recorder.Code) - } - if !strings.Contains(recorder.Body.String(), "User is not a member of this team") { - t.Fatalf("unexpected response body: %s", recorder.Body.String()) - } -} - -func TestDeleteTeamsTeamIDMembersUserId_RechecksDefaultAfterLock(t *testing.T) { - t.Parallel() - - testDB := testutils.SetupDatabase(t) - ctx := t.Context() - - teamID := testutils.CreateTestTeam(t, testDB) - targetUserID := createHandlerTestUser(t, testDB) - otherUserID := createHandlerTestUser(t, testDB) - - insertHandlerTestTeamMember(t, testDB, targetUserID, teamID, false) - insertHandlerTestTeamMember(t, testDB, otherUserID, teamID, true) - - _, tx, err := testDB.SqlcClient.WithTx(ctx) - if err != nil { - t.Fatalf("failed to start locking transaction: %v", err) - } - defer func() { - _ = tx.Rollback(ctx) - }() - - var lockedUserID uuid.UUID - err = tx.QueryRow( - ctx, - `SELECT user_id - FROM public.users_teams - WHERE team_id = $1 AND user_id = $2 - FOR UPDATE`, - teamID, - targetUserID, - ).Scan(&lockedUserID) - if err != nil { - t.Fatalf("failed to lock target team member: %v", err) - } - - recorder := httptest.NewRecorder() - ginCtx, _ := gin.CreateTestContext(recorder) - ginCtx.Request = httptest.NewRequestWithContext(ctx, http.MethodDelete, "/", nil) - auth.SetTeamInfoForTest(t, ginCtx, &authtypes.Team{ - Team: &authqueries.Team{ID: teamID}, - }) - - store := &APIStore{db: testDB.SqlcClient} - done := make(chan struct{}) - - go func() { - store.DeleteTeamsTeamIDMembersUserId(ginCtx, teamID, targetUserID) - close(done) - }() - - select { - case <-done: - t.Fatalf("expected delete handler to wait for the locked member row") - case <-time.After(100 * time.Millisecond): - } - - _, err = tx.Exec( - ctx, - `UPDATE public.users_teams - SET is_default = false - WHERE user_id = $1 AND is_default = true`, - targetUserID, - ) - if err != nil { - t.Fatalf("failed to clear target default team member: %v", err) - } - - _, err = tx.Exec( - ctx, - `UPDATE public.users_teams - SET is_default = true - WHERE team_id = $1 AND user_id = $2`, - teamID, - targetUserID, - ) - if err != nil { - t.Fatalf("failed to promote target team member to default: %v", err) - } - - if err := tx.Commit(ctx); err != nil { - t.Fatalf("failed to commit locking transaction: %v", err) - } - - select { - case <-done: - case <-time.After(time.Second): - t.Fatal("delete handler did not finish after releasing the lock") - } - - if recorder.Code != http.StatusBadRequest { - t.Fatalf("expected status 400, got %d", recorder.Code) - } - if !strings.Contains(recorder.Body.String(), "Cannot remove a default team member") { - t.Fatalf("unexpected response body: %s", recorder.Body.String()) - } - - relation, err := testDB.SqlcClient.GetTeamMemberRelation(ctx, queries.GetTeamMemberRelationParams{ - TeamID: teamID, - UserID: targetUserID, - }) - if err != nil { - t.Fatalf("expected target team member relation to remain, got %v", err) - } - if !relation.IsDefault { - t.Fatal("expected target team member to remain marked as default") - } -} - -func createHandlerTestUser(t *testing.T, db *testutils.Database) uuid.UUID { - t.Helper() - - userID := uuid.New() - email := handlerTestUserEmail(userID) - - if err := db.AuthDB.UpsertPublicUser(t.Context(), userID); err != nil { - t.Fatalf("failed to create public user: %v", err) - } - - team, err := db.AuthDB.CreateTeam(t.Context(), authqueries.CreateTeamParams{ - Name: email, - Tier: testBaseTier, - Email: email, - }) - if err != nil { - t.Fatalf("failed to create default team: %v", err) - } - - if err := db.AuthDB.CreateTeamMembership(t.Context(), authqueries.CreateTeamMembershipParams{ - UserID: userID, - TeamID: team.ID, - IsDefault: true, - AddedBy: nil, - }); err != nil { - t.Fatalf("failed to create default team membership: %v", err) - } - - return userID -} - -func handlerTestUserEmail(userID uuid.UUID) string { - return "user-" + userID.String() + "@example.com" -} - -func newPostTeamsTestStore(t *testing.T, db *testutils.Database, sink *fakeTeamProvisionSink) *APIStore { - t.Helper() - - provisioningService := provisioning.New(db.AuthDB, handlerTestIdentityProvider{}, sink) - - return &APIStore{ - db: db.SqlcClient, - authDB: db.AuthDB, - provisioningService: provisioningService, - } -} - -type handlerTestIdentityProvider struct{} - -func (handlerTestIdentityProvider) ProfilesByUserID(_ context.Context, userIDs []uuid.UUID) (map[uuid.UUID]identity.Profile, error) { - profiles := make(map[uuid.UUID]identity.Profile, len(userIDs)) - for _, userID := range userIDs { - if userID == uuid.Nil { - continue - } - profiles[userID] = identity.Profile{ - UserID: userID, - Email: handlerTestUserEmail(userID), - } - } - - return profiles, nil -} - -func (handlerTestIdentityProvider) FindProfilesByEmail(_ context.Context, email string) ([]identity.Profile, error) { - userIDText := strings.TrimSuffix(strings.TrimPrefix(email, "user-"), "@example.com") - userID, _ := uuid.Parse(userIDText) - if userID == uuid.Nil { - return []identity.Profile{}, nil - } - - return []identity.Profile{{ - UserID: userID, - Email: email, - }}, nil -} - -func (handlerTestIdentityProvider) TeamCreatorContext(context.Context, uuid.UUID) (*teamprovision.CreatorContextV1, error) { - return nil, nil -} - -func (handlerTestIdentityProvider) IdentityOrganizationID(context.Context, string, string) (uuid.UUID, error) { - return uuid.Nil, nil -} - -func (handlerTestIdentityProvider) UserOrganizationID(context.Context, uuid.UUID) (uuid.UUID, error) { - return uuid.Nil, nil -} - -func (handlerTestIdentityProvider) SetIdentityExternalID(context.Context, string, string, uuid.UUID) error { - return nil -} - -func (handlerTestIdentityProvider) PrepareDeleteUser(context.Context, uuid.UUID) (identity.DeleteUserHandle, error) { - return nil, nil -} - func insertHandlerTestTeamMember(t *testing.T, db *testutils.Database, userID, teamID uuid.UUID, isDefault bool) { t.Helper() if isDefault { - if err := db.AuthDB.TestsRawSQL(t.Context(), ` + require.NoError(t, db.AuthDB.TestsRawSQL(t.Context(), ` UPDATE public.users_teams SET is_default = false WHERE user_id = $1 -`, userID); err != nil { - t.Fatalf("failed to clear default team member relation: %v", err) - } +`, userID)) } - err := db.AuthDB.TestsRawSQL(t.Context(), ` + require.NoError(t, db.AuthDB.TestsRawSQL(t.Context(), ` INSERT INTO public.users_teams (user_id, team_id, is_default) VALUES ($1, $2, $3) -`, userID, teamID, isDefault) - if err != nil { - t.Fatalf("failed to create team member relation: %v", err) - } -} - -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 TestPostTeams_LocalPolicyDeniedReturnsBadRequestWithoutCreatingTeam(t *testing.T) { - t.Parallel() - - testDB := testutils.SetupDatabase(t) - ctx := t.Context() - userID := createHandlerTestUser(t, testDB) - sink := &fakeTeamProvisionSink{} - - for range 2 { - team, err := testDB.AuthDB.CreateTeam(ctx, authqueries.CreateTeamParams{ - Name: "extra", - Tier: testBaseTier, - Email: handlerTestUserEmail(userID), - }) - if err != nil { - t.Fatalf("failed to create extra team: %v", err) - } - if err := testDB.AuthDB.CreateTeamMembership(ctx, authqueries.CreateTeamMembershipParams{ - UserID: userID, - TeamID: team.ID, - IsDefault: false, - AddedBy: &userID, - }); err != nil { - t.Fatalf("failed to attach extra team membership: %v", err) - } - } - - recorder := httptest.NewRecorder() - ginCtx, _ := gin.CreateTestContext(recorder) - ginCtx.Request = httptest.NewRequestWithContext(ctx, http.MethodPost, "/", strings.NewReader(`{"name":"Acme"}`)) - ginCtx.Request.Header.Set("Content-Type", "application/json") - auth.SetUserIDForTest(t, ginCtx, userID) - - store := newPostTeamsTestStore(t, testDB, sink) - store.PostTeams(ginCtx) - - if recorder.Code != http.StatusBadRequest { - t.Fatalf("expected status 400, got %d", recorder.Code) - } - if len(sink.requests) != 0 { - t.Fatalf("expected no provisioning call, got %d", len(sink.requests)) - } - - rows, err := testDB.AuthDB.GetTeamsWithUsersTeamsWithTier(ctx, userID) - if err != nil { - t.Fatalf("failed to query user teams: %v", err) - } - if len(rows) != 3 { - t.Fatalf("expected existing teams to remain unchanged, got %d rows", len(rows)) - } -} - -func TestPostTeams_InvalidNameReturnsBadRequest(t *testing.T) { - t.Parallel() - - testDB := testutils.SetupDatabase(t) - ctx := t.Context() - userID := createHandlerTestUser(t, testDB) - - for _, body := range []string{`{}`, `{"name":""}`, `{"name":" "}`} { - recorder := httptest.NewRecorder() - ginCtx, _ := gin.CreateTestContext(recorder) - ginCtx.Request = httptest.NewRequestWithContext(ctx, http.MethodPost, "/", strings.NewReader(body)) - ginCtx.Request.Header.Set("Content-Type", "application/json") - auth.SetUserIDForTest(t, ginCtx, userID) - - sink := &fakeTeamProvisionSink{} - store := newPostTeamsTestStore(t, testDB, sink) - store.PostTeams(ginCtx) - - if recorder.Code != http.StatusBadRequest { - t.Fatalf("expected status 400 for body %s, got %d", body, recorder.Code) - } - if len(sink.requests) != 0 { - t.Fatalf("expected no provisioning call for body %s, got %d", body, len(sink.requests)) - } - } -} - -func TestPostTeams_InvalidRequestBodyReturnsBadRequest(t *testing.T) { - t.Parallel() - - testDB := testutils.SetupDatabase(t) - ctx := t.Context() - userID := createHandlerTestUser(t, testDB) - sink := &fakeTeamProvisionSink{} - - recorder := httptest.NewRecorder() - ginCtx, _ := gin.CreateTestContext(recorder) - ginCtx.Request = httptest.NewRequestWithContext(ctx, http.MethodPost, "/", strings.NewReader(`{"name":`)) - ginCtx.Request.Header.Set("Content-Type", "application/json") - auth.SetUserIDForTest(t, ginCtx, userID) - - store := newPostTeamsTestStore(t, testDB, sink) - store.PostTeams(ginCtx) - - if recorder.Code != http.StatusBadRequest { - t.Fatalf("PostTeams(invalid JSON) status = %d, want %d", recorder.Code, http.StatusBadRequest) - } - if !strings.Contains(recorder.Body.String(), "Invalid request body") { - t.Fatalf("PostTeams(invalid JSON) body = %q, want message containing %q", recorder.Body.String(), "Invalid request body") - } - if len(sink.requests) != 0 { - t.Fatalf("PostTeams(invalid JSON) provisioning calls = %d, want %d", len(sink.requests), 0) - } -} - -func TestPostTeams_TrimsNameBeforeCreate(t *testing.T) { - t.Parallel() - - testDB := testutils.SetupDatabase(t) - ctx := t.Context() - userID := createHandlerTestUser(t, testDB) - sink := &fakeTeamProvisionSink{} - - recorder := httptest.NewRecorder() - ginCtx, _ := gin.CreateTestContext(recorder) - ginCtx.Request = httptest.NewRequestWithContext(ctx, http.MethodPost, "/", strings.NewReader(`{"name":" Acme "}`)) - ginCtx.Request.Header.Set("Content-Type", "application/json") - auth.SetUserIDForTest(t, ginCtx, userID) - - store := newPostTeamsTestStore(t, testDB, sink) - store.PostTeams(ginCtx) - - if recorder.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d", recorder.Code) - } - - rows, err := testDB.AuthDB.GetTeamsWithUsersTeamsWithTier(ctx, userID) - if err != nil { - t.Fatalf("failed to query user teams: %v", err) - } - - foundCreatedTeam := false - for _, row := range rows { - if row.IsDefault { - continue - } - - foundCreatedTeam = true - if row.Team.Name != "Acme" { - t.Fatalf("expected trimmed team name %q, got %q", "Acme", row.Team.Name) - } - } - if !foundCreatedTeam { - t.Fatal("expected created team to exist") - } -} - -func TestPostTeams_ProvisioningFailureRollsBackCreatedTeam(t *testing.T) { - t.Parallel() - - testDB := testutils.SetupDatabase(t) - ctx := t.Context() - userID := createHandlerTestUser(t, testDB) - sink := &fakeTeamProvisionSink{ - err: &internalteamprovision.ProvisionError{ - StatusCode: http.StatusBadRequest, - Message: "limit reached", - }, - } - - recorder := httptest.NewRecorder() - ginCtx, _ := gin.CreateTestContext(recorder) - ginCtx.Request = httptest.NewRequestWithContext(ctx, http.MethodPost, "/", strings.NewReader(`{"name":"Acme"}`)) - ginCtx.Request.Header.Set("Content-Type", "application/json") - auth.SetUserIDForTest(t, ginCtx, userID) - - store := newPostTeamsTestStore(t, testDB, sink) - store.PostTeams(ginCtx) - - if recorder.Code != http.StatusBadRequest { - t.Fatalf("expected status 400, got %d", recorder.Code) - } - if len(sink.requests) != 1 { - t.Fatalf("expected one provisioning call, got %d", len(sink.requests)) - } - - rows, err := testDB.AuthDB.GetTeamsWithUsersTeamsWithTier(ctx, userID) - if err != nil { - t.Fatalf("failed to query user teams: %v", err) - } - if len(rows) != 1 { - t.Fatalf("expected only the default team to remain, got %d rows", len(rows)) - } - if !rows[0].IsDefault { - t.Fatal("expected remaining team to be the default team") - } -} - -func TestPostTeams_ProvisioningFailurePreservesProvisionErrorStatus(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - status int - message string - }{ - {name: "too_many_requests", status: http.StatusTooManyRequests, message: "rate limited"}, - {name: "service_unavailable", status: http.StatusServiceUnavailable, message: "billing unavailable"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - testDB := testutils.SetupDatabase(t) - ctx := t.Context() - userID := createHandlerTestUser(t, testDB) - sink := &fakeTeamProvisionSink{ - err: &internalteamprovision.ProvisionError{ - StatusCode: tt.status, - Message: tt.message, - }, - } - - recorder := httptest.NewRecorder() - ginCtx, _ := gin.CreateTestContext(recorder) - ginCtx.Request = httptest.NewRequestWithContext(ctx, http.MethodPost, "/", strings.NewReader(`{"name":"Acme"}`)) - ginCtx.Request.Header.Set("Content-Type", "application/json") - auth.SetUserIDForTest(t, ginCtx, userID) - - store := newPostTeamsTestStore(t, testDB, sink) - store.PostTeams(ginCtx) - - if recorder.Code != tt.status { - t.Fatalf("PostTeams(provision status %d) status = %d, want %d", tt.status, recorder.Code, tt.status) - } - if len(sink.requests) != 1 { - t.Fatalf("PostTeams(provision status %d) provisioning calls = %d, want %d", tt.status, len(sink.requests), 1) - } - - var responseBody map[string]any - if err := json.Unmarshal(recorder.Body.Bytes(), &responseBody); err != nil { - t.Fatalf("json.Unmarshal(PostTeams response) error = %v, want nil", err) - } - - codeValue, ok := responseBody["code"].(float64) - if !ok { - t.Fatalf("PostTeams(provision status %d) response code type = %T, want float64", tt.status, responseBody["code"]) - } - if got := int(codeValue); got != tt.status { - t.Fatalf("PostTeams(provision status %d) response code = %d, want %d", tt.status, got, tt.status) - } - - messageValue, ok := responseBody["message"].(string) - if !ok { - t.Fatalf("PostTeams(provision status %d) response message type = %T, want string", tt.status, responseBody["message"]) - } - if messageValue != tt.message { - t.Fatalf("PostTeams(provision status %d) response message = %q, want %q", tt.status, messageValue, tt.message) - } - - rows, err := testDB.AuthDB.GetTeamsWithUsersTeamsWithTier(ctx, userID) - if err != nil { - t.Fatalf("GetTeamsWithUsersTeamsWithTier(userID=%s) error = %v, want nil", userID, err) - } - if len(rows) != 1 { - t.Fatalf("GetTeamsWithUsersTeamsWithTier(userID=%s) rows = %d, want %d", userID, len(rows), 1) - } - if !rows[0].IsDefault { - t.Fatal("expected remaining team to be the default team") - } - }) - } -} - -type fakeTeamProvisionSink struct { - mu sync.Mutex - requests []teamprovision.TeamBillingProvisionRequestedV1 - err error -} - -func (s *fakeTeamProvisionSink) ProvisionTeam(_ context.Context, req teamprovision.TeamBillingProvisionRequestedV1) error { - s.mu.Lock() - defer s.mu.Unlock() - - s.requests = append(s.requests, req) - - return s.err +`, userID, teamID, isDefault)) } type noopAuthService struct{} diff --git a/packages/dashboard-api/internal/handlers/team_members.go b/packages/dashboard-api/internal/handlers/team_members.go index 56124109e7..6a86e4a1bf 100644 --- a/packages/dashboard-api/internal/handlers/team_members.go +++ b/packages/dashboard-api/internal/handlers/team_members.go @@ -1,18 +1,13 @@ package handlers import ( - "fmt" "net/http" "github.com/gin-gonic/gin" "github.com/google/uuid" "go.uber.org/zap" - "github.com/e2b-dev/infra/packages/auth/pkg/auth" "github.com/e2b-dev/infra/packages/dashboard-api/internal/api" - "github.com/e2b-dev/infra/packages/db/pkg/dberrors" - "github.com/e2b-dev/infra/packages/db/queries" - "github.com/e2b-dev/infra/packages/shared/pkg/ginutils" "github.com/e2b-dev/infra/packages/shared/pkg/logger" "github.com/e2b-dev/infra/packages/shared/pkg/telemetry" ) @@ -91,181 +86,3 @@ func (s *APIStore) GetTeamsTeamIDMembers(c *gin.Context, teamID api.TeamID) { Members: members, }) } - -func (s *APIStore) PostTeamsTeamIDMembers(c *gin.Context, teamID api.TeamID) { - ctx := c.Request.Context() - telemetry.ReportEvent(ctx, "add team member") - - authTeamID, ok := s.requireAuthedTeamMatchesPath(c, teamID) - if !ok { - return - } - - userID := auth.MustGetUserID(c) - telemetry.SetAttributes(ctx, telemetry.WithTeamID(authTeamID.String())) - - body, err := ginutils.ParseBody[api.AddTeamMemberRequest](ctx, c) - if err != nil { - s.sendAPIStoreError(c, http.StatusBadRequest, "Invalid request body") - - return - } - - profiles, err := s.identityService.FindProfilesByEmail(ctx, string(body.Email)) - if err != nil { - if s.abortIfNoIdentityProvider(c, err) { - return - } - - logger.L().Error(ctx, "failed to look up user by email", zap.Error(err)) - s.sendAPIStoreError(c, http.StatusInternalServerError, "Failed to look up user") - - return - } - - if len(profiles) == 0 { - s.sendAPIStoreError(c, http.StatusNotFound, "User with this email does not exist. Please ask them to sign up first.") - - return - } - - if len(profiles) > 1 { - logger.L().Error(ctx, "ambiguous user email lookup", zap.Int("matches", len(profiles))) - s.sendAPIStoreError(c, http.StatusConflict, "Multiple users with this email exist. Please contact support.") - - return - } - - user := profiles[0] - - if teamInfo, ok := auth.GetTeamInfo(c); ok && teamInfo != nil && teamInfo.Team != nil && teamInfo.Team.SsoOrganizationID != nil { - inviteeOrgID, err := s.identityService.UserOrganizationID(ctx, user.UserID) - if err != nil { - if s.abortIfNoIdentityProvider(c, err) { - return - } - - logger.L().Error(ctx, "failed to resolve invitee organization", zap.Error(err), logger.WithUserID(user.UserID.String())) - s.sendAPIStoreError(c, http.StatusInternalServerError, "Failed to resolve invitee organization") - - return - } - - if inviteeOrgID != *teamInfo.Team.SsoOrganizationID { - s.sendAPIStoreError(c, http.StatusBadRequest, fmt.Sprintf("%s is not part of this team's SSO organization and cannot be invited.", user.Email)) - - return - } - } - - if err := s.authDB.UpsertPublicUser(ctx, user.UserID); err != nil { - logger.L().Error(ctx, "failed to create public user anchor", zap.Error(err), logger.WithUserID(user.UserID.String())) - s.sendAPIStoreError(c, http.StatusInternalServerError, "Failed to add team member") - - return - } - - err = s.db.AddTeamMember(ctx, queries.AddTeamMemberParams{ - UserID: user.UserID, - TeamID: authTeamID, - AddedBy: userID, - }) - if err != nil { - if dberrors.IsUniqueConstraintViolation(err) { - s.sendAPIStoreError(c, http.StatusBadRequest, "User is already a member of this team") - - return - } - - logger.L().Error(ctx, "failed to add team member", zap.Error(err), logger.WithTeamID(authTeamID.String())) - s.sendAPIStoreError(c, http.StatusInternalServerError, "Failed to add team member") - - return - } - - s.authService.InvalidateTeamMemberCache(ctx, user.UserID, authTeamID.String()) - - c.Status(http.StatusCreated) -} - -func (s *APIStore) DeleteTeamsTeamIDMembersUserId(c *gin.Context, teamID api.TeamID, userId api.UserId) { - ctx := c.Request.Context() - telemetry.ReportEvent(ctx, "remove team member") - - authTeamID, ok := s.requireAuthedTeamMatchesPath(c, teamID) - if !ok { - return - } - - telemetry.SetAttributes(ctx, telemetry.WithTeamID(authTeamID.String())) - - txDB, tx, err := s.db.WithTx(ctx) - if err != nil { - logger.L().Error(ctx, "failed to start transaction for removing team member", zap.Error(err)) - s.sendAPIStoreError(c, http.StatusInternalServerError, "Failed to remove team member") - - return - } - defer func() { - _ = tx.Rollback(ctx) - }() - - lockedMembers, err := txDB.LockTeamMembersForUpdate(ctx, authTeamID) - if err != nil { - logger.L().Error(ctx, "failed to lock team members", zap.Error(err)) - s.sendAPIStoreError(c, http.StatusInternalServerError, "Failed to check team members") - - return - } - - relation, err := txDB.GetTeamMemberRelation(ctx, queries.GetTeamMemberRelationParams{ - TeamID: authTeamID, - UserID: userId, - }) - if err != nil { - if dberrors.IsNotFoundError(err) { - s.sendAPIStoreError(c, http.StatusBadRequest, "User is not a member of this team") - - return - } - - logger.L().Error(ctx, "failed to get team member relation", zap.Error(err)) - s.sendAPIStoreError(c, http.StatusInternalServerError, "Failed to get team member") - - return - } - - if relation.IsDefault { - s.sendAPIStoreError(c, http.StatusBadRequest, "Cannot remove a default team member") - - return - } - - if len(lockedMembers) <= 1 { - s.sendAPIStoreError(c, http.StatusBadRequest, "Cannot remove the last team member") - - return - } - - err = txDB.RemoveTeamMember(ctx, queries.RemoveTeamMemberParams{ - TeamID: authTeamID, - UserID: userId, - }) - if err != nil { - logger.L().Error(ctx, "failed to remove team member", zap.Error(err), logger.WithTeamID(authTeamID.String())) - s.sendAPIStoreError(c, http.StatusInternalServerError, "Failed to remove team member") - - return - } - - if err := tx.Commit(ctx); err != nil { - logger.L().Error(ctx, "failed to commit team member removal", zap.Error(err), logger.WithTeamID(authTeamID.String())) - s.sendAPIStoreError(c, http.StatusInternalServerError, "Failed to remove team member") - - return - } - - s.authService.InvalidateTeamMemberCache(ctx, userId, authTeamID.String()) - - c.Status(http.StatusNoContent) -} diff --git a/packages/dashboard-api/internal/handlers/team_update.go b/packages/dashboard-api/internal/handlers/team_update.go deleted file mode 100644 index 052430bc0e..0000000000 --- a/packages/dashboard-api/internal/handlers/team_update.go +++ /dev/null @@ -1,113 +0,0 @@ -package handlers - -import ( - "bytes" - "encoding/json" - "errors" - "io" - "net/http" - "strings" - - "github.com/gin-gonic/gin" - "go.uber.org/zap" - - "github.com/e2b-dev/infra/packages/dashboard-api/internal/api" - dashboardqueries "github.com/e2b-dev/infra/packages/db/pkg/dashboard/queries" - "github.com/e2b-dev/infra/packages/shared/pkg/ginutils" - "github.com/e2b-dev/infra/packages/shared/pkg/logger" - "github.com/e2b-dev/infra/packages/shared/pkg/telemetry" -) - -func (s *APIStore) PatchTeamsTeamID(c *gin.Context, teamID api.TeamID) { - ctx := c.Request.Context() - telemetry.ReportEvent(ctx, "update team") - - authTeamID, ok := s.requireAuthedTeamMatchesPath(c, teamID) - if !ok { - return - } - - telemetry.SetAttributes(ctx, telemetry.WithTeamID(authTeamID.String())) - - body, err := ginutils.ParseBodyWith(ctx, c, parseUpdateTeamBody) - if err != nil { - s.sendAPIStoreError(c, http.StatusBadRequest, "Invalid request body") - - return - } - - if !body.NameSet { - s.sendAPIStoreError(c, http.StatusBadRequest, "At least one field must be provided") - - return - } - - if body.NameSet && strings.TrimSpace(body.Name) == "" { - s.sendAPIStoreError(c, http.StatusBadRequest, "Name must not be empty") - - return - } - - row, err := s.db.Dashboard.UpdateTeam(ctx, dashboardqueries.UpdateTeamParams{ - TeamID: authTeamID, - Name: body.NamePtr(), - NameSet: body.NameSet, - }) - if err != nil { - logger.L().Error(ctx, "failed to update team", zap.Error(err), logger.WithTeamID(authTeamID.String())) - s.sendAPIStoreError(c, http.StatusInternalServerError, "Failed to update team") - - return - } - - c.JSON(http.StatusOK, api.UpdateTeamResponse{ - Id: row.ID, - Name: row.Name, - }) -} - -type updateTeamBody struct { - NameSet bool - Name string -} - -func (b updateTeamBody) NamePtr() *string { - if !b.NameSet { - return nil - } - - return &b.Name -} - -func parseUpdateTeamBody(bodyReader io.Reader) (updateTeamBody, error) { - var body updateTeamBody - - var payload map[string]json.RawMessage - decoder := json.NewDecoder(bodyReader) - if err := decoder.Decode(&payload); err != nil { - return body, err - } - - for field := range payload { - if field != "name" { - return body, errors.New("unknown field") - } - } - - nameRaw, hasName := payload["name"] - if hasName { - body.NameSet = true - if bytes.Equal(nameRaw, []byte("null")) { - return body, errors.New("name cannot be null") - } - - var name string - if err := json.Unmarshal(nameRaw, &name); err != nil { - return body, err - } - - body.Name = name - } - - return body, nil -} diff --git a/packages/dashboard-api/internal/handlers/utils_provisioning.go b/packages/dashboard-api/internal/handlers/utils_provisioning.go deleted file mode 100644 index 47f83ad3ff..0000000000 --- a/packages/dashboard-api/internal/handlers/utils_provisioning.go +++ /dev/null @@ -1,51 +0,0 @@ -package handlers - -import ( - "context" - "errors" - "net/http" - - "github.com/gin-gonic/gin" - "go.opentelemetry.io/otel/attribute" - - "github.com/e2b-dev/infra/packages/dashboard-api/internal/identity" - internalteamprovision "github.com/e2b-dev/infra/packages/dashboard-api/internal/teamprovision" - "github.com/e2b-dev/infra/packages/shared/pkg/telemetry" -) - -func (s *APIStore) sendProvisioningError(ctx context.Context, c *gin.Context, operation string, err error) { - attrs := []attribute.KeyValue{ - attribute.String("team.provision.operation", operation), - } - - if errors.Is(err, identity.ErrNoIdentityProvider) { - telemetry.ReportErrorByCode(ctx, http.StatusServiceUnavailable, operation+" failed", err, attrs...) - s.sendAPIStoreError(c, http.StatusServiceUnavailable, identityProviderUnavailableMessage) - - return - } - - if errors.Is(err, identity.ErrUserNotFound) { - telemetry.ReportErrorByCode(ctx, http.StatusNotFound, operation+" failed", err, attrs...) - s.sendAPIStoreError(c, http.StatusNotFound, "User not found") - - return - } - - if provisionErr, ok := errors.AsType[*internalteamprovision.ProvisionError](err); ok { - if provisionErr.StatusCode < http.StatusBadRequest || provisionErr.StatusCode >= 600 { - telemetry.ReportErrorByCode(ctx, http.StatusInternalServerError, operation+" failed", err, attrs...) - s.sendAPIStoreError(c, http.StatusInternalServerError, "Failed to "+operation) - - return - } - - telemetry.ReportErrorByCode(ctx, provisionErr.StatusCode, operation+" failed", err, attrs...) - s.sendAPIStoreError(c, provisionErr.StatusCode, provisionErr.Error()) - - return - } - - telemetry.ReportErrorByCode(ctx, http.StatusInternalServerError, operation+" failed", err, attrs...) - s.sendAPIStoreError(c, http.StatusInternalServerError, "Failed to "+operation) -} diff --git a/packages/dashboard-api/internal/middleware/legacy_team_mutations.go b/packages/dashboard-api/internal/middleware/legacy_team_mutations.go deleted file mode 100644 index e27913f89d..0000000000 --- a/packages/dashboard-api/internal/middleware/legacy_team_mutations.go +++ /dev/null @@ -1,39 +0,0 @@ -package middleware - -import ( - "context" - "net/http" - - "github.com/gin-gonic/gin" - - "github.com/e2b-dev/infra/packages/shared/pkg/featureflags" - sharedmiddleware "github.com/e2b-dev/infra/packages/shared/pkg/middleware" -) - -const legacyTeamMutationDisabledMessage = "Legacy team mutations are no longer available. Use the workspace API." - -var legacyTeamMutationRoutes = []sharedmiddleware.RouteTemplate{ - {Method: http.MethodPost, Path: "/teams"}, - {Method: http.MethodPatch, Path: "/teams/:teamID"}, - {Method: http.MethodPost, Path: "/teams/:teamID/members"}, - {Method: http.MethodDelete, Path: "/teams/:teamID/members/:userId"}, - {Method: http.MethodPost, Path: "/admin/users/bootstrap"}, - {Method: http.MethodDelete, Path: "/admin/users/:userId"}, - {Method: http.MethodPost, Path: "/admin/teams/bootstrap"}, - {Method: http.MethodPut, Path: "/admin/teams/:teamID/cluster"}, - {Method: http.MethodDelete, Path: "/admin/teams/:teamID/cluster/:clusterID"}, -} - -func DisableLegacyTeamMutations(featureFlags *featureflags.Client) gin.HandlerFunc { - return sharedmiddleware.RejectRoutes( - legacyTeamMutationRoutes, - func(ctx context.Context) bool { - return featureFlags.BoolFlag(ctx, featureflags.DisableLegacyTeamMutationsFlag) - }, - sharedmiddleware.RouteRejection{ - Reason: "legacy_team_mutations_disabled", - Status: http.StatusPreconditionFailed, - Message: legacyTeamMutationDisabledMessage, - }, - ) -} diff --git a/packages/dashboard-api/internal/middleware/legacy_team_mutations_test.go b/packages/dashboard-api/internal/middleware/legacy_team_mutations_test.go deleted file mode 100644 index 8a69841256..0000000000 --- a/packages/dashboard-api/internal/middleware/legacy_team_mutations_test.go +++ /dev/null @@ -1,122 +0,0 @@ -package middleware - -import ( - "context" - "net/http" - "net/http/httptest" - "testing" - - "github.com/gin-gonic/gin" - "github.com/launchdarkly/go-server-sdk/v7/testhelpers/ldtestdata" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/e2b-dev/infra/packages/shared/pkg/featureflags" -) - -func TestDisableLegacyTeamMutations(t *testing.T) { - t.Parallel() - - type route struct { - method string - path string - } - - legacyRoutes := []route{ - {http.MethodPost, "/teams"}, - {http.MethodPatch, "/teams/team-1"}, - {http.MethodPost, "/teams/team-1/members"}, - {http.MethodDelete, "/teams/team-1/members/user-1"}, - {http.MethodPost, "/admin/users/bootstrap"}, - {http.MethodDelete, "/admin/users/user-1"}, - {http.MethodPost, "/admin/teams/bootstrap"}, - {http.MethodPut, "/admin/teams/team-1/cluster"}, - {http.MethodDelete, "/admin/teams/team-1/cluster/cluster-1"}, - } - - ungatedRoutes := []route{ - {http.MethodGet, "/teams"}, - {http.MethodPost, "/admin/user-profiles/resolve"}, - {http.MethodPost, "/admin/user-profiles/by-email"}, - {http.MethodPost, "/admin/clusters"}, - {http.MethodPut, "/v1/management/projects/project-1"}, - {http.MethodPost, "/api-keys"}, - } - - for _, route := range legacyRoutes { - t.Run("flag off "+route.method+" "+route.path, func(t *testing.T) { - t.Parallel() - - response, reachedHandler := serveLegacyTeamMutationRoute(t, false, route.method, route.path) - - assert.Equal(t, http.StatusNoContent, response.Code) - assert.True(t, reachedHandler) - }) - - t.Run("flag on "+route.method+" "+route.path, func(t *testing.T) { - t.Parallel() - - response, reachedHandler := serveLegacyTeamMutationRoute(t, true, route.method, route.path) - - assert.Equal(t, http.StatusPreconditionFailed, response.Code) - assert.JSONEq(t, `{"code":412,"message":"Legacy team mutations are no longer available. Use the workspace API."}`, response.Body.String()) - assert.False(t, reachedHandler) - }) - } - - for _, route := range ungatedRoutes { - t.Run("flag on remains reachable "+route.method+" "+route.path, func(t *testing.T) { - t.Parallel() - - response, reachedHandler := serveLegacyTeamMutationRoute(t, true, route.method, route.path) - - assert.Equal(t, http.StatusNoContent, response.Code) - assert.True(t, reachedHandler) - }) - } -} - -func serveLegacyTeamMutationRoute(t *testing.T, enabled bool, method, path string) (*httptest.ResponseRecorder, bool) { - t.Helper() - - dataSource := ldtestdata.DataSource() - featureFlags, err := featureflags.NewClientWithDatasource(dataSource) - require.NoError(t, err) - t.Cleanup(func() { - require.NoError(t, featureFlags.Close(context.WithoutCancel(t.Context()))) - }) - - if enabled { - dataSource.Update(dataSource.Flag(featureflags.DisableLegacyTeamMutationsFlag.Key()).VariationForAll(true)) - } - - router := gin.New() - router.Use(DisableLegacyTeamMutations(featureFlags)) - - reachedHandler := false - handler := func(c *gin.Context) { - reachedHandler = true - c.Status(http.StatusNoContent) - } - - router.POST("/teams", handler) - router.PATCH("/teams/:teamID", handler) - router.POST("/teams/:teamID/members", handler) - router.DELETE("/teams/:teamID/members/:userId", handler) - router.POST("/admin/users/bootstrap", handler) - router.DELETE("/admin/users/:userId", handler) - router.POST("/admin/teams/bootstrap", handler) - router.PUT("/admin/teams/:teamID/cluster", handler) - router.DELETE("/admin/teams/:teamID/cluster/:clusterID", handler) - router.GET("/teams", handler) - router.POST("/admin/user-profiles/resolve", handler) - router.POST("/admin/user-profiles/by-email", handler) - router.POST("/admin/clusters", handler) - router.PUT("/v1/management/projects/:projectID", handler) - router.POST("/api-keys", handler) - - response := httptest.NewRecorder() - router.ServeHTTP(response, httptest.NewRequestWithContext(t.Context(), method, path, nil)) - - return response, reachedHandler -} diff --git a/packages/dashboard-api/internal/provisioning/bootstrap.go b/packages/dashboard-api/internal/provisioning/bootstrap.go deleted file mode 100644 index bced0251d2..0000000000 --- a/packages/dashboard-api/internal/provisioning/bootstrap.go +++ /dev/null @@ -1,190 +0,0 @@ -package provisioning - -import ( - "context" - "errors" - "fmt" - "net/http" - "strings" - "time" - - "github.com/google/uuid" - - "github.com/e2b-dev/infra/packages/dashboard-api/internal/identity" - internalteamprovision "github.com/e2b-dev/infra/packages/dashboard-api/internal/teamprovision" - authqueries "github.com/e2b-dev/infra/packages/db/pkg/auth/queries" - "github.com/e2b-dev/infra/packages/db/pkg/dberrors" - "github.com/e2b-dev/infra/packages/shared/pkg/teamprovision" -) - -func (s *Service) BootstrapOIDCUser(ctx context.Context, input OIDCUserBootstrapInput) (ProvisionedTeam, error) { - profile := bootstrapUserProfile{ - UserID: uuid.New(), - Email: input.OIDCUserEmail, - DefaultTeamName: defaultTeamNameFromOIDCUserName(input.OIDCUserName), - CreatorContext: normalizeCreatorContext(&teamprovision.CreatorContextV1{ - IPAddress: input.SignupIP, - UserAgent: input.SignupUserAgent, - AuthMethod: teamprovision.AuthMethodSocial, - }), - } - - return s.bootstrapUser(ctx, profile, bootstrapUserIdentity{ - Issuer: strings.TrimSpace(input.OIDCIssuer), - Subject: input.OIDCUserID, - }) -} - -func (s *Service) bootstrapUser(ctx context.Context, profile bootstrapUserProfile, oidcIdentity bootstrapUserIdentity) (ProvisionedTeam, error) { - ssoOrgID, err := s.identityService.IdentityOrganizationID(ctx, oidcIdentity.Issuer, oidcIdentity.Subject) - if err != nil { - if errors.Is(err, identity.ErrUnknownIssuer) { - return ProvisionedTeam{}, &internalteamprovision.ProvisionError{ - StatusCode: http.StatusBadRequest, - Message: "oidc_issuer does not match a configured identity provider", - } - } - - return ProvisionedTeam{}, fmt.Errorf("resolve sso organization: %w", err) - } - - authTxDB, tx, err := s.authDB.WithTx(ctx) - if err != nil { - return ProvisionedTeam{}, fmt.Errorf("start transaction: %w", err) - } - defer func() { - _ = tx.Rollback(ctx) - }() - - existing, err := authTxDB.GetUserIdentity(ctx, authqueries.GetUserIdentityParams{ - OidcIss: oidcIdentity.Issuer, - OidcSub: oidcIdentity.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) - } - canonicalUserID, err := authTxDB.UpsertPublicIdentity(ctx, authqueries.UpsertPublicIdentityParams{ - OidcIss: oidcIdentity.Issuer, - OidcSub: oidcIdentity.Subject, - UserID: candidateUserID, - }) - if err != nil { - return ProvisionedTeam{}, fmt.Errorf("upsert public identity: %w", err) - } - if canonicalUserID != candidateUserID { - if err := authTxDB.DeletePublicUser(ctx, candidateUserID); err != nil { - return ProvisionedTeam{}, fmt.Errorf("delete orphan public user: %w", err) - } - profile.UserID = canonicalUserID - } - - if _, err := authTxDB.LockPublicUserForUpdate(ctx, profile.UserID); err != nil { - return ProvisionedTeam{}, fmt.Errorf("lock public user: %w", err) - } - - 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) - } - - if time.Since(existingTeam.CreatedAt) < bootstrapProvisionRetryAge { - req := teamprovision.TeamBillingProvisionRequestedV1{ - TeamID: existingTeam.ID, - TeamName: existingTeam.Name, - TeamEmail: existingTeam.Email, - CreatorUserID: profile.UserID, - CreatorContext: s.teamCreatorContextForProvisioning(ctx, profile), - Reason: teamprovision.ReasonDefaultSignupTeam, - } - _ = s.billing.ProvisionTeam(ctx, req) - } - - if err := backfillIdentityExternalID(ctx, s.identityService, oidcIdentity, profile.UserID); err != nil { - return ProvisionedTeam{}, err - } - - return newProvisionedTeam(existingTeam.ID, existingTeam.Name, existingTeam.Email, existingTeam.Slug, existingTeam.IsBlocked, existingTeam.BlockedReason), nil - } - if !dberrors.IsNotFoundError(err) { - return ProvisionedTeam{}, fmt.Errorf("get default team: %w", err) - } - - if ssoOrgID != uuid.Nil { - landing, err := s.enrollSSOMember(ctx, authTxDB, profile.UserID, ssoOrgID) - if err != nil { - return ProvisionedTeam{}, err - } - - if err := tx.Commit(ctx); err != nil { - return ProvisionedTeam{}, fmt.Errorf("commit sso bootstrap transaction: %w", err) - } - - if err := backfillIdentityExternalID(ctx, s.identityService, oidcIdentity, profile.UserID); err != nil { - return ProvisionedTeam{}, err - } - - return landing, nil - } - - team, err := authTxDB.CreateTeam(ctx, authqueries.CreateTeamParams{ - Name: profile.DefaultTeamName, - Tier: baseTierID, - Email: profile.Email, - IsBlocked: false, - BlockedReason: nil, - }) - if err != nil { - return ProvisionedTeam{}, fmt.Errorf("create default team: %w", err) - } - - if err := authTxDB.CreateTeamMembership(ctx, authqueries.CreateTeamMembershipParams{ - UserID: profile.UserID, - TeamID: team.ID, - IsDefault: true, - AddedBy: nil, - }); err != nil { - return ProvisionedTeam{}, fmt.Errorf("create default team membership: %w", err) - } - - if err := tx.Commit(ctx); err != nil { - return ProvisionedTeam{}, fmt.Errorf("commit user bootstrap transaction: %w", err) - } - - // Emit the billing event before the external_id backfill: ProvisionTeam is - // fire-and-forget, but the backfill returns early on failure, and the - // existing-team recovery path only re-fires ProvisionTeam within - // bootstrapProvisionRetryAge of team creation. Provisioning first guarantees the - // freshly committed team is never left billing-orphaned by an Ory PATCH failure. - req := teamprovision.TeamBillingProvisionRequestedV1{ - TeamID: team.ID, - TeamName: team.Name, - TeamEmail: team.Email, - CreatorUserID: profile.UserID, - CreatorContext: s.teamCreatorContextForProvisioning(ctx, profile), - Reason: teamprovision.ReasonDefaultSignupTeam, - } - _ = s.billing.ProvisionTeam(ctx, req) - - if err := backfillIdentityExternalID(ctx, s.identityService, oidcIdentity, profile.UserID); err != nil { - return ProvisionedTeam{}, err - } - - return newProvisionedTeam(team.ID, team.Name, team.Email, team.Slug, team.IsBlocked, team.BlockedReason), nil -} - -func backfillIdentityExternalID(ctx context.Context, identityService identity.Service, oidcIdentity bootstrapUserIdentity, userID uuid.UUID) error { - if err := identityService.SetIdentityExternalID(ctx, oidcIdentity.Issuer, oidcIdentity.Subject, userID); err != nil { - return fmt.Errorf("set identity external id: %w", err) - } - - return nil -} diff --git a/packages/dashboard-api/internal/provisioning/bootstrap_test.go b/packages/dashboard-api/internal/provisioning/bootstrap_test.go deleted file mode 100644 index efef8dfaf0..0000000000 --- a/packages/dashboard-api/internal/provisioning/bootstrap_test.go +++ /dev/null @@ -1,408 +0,0 @@ -package provisioning - -import ( - "context" - "errors" - "net/http" - "sync" - "testing" - - "github.com/google/uuid" - "github.com/jackc/pgx/v5" - - internalteamprovision "github.com/e2b-dev/infra/packages/dashboard-api/internal/teamprovision" - authqueries "github.com/e2b-dev/infra/packages/db/pkg/auth/queries" - "github.com/e2b-dev/infra/packages/db/pkg/testutils" - "github.com/e2b-dev/infra/packages/shared/pkg/teamprovision" -) - -func TestBootstrapAuthProviderUser_CreatesIdentityAndDefaultTeam(t *testing.T) { - t.Parallel() - - testDB := testutils.SetupDatabase(t) - ctx := t.Context() - sink := &fakeTeamProvisionSink{} - - svc := New(testDB.AuthDB, testIdentityProvider{}, sink) - - input := OIDCUserBootstrapInput{ - OIDCIssuer: testIssuer, - OIDCUserID: uuid.NewString(), - OIDCUserEmail: "ada@example.test", - OIDCUserName: nil, - SignupIP: "198.51.100.20", - SignupUserAgent: "Mozilla/5.0", - } - - team, err := svc.BootstrapOIDCUser(ctx, input) - if err != nil { - t.Fatalf("expected bootstrap to succeed: %v", err) - } - - userIdentity, err := testDB.AuthDB.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.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 != "Personal Project" { - t.Fatalf("expected team name %q, got %q", "Personal Project", 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) - } - if sink.requests[0].CreatorContext == nil { - t.Fatal("expected sink creator context") - } - if sink.requests[0].CreatorContext.IPAddress != "198.51.100.20" { - t.Fatalf("expected sink creator ip %q, got %q", "198.51.100.20", sink.requests[0].CreatorContext.IPAddress) - } - if sink.requests[0].CreatorContext.UserAgent != "Mozilla/5.0" { - t.Fatalf("expected sink creator user agent %q, got %q", "Mozilla/5.0", sink.requests[0].CreatorContext.UserAgent) - } - if sink.requests[0].CreatorContext.AuthMethod != teamprovision.AuthMethodSocial { - t.Fatalf("expected sink creator auth method %q, got %q", teamprovision.AuthMethodSocial, sink.requests[0].CreatorContext.AuthMethod) - } -} - -type recordingIdentityProvider struct { - testIdentityProvider - - externalIDSubject string - externalID uuid.UUID - externalIDCalls int -} - -func (r *recordingIdentityProvider) SetIdentityExternalID(_ context.Context, issuer, subject string, externalID uuid.UUID) error { - r.externalIDSubject = subject - r.externalID = externalID - r.externalIDCalls++ - - return testIssuerRegistered(issuer) -} - -func TestBootstrapOIDCUser_PopulatesOryExternalID(t *testing.T) { - t.Parallel() - - testDB := testutils.SetupDatabase(t) - ctx := t.Context() - sink := &fakeTeamProvisionSink{} - profiles := &recordingIdentityProvider{} - - svc := New(testDB.AuthDB, profiles, sink) - - input := OIDCUserBootstrapInput{ - OIDCIssuer: testIssuer, - OIDCUserID: uuid.NewString(), - OIDCUserEmail: "ada@example.test", - } - - if _, err := svc.BootstrapOIDCUser(ctx, input); err != nil { - t.Fatalf("expected bootstrap to succeed: %v", err) - } - - userIdentity, err := testDB.AuthDB.GetUserIdentity(ctx, authqueries.GetUserIdentityParams{ - OidcIss: input.OIDCIssuer, - OidcSub: input.OIDCUserID, - }) - if err != nil { - t.Fatalf("expected user identity to be created: %v", err) - } - - if profiles.externalIDCalls != 1 { - t.Fatalf("expected one external id update, got %d", profiles.externalIDCalls) - } - if profiles.externalIDSubject != input.OIDCUserID { - t.Fatalf("expected external id set on subject %q, got %q", input.OIDCUserID, profiles.externalIDSubject) - } - if profiles.externalID != userIdentity.UserID { - t.Fatalf("expected external id %s, got %s", userIdentity.UserID, profiles.externalID) - } -} - -func TestBootstrapOIDCUser_RoutesConfiguredSecondaryIssuer(t *testing.T) { - t.Parallel() - - testDB := testutils.SetupDatabase(t) - ctx := t.Context() - sink := &fakeTeamProvisionSink{} - profiles := &recordingIdentityProvider{} - svc := New(testDB.AuthDB, profiles, sink) - - input := OIDCUserBootstrapInput{ - OIDCIssuer: secondTestIssuer, - OIDCUserID: uuid.NewString(), - OIDCUserEmail: "ada@example.test", - } - - if _, err := svc.BootstrapOIDCUser(ctx, input); err != nil { - t.Fatalf("expected bootstrap to succeed: %v", err) - } - - if _, err := testDB.AuthDB.GetUserIdentity(ctx, authqueries.GetUserIdentityParams{ - OidcIss: secondTestIssuer, - OidcSub: input.OIDCUserID, - }); err != nil { - t.Fatalf("expected secondary issuer identity to be created: %v", err) - } - if profiles.externalIDSubject != input.OIDCUserID { - t.Fatalf("expected external id set on secondary issuer subject %q, got %q", input.OIDCUserID, profiles.externalIDSubject) - } -} - -type failingIdentityProvider struct { - testIdentityProvider - - externalIDCalls int -} - -func (f *failingIdentityProvider) SetIdentityExternalID(_ context.Context, issuer, _ string, _ uuid.UUID) error { - f.externalIDCalls++ - if err := testIssuerRegistered(issuer); err != nil { - return err - } - - return errors.New("ory unavailable") -} - -func TestBootstrapOIDCUser_ExternalIDFailureKeepsUserProvisioned(t *testing.T) { - t.Parallel() - - testDB := testutils.SetupDatabase(t) - ctx := t.Context() - sink := &fakeTeamProvisionSink{} - profiles := &failingIdentityProvider{} - - svc := New(testDB.AuthDB, profiles, sink) - - input := OIDCUserBootstrapInput{ - OIDCIssuer: testIssuer, - OIDCUserID: uuid.NewString(), - OIDCUserEmail: "ada@example.test", - } - - if _, err := svc.BootstrapOIDCUser(ctx, input); err == nil { - t.Fatal("expected bootstrap to fail when external_id patch fails") - } - if profiles.externalIDCalls != 1 { - t.Fatalf("expected one external id attempt, got %d", profiles.externalIDCalls) - } - - userIdentity, err := testDB.AuthDB.GetUserIdentity(ctx, authqueries.GetUserIdentityParams{ - OidcIss: input.OIDCIssuer, - OidcSub: input.OIDCUserID, - }) - if err != nil { - t.Fatalf("expected user identity to survive external_id failure: %v", err) - } - if _, err := testDB.AuthDB.GetDefaultTeamByUserID(ctx, userIdentity.UserID); err != nil { - t.Fatalf("expected default team to survive external_id failure: %v", err) - } - - if len(sink.requests) != 1 { - t.Fatalf("expected one billing provisioning call despite external_id failure, got %d", len(sink.requests)) - } -} - -func TestBootstrapOIDCUser_ReRunBackfillsExternalID(t *testing.T) { - t.Parallel() - - testDB := testutils.SetupDatabase(t) - ctx := t.Context() - sink := &fakeTeamProvisionSink{} - - svc := New(testDB.AuthDB, &failingIdentityProvider{}, sink) - - input := OIDCUserBootstrapInput{ - OIDCIssuer: testIssuer, - OIDCUserID: uuid.NewString(), - OIDCUserEmail: "ada@example.test", - } - - if _, err := svc.BootstrapOIDCUser(ctx, input); err == nil { - t.Fatal("expected first bootstrap to fail on external_id patch") - } - - userIdentity, err := testDB.AuthDB.GetUserIdentity(ctx, authqueries.GetUserIdentityParams{ - OidcIss: input.OIDCIssuer, - OidcSub: input.OIDCUserID, - }) - if err != nil { - t.Fatalf("expected user identity from first bootstrap: %v", err) - } - existingTeam, err := testDB.AuthDB.GetDefaultTeamByUserID(ctx, userIdentity.UserID) - if err != nil { - t.Fatalf("expected default team from first bootstrap: %v", err) - } - - recording := &recordingIdentityProvider{} - svc = New(testDB.AuthDB, recording, sink) - - secondTeam, err := svc.BootstrapOIDCUser(ctx, input) - if err != nil { - t.Fatalf("expected re-run bootstrap to succeed: %v", err) - } - if secondTeam.ID != existingTeam.ID { - t.Fatalf("expected re-run to reuse existing team %s, got %s", existingTeam.ID, secondTeam.ID) - } - if recording.externalIDCalls != 1 { - t.Fatalf("expected re-run to backfill external_id once, got %d", recording.externalIDCalls) - } - if recording.externalID != userIdentity.UserID { - t.Fatalf("expected external id %s, got %s", userIdentity.UserID, recording.externalID) - } -} - -func TestBootstrapOIDCUser_ConcurrentRequestsSingleIdentityAndTeam(t *testing.T) { - t.Parallel() - - testDB := testutils.SetupDatabase(t) - ctx := t.Context() - sink := &fakeTeamProvisionSink{} - - svc := New(testDB.AuthDB, testIdentityProvider{}, sink) - - input := OIDCUserBootstrapInput{ - OIDCIssuer: testIssuer, - 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 := svc.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.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 TestBootstrapOIDCUser_AcceptsConfiguredIssuer(t *testing.T) { - t.Parallel() - - testDB := testutils.SetupDatabase(t) - ctx := t.Context() - sink := &fakeTeamProvisionSink{} - - svc := New(testDB.AuthDB, testIdentityProvider{}, sink) - - team, err := svc.BootstrapOIDCUser(ctx, OIDCUserBootstrapInput{ - OIDCIssuer: testIssuer, - OIDCUserID: uuid.NewString(), - OIDCUserEmail: "ada@example.test", - OIDCUserName: nil, - }) - if err != nil { - t.Fatalf("expected bootstrap to succeed with the configured Ory issuer: %v", err) - } - if team.ID == uuid.Nil { - t.Fatal("expected provisioned team") - } -} - -func TestBootstrapOIDCUser_UnknownIssuerReturnsBadRequest(t *testing.T) { - t.Parallel() - - testDB := testutils.SetupDatabase(t) - ctx := t.Context() - sink := &fakeTeamProvisionSink{} - - svc := New(testDB.AuthDB, testIdentityProvider{}, sink) - - _, err := svc.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)) - } -} diff --git a/packages/dashboard-api/internal/provisioning/creator_context.go b/packages/dashboard-api/internal/provisioning/creator_context.go deleted file mode 100644 index 8840081ccf..0000000000 --- a/packages/dashboard-api/internal/provisioning/creator_context.go +++ /dev/null @@ -1,58 +0,0 @@ -package provisioning - -import ( - "context" - "strings" - - "github.com/google/uuid" - "go.uber.org/zap" - - "github.com/e2b-dev/infra/packages/shared/pkg/logger" - "github.com/e2b-dev/infra/packages/shared/pkg/teamprovision" -) - -func (s *Service) resolveTeamCreatorContext(ctx context.Context, userID uuid.UUID) *teamprovision.CreatorContextV1 { - if userID == uuid.Nil || s.identityService == nil { - return nil - } - - resolveCtx, cancel := context.WithTimeout(ctx, creatorContextResolveTimeout) - defer cancel() - - creatorContext, err := s.identityService.TeamCreatorContext(resolveCtx, userID) - if err != nil { - logger.L().Warn(ctx, "failed to resolve creator context for team provisioning", - zap.String("user_id", userID.String()), - zap.Error(err), - ) - - return nil - } - - return normalizeCreatorContext(creatorContext) -} - -func (s *Service) teamCreatorContextForProvisioning(ctx context.Context, profile bootstrapUserProfile) *teamprovision.CreatorContextV1 { - if profile.CreatorContext != nil { - return normalizeCreatorContext(profile.CreatorContext) - } - - return s.resolveTeamCreatorContext(ctx, profile.UserID) -} - -func normalizeCreatorContext(creatorContext *teamprovision.CreatorContextV1) *teamprovision.CreatorContextV1 { - if creatorContext == nil { - return nil - } - - ipAddress := strings.TrimSpace(creatorContext.IPAddress) - if ipAddress == "" { - return nil - } - - return &teamprovision.CreatorContextV1{ - IPAddress: ipAddress, - UserAgent: strings.TrimSpace(creatorContext.UserAgent), - AuthMethod: strings.TrimSpace(creatorContext.AuthMethod), - } -} diff --git a/packages/dashboard-api/internal/provisioning/names.go b/packages/dashboard-api/internal/provisioning/names.go deleted file mode 100644 index 78ac1fc406..0000000000 --- a/packages/dashboard-api/internal/provisioning/names.go +++ /dev/null @@ -1,34 +0,0 @@ -package provisioning - -import ( - "strings" - "unicode" -) - -func defaultTeamNameFromOIDCUserName(name *string) string { - if name == nil || strings.TrimSpace(*name) == "" { - return "Personal Project" - } - - return capitalizeFirstLetter(firstWord(*name)) + "'s Project" -} - -func firstWord(value string) string { - parts := strings.Fields(value) - if len(parts) == 0 { - return "" - } - - return parts[0] -} - -func capitalizeFirstLetter(value string) string { - runes := []rune(strings.TrimSpace(value)) - if len(runes) == 0 { - return "" - } - - runes[0] = unicode.ToUpper(runes[0]) - - return string(runes) -} diff --git a/packages/dashboard-api/internal/provisioning/names_test.go b/packages/dashboard-api/internal/provisioning/names_test.go deleted file mode 100644 index 61ebc4de2e..0000000000 --- a/packages/dashboard-api/internal/provisioning/names_test.go +++ /dev/null @@ -1,56 +0,0 @@ -package provisioning - -import "testing" - -func TestDefaultTeamNameFromOIDCUserName(t *testing.T) { - t.Parallel() - - stringPtr := func(value string) *string { return &value } - - tests := []struct { - name string - userName *string - want string - }{ - { - name: "nil name falls back", - userName: nil, - want: "Personal Project", - }, - { - name: "empty name falls back", - userName: stringPtr(""), - want: "Personal Project", - }, - { - name: "whitespace-only name falls back", - userName: stringPtr(" "), - want: "Personal Project", - }, - { - name: "multi-word name uses capitalized first word", - userName: stringPtr("jakub kracina"), - want: "Jakub's Project", - }, - { - name: "single word is capitalized", - userName: stringPtr("ada"), - want: "Ada's Project", - }, - { - name: "unicode first letter is capitalized", - userName: stringPtr("žofia novak"), - want: "Žofia's Project", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - if got := defaultTeamNameFromOIDCUserName(tt.userName); got != tt.want { - t.Fatalf("expected generated name %q, got %q", tt.want, got) - } - }) - } -} diff --git a/packages/dashboard-api/internal/provisioning/service.go b/packages/dashboard-api/internal/provisioning/service.go deleted file mode 100644 index 9d63af25f4..0000000000 --- a/packages/dashboard-api/internal/provisioning/service.go +++ /dev/null @@ -1,76 +0,0 @@ -package provisioning - -import ( - "time" - - "github.com/google/uuid" - - "github.com/e2b-dev/infra/packages/dashboard-api/internal/identity" - internalteamprovision "github.com/e2b-dev/infra/packages/dashboard-api/internal/teamprovision" - authdb "github.com/e2b-dev/infra/packages/db/pkg/auth" - "github.com/e2b-dev/infra/packages/shared/pkg/teamprovision" -) - -const ( - baseTierID = "base_v1" - maxTeamsPerUser = 3 - maxTeamsPerUserWithProTier = 10 - bootstrapProvisionRetryAge = 30 * time.Second - teamProvisionRollbackTimeout = 5 * time.Second - creatorContextResolveTimeout = 2 * time.Second -) - -type Service struct { - authDB *authdb.Client - identityService identity.Service - billing internalteamprovision.TeamProvisionSink -} - -func New(authDB *authdb.Client, identityService identity.Service, billing internalteamprovision.TeamProvisionSink) *Service { - return &Service{ - authDB: authDB, - identityService: identityService, - billing: billing, - } -} - -type ProvisionedTeam struct { - ID uuid.UUID - Name string - Email string - Slug string - IsBlocked bool - BlockedReason *string -} - -type bootstrapUserProfile struct { - UserID uuid.UUID - Email string - DefaultTeamName string - CreatorContext *teamprovision.CreatorContextV1 -} - -type bootstrapUserIdentity struct { - Issuer string - Subject string -} - -type OIDCUserBootstrapInput struct { - OIDCIssuer string - OIDCUserID string - OIDCUserEmail string - OIDCUserName *string - SignupIP string - SignupUserAgent string -} - -func newProvisionedTeam(id uuid.UUID, name, email, slug string, isBlocked bool, blockedReason *string) ProvisionedTeam { - return ProvisionedTeam{ - ID: id, - Name: name, - Email: email, - Slug: slug, - IsBlocked: isBlocked, - BlockedReason: blockedReason, - } -} diff --git a/packages/dashboard-api/internal/provisioning/service_test.go b/packages/dashboard-api/internal/provisioning/service_test.go deleted file mode 100644 index d03f64abde..0000000000 --- a/packages/dashboard-api/internal/provisioning/service_test.go +++ /dev/null @@ -1,144 +0,0 @@ -package provisioning - -import ( - "context" - "strings" - "sync" - "testing" - "time" - - "github.com/google/uuid" - - "github.com/e2b-dev/infra/packages/dashboard-api/internal/identity" - authqueries "github.com/e2b-dev/infra/packages/db/pkg/auth/queries" - "github.com/e2b-dev/infra/packages/db/pkg/testutils" - "github.com/e2b-dev/infra/packages/shared/pkg/teamprovision" -) - -const ( - testIssuer = "https://ory.example.test" - secondTestIssuer = "https://ory-secondary.example.test" -) - -func createTestUser(t *testing.T, db *testutils.Database) uuid.UUID { - t.Helper() - - createdAt := time.Now() - - return createTestUserWithCreatedAt(t, db, &createdAt) -} - -func createTestUserAt(t *testing.T, db *testutils.Database, createdAt time.Time) uuid.UUID { - t.Helper() - - return createTestUserWithCreatedAt(t, db, &createdAt) -} - -func createTestUserWithCreatedAt(t *testing.T, db *testutils.Database, createdAt *time.Time) uuid.UUID { - t.Helper() - - userID := uuid.New() - email := testUserEmail(userID) - _ = createdAt - - if err := db.AuthDB.UpsertPublicUser(t.Context(), userID); err != nil { - t.Fatalf("failed to create public user: %v", err) - } - - team, err := db.AuthDB.CreateTeam(t.Context(), authqueries.CreateTeamParams{ - Name: email, - Tier: baseTierID, - Email: email, - }) - if err != nil { - t.Fatalf("failed to create default team: %v", err) - } - - if err := db.AuthDB.CreateTeamMembership(t.Context(), authqueries.CreateTeamMembershipParams{ - UserID: userID, - TeamID: team.ID, - IsDefault: true, - AddedBy: nil, - }); err != nil { - t.Fatalf("failed to create default team membership: %v", err) - } - - return userID -} - -func testUserEmail(userID uuid.UUID) string { - return "user-" + userID.String() + "@example.com" -} - -type testIdentityProvider struct{} - -func testIssuerRegistered(issuer string) error { - if issuer != testIssuer && issuer != secondTestIssuer { - return identity.ErrUnknownIssuer - } - - return nil -} - -func (testIdentityProvider) ProfilesByUserID(_ context.Context, userIDs []uuid.UUID) (map[uuid.UUID]identity.Profile, error) { - profiles := make(map[uuid.UUID]identity.Profile, len(userIDs)) - for _, userID := range userIDs { - if userID == uuid.Nil { - continue - } - profiles[userID] = identity.Profile{ - UserID: userID, - Email: testUserEmail(userID), - } - } - - return profiles, nil -} - -func (testIdentityProvider) FindProfilesByEmail(_ context.Context, email string) ([]identity.Profile, error) { - userIDText := strings.TrimSuffix(strings.TrimPrefix(email, "user-"), "@example.com") - userID, _ := uuid.Parse(userIDText) - if userID == uuid.Nil { - return []identity.Profile{}, nil - } - - return []identity.Profile{{ - UserID: userID, - Email: email, - }}, nil -} - -func (testIdentityProvider) TeamCreatorContext(context.Context, uuid.UUID) (*teamprovision.CreatorContextV1, error) { - return nil, nil -} - -func (testIdentityProvider) IdentityOrganizationID(_ context.Context, issuer, _ string) (uuid.UUID, error) { - return uuid.Nil, testIssuerRegistered(issuer) -} - -func (testIdentityProvider) UserOrganizationID(context.Context, uuid.UUID) (uuid.UUID, error) { - return uuid.Nil, nil -} - -func (testIdentityProvider) SetIdentityExternalID(_ context.Context, issuer, _ string, _ uuid.UUID) error { - return testIssuerRegistered(issuer) -} - -func (testIdentityProvider) PrepareDeleteUser(context.Context, uuid.UUID) (identity.DeleteUserHandle, error) { - return nil, nil -} - -type fakeTeamProvisionSink struct { - mu sync.Mutex - requests []teamprovision.TeamBillingProvisionRequestedV1 - err error -} - -func (s *fakeTeamProvisionSink) ProvisionTeam(_ context.Context, req teamprovision.TeamBillingProvisionRequestedV1) error { - s.mu.Lock() - defer s.mu.Unlock() - - s.requests = append(s.requests, req) - - return s.err -} diff --git a/packages/dashboard-api/internal/provisioning/sso.go b/packages/dashboard-api/internal/provisioning/sso.go deleted file mode 100644 index 7a90786918..0000000000 --- a/packages/dashboard-api/internal/provisioning/sso.go +++ /dev/null @@ -1,55 +0,0 @@ -package provisioning - -import ( - "context" - "fmt" - "net/http" - - "github.com/google/uuid" - - internalteamprovision "github.com/e2b-dev/infra/packages/dashboard-api/internal/teamprovision" - authqueries "github.com/e2b-dev/infra/packages/db/pkg/auth/queries" -) - -func (s *Service) enrollSSOMember(ctx context.Context, authTxDB *authqueries.Queries, userID, orgID uuid.UUID) (ProvisionedTeam, error) { - autoTeams, err := authTxDB.GetAutoJoinTeamsBySSOOrganizationID(ctx, orgID) - if err != nil { - return ProvisionedTeam{}, fmt.Errorf("get auto-join teams for sso organization: %w", err) - } - if len(autoTeams) == 0 { - return ProvisionedTeam{}, &internalteamprovision.ProvisionError{ - StatusCode: http.StatusForbidden, - Message: "Your organization's SSO is not fully set up yet. Please contact support.", - } - } - - for _, row := range autoTeams { - if err := authTxDB.CreateTeamMembershipIfMissing(ctx, authqueries.CreateTeamMembershipIfMissingParams{ - UserID: userID, - TeamID: row.Team.ID, - IsDefault: false, - AddedBy: nil, - }); err != nil { - return ProvisionedTeam{}, fmt.Errorf("create sso team membership: %w", err) - } - } - - landing := autoTeams[0].Team - - return newProvisionedTeam(landing.ID, landing.Name, landing.Email, landing.Slug, landing.IsBlocked, landing.BlockedReason), nil -} - -func (s *Service) ensureNotSSOManaged(ctx context.Context, userID uuid.UUID) error { - orgID, err := s.identityService.UserOrganizationID(ctx, userID) - if err != nil { - return fmt.Errorf("resolve sso organization: %w", err) - } - if orgID != uuid.Nil { - return &internalteamprovision.ProvisionError{ - StatusCode: http.StatusForbidden, - Message: "SSO-managed accounts can't create projects. Contact your organization admin.", - } - } - - return nil -} diff --git a/packages/dashboard-api/internal/provisioning/sso_test.go b/packages/dashboard-api/internal/provisioning/sso_test.go deleted file mode 100644 index 6ef1651772..0000000000 --- a/packages/dashboard-api/internal/provisioning/sso_test.go +++ /dev/null @@ -1,198 +0,0 @@ -package provisioning - -import ( - "context" - "errors" - "net/http" - "testing" - "time" - - "github.com/google/uuid" - - internalteamprovision "github.com/e2b-dev/infra/packages/dashboard-api/internal/teamprovision" - authqueries "github.com/e2b-dev/infra/packages/db/pkg/auth/queries" - "github.com/e2b-dev/infra/packages/db/pkg/testutils" -) - -type ssoIdentityProvider struct { - testIdentityProvider - - orgBySubject map[string]uuid.UUID - orgByUser map[uuid.UUID]uuid.UUID -} - -func (p ssoIdentityProvider) IdentityOrganizationID(_ context.Context, issuer, subject string) (uuid.UUID, error) { - return p.orgBySubject[subject], testIssuerRegistered(issuer) -} - -func (p ssoIdentityProvider) UserOrganizationID(_ context.Context, userID uuid.UUID) (uuid.UUID, error) { - return p.orgByUser[userID], nil -} - -func setTeamSSOOrg(t *testing.T, db *testutils.Database, teamID, orgID uuid.UUID, autoJoin bool, createdAt time.Time) { - t.Helper() - - if err := db.SqlcClient.TestsRawSQL(t.Context(), - "UPDATE public.teams SET sso_organization_id = $1, sso_auto_join = $2, created_at = $3 WHERE id = $4", - orgID, autoJoin, createdAt, teamID, - ); err != nil { - t.Fatalf("failed to set team sso org: %v", err) - } -} - -func TestBootstrapOIDCUser_SSOJoinsMappedTeams(t *testing.T) { - t.Parallel() - - testDB := testutils.SetupDatabase(t) - ctx := t.Context() - sink := &fakeTeamProvisionSink{} - - orgID := uuid.New() - subject := uuid.NewString() - - teamNewer := testutils.CreateTestTeam(t, testDB) - teamOlder := testutils.CreateTestTeam(t, testDB) - setTeamSSOOrg(t, testDB, teamNewer, orgID, true, time.Now().Add(-1*time.Hour)) - setTeamSSOOrg(t, testDB, teamOlder, orgID, true, time.Now().Add(-2*time.Hour)) - - provider := ssoIdentityProvider{orgBySubject: map[string]uuid.UUID{subject: orgID}} - svc := New(testDB.AuthDB, provider, sink) - - input := OIDCUserBootstrapInput{ - OIDCIssuer: testIssuer, - OIDCUserID: subject, - OIDCUserEmail: "ada@example.test", - } - - team, err := svc.BootstrapOIDCUser(ctx, input) - if err != nil { - t.Fatalf("expected sso bootstrap to succeed: %v", err) - } - if team.ID != teamOlder { - t.Fatalf("expected landing team %s (earliest created), got %s", teamOlder, team.ID) - } - - userIdentity, err := testDB.AuthDB.GetUserIdentity(ctx, authqueries.GetUserIdentityParams{ - OidcIss: input.OIDCIssuer, - OidcSub: input.OIDCUserID, - }) - if err != nil { - t.Fatalf("expected user identity to be created: %v", err) - } - - if _, err := testDB.AuthDB.GetDefaultTeamByUserID(ctx, userIdentity.UserID); err == nil { - t.Fatal("expected no default team for an SSO member") - } - - memberships, err := testDB.AuthDB.GetTeamsWithUsersTeams(ctx, userIdentity.UserID) - if err != nil { - t.Fatalf("failed to read memberships: %v", err) - } - if len(memberships) != 2 { - t.Fatalf("expected membership in both mapped teams, got %d", len(memberships)) - } - joined := map[uuid.UUID]bool{} - for _, m := range memberships { - if m.IsDefault { - t.Fatalf("expected no default SSO membership, but %s is default", m.Team.ID) - } - joined[m.Team.ID] = true - } - if !joined[teamOlder] || !joined[teamNewer] { - t.Fatalf("expected membership in both %s and %s, got %v", teamOlder, teamNewer, joined) - } - - if len(sink.requests) != 0 { - t.Fatalf("expected no billing provisioning for SSO teams, got %d", len(sink.requests)) - } -} - -func TestBootstrapOIDCUser_SSOFailsClosedWhenNoTeamMapped(t *testing.T) { - t.Parallel() - - testDB := testutils.SetupDatabase(t) - ctx := t.Context() - sink := &fakeTeamProvisionSink{} - - orgID := uuid.New() - subject := uuid.NewString() - - provider := ssoIdentityProvider{orgBySubject: map[string]uuid.UUID{subject: orgID}} - svc := New(testDB.AuthDB, provider, sink) - - input := OIDCUserBootstrapInput{ - OIDCIssuer: testIssuer, - OIDCUserID: subject, - OIDCUserEmail: "grace@example.test", - } - - _, err := svc.BootstrapOIDCUser(ctx, input) - if err == nil { - t.Fatal("expected fail-closed error when organization maps to no team") - } - - var provErr *internalteamprovision.ProvisionError - if !errors.As(err, &provErr) || provErr.StatusCode != http.StatusForbidden { - t.Fatalf("expected 403 ProvisionError, got %v", err) - } - - if _, err := testDB.AuthDB.GetUserIdentity(ctx, authqueries.GetUserIdentityParams{ - OidcIss: input.OIDCIssuer, - OidcSub: input.OIDCUserID, - }); err == nil { - t.Fatal("expected no user identity after fail-closed bootstrap") - } - - if len(sink.requests) != 0 { - t.Fatalf("expected no billing provisioning, got %d", len(sink.requests)) - } -} - -func TestBootstrapOIDCUser_SSOSkipsManualTeams(t *testing.T) { - t.Parallel() - - testDB := testutils.SetupDatabase(t) - ctx := t.Context() - sink := &fakeTeamProvisionSink{} - - orgID := uuid.New() - subject := uuid.NewString() - - manualTeam := testutils.CreateTestTeam(t, testDB) - setTeamSSOOrg(t, testDB, manualTeam, orgID, false, time.Now().Add(-1*time.Hour)) - - provider := ssoIdentityProvider{orgBySubject: map[string]uuid.UUID{subject: orgID}} - svc := New(testDB.AuthDB, provider, sink) - - input := OIDCUserBootstrapInput{ - OIDCIssuer: testIssuer, - OIDCUserID: subject, - OIDCUserEmail: "manual@example.test", - } - - _, err := svc.BootstrapOIDCUser(ctx, input) - var provErr *internalteamprovision.ProvisionError - if !errors.As(err, &provErr) || provErr.StatusCode != http.StatusForbidden { - t.Fatalf("expected 403 fail-closed when the org has only manual teams, got %v", err) - } -} - -func TestCreateTeam_SSOUserRejected(t *testing.T) { - t.Parallel() - - ctx := t.Context() - userID := uuid.New() - - provider := ssoIdentityProvider{orgByUser: map[uuid.UUID]uuid.UUID{userID: uuid.New()}} - svc := New(nil, provider, &fakeTeamProvisionSink{}) - - _, err := svc.CreateTeam(ctx, userID, "My Team") - if err == nil { - t.Fatal("expected SSO user to be blocked from creating a team") - } - - var provErr *internalteamprovision.ProvisionError - if !errors.As(err, &provErr) || provErr.StatusCode != http.StatusForbidden { - t.Fatalf("expected 403 ProvisionError, got %v", err) - } -} diff --git a/packages/dashboard-api/internal/provisioning/team.go b/packages/dashboard-api/internal/provisioning/team.go deleted file mode 100644 index 189253728f..0000000000 --- a/packages/dashboard-api/internal/provisioning/team.go +++ /dev/null @@ -1,178 +0,0 @@ -package provisioning - -import ( - "context" - "fmt" - "net/http" - - "github.com/google/uuid" - - "github.com/e2b-dev/infra/packages/dashboard-api/internal/identity" - internalteamprovision "github.com/e2b-dev/infra/packages/dashboard-api/internal/teamprovision" - authqueries "github.com/e2b-dev/infra/packages/db/pkg/auth/queries" - "github.com/e2b-dev/infra/packages/shared/pkg/teamprovision" -) - -func (s *Service) CreateTeam(ctx context.Context, userID uuid.UUID, name string) (ProvisionedTeam, error) { - if err := s.ensureNotSSOManaged(ctx, userID); err != nil { - return ProvisionedTeam{}, err - } - - profile, err := s.resolveProfile(ctx, userID) - if err != nil { - return ProvisionedTeam{}, err - } - - authTxDB, tx, err := s.authDB.WithTx(ctx) - if err != nil { - return ProvisionedTeam{}, fmt.Errorf("start transaction: %w", err) - } - defer func() { - _ = tx.Rollback(ctx) - }() - - if err := authTxDB.UpsertPublicUser(ctx, userID); err != nil { - return ProvisionedTeam{}, fmt.Errorf("upsert public user: %w", err) - } - - if _, err := authTxDB.LockPublicUserForUpdate(ctx, userID); err != nil { - return ProvisionedTeam{}, fmt.Errorf("lock public user: %w", err) - } - - if err := validateTeamCreationAllowed(ctx, authTxDB, userID); err != nil { - return ProvisionedTeam{}, err - } - - team, err := authTxDB.CreateTeam(ctx, authqueries.CreateTeamParams{ - Name: name, - Tier: baseTierID, - Email: profile.Email, - IsBlocked: false, - BlockedReason: nil, - }) - if err != nil { - return ProvisionedTeam{}, fmt.Errorf("create team: %w", err) - } - - if err := authTxDB.CreateTeamMembership(ctx, authqueries.CreateTeamMembershipParams{ - UserID: userID, - TeamID: team.ID, - IsDefault: false, - AddedBy: &userID, - }); err != nil { - return ProvisionedTeam{}, fmt.Errorf("create team membership: %w", err) - } - - if err := tx.Commit(ctx); err != nil { - return ProvisionedTeam{}, fmt.Errorf("commit team creation transaction: %w", err) - } - - req := teamprovision.TeamBillingProvisionRequestedV1{ - TeamID: team.ID, - TeamName: team.Name, - TeamEmail: team.Email, - CreatorUserID: userID, - CreatorContext: s.resolveTeamCreatorContext(ctx, userID), - Reason: teamprovision.ReasonAdditionalTeam, - } - if err := s.provisionBillingOrDeleteTeam(ctx, team.ID, req); err != nil { - return ProvisionedTeam{}, err - } - - return newProvisionedTeam(team.ID, team.Name, team.Email, team.Slug, team.IsBlocked, team.BlockedReason), nil -} - -func (s *Service) BootstrapTeam(ctx context.Context, name string, email string) (ProvisionedTeam, error) { - team, err := s.authDB.CreateTeam(ctx, authqueries.CreateTeamParams{ - Name: name, - Tier: baseTierID, - Email: email, - IsBlocked: false, - BlockedReason: nil, - }) - if err != nil { - return ProvisionedTeam{}, fmt.Errorf("create team: %w", err) - } - - req := teamprovision.TeamBillingProvisionRequestedV1{ - TeamID: team.ID, - TeamName: team.Name, - TeamEmail: team.Email, - CreatorUserID: uuid.Nil, - Reason: teamprovision.ReasonAdditionalTeam, - } - if err := s.provisionBillingOrDeleteTeam(ctx, team.ID, req); err != nil { - return ProvisionedTeam{}, err - } - - return newProvisionedTeam(team.ID, team.Name, team.Email, team.Slug, team.IsBlocked, team.BlockedReason), nil -} - -func (s *Service) resolveProfile(ctx context.Context, userID uuid.UUID) (identity.Profile, error) { - profiles, err := s.identityService.ProfilesByUserID(ctx, []uuid.UUID{userID}) - if err != nil { - return identity.Profile{}, fmt.Errorf("get user profile: %w", err) - } - - profile, ok := profiles[userID] - if !ok { - return identity.Profile{}, fmt.Errorf("%w: %s", identity.ErrUserNotFound, userID) - } - - return profile, nil -} - -func (s *Service) provisionBillingOrDeleteTeam(ctx context.Context, teamID uuid.UUID, req teamprovision.TeamBillingProvisionRequestedV1) error { - if err := s.billing.ProvisionTeam(ctx, req); err != nil { - rollbackCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), teamProvisionRollbackTimeout) - defer cancel() - - if deleteErr := s.authDB.DeleteTeamByID(rollbackCtx, teamID); deleteErr != nil { - return fmt.Errorf("delete team after provisioning failure: provision=%s delete=%w", err.Error(), deleteErr) - } - - return err - } - - return nil -} - -func validateTeamCreationAllowed(ctx context.Context, authTxDB *authqueries.Queries, ownerUserID uuid.UUID) error { - teams, err := authTxDB.GetTeamsWithUsersTeamsWithTierForUpdate(ctx, ownerUserID) - if err != nil { - return fmt.Errorf("query user teams for limit check: %w", err) - } - - hasProTier := false - for _, row := range teams { - if row.Tier != baseTierID { - hasProTier = true - } - if row.IsBanned { - return &internalteamprovision.ProvisionError{ - StatusCode: http.StatusBadRequest, - Message: "You're unable to create a project right now. Please contact support if this persists.", - } - } - } - - teamLimit := maxTeamsPerUser - limitMessage := fmt.Sprintf( - "You can't create more than %d projects, you can upgrade to Pro tier to create up to %d projects", - maxTeamsPerUser, - maxTeamsPerUserWithProTier, - ) - if hasProTier { - teamLimit = maxTeamsPerUserWithProTier - limitMessage = fmt.Sprintf("You can't create more than %d projects", maxTeamsPerUserWithProTier) - } - - if len(teams) >= teamLimit { - return &internalteamprovision.ProvisionError{ - StatusCode: http.StatusBadRequest, - Message: limitMessage, - } - } - - return nil -} diff --git a/packages/dashboard-api/internal/provisioning/team_test.go b/packages/dashboard-api/internal/provisioning/team_test.go deleted file mode 100644 index 657eb5f86c..0000000000 --- a/packages/dashboard-api/internal/provisioning/team_test.go +++ /dev/null @@ -1,210 +0,0 @@ -package provisioning - -import ( - "errors" - "fmt" - "net/http" - "sync" - "testing" - "time" - - internalteamprovision "github.com/e2b-dev/infra/packages/dashboard-api/internal/teamprovision" - "github.com/e2b-dev/infra/packages/db/pkg/testutils" -) - -func TestCreateTeam_RecentUserCreatesUnblockedTeam(t *testing.T) { - t.Parallel() - - testDB := testutils.SetupDatabase(t) - ctx := t.Context() - userID := createTestUserAt(t, testDB, time.Now().Add(-time.Hour)) - - svc := New(testDB.AuthDB, testIdentityProvider{}, &fakeTeamProvisionSink{}) - - team, err := svc.CreateTeam(ctx, userID, "Acme") - if err != nil { - t.Fatalf("expected team creation to succeed for recent user, got %v", err) - } - if team.IsBlocked { - t.Fatal("expected recent user team to remain unblocked") - } - if team.BlockedReason != nil { - t.Fatalf("expected nil blocked reason, got %v", team.BlockedReason) - } -} - -func TestCreateTeam_ConcurrentRequestsRespectLocalPolicyWithZeroMemberships(t *testing.T) { - t.Parallel() - - testDB := testutils.SetupDatabase(t) - ctx := t.Context() - userID := createTestUser(t, testDB) - - existingTeam, err := testDB.AuthDB.GetDefaultTeamByUserID(ctx, userID) - if err != nil { - t.Fatalf("expected default team: %v", err) - } - if err := testDB.AuthDB.DeleteTeamByID(ctx, existingTeam.ID); err != nil { - t.Fatalf("failed to remove default team: %v", err) - } - - svc := New(testDB.AuthDB, testIdentityProvider{}, &fakeTeamProvisionSink{}) - - var wg sync.WaitGroup - results := make(chan error, 4) - - for _, name := range []string{"Acme-1", "Acme-2", "Acme-3", "Acme-4"} { - wg.Add(1) - go func(teamName string) { - defer wg.Done() - _, err := svc.CreateTeam(ctx, userID, teamName) - results <- err - }(name) - } - - wg.Wait() - close(results) - - var successCount int - var badRequestCount int - for err := range results { - if err == nil { - successCount++ - - continue - } - - var provisionErr *internalteamprovision.ProvisionError - if !errors.As(err, &provisionErr) { - t.Fatalf("expected provisioning error, got %T: %v", err, err) - } - if provisionErr.StatusCode == http.StatusBadRequest { - badRequestCount++ - - continue - } - - t.Fatalf("expected bad request or success, got %d", provisionErr.StatusCode) - } - - if successCount != maxTeamsPerUser { - t.Fatalf("expected %d successes, got %d", maxTeamsPerUser, successCount) - } - if badRequestCount != 1 { - t.Fatalf("expected one bad request, got %d", badRequestCount) - } -} - -func provisionErrorMessage(t *testing.T, err error) string { - t.Helper() - - if err == nil { - t.Fatal("expected team creation to fail") - } - - var provisionErr *internalteamprovision.ProvisionError - if !errors.As(err, &provisionErr) { - t.Fatalf("expected provisioning error, got %T: %v", err, err) - } - if provisionErr.StatusCode != http.StatusBadRequest { - t.Fatalf("expected status %d, got %d", http.StatusBadRequest, provisionErr.StatusCode) - } - - return provisionErr.Message -} - -func TestCreateTeam_BaseTierLimitReturnsUpgradeMessage(t *testing.T) { - t.Parallel() - - testDB := testutils.SetupDatabase(t) - ctx := t.Context() - userID := createTestUser(t, testDB) - - svc := New(testDB.AuthDB, testIdentityProvider{}, &fakeTeamProvisionSink{}) - - for i := 1; i < maxTeamsPerUser; i++ { - if _, err := svc.CreateTeam(ctx, userID, fmt.Sprintf("Acme-%d", i)); err != nil { - t.Fatalf("expected team creation %d to succeed, got %v", i, err) - } - } - - _, err := svc.CreateTeam(ctx, userID, "Acme-over-limit") - expected := fmt.Sprintf( - "You can't create more than %d projects, you can upgrade to Pro tier to create up to %d projects", - maxTeamsPerUser, - maxTeamsPerUserWithProTier, - ) - if message := provisionErrorMessage(t, err); message != expected { - t.Fatalf("expected limit message %q, got %q", expected, message) - } -} - -func TestCreateTeam_ProTierLimitReturnsLimitMessage(t *testing.T) { - t.Parallel() - - testDB := testutils.SetupDatabase(t) - ctx := t.Context() - userID := createTestUser(t, testDB) - - if err := testDB.AuthDB.TestsRawSQL(ctx, ` - INSERT INTO public.tiers ( - id, name, disk_mb, concurrent_instances, max_length_hours, - default_free_disk_size_mb, max_disk_size_mb - ) - VALUES ('pro_test_v1', 'Pro test tier', 10240, 20, 24, 8000, 30000) - `); err != nil { - t.Fatalf("failed to create pro tier: %v", err) - } - - defaultTeam, err := testDB.AuthDB.GetDefaultTeamByUserID(ctx, userID) - if err != nil { - t.Fatalf("expected default team: %v", err) - } - if err := testDB.AuthDB.TestsRawSQL(ctx, - `UPDATE public.teams SET tier = 'pro_test_v1' WHERE id = $1`, - defaultTeam.ID, - ); err != nil { - t.Fatalf("failed to upgrade team tier: %v", err) - } - - svc := New(testDB.AuthDB, testIdentityProvider{}, &fakeTeamProvisionSink{}) - - for i := 1; i < maxTeamsPerUserWithProTier; i++ { - if _, err := svc.CreateTeam(ctx, userID, fmt.Sprintf("Acme-%d", i)); err != nil { - t.Fatalf("expected team creation %d to succeed, got %v", i, err) - } - } - - _, err = svc.CreateTeam(ctx, userID, "Acme-over-limit") - expected := fmt.Sprintf("You can't create more than %d projects", maxTeamsPerUserWithProTier) - if message := provisionErrorMessage(t, err); message != expected { - t.Fatalf("expected limit message %q, got %q", expected, message) - } -} - -func TestCreateTeam_BannedTeamReturnsSupportMessage(t *testing.T) { - t.Parallel() - - testDB := testutils.SetupDatabase(t) - ctx := t.Context() - userID := createTestUser(t, testDB) - - defaultTeam, err := testDB.AuthDB.GetDefaultTeamByUserID(ctx, userID) - if err != nil { - t.Fatalf("expected default team: %v", err) - } - if err := testDB.AuthDB.TestsRawSQL(ctx, - `UPDATE public.teams SET is_banned = true WHERE id = $1`, - defaultTeam.ID, - ); err != nil { - t.Fatalf("failed to ban team: %v", err) - } - - svc := New(testDB.AuthDB, testIdentityProvider{}, &fakeTeamProvisionSink{}) - - _, err = svc.CreateTeam(ctx, userID, "Acme") - expected := "You're unable to create a project right now. Please contact support if this persists." - if message := provisionErrorMessage(t, err); message != expected { - t.Fatalf("expected banned message %q, got %q", expected, message) - } -} diff --git a/packages/dashboard-api/internal/teamprovision/factory.go b/packages/dashboard-api/internal/teamprovision/factory.go deleted file mode 100644 index 3047cc3766..0000000000 --- a/packages/dashboard-api/internal/teamprovision/factory.go +++ /dev/null @@ -1,46 +0,0 @@ -package teamprovision - -import ( - "context" - "errors" - "strings" - - "go.uber.org/zap" - - "github.com/e2b-dev/infra/packages/shared/pkg/logger" -) - -var ( - ErrMissingBaseURL = errors.New("billing server url is required when billing server api token is configured") - ErrMissingAPIToken = errors.New("billing server api token is required when billing server url is configured") -) - -func NewProvisionSink(ctx context.Context, baseURL, apiToken string) (TeamProvisionSink, error) { - baseURL = strings.TrimSpace(baseURL) - apiToken = strings.TrimSpace(apiToken) - - if baseURL == "" && apiToken == "" { - logger.L().Info(ctx, "team provision sink configured", - zap.String("sink", "noop"), - zap.String("result", "disabled"), - ) - - return NewNoopProvisionSink(), nil - } - - if baseURL == "" { - return nil, ErrMissingBaseURL - } - - if apiToken == "" { - return nil, ErrMissingAPIToken - } - - logger.L().Info(ctx, "team provision sink configured", - zap.String("sink", "http"), - zap.String("result", "enabled"), - zap.String("base_url", baseURL), - ) - - return NewHTTPProvisionSink(baseURL, apiToken), nil -} diff --git a/packages/dashboard-api/internal/teamprovision/factory_test.go b/packages/dashboard-api/internal/teamprovision/factory_test.go deleted file mode 100644 index 29fcf9c0c8..0000000000 --- a/packages/dashboard-api/internal/teamprovision/factory_test.go +++ /dev/null @@ -1,68 +0,0 @@ -package teamprovision - -import ( - "testing" - - "github.com/stretchr/testify/require" -) - -func TestNewProvisionSink(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - baseURL string - apiToken string - wantErr error - wantNoop bool - }{ - { - name: "noop when billing is not configured", - wantNoop: true, - }, - { - name: "noop when billing secrets are whitespace", - baseURL: " ", - apiToken: "\t", - wantNoop: true, - }, - { - name: "http when billing secrets are configured", - baseURL: "https://billing.example.com", - apiToken: "token", - }, - { - name: "error when only url is configured", - baseURL: "https://billing.example.com", - wantErr: ErrMissingAPIToken, - }, - { - name: "error when only token is configured", - apiToken: "token", - wantErr: ErrMissingBaseURL, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - sink, err := NewProvisionSink(t.Context(), tt.baseURL, tt.apiToken) - if tt.wantErr != nil { - require.ErrorIs(t, err, tt.wantErr) - require.Nil(t, sink) - - return - } - - require.NoError(t, err) - if tt.wantNoop { - require.IsType(t, &NoopProvisionSink{}, sink) - - return - } - - require.IsType(t, &HTTPProvisionSink{}, sink) - }) - } -} diff --git a/packages/dashboard-api/internal/teamprovision/http_sink.go b/packages/dashboard-api/internal/teamprovision/http_sink.go deleted file mode 100644 index b0c7a8df05..0000000000 --- a/packages/dashboard-api/internal/teamprovision/http_sink.go +++ /dev/null @@ -1,231 +0,0 @@ -package teamprovision - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "io" - "math/rand" - "net/http" - "strings" - "time" - - "github.com/hashicorp/go-retryablehttp" - "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" - "go.opentelemetry.io/otel/attribute" - "go.uber.org/zap" - - "github.com/e2b-dev/infra/packages/shared/pkg/logger" - sharedteamprovision "github.com/e2b-dev/infra/packages/shared/pkg/teamprovision" - "github.com/e2b-dev/infra/packages/shared/pkg/telemetry" -) - -const billingServerAPIKeyHeader = "X-Billing-Server-API-Key" - -const ( - defaultProvisionTimeout = 30 * time.Second - defaultProvisionRetryMaxAttempts = 3 - defaultProvisionRetryInitialWait = 100 * time.Millisecond - defaultProvisionRetryWaitCeiling = 2 * time.Second - defaultProvisionAttemptTimeout = defaultProvisionTimeout / defaultProvisionRetryMaxAttempts - provisionBackoffMultiplier = 2.0 - // Error responses only need enough body to extract a short API message without buffering large upstream payloads. - provisionErrorMessageReadLimit = 2 * 1024 -) - -type HTTPProvisionSink struct { - baseURL string - apiToken string - client *retryablehttp.Client - timeout time.Duration -} - -var _ TeamProvisionSink = (*HTTPProvisionSink)(nil) - -type errorResponse struct { - Message string `json:"message"` -} - -func NewHTTPProvisionSink(baseURL, apiToken string) *HTTPProvisionSink { - return &HTTPProvisionSink{ - baseURL: strings.TrimRight(baseURL, "/"), - apiToken: apiToken, - client: newRetryableProvisionClient(defaultProvisionAttemptTimeout), - timeout: defaultProvisionTimeout, - } -} - -func (s *HTTPProvisionSink) ProvisionTeam(ctx context.Context, req sharedteamprovision.TeamBillingProvisionRequestedV1) error { - baseAttrs := provisionTelemetryAttrs(req, provisionSinkHTTP) - telemetry.SetAttributes(ctx, baseAttrs...) - telemetry.ReportEvent(ctx, "team_provision.started", baseAttrs...) - - if s.baseURL == "" || s.apiToken == "" { - err := &ProvisionError{ - StatusCode: http.StatusServiceUnavailable, - Message: "billing provisioning sink is not configured", - } - failureAttrs := provisionTelemetryAttrs(req, provisionSinkHTTP, - attribute.String("team.provision.result", "failed"), - attribute.Int64("http.response.status_code", int64(err.StatusCode)), - ) - telemetry.ReportErrorByCode(ctx, err.StatusCode, "team provisioning failed", err, failureAttrs...) - - return err - } - - body, err := json.Marshal(req) - if err != nil { - telemetry.ReportCriticalError(ctx, "marshal billing provisioning request", err, baseAttrs...) - - return fmt.Errorf("marshal billing provisioning request: %w", err) - } - - retryCtx, cancel := context.WithTimeout(ctx, s.timeout) - defer cancel() - startedAt := time.Now() - resp, err := s.provisionTeamOnce(retryCtx, body) - if resp != nil { - defer resp.Body.Close() - } - - duration := time.Since(startedAt) - if err == nil && resp != nil && resp.StatusCode == http.StatusOK { - successAttrs := provisionTelemetryAttrs(req, provisionSinkHTTP, - attribute.String("team.provision.result", "success"), - attribute.Int64("http.response.status_code", int64(http.StatusOK)), - attribute.Int64("team.provision.duration_ms", duration.Milliseconds()), - ) - telemetry.ReportEvent(ctx, "team_provision.completed", successAttrs...) - - fields := append(provisionLogFields(req, provisionSinkHTTP), - zap.String("team.provision.result", "success"), - zap.Int("http.response.status_code", http.StatusOK), - zap.Duration("team.provision.duration", duration), - ) - logger.L().Info(ctx, "team provisioning completed", fields...) - - return nil - } - - provisionErr := buildProvisionError(resp, err) - failureAttrs := provisionTelemetryAttrs(req, provisionSinkHTTP, - attribute.String("team.provision.result", "failed"), - attribute.Int64("team.provision.duration_ms", duration.Milliseconds()), - attribute.Int64("http.response.status_code", int64(provisionErr.StatusCode)), - ) - telemetry.ReportErrorByCode(ctx, provisionErr.StatusCode, "team provisioning failed", provisionErr, failureAttrs...) - - return provisionErr -} - -func (s *HTTPProvisionSink) provisionTeamOnce(ctx context.Context, body []byte) (*http.Response, error) { - httpReq, err := retryablehttp.NewRequestWithContext( - ctx, - http.MethodPost, - s.baseURL+"/internal/teams/provision", - body, - ) - if err != nil { - return nil, fmt.Errorf("create billing provisioning request: %w", err) - } - - httpReq.Header.Set("Content-Type", "application/json") - httpReq.Header.Set(billingServerAPIKeyHeader, s.apiToken) - - return s.client.Do(httpReq) -} - -func buildProvisionError(resp *http.Response, err error) *ProvisionError { - if resp != nil { - message, readErr := readProvisionErrorMessage(resp) - if readErr != nil { - return &ProvisionError{ - StatusCode: http.StatusBadGateway, - Message: "billing provisioning response was unreadable", - Err: fmt.Errorf("read billing provisioning error response: %w", readErr), - } - } - - return &ProvisionError{ - StatusCode: resp.StatusCode, - Message: message, - Err: err, - } - } - - if errors.Is(err, context.DeadlineExceeded) { - return &ProvisionError{ - StatusCode: http.StatusGatewayTimeout, - Message: "billing provisioning request timed out", - Err: err, - } - } - if errors.Is(err, context.Canceled) { - return &ProvisionError{ - StatusCode: http.StatusServiceUnavailable, - Message: "billing provisioning request was canceled", - Err: err, - } - } - - return &ProvisionError{ - StatusCode: http.StatusServiceUnavailable, - Message: "billing provisioning request failed", - Err: err, - } -} - -func newRetryableProvisionClient(timeout time.Duration) *retryablehttp.Client { - client := retryablehttp.NewClient() - client.Logger = nil - client.RetryMax = defaultProvisionRetryMaxAttempts - 1 - client.RetryWaitMin = defaultProvisionRetryInitialWait - client.RetryWaitMax = defaultProvisionRetryWaitCeiling - client.ErrorHandler = retryablehttp.PassthroughErrorHandler - client.Backoff = func(minWait, maxWait time.Duration, attemptNum int, resp *http.Response) time.Duration { - if resp != nil && (resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode == http.StatusServiceUnavailable) { - return retryablehttp.DefaultBackoff(minWait, maxWait, attemptNum, resp) - } - - backoff := minWait - for range attemptNum { - backoff = time.Duration(float64(backoff) * provisionBackoffMultiplier) - if backoff > maxWait { - backoff = maxWait - - break - } - } - - if backoff > 0 { - return time.Duration(rand.Int63n(int64(backoff))) - } - - return backoff - } - client.HTTPClient.Timeout = timeout - client.HTTPClient.Transport = otelhttp.NewTransport(client.HTTPClient.Transport) - - return client -} - -func readProvisionErrorMessage(resp *http.Response) (string, error) { - body, err := io.ReadAll(io.LimitReader(resp.Body, provisionErrorMessageReadLimit)) - if err != nil { - return "", err - } - - var apiErr errorResponse - if err := json.Unmarshal(body, &apiErr); err == nil && apiErr.Message != "" { - return apiErr.Message, nil - } - - message := strings.TrimSpace(string(body)) - if message != "" { - return message, nil - } - - return http.StatusText(resp.StatusCode), nil -} diff --git a/packages/dashboard-api/internal/teamprovision/http_sink_test.go b/packages/dashboard-api/internal/teamprovision/http_sink_test.go deleted file mode 100644 index 3730bb4a7d..0000000000 --- a/packages/dashboard-api/internal/teamprovision/http_sink_test.go +++ /dev/null @@ -1,96 +0,0 @@ -package teamprovision - -import ( - "net/http" - "net/http/httptest" - "sync/atomic" - "testing" - "time" - - "github.com/google/uuid" - "github.com/stretchr/testify/require" - - sharedteamprovision "github.com/e2b-dev/infra/packages/shared/pkg/teamprovision" -) - -func TestHTTPProvisionSink_ReturnsJSONErrorMessage(t *testing.T) { - t.Parallel() - - var requestCount atomic.Int32 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - requestCount.Add(1) - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`{"message":"invalid payload"}`)) - })) - defer server.Close() - - sink := NewHTTPProvisionSink(server.URL, "token") - err := sink.ProvisionTeam(t.Context(), testProvisionRequest()) - require.Error(t, err) - - var provisionErr *ProvisionError - require.ErrorAs(t, err, &provisionErr) - require.Equal(t, http.StatusBadRequest, provisionErr.StatusCode) - require.Equal(t, "invalid payload", provisionErr.Message) - require.EqualValues(t, 1, requestCount.Load()) -} - -func TestHTTPProvisionSink_RetriesRetryableResponsesAndSucceeds(t *testing.T) { - t.Parallel() - - var requestCount atomic.Int32 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - attempt := requestCount.Add(1) - if attempt == 1 { - w.WriteHeader(http.StatusServiceUnavailable) - _, _ = w.Write([]byte("temporary outage")) - - return - } - - w.WriteHeader(http.StatusOK) - })) - defer server.Close() - - sink := NewHTTPProvisionSink(server.URL, "token") - sink.client.RetryWaitMin = time.Millisecond - sink.client.RetryWaitMax = time.Millisecond - err := sink.ProvisionTeam(t.Context(), testProvisionRequest()) - require.NoError(t, err) - require.EqualValues(t, 2, requestCount.Load()) -} - -func TestHTTPProvisionSink_RetriesRequestTimeoutWithinOverallBudget(t *testing.T) { - t.Parallel() - - var requestCount atomic.Int32 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - attempt := requestCount.Add(1) - if attempt == 1 { - time.Sleep(40 * time.Millisecond) - } - - w.WriteHeader(http.StatusOK) - })) - defer server.Close() - - sink := NewHTTPProvisionSink(server.URL, "token") - sink.timeout = 80 * time.Millisecond - sink.client.HTTPClient.Timeout = 25 * time.Millisecond - sink.client.RetryWaitMin = time.Millisecond - sink.client.RetryWaitMax = time.Millisecond - - err := sink.ProvisionTeam(t.Context(), testProvisionRequest()) - require.NoError(t, err) - require.EqualValues(t, 2, requestCount.Load()) -} - -func testProvisionRequest() sharedteamprovision.TeamBillingProvisionRequestedV1 { - return sharedteamprovision.TeamBillingProvisionRequestedV1{ - TeamID: uuid.New(), - TeamName: "Acme", - TeamEmail: "acme@example.com", - CreatorUserID: uuid.New(), - Reason: sharedteamprovision.ReasonAdditionalTeam, - } -} diff --git a/packages/dashboard-api/internal/teamprovision/noop_sink.go b/packages/dashboard-api/internal/teamprovision/noop_sink.go deleted file mode 100644 index 60df22408a..0000000000 --- a/packages/dashboard-api/internal/teamprovision/noop_sink.go +++ /dev/null @@ -1,35 +0,0 @@ -package teamprovision - -import ( - "context" - - "go.opentelemetry.io/otel/attribute" - "go.uber.org/zap" - - "github.com/e2b-dev/infra/packages/shared/pkg/logger" - sharedteamprovision "github.com/e2b-dev/infra/packages/shared/pkg/teamprovision" - "github.com/e2b-dev/infra/packages/shared/pkg/telemetry" -) - -type NoopProvisionSink struct{} - -var _ TeamProvisionSink = (*NoopProvisionSink)(nil) - -func NewNoopProvisionSink() *NoopProvisionSink { - return &NoopProvisionSink{} -} - -func (s *NoopProvisionSink) ProvisionTeam(ctx context.Context, req sharedteamprovision.TeamBillingProvisionRequestedV1) error { - attrs := provisionTelemetryAttrs(req, provisionSinkNoop, - attribute.String("team.provision.result", "skipped"), - ) - telemetry.SetAttributes(ctx, attrs...) - telemetry.ReportEvent(ctx, "team_provision.skipped", attrs...) - - fields := append(provisionLogFields(req, provisionSinkNoop), - zap.String("team.provision.result", "skipped"), - ) - logger.L().Info(ctx, "team provisioning skipped", fields...) - - return nil -} diff --git a/packages/dashboard-api/internal/teamprovision/sink.go b/packages/dashboard-api/internal/teamprovision/sink.go deleted file mode 100644 index b065d2cf9f..0000000000 --- a/packages/dashboard-api/internal/teamprovision/sink.go +++ /dev/null @@ -1,64 +0,0 @@ -package teamprovision - -import ( - "context" - "fmt" - - "go.opentelemetry.io/otel/attribute" - "go.uber.org/zap" - - "github.com/e2b-dev/infra/packages/shared/pkg/logger" - sharedteamprovision "github.com/e2b-dev/infra/packages/shared/pkg/teamprovision" - "github.com/e2b-dev/infra/packages/shared/pkg/telemetry" -) - -type TeamProvisionSink interface { - ProvisionTeam(ctx context.Context, req sharedteamprovision.TeamBillingProvisionRequestedV1) error -} - -const ( - provisionSinkHTTP = "http" - provisionSinkNoop = "noop" -) - -type ProvisionError struct { - StatusCode int - Message string - Err error -} - -func (e *ProvisionError) Error() string { - if e.Message != "" { - return e.Message - } - - return fmt.Sprintf("billing provisioning failed with status %d", e.StatusCode) -} - -func (e *ProvisionError) Unwrap() error { - if e == nil { - return nil - } - - return e.Err -} - -func provisionLogFields(req sharedteamprovision.TeamBillingProvisionRequestedV1, sink string) []zap.Field { - return []zap.Field{ - logger.WithTeamID(req.TeamID.String()), - logger.WithUserID(req.CreatorUserID.String()), - zap.String("team.provision.reason", req.Reason), - zap.String("team.provision.sink", sink), - } -} - -func provisionTelemetryAttrs(req sharedteamprovision.TeamBillingProvisionRequestedV1, sink string, attrs ...attribute.KeyValue) []attribute.KeyValue { - base := []attribute.KeyValue{ - telemetry.WithTeamID(req.TeamID.String()), - telemetry.WithUserID(req.CreatorUserID.String()), - attribute.String("team.provision.reason", req.Reason), - attribute.String("team.provision.sink", sink), - } - - return append(base, attrs...) -} diff --git a/packages/dashboard-api/main.go b/packages/dashboard-api/main.go index b3f702b251..45fe927ed0 100644 --- a/packages/dashboard-api/main.go +++ b/packages/dashboard-api/main.go @@ -30,7 +30,6 @@ import ( "github.com/e2b-dev/infra/packages/dashboard-api/internal/handlers" "github.com/e2b-dev/infra/packages/dashboard-api/internal/identity" dashboardmiddleware "github.com/e2b-dev/infra/packages/dashboard-api/internal/middleware" - internalteamprovision "github.com/e2b-dev/infra/packages/dashboard-api/internal/teamprovision" 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,18 +205,7 @@ func run() int { return 1 } - teamProvisionSink, err := internalteamprovision.NewProvisionSink( - ctx, - config.BillingServerURL, - config.BillingServerAPIToken, - ) - if err != nil { - l.Error(ctx, "initializing team provision sink", zap.Error(err)) - - return 1 - } - - apiStore := handlers.NewAPIStore(config, db, authDB, clickhouseClient, authService, identityService, teamProvisionSink) + apiStore := handlers.NewAPIStore(config, db, authDB, clickhouseClient, authService, identityService) swagger, err := api.GetSwagger() if err != nil { @@ -249,7 +237,7 @@ func run() int { nil, ) - s := newHTTPServer(config.Port, l, tel, swagger, authenticationFunc, featureFlags, apiStore) + s := newHTTPServer(config.Port, l, tel, swagger, authenticationFunc, apiStore) signalCtx, sigCancel := signal.NotifyContext(ctx, syscall.SIGTERM, syscall.SIGINT) defer sigCancel() @@ -324,7 +312,6 @@ func newHTTPServer( tel *telemetry.Client, swagger *openapi3.T, authenticationFunc openapi3filter.AuthenticationFunc, - featureFlags *featureflags.Client, store api.ServerInterface, ) *http.Server { r := gin.New() @@ -395,7 +382,6 @@ func newHTTPServer( }), ) - r.Use(dashboardmiddleware.DisableLegacyTeamMutations(featureFlags)) r.Use(dashboardmiddleware.EnforceBlockedTeam()) api.RegisterHandlers(r, store) diff --git a/packages/dashboard-api/main_test.go b/packages/dashboard-api/main_test.go deleted file mode 100644 index 6e6582619c..0000000000 --- a/packages/dashboard-api/main_test.go +++ /dev/null @@ -1,96 +0,0 @@ -package main - -import ( - "context" - "errors" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/getkin/kin-openapi/openapi3filter" - "github.com/gin-gonic/gin" - "github.com/launchdarkly/go-server-sdk/v7/testhelpers/ldtestdata" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - sharedauth "github.com/e2b-dev/infra/packages/auth/pkg/auth" - "github.com/e2b-dev/infra/packages/dashboard-api/internal/api" - "github.com/e2b-dev/infra/packages/shared/pkg/featureflags" - "github.com/e2b-dev/infra/packages/shared/pkg/logger" - "github.com/e2b-dev/infra/packages/shared/pkg/telemetry" -) - -type legacyTeamMutationTestStore struct { - api.ServerInterface - - reached bool -} - -func (store *legacyTeamMutationTestStore) PostTeams(c *gin.Context) { - store.reached = true - c.Status(http.StatusNoContent) -} - -func TestLegacyTeamMutationGateRunsAfterAuthentication(t *testing.T) { - t.Parallel() - - dataSource := ldtestdata.DataSource() - featureFlags, err := featureflags.NewClientWithDatasource(dataSource) - require.NoError(t, err) - t.Cleanup(func() { - require.NoError(t, featureFlags.Close(context.WithoutCancel(t.Context()))) - }) - swagger, err := api.GetSpec() - require.NoError(t, err) - swagger.Servers = nil - - store := &legacyTeamMutationTestStore{} - authenticationFunc := func(_ context.Context, input *openapi3filter.AuthenticationInput) error { - if input.RequestValidationInput.Request.Header.Get(sharedauth.HeaderAuthorization) == "" { - return errors.New("authentication required") - } - - return nil - } - server := newHTTPServer( - 0, - logger.NewNopLogger(), - telemetry.NewNoopClient(), - swagger, - authenticationFunc, - featureFlags, - store, - ) - - serve := func(authorization string) *httptest.ResponseRecorder { - t.Helper() - - store.reached = false - request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/teams", strings.NewReader(`{"name":"team"}`)) - request.Header.Set("Content-Type", "application/json") - if authorization != "" { - request.Header.Set(sharedauth.HeaderAuthorization, authorization) - } - response := httptest.NewRecorder() - server.Handler.ServeHTTP(response, request) - - return response - } - - response := serve("") - require.NotEqual(t, http.StatusPreconditionFailed, response.Code) - assert.False(t, store.reached) - unauthenticatedStatus := response.Code - - dataSource.Update(dataSource.Flag(featureflags.DisableLegacyTeamMutationsFlag.Key()).VariationForAll(true)) - - response = serve("") - assert.Equal(t, unauthenticatedStatus, response.Code) - assert.False(t, store.reached) - - response = serve("Bearer test-token") - assert.Equal(t, http.StatusPreconditionFailed, response.Code) - assert.JSONEq(t, `{"code":412,"message":"Legacy team mutations are no longer available. Use the workspace API."}`, response.Body.String()) - assert.False(t, store.reached) -} diff --git a/packages/dashboard-api/management_readiness_test.go b/packages/dashboard-api/management_readiness_test.go index 79767118e1..1b72de19f0 100644 --- a/packages/dashboard-api/management_readiness_test.go +++ b/packages/dashboard-api/management_readiness_test.go @@ -1,7 +1,6 @@ package main import ( - "context" "crypto/ed25519" "crypto/rand" "encoding/json" @@ -14,13 +13,11 @@ import ( jose "github.com/go-jose/go-jose/v4" "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" - "github.com/launchdarkly/go-server-sdk/v7/testhelpers/ldtestdata" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" sharedauth "github.com/e2b-dev/infra/packages/auth/pkg/auth" "github.com/e2b-dev/infra/packages/dashboard-api/internal/api" - "github.com/e2b-dev/infra/packages/shared/pkg/featureflags" "github.com/e2b-dev/infra/packages/shared/pkg/logger" "github.com/e2b-dev/infra/packages/shared/pkg/telemetry" ) @@ -47,11 +44,6 @@ func TestManagementClusterReadinessRequiresServiceJWT(t *testing.T) { t.Cleanup(keys.Close) verifier, err := sharedauth.NewJWKSVerifier(t.Context(), sharedauth.ProviderConfig{JWT: []sharedauth.JWTConfig{{Issuer: sharedauth.JWTIssuer{URL: keys.URL, Audiences: []string{"regional"}}}}}, keys.Client()) require.NoError(t, err) - data := ldtestdata.DataSource() - data.Update(data.Flag(featureflags.DisableLegacyTeamMutationsFlag.Key()).VariationForAll(true)) - flags, err := featureflags.NewClientWithDatasource(data) - require.NoError(t, err) - t.Cleanup(func() { require.NoError(t, flags.Close(context.WithoutCancel(t.Context()))) }) swagger, err := api.GetSpec() require.NoError(t, err) swagger.Servers = nil @@ -73,7 +65,7 @@ func TestManagementClusterReadinessRequiresServiceJWT(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() store := &managementReadinessStore{} - server := newHTTPServer(0, logger.NewNopLogger(), telemetry.NewNoopClient(), swagger, authenticate, flags, store) + server := newHTTPServer(0, logger.NewNopLogger(), telemetry.NewNoopClient(), swagger, authenticate, store) req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/v1/management/clusters/"+clusterID.String()+"/destroy-readiness", nil) req.Header.Set("Authorization", tc.authorization) req.Header.Set(sharedauth.HeaderAdminToken, tc.adminKey) diff --git a/packages/shared/pkg/featureflags/flags.go b/packages/shared/pkg/featureflags/flags.go index 6bf36260f5..ed6279edaa 100644 --- a/packages/shared/pkg/featureflags/flags.go +++ b/packages/shared/pkg/featureflags/flags.go @@ -371,8 +371,6 @@ var ( // cannot reach LaunchDarkly keeps the routes dark. CustomerSecretsFlag = NewBoolFlag("customer-secrets", false) - DisableLegacyTeamMutationsFlag = NewBoolFlag("disable-legacy-team-mutations", false) - // V4HeaderForUncompressedFlag forces the V4 header layout on uncompressed // uploads. Independent of compress-config: it changes the header format, // not whether data is compressed. diff --git a/packages/shared/pkg/featureflags/flags_test.go b/packages/shared/pkg/featureflags/flags_test.go index ea47adb716..a4d01a49e4 100644 --- a/packages/shared/pkg/featureflags/flags_test.go +++ b/packages/shared/pkg/featureflags/flags_test.go @@ -62,20 +62,6 @@ func TestResolveFirecrackerVersion_LegacyGoldens(t *testing.T) { } } -func TestDisableLegacyTeamMutationsFlagFallback(t *testing.T) { - t.Parallel() - - client, err := NewClientWithDatasource(ldtestdata.DataSource()) - require.NoError(t, err) - t.Cleanup(func() { - require.NoError(t, client.Close(context.WithoutCancel(t.Context()))) - }) - - assert.False(t, client.BoolFlag(t.Context(), DisableLegacyTeamMutationsFlag)) -} - -// Guards the default map shipped in this package: every entry's key must be -// the LD key its value derives to, or the lookup silently never hits. func TestFirecrackerVersionMap_KeysMatchLDKeys(t *testing.T) { t.Parallel() diff --git a/spec/openapi-dashboard.yml b/spec/openapi-dashboard.yml index a6b5b73abe..d959413264 100644 --- a/spec/openapi-dashboard.yml +++ b/spec/openapi-dashboard.yml @@ -303,12 +303,6 @@ components: application/json: schema: $ref: "#/components/schemas/Error" - "412": - description: Legacy team mutations are disabled - content: - application/json: - schema: - $ref: "#/components/schemas/Error" "500": description: Server error content: @@ -391,45 +385,6 @@ 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 - signup_ip: - type: string - signup_user_agent: - type: string - - AdminTeamBootstrapRequest: - type: object - required: - - name - - email - properties: - name: - type: string - minLength: 1 - description: Team name. - email: - type: string - format: email - description: Billing/contact email for the team. - AdminClusterCreateRequest: type: object required: @@ -480,19 +435,6 @@ components: maxLength: 1000 description: Shown to the team as the reason it is blocked. - AdminTeamClusterAssignmentRequest: - type: object - required: - - cluster_id - properties: - cluster_id: - type: string - format: uuid - preserve_existing: - type: boolean - default: false - description: Assign only when the team is unassigned or already assigned to cluster_id. - AdminTeamClusterAssignmentResponse: type: object required: @@ -879,46 +821,6 @@ components: items: $ref: "#/components/schemas/TeamMember" - UpdateTeamRequest: - type: object - minProperties: 1 - properties: - name: - type: string - minLength: 1 - maxLength: 255 - - UpdateTeamResponse: - type: object - required: - - id - - name - properties: - id: - type: string - format: uuid - name: - type: string - - AddTeamMemberRequest: - type: object - required: - - email - properties: - email: - type: string - format: email - - CreateTeamRequest: - type: object - required: - - name - properties: - name: - type: string - minLength: 1 - maxLength: 255 - DefaultTemplateAlias: type: object required: @@ -1652,94 +1554,6 @@ paths: $ref: "#/components/responses/401" "500": $ref: "#/components/responses/500" - post: - summary: Create team - tags: [teams] - security: - - AuthProviderBearerAuth: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/CreateTeamRequest" - responses: - "200": - description: Successfully created team. - content: - application/json: - schema: - $ref: "#/components/schemas/TeamResolveResponse" - "400": - $ref: "#/components/responses/400" - "401": - $ref: "#/components/responses/401" - "412": - $ref: "#/components/responses/412" - "500": - $ref: "#/components/responses/500" - - /admin/users/bootstrap: - post: - summary: Bootstrap auth provider user - tags: [teams] - security: - - AdminApiKeyAuth: [] - - AdminJWTAuth: [] - 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" - "412": - $ref: "#/components/responses/412" - "500": - $ref: "#/components/responses/500" - - /admin/teams/bootstrap: - post: - summary: Bootstrap team - description: Creates and provisions a team for an admin-authenticated bootstrap workflow. - tags: [admin] - security: - - AdminApiKeyAuth: [] - - AdminJWTAuth: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/AdminTeamBootstrapRequest" - responses: - "200": - description: Successfully bootstrapped team. - content: - application/json: - schema: - $ref: "#/components/schemas/TeamResolveResponse" - "400": - $ref: "#/components/responses/400" - "401": - $ref: "#/components/responses/401" - "412": - $ref: "#/components/responses/412" - "502": - $ref: "#/components/responses/502" - "500": - $ref: "#/components/responses/500" - /admin/clusters: post: summary: Create a cluster @@ -1843,67 +1657,7 @@ paths: $ref: "#/components/responses/404" "500": $ref: "#/components/responses/500" - put: - summary: Assign a cluster to a team - description: >- - Updates the team's cluster reference to an existing cluster. New and replacement - assignments require a tier identifier containing `enterprise`, case-insensitively. - Replaying the identical assignment succeeds even if the team's tier later changes. - tags: [admin] - security: - - AdminApiKeyAuth: [] - - AdminJWTAuth: [] - parameters: - - $ref: "#/components/parameters/teamID" - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/AdminTeamClusterAssignmentRequest" - responses: - "204": - description: Cluster assigned. - "400": - $ref: "#/components/responses/400" - "401": - $ref: "#/components/responses/401" - "404": - $ref: "#/components/responses/404" - "409": - $ref: "#/components/responses/409" - "412": - $ref: "#/components/responses/412" - "500": - $ref: "#/components/responses/500" - /admin/teams/{teamID}/cluster/{clusterID}: - delete: - summary: Detach a cluster from a team - description: Clears the assignment only when the team is unassigned or assigned to the specified cluster. - tags: [admin] - security: - - AdminApiKeyAuth: [] - - AdminJWTAuth: [] - parameters: - - $ref: "#/components/parameters/teamID" - - $ref: "#/components/parameters/clusterID" - responses: - "204": - description: Cluster detached or the team was already unassigned. - "400": - $ref: "#/components/responses/400" - "401": - $ref: "#/components/responses/401" - "404": - $ref: "#/components/responses/404" - "409": - $ref: "#/components/responses/409" - "412": - $ref: "#/components/responses/412" - "500": - $ref: "#/components/responses/500" - /admin/teams/{teamID}/ban: put: summary: Ban a team @@ -2066,31 +1820,6 @@ paths: $ref: "#/components/responses/500" /admin/users/{userId}: - delete: - summary: Delete user - description: Deletes a user by removing the identity provider record, user_identities mapping, and public.users row. - tags: [admin] - security: - - AdminApiKeyAuth: [] - - AdminJWTAuth: [] - parameters: - - $ref: "#/components/parameters/userId" - responses: - "204": - description: Successfully deleted user. - "400": - $ref: "#/components/responses/400" - "401": - $ref: "#/components/responses/401" - "404": - $ref: "#/components/responses/404" - "409": - $ref: "#/components/responses/409" - "412": - $ref: "#/components/responses/412" - "500": - $ref: "#/components/responses/500" - /teams/resolve: get: summary: Resolve team identity @@ -2117,38 +1846,6 @@ paths: $ref: "#/components/responses/500" /teams/{teamID}: - patch: - summary: Update team - tags: [teams] - security: - - AuthProviderBearerAuth: [] - AuthProviderTeamAuth: [] - parameters: - - $ref: "#/components/parameters/teamID" - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/UpdateTeamRequest" - responses: - "200": - description: Successfully updated team. - content: - application/json: - schema: - $ref: "#/components/schemas/UpdateTeamResponse" - "400": - $ref: "#/components/responses/400" - "401": - $ref: "#/components/responses/401" - "403": - $ref: "#/components/responses/403" - "412": - $ref: "#/components/responses/412" - "500": - $ref: "#/components/responses/500" - /teams/{teamID}/status: get: summary: Get team access status @@ -2223,60 +1920,7 @@ paths: $ref: "#/components/responses/403" "500": $ref: "#/components/responses/500" - post: - summary: Add team member - tags: [teams] - security: - - AuthProviderBearerAuth: [] - AuthProviderTeamAuth: [] - parameters: - - $ref: "#/components/parameters/teamID" - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/AddTeamMemberRequest" - responses: - "201": - description: Successfully added team member. - "400": - $ref: "#/components/responses/400" - "401": - $ref: "#/components/responses/401" - "403": - $ref: "#/components/responses/403" - "412": - $ref: "#/components/responses/412" - "404": - $ref: "#/components/responses/404" - "500": - $ref: "#/components/responses/500" - /teams/{teamID}/members/{userId}: - delete: - summary: Remove team member - tags: [teams] - security: - - AuthProviderBearerAuth: [] - AuthProviderTeamAuth: [] - parameters: - - $ref: "#/components/parameters/teamID" - - $ref: "#/components/parameters/userId" - responses: - "204": - description: Successfully removed team member. - "400": - $ref: "#/components/responses/400" - "401": - $ref: "#/components/responses/401" - "403": - $ref: "#/components/responses/403" - "412": - $ref: "#/components/responses/412" - "500": - $ref: "#/components/responses/500" - /templates: get: summary: List team templates