From c7347fac2d64400a2485303a9b4057f3f0cafc27 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Thu, 24 Sep 2026 14:30:01 -0700 Subject: [PATCH 1/3] refactor(dashboard-api): remove legacy team mutations --- CLAUDE.md | 2 +- docs/ARCHITECTURE.md | 11 +- .../dashboard-api/internal/api/api.gen.go | 621 ++++---------- packages/dashboard-api/internal/cfg/model.go | 3 - .../internal/handlers/admin_team_cluster.go | 38 +- .../handlers/admin_team_cluster_test.go | 678 ---------------- .../handlers/admin_teams_bootstrap.go | 43 - .../handlers/admin_teams_bootstrap_test.go | 164 ---- .../handlers/admin_users_bootstrap.go | 65 -- .../internal/handlers/admin_users_delete.go | 93 --- .../handlers/identity_unavailable_test.go | 43 - .../handlers/management_project_cluster.go | 2 +- .../dashboard-api/internal/handlers/store.go | 33 +- .../internal/handlers/team_creation.go | 52 -- .../internal/handlers/team_handlers_test.go | 759 +----------------- .../internal/handlers/team_members.go | 183 ----- .../internal/handlers/team_update.go | 113 --- .../internal/handlers/utils_provisioning.go | 51 -- .../internal/identity/creator_context.go | 23 - .../internal/identity/directory.go | 1 - .../internal/identity/identity.go | 13 - .../dashboard-api/internal/identity/ory.go | 22 - .../internal/identity/ory_test.go | 44 - .../internal/identity/service.go | 74 -- .../internal/identity/service_test.go | 96 --- .../internal/identity/unavailable.go | 10 - .../internal/identity/unavailable_test.go | 6 - .../middleware/legacy_team_mutations.go | 39 - .../middleware/legacy_team_mutations_test.go | 122 --- .../internal/provisioning/bootstrap.go | 190 ----- .../internal/provisioning/bootstrap_test.go | 408 ---------- .../internal/provisioning/creator_context.go | 58 -- .../internal/provisioning/names.go | 34 - .../internal/provisioning/names_test.go | 56 -- .../internal/provisioning/service.go | 76 -- .../internal/provisioning/service_test.go | 144 ---- .../internal/provisioning/sso.go | 55 -- .../internal/provisioning/sso_test.go | 198 ----- .../internal/provisioning/team.go | 178 ---- .../internal/provisioning/team_test.go | 210 ----- .../internal/teamprovision/factory.go | 46 -- .../internal/teamprovision/factory_test.go | 68 -- .../internal/teamprovision/http_sink.go | 231 ------ .../internal/teamprovision/http_sink_test.go | 96 --- .../internal/teamprovision/noop_sink.go | 35 - .../internal/teamprovision/sink.go | 64 -- packages/dashboard-api/main.go | 18 +- packages/dashboard-api/main_test.go | 96 --- .../management_readiness_test.go | 10 +- packages/shared/pkg/featureflags/flags.go | 2 - .../shared/pkg/featureflags/flags_test.go | 14 - packages/shared/pkg/teamprovision/request.go | 28 - spec/openapi-dashboard.yml | 354 -------- 53 files changed, 192 insertions(+), 5881 deletions(-) delete mode 100644 packages/dashboard-api/internal/handlers/admin_teams_bootstrap.go delete mode 100644 packages/dashboard-api/internal/handlers/admin_teams_bootstrap_test.go delete mode 100644 packages/dashboard-api/internal/handlers/admin_users_bootstrap.go delete mode 100644 packages/dashboard-api/internal/handlers/admin_users_delete.go delete mode 100644 packages/dashboard-api/internal/handlers/identity_unavailable_test.go delete mode 100644 packages/dashboard-api/internal/handlers/team_creation.go delete mode 100644 packages/dashboard-api/internal/handlers/team_update.go delete mode 100644 packages/dashboard-api/internal/handlers/utils_provisioning.go delete mode 100644 packages/dashboard-api/internal/identity/creator_context.go delete mode 100644 packages/dashboard-api/internal/middleware/legacy_team_mutations.go delete mode 100644 packages/dashboard-api/internal/middleware/legacy_team_mutations_test.go delete mode 100644 packages/dashboard-api/internal/provisioning/bootstrap.go delete mode 100644 packages/dashboard-api/internal/provisioning/bootstrap_test.go delete mode 100644 packages/dashboard-api/internal/provisioning/creator_context.go delete mode 100644 packages/dashboard-api/internal/provisioning/names.go delete mode 100644 packages/dashboard-api/internal/provisioning/names_test.go delete mode 100644 packages/dashboard-api/internal/provisioning/service.go delete mode 100644 packages/dashboard-api/internal/provisioning/service_test.go delete mode 100644 packages/dashboard-api/internal/provisioning/sso.go delete mode 100644 packages/dashboard-api/internal/provisioning/sso_test.go delete mode 100644 packages/dashboard-api/internal/provisioning/team.go delete mode 100644 packages/dashboard-api/internal/provisioning/team_test.go delete mode 100644 packages/dashboard-api/internal/teamprovision/factory.go delete mode 100644 packages/dashboard-api/internal/teamprovision/factory_test.go delete mode 100644 packages/dashboard-api/internal/teamprovision/http_sink.go delete mode 100644 packages/dashboard-api/internal/teamprovision/http_sink_test.go delete mode 100644 packages/dashboard-api/internal/teamprovision/noop_sink.go delete mode 100644 packages/dashboard-api/internal/teamprovision/sink.go delete mode 100644 packages/dashboard-api/main_test.go delete mode 100644 packages/shared/pkg/teamprovision/request.go 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..8390cfe474 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=", + "7H3tchw3kuCrIPo2YjwXzSZlyY61NvaHRMljzVi2QqLWF6H1keiq7G6sqoAygCLV1jDiHuKe8J7kIhNA", + "Feqzq5sflrX6Y1Nd+Egk8huJxMdZovJCSZDWzB5/nBVc8xwsaPrXshRZei5S/DsFk2hRWKHk7PHsRQrS", + "ipUAzdSK2Q0waruYzWcCvxfcbmbzmeQ5zB7X48xnGn4rhYZ09tjqEuYzk2wg5zjBSumc29njWVlSS7st", + "sK+xWsj17Pp6Xg1zrvS5hbzIuIUuaD/THzxjK5FZ0Gy5dbAxUcE8Z6F740el6995JriplvNbCXrbXU8D", + "kHgtw7CbLsCnKs/5kQHEvYWUZcJYxKqD+sUzw6xia7DMWG5LA4atlEbQ4EORqRRmj1c8MzAOqhnFvbCQ", + "mwmbMJ/l/MML1/jByUn1nWvNcdJSit9K8A1wkuv5zNhthm1w6FmFibCWfdFR4cAqJmSSlSlMRUU1Ze/K", + "/0XDavZ49j+Oa4Y4ds3M8VOc+g11n123Fj20QnOelNoo3bNA+p1psKWWkCKBIgMVGi6FKo1bsAZTKGmA", + "CckuEg2IinNu/xn284K5rRoiUT/5BKI055nIhe3C+ZJ/EHmZM1nmS8fnhCzEvIOdFaBZwdcwBIQbOIYh", + "hRUvMzt7/M3JvCY2Ie3Dr2dEXDijp61cSP+vCuVCWliDJuCTrDQW9ItnU6STbzwgn+qhbiagCq3+CxI7", + "DSTfeACkeqibgWS4TJfqwzSQfOMBkOqhxkDqgmD5ujv5WRC0lq8ZToBklWwgeT9ETDjM2MS5kD+CXNtN", + "TDBNMM65MWItc5B2OtFr4OmW1T2ZVldtJrB8PQR23fHHYW74dowZvv5mJy8013aboseN6mRPhL1bEkBt", + "uCdvyvh2/GEyCdez1qosbnMLPhql7fU/PyK7GHse7cj1Pz9avr4OW8G+wpZHlq/XkM7Z6+9PHz58+N1P", + "XKq/jjCVOZ26U35lkzcpFcYKmVhkj322CJuPMAvyw61tkwGuk03PNnEDR0IakEZYcQnMlEuHjmBXKlnJ", + "rgV7kmXqClKWbLjmiQVtGNfALvjR7xdzdnFy9B3+b4H/Ocf/HF0MLd4D1JBs/EOQbN8+mqNUtqCx6/9+", + "x49+Pzn6bnF+9Ov//JfZfHzfkDi6C32jtGVKp6CRiogWK9rE9bnOg9DimL27NPPkirPN5jOQuB3vWr9W", + "JJ34IeM/qc2vQ4si3bRbrQzoshsrE+D5NJ2KLYeAcIPcTMHjIG+yslfF8pyZrFw71jMquxxkOWy2r1r3", + "qJ6KBtd6EBXVYIdBcRcC95JnJaCMrYCrRO2CvQa0zyBlAu1iw7APSxUYJpVlObfJxjPTbyWYG6rJapF/", + "TlO9hr8ol5lIugv43snUqiHu0qUwYikyYbfsKyQE9u/MdZ8z8vLYvzOcREueDeo3P18PepdKZcBlC74D", + "1IHbatQGQfbg3GbuQgj0h0zrjyLdQ/KPkcKIOE9UVuaS5k2FhgS/HSDBa7ezLcWjL05gd5uWRdpsEv0w", + "LNlLM9Wjw5YDwsQPcjO5SoOktwHJzcJe1/NZEE4Up3h0coL/S5S0IIkAeFFkIuEI3/F/GQTyYzT+WFzj", + "udZKuzmai3zK0yC4Ztfz2aOTB3c/55PSbhC1blQGrh1O/vDuJ/9e6aVIU5Buxkd3P+NPyrKVKmXqZvzu", + "7mc8VXKViYR29Jv7oKI3oC9B1zv5zX2Q0c8FaEdBwqlikRcZ5CAtpC4i4kbACZ6kuZBId6+0uhQp6Fda", + "rURGQeVCqwK0FY7rIOci6wqD5/gz42mqwRi20ip30SYlV2JdakgZL+2GFX54lBKyzDK+zCBIgk6EdVDw", + "eF3Hnn/9lGROFLvGgXeLtFoIvaslk1tZLYzVEu0aBGQIO+ZHpd6XBa39tRcSwwirwHK/7ILrAHBeO9N2", + "EBS3VHOjOHcuZPjnhKB3L67N3qsiod9dT+FbTA5fD83RjWW3AK9mGoT81EVOT0n9D+4AMsG50mt/lLST", + "BXw8tvfkqTreMRbHiJiA/FeRQl4oC9IysknQ8mGvoTRosiUJQGqYktmWXW1AErOKPC/dUIFtK/Hhxk54", + "NoG/5jOQaaGEtDv9x7rpuc1Mn00aDIidA/mY7Hmh1YfteapyLuQkFFv1HuQUTzemBwIqWmhrIWHUqdQy", + "RODN7d9PrkV9B8FAx/hpppL3gwSrgXtt1NJoG3Ul0asKnj3jhv52HZiwSDZLHBvIzo9CNw9OvCCZjG4P", + "xeg6PEqfVPHQPwStdED2Qq5Uz6zOM3jS46tQr4pNmRU5GMvzAp1wH7yMgswVwCm3cISN+5hwJaQwm9H5", + "FBoEu2aco18fBhucfiebkSs4Eqai70xDRkedRFhIQAgoQcAvuaAZEIRK2Hen6YcjOqGks8v9jjldp5dg", + "DF/3nLN/z0VWamC5a+AEquuDTHCx4iKD9GLOlN2AvhIG2AXCeTHBCmpTX0VDjQ2u1tWGdZBE31R46KMM", + "D3zOiwJS0iYpN5ul4jplSSYQV4vICaZdQnDnM7dWhAN1jDE9zm0DAjQWuqzyidHunnkfO/Xjn5wIaVEV", + "wfWQ4U7qMz8KMyKfU26npyTgUJDSsH0pCRI+2NPxgKhVrODGBG2GPUIslLQi5cM4ZCE9If7A+VSubYgm", + "7odFWmQDvmF0vfEJG8MoW9bE0idmf+xNHZnNp6G4za+7rOUmMH3LOn319lSVsoe9T1+9ZYnSLrcnPouf", + "NWOv3z6ajUdb57NnLn53FiVItaxxF5ecTGqtAZ9g9z6ao/VX6+tEjLuQUocX6SR/rGFGTDMFQF6m/wHa", + "CDXNJHbyrvNzHbLuWuma5y+X7dXSHnVXawp+JXvRM9DBKsuzZ8K8fyN+h4FpBhYVjXKZFOWkCfvEXSCV", + "eq/Cmv3AXSjnDW1dxd8j4migoo9Negmun4z7rSGegyl4AhO2vbVqN+gEoEaEUhWiP5TDdkqaeoZeSMNm", + "PO3KGfzGjPgd2nIGrYiX4umouDnpoy8XiOua/ZSN14mXYWOG3xaz+RQRkQ8pfjeS/7zY7b8gOPVwfWj7", + "AXhmN8PbOgjKD2XO5ZEGnlIUYUPjuFwmpsGUmd0N3xhgsarv4rkod6qUKBwZdr1rtQ3LoXv24NJJ5OvI", + "dXBpZAJrSJROoX+duyVnS380IcGP7NJ9radlV9wwjy92JeymA8dOLfSZOwE55Epv+/b2JX3ZtbETafbP", + "5mvUB7sjwQJSTm7aQoMBaeO5wgDsxbPFbGSCaYeacb7GBL8oyoKP5hl00OfDLn0l0CJaaQiFJmP2CcyX", + "XPI1Hfn46NhrWAtjXVz31oLUX8K9I+HeegteuURmZ7FlP69mj9+NM2Wn69vCgLavq+Po9sYdEs/sjWP+", + "2gd4O1DcUkhgcMiQzO1CvyQUMCmQSbgCzTDTiPSEhiLjCbiQMTWa+0sMmEjC5dYltFyCdIklsuSZH1Jp", + "Vkr6Ezmy5QVnFG/uQvfLBlA8xOnmKKk0rEqDmVDCUQ27Uvp9xOixgzMQCH/t490EPKQotT0URz584I59", + "zYKdZsARRSS3fCOEgnJ4eiVVwFePjlBSWSVFwgrQR2FJof2C/ZylKMI0S0t3sAzVRxPOf0gvq9JirqZc", + "I6KNVQig27V9He5OzN7DPq+2ZRJ/UL6rk0Ny67kkHjfnH85XGuAcJeE5mkDn+XL26/W826zV4tdOokWg", + "hb8YBqsVJJTQRAlglLXkEt59wmCVL5fwLAO9YM8vQW/ZSkBGu8iXRmWlhccucm1ECg6fqKi49Scia3EJ", + "Lh+pAI3opTAS18JucrAicRlrhqkrufhP+Z/ybAPMI92wJWTqilnNk/cEyekPz0//gQYCinQhsRtaQqDN", + "nF1tRLLBGf0xPPayZEEQNNwtk+V8y5awCIhwaXUmAO7cohwYJQAaVkqkqkwpA7o9b1HqQhlg/+///F+/", + "Vs6K0myY5XoN1iV/cbbKFFkx3LrTR03Zg8YfQBbKGIGM+B6gwBFWpUVLI4XECQ6+RIJ1CDWM+2Rj7kgY", + "qnMDJ0KZ5p7ruWSc5WLtNB9h9qJNIRcE4UUvfV1QErOSwBIQGbKKQ4W9UlWSm0wZCJoPf2E8o+ZcawGG", + "CYtS0JGOW70BmRq2VHbD8tK4fyMWcga/lTz7N8Z7mks/Qy27FuwNSJKaNJQwYX99Z0rEvNqIDND6BoGW", + "OlNZGtBCWwyJkqkHWyPZiixjupQofbtSNlEyKbXG2w9eDYPpDXSNh+eiYYKldO7uYR0wmM/V6+5cjxW5", + "AeabM2x+RBESTO++soFanYzmtSnpnJog0RUxiZKwYC/5lhlhUWddUWAYCVsHVkXu9RmpQ1M5yilNybOM", + "hjLsCrLMjyAsEkIKmSBpc7VRpoYePrhj/HgaYTxLDXh74zEMwlzeH2Eb3wC4pHsr1mbnKd8esoUdid27", + "cySOAhNyw3rZdR7Yc+MOFlEgoUO6EtpYlztrNl4BLtg/oLCslFZkDAjLKRSZ2qIiBBoGNygwDzMF8PcO", + "56ri9sX+0elewPvXnCtju8SIojsQJDekNYZo7EDwNM8PIwbsHCz0jIzs840q9aFU0Re5ndB12Ho6q9T4", + "X0wVuyB/D5WKNFeg50xzYby1hmRBO+7Vo7C1VRCc8mCDOU20YM8CzwrDFPbnTIJFA3POjCLNgeoRDEu4", + "ZEsK5Kwysd5YEiEyAacxtUajRMigXOiGSo+R4ZIvvH2NAilJoLCQuuRnrQoTC5JYSgkbmQukPYL2TSHj", + "yAcarN66rL6itBZZL17yEg0SJSPsbLip7KcMVpY07qnKC67JzibVz9dcSGMZcJ2JyDuIj37yxvXQW7JH", + "exXYCNVGVNhgjFpejmqzrnAc01eTjOSXkC9BPymKbDvoxrucLSv2CL8PzOOCInbbSgf8tnvyJcx5lTUf", + "JdD7u+hDPpkwLKeJzEYUwWYtDXFn0HV0e4j9nAtroVLEPtPUmTNGlRpdSg0YDGBSySPfud+j85GjgfOs", + "SHbcEtWF+fbY4Arx3c01pgTduhP39cmjf53vjJyUyxCG2K9ra2UegnrASetqhjB6BXO9ULrfWkmZtsQL", + "Hhia2fhlK5ONVlL83nbVSAIi6lFGOQ2PkpfEMKeYtEzQPiaXxceUfVQip16Na+otp6LASy8k3mJv0grQ", + "Ie2a58rJy3zBXji3xV9cJSk/R1kcDg7mIXXaL/Uv5AkGJnCmonSKg+XqkhwL8ri86PXaItSMQCErKSax", + "KrOM2Y1W5XrDStqEhr8dNIDl78GwQkMCKaAOUkH10ZLQyuXbrkswkBV+qqRFtzPkhYf4P/NHBZFoj2nx", + "m292UnEVJtyvm+m9H3ga4h+k110mWgReQLvdYJuVQqfTeXSt7DXkWfLh/GXDDWzZFWhnsFlnk1ZUh13q", + "cJnTuLiF8IEuC69bt6gIBlKbEq6Q5g35llufsnJFU6KPSVMH3buBzCNbGBcFUBkrMi49tT86+W6O3wNA", + "ZFGXyQb/Tz8TYUJrg759OD8kfOqvVg4ntr/cdSRywFHtg6//tc82fONGeE0EOXKk3goY4c/tg4HeJI3o", + "MHJM4VZ5MJ3jvtFT8rol9quC3+2rSwaY+8icaxqjzmq+WomEyIAy9TDsMuV4xtkyr8GCxImefyjcNo+F", + "XMOkIJH5c6WhDj6gYsf4Gw3LdBiXXQmZqivG14pYRtiqTcot9/lXmZJr0HVyaK+ij0/aRq2f0I7I97bW", + "txIfnAU9vK5cSWGVdqHvlGVqvf8iDylvMpCbqAfOuM9EDvVtgbBm9Kt9p8kn28aqoth/Eup08AHuYde0", + "nUJuRFMDUP7kbucRS+NKd1w7psb1pNO+Dk0OMmOfcMUceafrhzM7sir2PsYnbw3oejRCrQDdb8x57tb8", + "yttEUZUx+MATiyaKCQcPX6G9JHF3MzLjrKJApuWZWpPa+uvuU1iCZR6WMoQJZ1/3CP40hfTptu8cbfdl", + "nZ2ZeTuHqOyogfSDnVkFwjyr/a/h09WdgPgLT69EYksNb3XzBlupxZTVhNt+TQe002w0w0vUV/Pi1bWS", + "66qJxvfbjCU1UYPJrnI96M4lhKGHYKuu7Q3BNnHvg3E7JXsbmw7B4/JARnKN3XHe6+pEdCchCPOUSwlp", + "P1EK87Q+t+0pStCAvmobjTpvgTS0sP0ygXfQ6cFpvs+mpflGFuS+qWnTlLDv8nTbt4UD7t3ELKlbSEGo", + "QmTPYp0yAYKmGX3zbLcJxD0qcjNu7BtM872ZZoiN2An0UF23mk7OY+nd+6dsN4ytzty+BMZ0ch0zqQJb", + "TTKiqjzsOqPb4aqpUWoAY0Zp72YDMa3U7piMYiLZJZzMLd2Licf8DC/GhKU9A9sbenoWLqwdmQ0vIGVG", + "yHUGR5Vdr4GnC/ZSUHIOrU/IleZHvBDsIox+ETIJKM3DNauvwmk6HjTdcFikTlIoNCTkJ/jV94QUwPRW", + "hzpQE7XKa1SVkJCzTGMW8quws52YhR5psF0OFNGy9UUjqV8VvgnTz5n/4XfQir19++KZ8/wknqzvUorT", + "cujqAEsn++fS5YWXdYS3AXI4ePBZ1DWlN0BskvZ4jnjTtW3sge91aG74NFzEYaMONjoJ5beIk+n547c1", + "aQ/GOnp48uZgT1aake3ZS3tPTP4MPbpb1cwPv8V9Grij/RP+3ImLfFVdLjp26dgOM36K8C39636SbKgs", + "Whxtq0AQxtdBwxW6I+UqlBmOgELVwV02zZ5Cs0MOU02hG6Sbt8ymPenXdZ0qYf5Ie+swG2vMSDjj67ou", + "RI/3R98m4ZWvCaWhRzCNujcyRqV3XbR34h0Evo4L/VLUb9IlE4etqSqpWlXnCs/0pVHX70eu7oxOefNr", + "OtFV2l1Ybc69f+Woxi7WM89jeupsQRdDkwl3gmfQyU1Ptx3CqdNrLF/PqVwnpEF/UIbcYurF8H7++nyd", + "jTO+fo7HsyM7Qce3ZqICQ76mVCnLMkAprSR0yqz3a686Sn7WV1X+J1UH0fsqAY9jxS+iPcsO1PxNq7IY", + "kq55eFhlHxIV5h5pdMPNS6VheOvoXE8PAEwoY0vYKpfGHcgW0rgxnUr0b+i0xwF2752lgs4x0uulTdnA", + "Edreg4u5YRd1SfWLSuRUrHpL7Ex42ydq36LWnRfMcfR95IMh22QYh1QloGej8ee6TryjMpy84zRPsjrb", + "q6BJ+yAPZ3m3EeU/pDLFzc+7Dj5Z2B25PfA0NByyTT2gqc9PJ5zcNHNp/GFnfT424XSkeYzmFxnv3hid", + "1Lezhu6EvBm6EjJ0clH1DFz0tPcSyEB3dDYmVx5xsvrMZs96rigM9Knynn7oy2Af7vR6j4ooOf/wHwdW", + "J2mBN+/diREs15NHUFdobaNsjDRGC4HwfLqQDgPuFs40bBcmZDVISi3s9g2OCVGN20L8A7ZYApTAkbPH", + "sw3wFHRgrcez/3VELY/O6E5vDQL1rEoO/v2XszDKErgG/X3YuL//chbKiJNAoa/1OBtrSe1Mg+TViyOc", + "tQ+KqIzpU5pjAB4XEzm3fjm7AYsGxm3YASI2OXrxrB4ngIjbIHwlRCssKo4ZVuytIvPsyasXs/nsMkQG", + "ZyeLB4sTBEEVIHkhZo9nDxcni5MZPfWxoV085oj9Y1+GkX4qVF8qsXO5KKXXtfWXp5q1TRMupUK7jeUq", + "Re+Q/EAVSiejIzl7pYyNq3aGF+TA2Kcq3d5aJefhOrLXTcr34bpGNfSvTx7cKSBuqt5q2h69VaiAynif", + "DE1SQX2Mjeqa6rvaPojKg+9q+11U2Xu8LTaK5QXFZTuS4h1dL27y/Tu6UGzKPOd6W9FbTW6zYJq+mxHJ", + "zn7FeVrke/yxenHt2pFwBn2VL57R7zEx8xX9N8tC9dXK3qcrnClYnmz87R+8YezuNoO8FFpRO6ZhBRpk", + "fWUCywOjGwqGGbWyRw6YdKiTkPUdHau5NDxxCY502bTQYEBfYk5j3B/BcaGejTBW6S1OWgCnjOc6+z5l", + "NDkOF4oVd/nSIaXBmafR+3XxC54Dwfa6yXG1DxRwb7HWox7x4vchIEnpKumaLw257f89+cDtCuOSlbKi", + "lnQSV5A+P14qZY3VHEMJ7W8f3UtB18dLLsf4hUotmDi/eMnlgr2oamKPUhNZM2c001Mu9yYlB+NEOsJp", + "sKAF2eqL/eng0ZS2j/4AOniLa6Jbqzzv2XU8Geh9v0e/N42i0g4zIVX6yasX7D1sXTYwvSkQXsqQ63h/", + "/y1cX6eSHpniqZOLqOytKpNNr6Iv7R+z/5/f7j8d2/thrkZHdU++xi7ulpONLhi5ci0HcTzBcC88H6qj", + "f1ZM7/bj5mzvkENX9OmDq5zi9tXfnKCb0/UFCUqnZ9WNXrrgTE5uUzBwpsniQKWE9lOjFpGnm0mi4eaE", + "ckc+RKes/yT/YVA4Ncj0ruyZT1eSjRP0oCwLBs/jj7M12L6TD1tq6evzuNBMbdzXJwahRhN8KBSmsFVt", + "Eg10osmzHtP4b9Ah19PKALstyXZy+1Q7/IjDiOMZYSu8bfjflVj/BtaT6l+iJIXptnebelv+ad28NK7s", + "GL2Kc7zcHlWR/BCNGYihvDXVqzvm6fa5D1zfmSic+GrTJAF5cvegjZH7G/eiAt7k3roH09yLiygUwk7c", + "Kd3fMy27bXIPfIX1YVZX9XzWIC03idNXatmTNv2lnPumzdYTXn86uqzK4nyOFOk3p0mSe1DiR/f+2XVk", + "E/Sr7ZgQ34b36fZT226qe1DbtyDGPII+J1J5G5HITgoZinm5bxHV4Ke6RN4QCVUne/tRDA18LtJzpasS", + "RlRkdlK/6k2RqT3CE87T2/sXou+UpHuep9kt8vx72gVfC0l+pXc779YIfTil7cMb0n6X7AeOHd/9OnRy", + "2NHrwrhyTqyqjxXYw//g+MP949hEj9qMU3z14syhlG/ug7Y6b/lMpq/W4z1fiKvPW28/cDRGWh/Dxl/v", + "Jq6nVXbvYbR1D6RFzw7uSU0pXZ8zi7ukkHvyl++MmjyOhojJPXMyRkHuQZXZHe5/68mWHiL4IX6MxVQU", + "4fBYLThuRZ+Oq3Dq8ceqdMn1sa7qJg2tuUo+ehN6+VpL+zJQNevdclCzINRkLvLQfU58dGOu8aj0hzER", + "21S05DmnSgsbDZCGHAcXKo2O/CB1nhhWMZVrw6yau+OCUAxPpnWh54yve8OkZwTDHRJWNz1uMnHR6mjp", + "i3t0dwb3v8eMqyGMttn9O9riOAQzsNX03fjYpS+fp+JzPl/NdDvHWvQi5VURWgKBitm5OivDu1yHdPaP", + "gL/BfNs7FUB9lWAmR1vcLcfPWPb0B2KIWAJljFJgCGx7J7r543Gd6b3zqMaTo6+GmUbvRmhwFWi9+MHq", + "kDw/asqrqm6tK97NfUnHMDCjkpFUgj9RBe6rGiZnd6zzY8jf/vROdXoqjk0WfYQNj8hP9HzmED05v8G5", + "ToSSSaR+HJW0GrLSIkLyFbI+WUpqV/Daj5Q8LhafqCd8i0GVvNrH6STSjjG2WtVvyI2Kx6vGFUdOb1GE", + "NA6lfZJTN1UnNAkpO/ciNt+E59g+TWJvlWDbj9Zdwr8PgnyRnpH0bGBmkEOiR2vHHZMo4pv5B7ZjEyEM", + "xL6KPZH6ZyEzIQHfKskQqphtqPZyCqmnf38I/1fv36xEZgGviM2ZAa6TjatQnaiszCVdlkXj+FJwSpUE", + "y1zkPIArlBxgj7Dy/dnC9zz35R6u53v0cWvYs4/Sdr8ek08b6i73ceDQX/rrgDOHCuwvxw47NKSN6LyW", + "AOG3lhQ49nw7zTsIYqDL7CGPUehwjd0FYPGGFHGvr48yzpnPAjB3SJGD73tPJsrO6u8zfLEH6fQQSAf0", + "STTysa6Pcz1BbbhSdNUc+MRD9GaFzwV0D550ythp4OmcCZnCB9+n+drtMO2cxTV8DpPvd2/6NIr6TTd7", + "Gvfjvxxl9FD3WfRawV70fIzNj5NQJ2t3gIQqGciqgNbUmgZTCLeqr/DJU3C3EsR0YuZrV5bwCyX3X7RE", + "3DSq2pgDibqu1dNL1ad4Cla7tfyAuj1Tifp5KLhzOFVPMGz5+t6Iv1UmaRfx04Fju1YRbQ/IBD4Zi/ZP", + "zDR07utFcC+SD+ShNdUM2qkZemauOYW5QZyNvMSUQKD3kY+Iz9w94Xnk6rg3qfja92g4uftwnat3dPdc", + "d14vdQ83lK/PHV4O6hN817067eGJR53IFb8nudIqUbVHYCyiPAf4F6lyWz51B7UHypKPlq+vj1v14qaI", + "lZ7ymM7IrH0tLCNHT6dCis+lVXXk5j0yZHKgrClOmsUa70GsFNxu9hdB+8qGuKeXRPfE7H2lL/cyoyOi", + "cPXkvnD8bXB829Jt81okEEYkweWD47x6HvRLRZTbqIhSP7fq0FPf+Zxe0ESYZgGTT7IoyYHVR9hXlw/+", + "uoho0j+JeURPYh7V5Ohuqt+ggIy/5T60Pa9hLbB1vEG3f8usns/P46Z1EN30bnigFx2NicTjnzn+XMrf", + "jFwGc1vI+CHUNU0AHqdgrFbbI5S4QoKZHKyg2rn0wF4FHQUsnNCrjyWUZkbywmwUZiy9iYWcF0ckoDoS", + "NVVUSsVVe6DJyIVH2eUsM59i7Gu7sqQjXdgZ9sKFsVSBeyrZvZ1fZVIReIUme45OTXStW8KLyWNC0JPo", + "M4fE1xUO77o21E9qHNG1zohxcx8ccyti5bnWaD12DbEno4vOSyqQXFXMWsJKkV6m3QnpnDEy7op1XTAk", + "qSp4EQBMR/RxEAf7t5zN8Uf/104TJsm4htSd3nNprsg7IX/km5MHdNeZXitvPNy8YKTjnHXgJ2I5cPKG", + "koyLPH482szrHZizS8wQwN9CcaS6WAp3ZauFDm9xcm3FiifYzVtM3rwjIyYBKr6igeNj0Vnmcx9yZlXK", + "twt2Si+vG2Y2qsxSYu4UCpD1a9QV07JSWoGPk/MgACZYNv7x8kmWjW/rXnTv2Dd/9HVTbPtgStsHh9pC", + "FZWE1KsGVXu03KFlVHHDFMvobfw2/Z3bRX4eN+kfdOm+A02faH0eXoUPe6kBS/qKzNc6uc3yn5MA8p/+", + "xFU/747xfClQpetdirjwRlZin46py6bdAl+242yYpNlM7QzrIOttVRpU5cKVhqdie3P/Wna4rcMwTEXa", + "1QSdLyzLwFIqhL8rRLW7FuyJZCpLQSPu0tIRMDANl8J49wJT+AoLaVUYiVQGqTyu1+Cl/JgCeVIU2daT", + "b6jhdS9S5lZKctEgkSpD8JrlR01ZgDaQ/imrIN0dU9K212xYlQ7cW/1NZMr+CkqRMThs4GCgytPMPhEc", + "fG+9qggUhT5vEtGZvsefRvTHkj3aV0iN3vE/SArfwN6ZHxY2arlVtAjT8KMp/716Raum6dYj+2TqcSEN", + "uwBpQRdaGLiYs4QbOBLSgDQCfbbMRR0zXvlhboyEZzEOQ+SRjliCa9+a3PnpEwx5t6wD6DyOVLbeYfOw", + "fDKC71NgCofniCmsulNrpL5Md19uQnUH7l7UuJ/tUAX+vLowGK4pa2BmK5ONVhJf2vqitRtR1mBAQxtv", + "7szp7ui4eTfKae87VARulhEtQJae8a9o0QF2CgaJz4fcEyCUKAlRWIhuRN+7We3u6t0XQ7rZaP6bWtdu", + "KLMRRcBhwM+fws7+JNQN2dlIhf5hF0gDLj09xqjdh22rx97efQzPz/hCLNfz9kb+ovR7eoC4FeyqqNe0", + "KklQ1DWENv/+yxmZLn6WQaBwXt+mrm0R/ejulTV+CGfg179e//8BAA==", } // 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 deleted file mode 100644 index a320da8513..0000000000 --- a/packages/dashboard-api/internal/handlers/identity_unavailable_test.go +++ /dev/null @@ -1,43 +0,0 @@ -package handlers - -import ( - "fmt" - "net/http" - "net/http/httptest" - "testing" - - "github.com/gin-gonic/gin" - "github.com/google/uuid" - "github.com/stretchr/testify/require" - - "github.com/e2b-dev/infra/packages/dashboard-api/internal/identity" -) - -func TestGetAdminUserProfilesUserIdWithoutIdentityProvider(t *testing.T) { - t.Parallel() - - userID := uuid.New() - recorder := httptest.NewRecorder() - ginCtx, _ := gin.CreateTestContext(recorder) - ginCtx.Request = httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/admin/user-profiles/"+userID.String(), nil) - - store := &APIStore{identityService: identity.NewUnavailableService()} - store.GetAdminUserProfilesUserId(ginCtx, userID) - - 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..3990368000 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/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" + "github.com/stretchr/testify/require" + + "github.com/e2b-dev/infra/packages/auth/pkg/auth" + authtypes "github.com/e2b-dev/infra/packages/auth/pkg/types" ) 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/identity/creator_context.go b/packages/dashboard-api/internal/identity/creator_context.go deleted file mode 100644 index 91c60faf25..0000000000 --- a/packages/dashboard-api/internal/identity/creator_context.go +++ /dev/null @@ -1,23 +0,0 @@ -package identity - -import ( - "strings" - - sharedteamprovision "github.com/e2b-dev/infra/packages/shared/pkg/teamprovision" -) - -const ( - signupIPMetadataKey = "signup_ip" - signupUserAgentMetadataKey = "signup_user_agent" -) - -func authMethodFromProviderNames(providerNames []string) string { - for _, provider := range providerNames { - provider = strings.TrimSpace(provider) - if provider != "" && !strings.EqualFold(provider, authProviderEmail) { - return sharedteamprovision.AuthMethodSocial - } - } - - return sharedteamprovision.AuthMethodPassword -} diff --git a/packages/dashboard-api/internal/identity/directory.go b/packages/dashboard-api/internal/identity/directory.go index 6961b97ed3..459e65f6f6 100644 --- a/packages/dashboard-api/internal/identity/directory.go +++ b/packages/dashboard-api/internal/identity/directory.go @@ -19,5 +19,4 @@ type Directory interface { ListIdentities(ctx context.Context, subjects []string) ([]Identity, error) SearchByEmail(ctx context.Context, email string) ([]Identity, error) SetExternalID(ctx context.Context, subject string, externalID uuid.UUID) error - DeleteIdentity(ctx context.Context, subject string) error } diff --git a/packages/dashboard-api/internal/identity/identity.go b/packages/dashboard-api/internal/identity/identity.go index c52e54c6f5..44145449d9 100644 --- a/packages/dashboard-api/internal/identity/identity.go +++ b/packages/dashboard-api/internal/identity/identity.go @@ -6,8 +6,6 @@ package identity import ( "github.com/google/uuid" - - sharedteamprovision "github.com/e2b-dev/infra/packages/shared/pkg/teamprovision" ) type Identity struct { @@ -17,9 +15,6 @@ type Identity struct { ProfilePictureURL string Providers []string OrganizationID uuid.UUID - SignupIP string - SignupUserAgent string - AuthMethod string } type Profile struct { @@ -39,11 +34,3 @@ func ProfileFromIdentity(userID uuid.UUID, id Identity) Profile { Providers: id.Providers, } } - -func CreatorContextFromIdentity(id Identity) *sharedteamprovision.CreatorContextV1 { - return &sharedteamprovision.CreatorContextV1{ - IPAddress: id.SignupIP, - UserAgent: id.SignupUserAgent, - AuthMethod: id.AuthMethod, - } -} diff --git a/packages/dashboard-api/internal/identity/ory.go b/packages/dashboard-api/internal/identity/ory.go index d193a41eae..052d237d7c 100644 --- a/packages/dashboard-api/internal/identity/ory.go +++ b/packages/dashboard-api/internal/identity/ory.go @@ -171,25 +171,6 @@ func (d *oryDirectory) SetExternalID(ctx context.Context, subject string, extern return nil } -func (d *oryDirectory) DeleteIdentity(ctx context.Context, subject string) error { - subject = strings.TrimSpace(subject) - if subject == "" { - return errors.New("ory identity subject is required") - } - - resp, err := d.identities.DeleteIdentityExecute( - d.identities.DeleteIdentity(d.authCtx(ctx), subject), - ) - if resp != nil && resp.Body != nil { - _ = resp.Body.Close() - } - if err != nil { - return fmt.Errorf("delete ory identity %s: %w", subject, err) - } - - return nil -} - func identityFromOry(oryIdentity ory.Identity) (Identity, error) { traits, _ := oryIdentity.Traits.(map[string]any) @@ -209,9 +190,6 @@ func identityFromOry(oryIdentity ory.Identity) (Identity, error) { ProfilePictureURL: metadataString(oryIdentity.MetadataPublic, "picture"), Providers: oryLinkedProviders(oryIdentity), OrganizationID: organizationID, - SignupIP: metadataString(oryIdentity.MetadataAdmin, signupIPMetadataKey), - SignupUserAgent: metadataString(oryIdentity.MetadataAdmin, signupUserAgentMetadataKey), - AuthMethod: authMethodFromProviderNames(providerNamesFromOryIdentity(oryIdentity)), }, nil } diff --git a/packages/dashboard-api/internal/identity/ory_test.go b/packages/dashboard-api/internal/identity/ory_test.go index db3f96b1a5..25beb03ec7 100644 --- a/packages/dashboard-api/internal/identity/ory_test.go +++ b/packages/dashboard-api/internal/identity/ory_test.go @@ -9,8 +9,6 @@ import ( "github.com/google/uuid" ory "github.com/ory/client-go" - - sharedteamprovision "github.com/e2b-dev/infra/packages/shared/pkg/teamprovision" ) func TestOryDirectory_SetExternalID(t *testing.T) { @@ -202,48 +200,6 @@ func TestIdentityFromOryProfileFields(t *testing.T) { } } -func TestIdentityFromOryCreatorContextUsesMetadataAdmin(t *testing.T) { - t.Parallel() - - credentials := map[string]ory.IdentityCredentials{ - "oidc": { - Config: map[string]any{ - "providers": []any{map[string]any{"provider": "github-main"}}, - }, - }, - } - oryIdentity := ory.Identity{ - Id: uuid.NewString(), - MetadataAdmin: map[string]any{ - "signup_ip": "198.51.100.20", - "signup_user_agent": "Dashboard/1.0", - "ip_address": "should-not-be-used", - "user_agent": "should-not-be-used", - }, - MetadataPublic: map[string]any{ - "signup_ip": "should-not-be-used", - "signup_user_agent": "should-not-be-used", - }, - Credentials: &credentials, - } - - id, err := identityFromOry(oryIdentity) - if err != nil { - t.Fatalf("identityFromOry returned error: %v", err) - } - - got := CreatorContextFromIdentity(id) - if got.IPAddress != "198.51.100.20" { - t.Fatalf("IPAddress = %q, want %q", got.IPAddress, "198.51.100.20") - } - if got.UserAgent != "Dashboard/1.0" { - t.Fatalf("UserAgent = %q, want %q", got.UserAgent, "Dashboard/1.0") - } - if got.AuthMethod != sharedteamprovision.AuthMethodSocial { - t.Fatalf("AuthMethod = %q, want %q", got.AuthMethod, sharedteamprovision.AuthMethodSocial) - } -} - func TestOryDirectory_GetIdentityReturnsOrganizationID(t *testing.T) { t.Parallel() diff --git a/packages/dashboard-api/internal/identity/service.go b/packages/dashboard-api/internal/identity/service.go index fe59f445bb..127bae77e6 100644 --- a/packages/dashboard-api/internal/identity/service.go +++ b/packages/dashboard-api/internal/identity/service.go @@ -8,8 +8,6 @@ import ( "strings" "github.com/google/uuid" - - sharedteamprovision "github.com/e2b-dev/infra/packages/shared/pkg/teamprovision" ) type Service interface { @@ -17,9 +15,7 @@ type Service interface { SetIdentityExternalID(ctx context.Context, issuer, subject string, externalID uuid.UUID) error ProfilesByUserID(ctx context.Context, userIDs []uuid.UUID) (map[uuid.UUID]Profile, error) UserOrganizationID(ctx context.Context, userID uuid.UUID) (uuid.UUID, error) - TeamCreatorContext(ctx context.Context, userID uuid.UUID) (*sharedteamprovision.CreatorContextV1, error) FindProfilesByEmail(ctx context.Context, email string) ([]Profile, error) - PrepareDeleteUser(ctx context.Context, userID uuid.UUID) (DeleteUserHandle, error) } type service struct { @@ -158,26 +154,6 @@ func (s *service) UserOrganizationID(ctx context.Context, userID uuid.UUID) (uui return orgID, nil } -func (s *service) TeamCreatorContext(ctx context.Context, userID uuid.UUID) (*sharedteamprovision.CreatorContextV1, error) { - if userID == uuid.Nil { - return nil, nil - } - - identities, err := s.identitiesByUserID(ctx, []uuid.UUID{userID}) - if err != nil { - return nil, err - } - - id, ok := identities[userID] - if !ok { - return nil, nil - } - - creatorContext := CreatorContextFromIdentity(id) - - return creatorContext, nil -} - func (s *service) FindProfilesByEmail(ctx context.Context, email string) ([]Profile, error) { normalized := strings.TrimSpace(email) if normalized == "" { @@ -223,56 +199,6 @@ func (s *service) FindProfilesByEmail(ctx context.Context, email string) ([]Prof return profiles, nil } -type DeleteUserHandle interface { - // Execute removes the external identities (e.g. Ory). It must be called - // only after the caller has already deleted the database rows. - Execute(ctx context.Context) error -} - -func (s *service) PrepareDeleteUser(ctx context.Context, userID uuid.UUID) (DeleteUserHandle, error) { - if userID == uuid.Nil { - return nil, errors.New("user id is required") - } - - linked, err := s.linkedIdentitiesForUsers(ctx, []uuid.UUID{userID}) - if err != nil { - return nil, err - } - if len(linked) == 0 { - return nil, fmt.Errorf("%w: no identity mapping for user %s", ErrUserNotFound, userID) - } - - targets := make([]deleteTarget, 0, len(linked)) - for _, row := range linked { - directory, err := s.directoryForIssuer(row.Issuer) - if err != nil { - return nil, err - } - targets = append(targets, deleteTarget{directory: directory, subject: row.Subject}) - } - - return &deleteUserHandle{targets: targets}, nil -} - -type deleteTarget struct { - directory Directory - subject string -} - -type deleteUserHandle struct { - targets []deleteTarget -} - -func (h *deleteUserHandle) Execute(ctx context.Context) error { - for _, target := range h.targets { - if err := target.directory.DeleteIdentity(ctx, target.subject); err != nil { - return fmt.Errorf("delete identity %s: %w", target.subject, err) - } - } - - return nil -} - func (s *service) identitiesByUserID(ctx context.Context, userIDs []uuid.UUID) (map[uuid.UUID]Identity, error) { unique := uniqueUUIDs(userIDs) if len(unique) == 0 { diff --git a/packages/dashboard-api/internal/identity/service_test.go b/packages/dashboard-api/internal/identity/service_test.go index eaf82bd463..f691b39be6 100644 --- a/packages/dashboard-api/internal/identity/service_test.go +++ b/packages/dashboard-api/internal/identity/service_test.go @@ -73,15 +73,6 @@ func (d *fakeDirectory) SetExternalID(_ context.Context, subject string, externa return nil } -func (d *fakeDirectory) DeleteIdentity(_ context.Context, subject string) error { - if d.deleteErr != nil { - return d.deleteErr - } - d.deleted = append(d.deleted, subject) - - return nil -} - type fakeLinkage struct { rows []LinkedIdentity @@ -343,42 +334,6 @@ func TestServiceUserOrganizationID_RejectsMultipleOrgs(t *testing.T) { } } -func TestServiceTeamCreatorContext(t *testing.T) { - t.Parallel() - - userID := uuid.New() - subject := uuid.NewString() - - directory := newFakeDirectory(Identity{ - Subject: subject, - SignupIP: "198.51.100.20", - SignupUserAgent: "Dashboard/1.0", - AuthMethod: "social", - }) - linkage := &fakeLinkage{rows: []LinkedIdentity{{Issuer: issuerA, Subject: subject, UserID: userID}}} - - svc, err := NewService(map[string]Directory{issuerA: directory}, linkage) - if err != nil { - t.Fatalf("failed to build service: %v", err) - } - - got, err := svc.TeamCreatorContext(t.Context(), userID) - if err != nil { - t.Fatalf("TeamCreatorContext returned error: %v", err) - } - if got == nil || got.IPAddress != "198.51.100.20" || got.UserAgent != "Dashboard/1.0" || got.AuthMethod != "social" { - t.Fatalf("unexpected creator context: %+v", got) - } - - missing, err := svc.TeamCreatorContext(t.Context(), uuid.New()) - if err != nil { - t.Fatalf("TeamCreatorContext returned error for unlinked user: %v", err) - } - if missing != nil { - t.Fatalf("expected nil creator context for unlinked user, got %+v", missing) - } -} - func TestServiceFindProfilesByEmailIntersectsLinkage(t *testing.T) { t.Parallel() @@ -410,54 +365,3 @@ func TestServiceFindProfilesByEmailIntersectsLinkage(t *testing.T) { t.Fatalf("expected user %s, got %s", userID, profiles[0].UserID) } } - -func TestServicePrepareDeleteUser(t *testing.T) { - t.Parallel() - - userID := uuid.New() - subjectA := uuid.NewString() - subjectB := uuid.NewString() - - directoryA := newFakeDirectory(Identity{Subject: subjectA}) - directoryB := newFakeDirectory(Identity{Subject: subjectB}) - linkage := &fakeLinkage{rows: []LinkedIdentity{ - {Issuer: issuerA, Subject: subjectA, UserID: userID}, - {Issuer: issuerB, Subject: subjectB, UserID: userID}, - }} - - svc, err := NewService(map[string]Directory{issuerA: directoryA, issuerB: directoryB}, linkage) - if err != nil { - t.Fatalf("failed to build service: %v", err) - } - - handle, err := svc.PrepareDeleteUser(t.Context(), userID) - if err != nil { - t.Fatalf("PrepareDeleteUser returned error: %v", err) - } - if err := handle.Execute(t.Context()); err != nil { - t.Fatalf("Execute returned error: %v", err) - } - - if len(directoryA.deleted) != 1 || directoryA.deleted[0] != subjectA { - t.Fatalf("expected subject A deleted via issuer A directory, got %v", directoryA.deleted) - } - if len(directoryB.deleted) != 1 || directoryB.deleted[0] != subjectB { - t.Fatalf("expected subject B deleted via issuer B directory, got %v", directoryB.deleted) - } -} - -func TestServicePrepareDeleteUserErrors(t *testing.T) { - t.Parallel() - - svc, err := NewService(map[string]Directory{issuerA: newFakeDirectory()}, &fakeLinkage{}) - if err != nil { - t.Fatalf("failed to build service: %v", err) - } - - if _, err := svc.PrepareDeleteUser(t.Context(), uuid.Nil); err == nil { - t.Fatal("expected error for nil user id") - } - if _, err := svc.PrepareDeleteUser(t.Context(), uuid.New()); !errors.Is(err, ErrUserNotFound) { - t.Fatalf("expected ErrUserNotFound for unlinked user, got: %v", err) - } -} diff --git a/packages/dashboard-api/internal/identity/unavailable.go b/packages/dashboard-api/internal/identity/unavailable.go index 6d1ced54c3..0ba3380f27 100644 --- a/packages/dashboard-api/internal/identity/unavailable.go +++ b/packages/dashboard-api/internal/identity/unavailable.go @@ -5,8 +5,6 @@ import ( "errors" "github.com/google/uuid" - - sharedteamprovision "github.com/e2b-dev/infra/packages/shared/pkg/teamprovision" ) // ErrNoIdentityProvider is what every method of the service returned by @@ -41,14 +39,6 @@ func (unavailableService) UserOrganizationID(_ context.Context, _ uuid.UUID) (uu return uuid.Nil, ErrNoIdentityProvider } -func (unavailableService) TeamCreatorContext(_ context.Context, _ uuid.UUID) (*sharedteamprovision.CreatorContextV1, error) { - return nil, ErrNoIdentityProvider -} - func (unavailableService) FindProfilesByEmail(_ context.Context, _ string) ([]Profile, error) { return nil, ErrNoIdentityProvider } - -func (unavailableService) PrepareDeleteUser(_ context.Context, _ uuid.UUID) (DeleteUserHandle, error) { - return nil, ErrNoIdentityProvider -} diff --git a/packages/dashboard-api/internal/identity/unavailable_test.go b/packages/dashboard-api/internal/identity/unavailable_test.go index abb4d4566a..1c5cabec94 100644 --- a/packages/dashboard-api/internal/identity/unavailable_test.go +++ b/packages/dashboard-api/internal/identity/unavailable_test.go @@ -25,12 +25,6 @@ func TestUnavailableServiceReportsNoIdentityProvider(t *testing.T) { _, err = service.UserOrganizationID(ctx, userID) require.ErrorIs(t, err, ErrNoIdentityProvider) - _, err = service.TeamCreatorContext(ctx, userID) - require.ErrorIs(t, err, ErrNoIdentityProvider) - _, err = service.FindProfilesByEmail(ctx, "user@example.com") require.ErrorIs(t, err, ErrNoIdentityProvider) - - _, err = service.PrepareDeleteUser(ctx, userID) - require.ErrorIs(t, err, ErrNoIdentityProvider) } 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/packages/shared/pkg/teamprovision/request.go b/packages/shared/pkg/teamprovision/request.go deleted file mode 100644 index 3f12c81406..0000000000 --- a/packages/shared/pkg/teamprovision/request.go +++ /dev/null @@ -1,28 +0,0 @@ -package teamprovision - -import "github.com/google/uuid" - -const ( - ReasonDefaultSignupTeam = "default_signup_team" - ReasonAdditionalTeam = "additional_team" -) - -const ( - AuthMethodPassword = "Password" - AuthMethodSocial = "Social" -) - -type CreatorContextV1 struct { - IPAddress string `json:"ip_address,omitempty"` - UserAgent string `json:"user_agent,omitempty"` - AuthMethod string `json:"auth_method,omitempty"` -} - -type TeamBillingProvisionRequestedV1 struct { - TeamID uuid.UUID `json:"team_id"` - TeamName string `json:"team_name"` - TeamEmail string `json:"team_email"` - CreatorUserID uuid.UUID `json:"creator_user_id"` - CreatorContext *CreatorContextV1 `json:"creator_context,omitempty"` - Reason string `json:"reason"` -} diff --git a/spec/openapi-dashboard.yml b/spec/openapi-dashboard.yml index a6b5b73abe..b07fa60eaf 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,8 @@ 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 +1659,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 +1822,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 +1848,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 +1922,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 From 24c3f0b911e4d7c59120820336c8fb7ea9da7d95 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Thu, 24 Sep 2026 14:30:56 -0700 Subject: [PATCH 2/3] fix(dashboard-api): retain identity read dependencies --- .../handlers/identity_unavailable_test.go | 28 ++++++ .../internal/identity/creator_context.go | 23 +++++ .../internal/identity/directory.go | 1 + .../internal/identity/identity.go | 13 +++ .../dashboard-api/internal/identity/ory.go | 22 +++++ .../internal/identity/ory_test.go | 44 +++++++++ .../internal/identity/service.go | 74 ++++++++++++++ .../internal/identity/service_test.go | 96 +++++++++++++++++++ .../internal/identity/unavailable.go | 10 ++ .../internal/identity/unavailable_test.go | 6 ++ packages/shared/pkg/teamprovision/request.go | 28 ++++++ 11 files changed, 345 insertions(+) create mode 100644 packages/dashboard-api/internal/handlers/identity_unavailable_test.go create mode 100644 packages/dashboard-api/internal/identity/creator_context.go create mode 100644 packages/shared/pkg/teamprovision/request.go diff --git a/packages/dashboard-api/internal/handlers/identity_unavailable_test.go b/packages/dashboard-api/internal/handlers/identity_unavailable_test.go new file mode 100644 index 0000000000..866ad917be --- /dev/null +++ b/packages/dashboard-api/internal/handlers/identity_unavailable_test.go @@ -0,0 +1,28 @@ +package handlers + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/e2b-dev/infra/packages/dashboard-api/internal/identity" +) + +func TestGetAdminUserProfilesUserIdWithoutIdentityProvider(t *testing.T) { + t.Parallel() + + userID := uuid.New() + recorder := httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(recorder) + ginCtx.Request = httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/admin/user-profiles/"+userID.String(), nil) + + store := &APIStore{identityService: identity.NewUnavailableService()} + store.GetAdminUserProfilesUserId(ginCtx, userID) + + 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()) +} diff --git a/packages/dashboard-api/internal/identity/creator_context.go b/packages/dashboard-api/internal/identity/creator_context.go new file mode 100644 index 0000000000..91c60faf25 --- /dev/null +++ b/packages/dashboard-api/internal/identity/creator_context.go @@ -0,0 +1,23 @@ +package identity + +import ( + "strings" + + sharedteamprovision "github.com/e2b-dev/infra/packages/shared/pkg/teamprovision" +) + +const ( + signupIPMetadataKey = "signup_ip" + signupUserAgentMetadataKey = "signup_user_agent" +) + +func authMethodFromProviderNames(providerNames []string) string { + for _, provider := range providerNames { + provider = strings.TrimSpace(provider) + if provider != "" && !strings.EqualFold(provider, authProviderEmail) { + return sharedteamprovision.AuthMethodSocial + } + } + + return sharedteamprovision.AuthMethodPassword +} diff --git a/packages/dashboard-api/internal/identity/directory.go b/packages/dashboard-api/internal/identity/directory.go index 459e65f6f6..6961b97ed3 100644 --- a/packages/dashboard-api/internal/identity/directory.go +++ b/packages/dashboard-api/internal/identity/directory.go @@ -19,4 +19,5 @@ type Directory interface { ListIdentities(ctx context.Context, subjects []string) ([]Identity, error) SearchByEmail(ctx context.Context, email string) ([]Identity, error) SetExternalID(ctx context.Context, subject string, externalID uuid.UUID) error + DeleteIdentity(ctx context.Context, subject string) error } diff --git a/packages/dashboard-api/internal/identity/identity.go b/packages/dashboard-api/internal/identity/identity.go index 44145449d9..c52e54c6f5 100644 --- a/packages/dashboard-api/internal/identity/identity.go +++ b/packages/dashboard-api/internal/identity/identity.go @@ -6,6 +6,8 @@ package identity import ( "github.com/google/uuid" + + sharedteamprovision "github.com/e2b-dev/infra/packages/shared/pkg/teamprovision" ) type Identity struct { @@ -15,6 +17,9 @@ type Identity struct { ProfilePictureURL string Providers []string OrganizationID uuid.UUID + SignupIP string + SignupUserAgent string + AuthMethod string } type Profile struct { @@ -34,3 +39,11 @@ func ProfileFromIdentity(userID uuid.UUID, id Identity) Profile { Providers: id.Providers, } } + +func CreatorContextFromIdentity(id Identity) *sharedteamprovision.CreatorContextV1 { + return &sharedteamprovision.CreatorContextV1{ + IPAddress: id.SignupIP, + UserAgent: id.SignupUserAgent, + AuthMethod: id.AuthMethod, + } +} diff --git a/packages/dashboard-api/internal/identity/ory.go b/packages/dashboard-api/internal/identity/ory.go index 052d237d7c..d193a41eae 100644 --- a/packages/dashboard-api/internal/identity/ory.go +++ b/packages/dashboard-api/internal/identity/ory.go @@ -171,6 +171,25 @@ func (d *oryDirectory) SetExternalID(ctx context.Context, subject string, extern return nil } +func (d *oryDirectory) DeleteIdentity(ctx context.Context, subject string) error { + subject = strings.TrimSpace(subject) + if subject == "" { + return errors.New("ory identity subject is required") + } + + resp, err := d.identities.DeleteIdentityExecute( + d.identities.DeleteIdentity(d.authCtx(ctx), subject), + ) + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } + if err != nil { + return fmt.Errorf("delete ory identity %s: %w", subject, err) + } + + return nil +} + func identityFromOry(oryIdentity ory.Identity) (Identity, error) { traits, _ := oryIdentity.Traits.(map[string]any) @@ -190,6 +209,9 @@ func identityFromOry(oryIdentity ory.Identity) (Identity, error) { ProfilePictureURL: metadataString(oryIdentity.MetadataPublic, "picture"), Providers: oryLinkedProviders(oryIdentity), OrganizationID: organizationID, + SignupIP: metadataString(oryIdentity.MetadataAdmin, signupIPMetadataKey), + SignupUserAgent: metadataString(oryIdentity.MetadataAdmin, signupUserAgentMetadataKey), + AuthMethod: authMethodFromProviderNames(providerNamesFromOryIdentity(oryIdentity)), }, nil } diff --git a/packages/dashboard-api/internal/identity/ory_test.go b/packages/dashboard-api/internal/identity/ory_test.go index 25beb03ec7..db3f96b1a5 100644 --- a/packages/dashboard-api/internal/identity/ory_test.go +++ b/packages/dashboard-api/internal/identity/ory_test.go @@ -9,6 +9,8 @@ import ( "github.com/google/uuid" ory "github.com/ory/client-go" + + sharedteamprovision "github.com/e2b-dev/infra/packages/shared/pkg/teamprovision" ) func TestOryDirectory_SetExternalID(t *testing.T) { @@ -200,6 +202,48 @@ func TestIdentityFromOryProfileFields(t *testing.T) { } } +func TestIdentityFromOryCreatorContextUsesMetadataAdmin(t *testing.T) { + t.Parallel() + + credentials := map[string]ory.IdentityCredentials{ + "oidc": { + Config: map[string]any{ + "providers": []any{map[string]any{"provider": "github-main"}}, + }, + }, + } + oryIdentity := ory.Identity{ + Id: uuid.NewString(), + MetadataAdmin: map[string]any{ + "signup_ip": "198.51.100.20", + "signup_user_agent": "Dashboard/1.0", + "ip_address": "should-not-be-used", + "user_agent": "should-not-be-used", + }, + MetadataPublic: map[string]any{ + "signup_ip": "should-not-be-used", + "signup_user_agent": "should-not-be-used", + }, + Credentials: &credentials, + } + + id, err := identityFromOry(oryIdentity) + if err != nil { + t.Fatalf("identityFromOry returned error: %v", err) + } + + got := CreatorContextFromIdentity(id) + if got.IPAddress != "198.51.100.20" { + t.Fatalf("IPAddress = %q, want %q", got.IPAddress, "198.51.100.20") + } + if got.UserAgent != "Dashboard/1.0" { + t.Fatalf("UserAgent = %q, want %q", got.UserAgent, "Dashboard/1.0") + } + if got.AuthMethod != sharedteamprovision.AuthMethodSocial { + t.Fatalf("AuthMethod = %q, want %q", got.AuthMethod, sharedteamprovision.AuthMethodSocial) + } +} + func TestOryDirectory_GetIdentityReturnsOrganizationID(t *testing.T) { t.Parallel() diff --git a/packages/dashboard-api/internal/identity/service.go b/packages/dashboard-api/internal/identity/service.go index 127bae77e6..fe59f445bb 100644 --- a/packages/dashboard-api/internal/identity/service.go +++ b/packages/dashboard-api/internal/identity/service.go @@ -8,6 +8,8 @@ import ( "strings" "github.com/google/uuid" + + sharedteamprovision "github.com/e2b-dev/infra/packages/shared/pkg/teamprovision" ) type Service interface { @@ -15,7 +17,9 @@ type Service interface { SetIdentityExternalID(ctx context.Context, issuer, subject string, externalID uuid.UUID) error ProfilesByUserID(ctx context.Context, userIDs []uuid.UUID) (map[uuid.UUID]Profile, error) UserOrganizationID(ctx context.Context, userID uuid.UUID) (uuid.UUID, error) + TeamCreatorContext(ctx context.Context, userID uuid.UUID) (*sharedteamprovision.CreatorContextV1, error) FindProfilesByEmail(ctx context.Context, email string) ([]Profile, error) + PrepareDeleteUser(ctx context.Context, userID uuid.UUID) (DeleteUserHandle, error) } type service struct { @@ -154,6 +158,26 @@ func (s *service) UserOrganizationID(ctx context.Context, userID uuid.UUID) (uui return orgID, nil } +func (s *service) TeamCreatorContext(ctx context.Context, userID uuid.UUID) (*sharedteamprovision.CreatorContextV1, error) { + if userID == uuid.Nil { + return nil, nil + } + + identities, err := s.identitiesByUserID(ctx, []uuid.UUID{userID}) + if err != nil { + return nil, err + } + + id, ok := identities[userID] + if !ok { + return nil, nil + } + + creatorContext := CreatorContextFromIdentity(id) + + return creatorContext, nil +} + func (s *service) FindProfilesByEmail(ctx context.Context, email string) ([]Profile, error) { normalized := strings.TrimSpace(email) if normalized == "" { @@ -199,6 +223,56 @@ func (s *service) FindProfilesByEmail(ctx context.Context, email string) ([]Prof return profiles, nil } +type DeleteUserHandle interface { + // Execute removes the external identities (e.g. Ory). It must be called + // only after the caller has already deleted the database rows. + Execute(ctx context.Context) error +} + +func (s *service) PrepareDeleteUser(ctx context.Context, userID uuid.UUID) (DeleteUserHandle, error) { + if userID == uuid.Nil { + return nil, errors.New("user id is required") + } + + linked, err := s.linkedIdentitiesForUsers(ctx, []uuid.UUID{userID}) + if err != nil { + return nil, err + } + if len(linked) == 0 { + return nil, fmt.Errorf("%w: no identity mapping for user %s", ErrUserNotFound, userID) + } + + targets := make([]deleteTarget, 0, len(linked)) + for _, row := range linked { + directory, err := s.directoryForIssuer(row.Issuer) + if err != nil { + return nil, err + } + targets = append(targets, deleteTarget{directory: directory, subject: row.Subject}) + } + + return &deleteUserHandle{targets: targets}, nil +} + +type deleteTarget struct { + directory Directory + subject string +} + +type deleteUserHandle struct { + targets []deleteTarget +} + +func (h *deleteUserHandle) Execute(ctx context.Context) error { + for _, target := range h.targets { + if err := target.directory.DeleteIdentity(ctx, target.subject); err != nil { + return fmt.Errorf("delete identity %s: %w", target.subject, err) + } + } + + return nil +} + func (s *service) identitiesByUserID(ctx context.Context, userIDs []uuid.UUID) (map[uuid.UUID]Identity, error) { unique := uniqueUUIDs(userIDs) if len(unique) == 0 { diff --git a/packages/dashboard-api/internal/identity/service_test.go b/packages/dashboard-api/internal/identity/service_test.go index f691b39be6..eaf82bd463 100644 --- a/packages/dashboard-api/internal/identity/service_test.go +++ b/packages/dashboard-api/internal/identity/service_test.go @@ -73,6 +73,15 @@ func (d *fakeDirectory) SetExternalID(_ context.Context, subject string, externa return nil } +func (d *fakeDirectory) DeleteIdentity(_ context.Context, subject string) error { + if d.deleteErr != nil { + return d.deleteErr + } + d.deleted = append(d.deleted, subject) + + return nil +} + type fakeLinkage struct { rows []LinkedIdentity @@ -334,6 +343,42 @@ func TestServiceUserOrganizationID_RejectsMultipleOrgs(t *testing.T) { } } +func TestServiceTeamCreatorContext(t *testing.T) { + t.Parallel() + + userID := uuid.New() + subject := uuid.NewString() + + directory := newFakeDirectory(Identity{ + Subject: subject, + SignupIP: "198.51.100.20", + SignupUserAgent: "Dashboard/1.0", + AuthMethod: "social", + }) + linkage := &fakeLinkage{rows: []LinkedIdentity{{Issuer: issuerA, Subject: subject, UserID: userID}}} + + svc, err := NewService(map[string]Directory{issuerA: directory}, linkage) + if err != nil { + t.Fatalf("failed to build service: %v", err) + } + + got, err := svc.TeamCreatorContext(t.Context(), userID) + if err != nil { + t.Fatalf("TeamCreatorContext returned error: %v", err) + } + if got == nil || got.IPAddress != "198.51.100.20" || got.UserAgent != "Dashboard/1.0" || got.AuthMethod != "social" { + t.Fatalf("unexpected creator context: %+v", got) + } + + missing, err := svc.TeamCreatorContext(t.Context(), uuid.New()) + if err != nil { + t.Fatalf("TeamCreatorContext returned error for unlinked user: %v", err) + } + if missing != nil { + t.Fatalf("expected nil creator context for unlinked user, got %+v", missing) + } +} + func TestServiceFindProfilesByEmailIntersectsLinkage(t *testing.T) { t.Parallel() @@ -365,3 +410,54 @@ func TestServiceFindProfilesByEmailIntersectsLinkage(t *testing.T) { t.Fatalf("expected user %s, got %s", userID, profiles[0].UserID) } } + +func TestServicePrepareDeleteUser(t *testing.T) { + t.Parallel() + + userID := uuid.New() + subjectA := uuid.NewString() + subjectB := uuid.NewString() + + directoryA := newFakeDirectory(Identity{Subject: subjectA}) + directoryB := newFakeDirectory(Identity{Subject: subjectB}) + linkage := &fakeLinkage{rows: []LinkedIdentity{ + {Issuer: issuerA, Subject: subjectA, UserID: userID}, + {Issuer: issuerB, Subject: subjectB, UserID: userID}, + }} + + svc, err := NewService(map[string]Directory{issuerA: directoryA, issuerB: directoryB}, linkage) + if err != nil { + t.Fatalf("failed to build service: %v", err) + } + + handle, err := svc.PrepareDeleteUser(t.Context(), userID) + if err != nil { + t.Fatalf("PrepareDeleteUser returned error: %v", err) + } + if err := handle.Execute(t.Context()); err != nil { + t.Fatalf("Execute returned error: %v", err) + } + + if len(directoryA.deleted) != 1 || directoryA.deleted[0] != subjectA { + t.Fatalf("expected subject A deleted via issuer A directory, got %v", directoryA.deleted) + } + if len(directoryB.deleted) != 1 || directoryB.deleted[0] != subjectB { + t.Fatalf("expected subject B deleted via issuer B directory, got %v", directoryB.deleted) + } +} + +func TestServicePrepareDeleteUserErrors(t *testing.T) { + t.Parallel() + + svc, err := NewService(map[string]Directory{issuerA: newFakeDirectory()}, &fakeLinkage{}) + if err != nil { + t.Fatalf("failed to build service: %v", err) + } + + if _, err := svc.PrepareDeleteUser(t.Context(), uuid.Nil); err == nil { + t.Fatal("expected error for nil user id") + } + if _, err := svc.PrepareDeleteUser(t.Context(), uuid.New()); !errors.Is(err, ErrUserNotFound) { + t.Fatalf("expected ErrUserNotFound for unlinked user, got: %v", err) + } +} diff --git a/packages/dashboard-api/internal/identity/unavailable.go b/packages/dashboard-api/internal/identity/unavailable.go index 0ba3380f27..6d1ced54c3 100644 --- a/packages/dashboard-api/internal/identity/unavailable.go +++ b/packages/dashboard-api/internal/identity/unavailable.go @@ -5,6 +5,8 @@ import ( "errors" "github.com/google/uuid" + + sharedteamprovision "github.com/e2b-dev/infra/packages/shared/pkg/teamprovision" ) // ErrNoIdentityProvider is what every method of the service returned by @@ -39,6 +41,14 @@ func (unavailableService) UserOrganizationID(_ context.Context, _ uuid.UUID) (uu return uuid.Nil, ErrNoIdentityProvider } +func (unavailableService) TeamCreatorContext(_ context.Context, _ uuid.UUID) (*sharedteamprovision.CreatorContextV1, error) { + return nil, ErrNoIdentityProvider +} + func (unavailableService) FindProfilesByEmail(_ context.Context, _ string) ([]Profile, error) { return nil, ErrNoIdentityProvider } + +func (unavailableService) PrepareDeleteUser(_ context.Context, _ uuid.UUID) (DeleteUserHandle, error) { + return nil, ErrNoIdentityProvider +} diff --git a/packages/dashboard-api/internal/identity/unavailable_test.go b/packages/dashboard-api/internal/identity/unavailable_test.go index 1c5cabec94..abb4d4566a 100644 --- a/packages/dashboard-api/internal/identity/unavailable_test.go +++ b/packages/dashboard-api/internal/identity/unavailable_test.go @@ -25,6 +25,12 @@ func TestUnavailableServiceReportsNoIdentityProvider(t *testing.T) { _, err = service.UserOrganizationID(ctx, userID) require.ErrorIs(t, err, ErrNoIdentityProvider) + _, err = service.TeamCreatorContext(ctx, userID) + require.ErrorIs(t, err, ErrNoIdentityProvider) + _, err = service.FindProfilesByEmail(ctx, "user@example.com") require.ErrorIs(t, err, ErrNoIdentityProvider) + + _, err = service.PrepareDeleteUser(ctx, userID) + require.ErrorIs(t, err, ErrNoIdentityProvider) } diff --git a/packages/shared/pkg/teamprovision/request.go b/packages/shared/pkg/teamprovision/request.go new file mode 100644 index 0000000000..3f12c81406 --- /dev/null +++ b/packages/shared/pkg/teamprovision/request.go @@ -0,0 +1,28 @@ +package teamprovision + +import "github.com/google/uuid" + +const ( + ReasonDefaultSignupTeam = "default_signup_team" + ReasonAdditionalTeam = "additional_team" +) + +const ( + AuthMethodPassword = "Password" + AuthMethodSocial = "Social" +) + +type CreatorContextV1 struct { + IPAddress string `json:"ip_address,omitempty"` + UserAgent string `json:"user_agent,omitempty"` + AuthMethod string `json:"auth_method,omitempty"` +} + +type TeamBillingProvisionRequestedV1 struct { + TeamID uuid.UUID `json:"team_id"` + TeamName string `json:"team_name"` + TeamEmail string `json:"team_email"` + CreatorUserID uuid.UUID `json:"creator_user_id"` + CreatorContext *CreatorContextV1 `json:"creator_context,omitempty"` + Reason string `json:"reason"` +} From 3432287e81dc288a650be5eb4c6f58e25fbae054 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Thu, 24 Sep 2026 14:48:28 -0700 Subject: [PATCH 3/3] fix(dashboard-api): clean removed route paths --- .../dashboard-api/internal/api/api.gen.go | 292 +++++++++--------- .../internal/handlers/team_handlers_test.go | 4 +- spec/openapi-dashboard.yml | 2 - 3 files changed, 148 insertions(+), 150 deletions(-) diff --git a/packages/dashboard-api/internal/api/api.gen.go b/packages/dashboard-api/internal/api/api.gen.go index 8390cfe474..d35465ac32 100644 --- a/packages/dashboard-api/internal/api/api.gen.go +++ b/packages/dashboard-api/internal/api/api.gen.go @@ -2324,152 +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{ - "7H3tchw3kuCrIPo2YjwXzSZlyY61NvaHRMljzVi2QqLWF6H1keiq7G6sqoAygCLV1jDiHuKe8J7kIhNA", - "Feqzq5sflrX6Y1Nd+Egk8huJxMdZovJCSZDWzB5/nBVc8xwsaPrXshRZei5S/DsFk2hRWKHk7PHsRQrS", - "ipUAzdSK2Q0waruYzWcCvxfcbmbzmeQ5zB7X48xnGn4rhYZ09tjqEuYzk2wg5zjBSumc29njWVlSS7st", - "sK+xWsj17Pp6Xg1zrvS5hbzIuIUuaD/THzxjK5FZ0Gy5dbAxUcE8Z6F740el6995JriplvNbCXrbXU8D", - "kHgtw7CbLsCnKs/5kQHEvYWUZcJYxKqD+sUzw6xia7DMWG5LA4atlEbQ4EORqRRmj1c8MzAOqhnFvbCQ", - "mwmbMJ/l/MML1/jByUn1nWvNcdJSit9K8A1wkuv5zNhthm1w6FmFibCWfdFR4cAqJmSSlSlMRUU1Ze/K", - "/0XDavZ49j+Oa4Y4ds3M8VOc+g11n123Fj20QnOelNoo3bNA+p1psKWWkCKBIgMVGi6FKo1bsAZTKGmA", - "CckuEg2IinNu/xn284K5rRoiUT/5BKI055nIhe3C+ZJ/EHmZM1nmS8fnhCzEvIOdFaBZwdcwBIQbOIYh", - "hRUvMzt7/M3JvCY2Ie3Dr2dEXDijp61cSP+vCuVCWliDJuCTrDQW9ItnU6STbzwgn+qhbiagCq3+CxI7", - "DSTfeACkeqibgWS4TJfqwzSQfOMBkOqhxkDqgmD5ujv5WRC0lq8ZToBklWwgeT9ETDjM2MS5kD+CXNtN", - "TDBNMM65MWItc5B2OtFr4OmW1T2ZVldtJrB8PQR23fHHYW74dowZvv5mJy8013aboseN6mRPhL1bEkBt", - "uCdvyvh2/GEyCdez1qosbnMLPhql7fU/PyK7GHse7cj1Pz9avr4OW8G+wpZHlq/XkM7Z6+9PHz58+N1P", - "XKq/jjCVOZ26U35lkzcpFcYKmVhkj322CJuPMAvyw61tkwGuk03PNnEDR0IakEZYcQnMlEuHjmBXKlnJ", - "rgV7kmXqClKWbLjmiQVtGNfALvjR7xdzdnFy9B3+b4H/Ocf/HF0MLd4D1JBs/EOQbN8+mqNUtqCx6/9+", - "x49+Pzn6bnF+9Ov//JfZfHzfkDi6C32jtGVKp6CRiogWK9rE9bnOg9DimL27NPPkirPN5jOQuB3vWr9W", - "JJ34IeM/qc2vQ4si3bRbrQzoshsrE+D5NJ2KLYeAcIPcTMHjIG+yslfF8pyZrFw71jMquxxkOWy2r1r3", - "qJ6KBtd6EBXVYIdBcRcC95JnJaCMrYCrRO2CvQa0zyBlAu1iw7APSxUYJpVlObfJxjPTbyWYG6rJapF/", - "TlO9hr8ol5lIugv43snUqiHu0qUwYikyYbfsKyQE9u/MdZ8z8vLYvzOcREueDeo3P18PepdKZcBlC74D", - "1IHbatQGQfbg3GbuQgj0h0zrjyLdQ/KPkcKIOE9UVuaS5k2FhgS/HSDBa7ezLcWjL05gd5uWRdpsEv0w", - "LNlLM9Wjw5YDwsQPcjO5SoOktwHJzcJe1/NZEE4Up3h0coL/S5S0IIkAeFFkIuEI3/F/GQTyYzT+WFzj", - "udZKuzmai3zK0yC4Ztfz2aOTB3c/55PSbhC1blQGrh1O/vDuJ/9e6aVIU5Buxkd3P+NPyrKVKmXqZvzu", - "7mc8VXKViYR29Jv7oKI3oC9B1zv5zX2Q0c8FaEdBwqlikRcZ5CAtpC4i4kbACZ6kuZBId6+0uhQp6Fda", - "rURGQeVCqwK0FY7rIOci6wqD5/gz42mqwRi20ip30SYlV2JdakgZL+2GFX54lBKyzDK+zCBIgk6EdVDw", - "eF3Hnn/9lGROFLvGgXeLtFoIvaslk1tZLYzVEu0aBGQIO+ZHpd6XBa39tRcSwwirwHK/7ILrAHBeO9N2", - "EBS3VHOjOHcuZPjnhKB3L67N3qsiod9dT+FbTA5fD83RjWW3AK9mGoT81EVOT0n9D+4AMsG50mt/lLST", - "BXw8tvfkqTreMRbHiJiA/FeRQl4oC9IysknQ8mGvoTRosiUJQGqYktmWXW1AErOKPC/dUIFtK/Hhxk54", - "NoG/5jOQaaGEtDv9x7rpuc1Mn00aDIidA/mY7Hmh1YfteapyLuQkFFv1HuQUTzemBwIqWmhrIWHUqdQy", - "RODN7d9PrkV9B8FAx/hpppL3gwSrgXtt1NJoG3Ul0asKnj3jhv52HZiwSDZLHBvIzo9CNw9OvCCZjG4P", - "xeg6PEqfVPHQPwStdED2Qq5Uz6zOM3jS46tQr4pNmRU5GMvzAp1wH7yMgswVwCm3cISN+5hwJaQwm9H5", - "FBoEu2aco18fBhucfiebkSs4Eqai70xDRkedRFhIQAgoQcAvuaAZEIRK2Hen6YcjOqGks8v9jjldp5dg", - "DF/3nLN/z0VWamC5a+AEquuDTHCx4iKD9GLOlN2AvhIG2AXCeTHBCmpTX0VDjQ2u1tWGdZBE31R46KMM", - "D3zOiwJS0iYpN5ul4jplSSYQV4vICaZdQnDnM7dWhAN1jDE9zm0DAjQWuqzyidHunnkfO/Xjn5wIaVEV", - "wfWQ4U7qMz8KMyKfU26npyTgUJDSsH0pCRI+2NPxgKhVrODGBG2GPUIslLQi5cM4ZCE9If7A+VSubYgm", - "7odFWmQDvmF0vfEJG8MoW9bE0idmf+xNHZnNp6G4za+7rOUmMH3LOn319lSVsoe9T1+9ZYnSLrcnPouf", - "NWOv3z6ajUdb57NnLn53FiVItaxxF5ecTGqtAZ9g9z6ao/VX6+tEjLuQUocX6SR/rGFGTDMFQF6m/wHa", - "CDXNJHbyrvNzHbLuWuma5y+X7dXSHnVXawp+JXvRM9DBKsuzZ8K8fyN+h4FpBhYVjXKZFOWkCfvEXSCV", - "eq/Cmv3AXSjnDW1dxd8j4migoo9Negmun4z7rSGegyl4AhO2vbVqN+gEoEaEUhWiP5TDdkqaeoZeSMNm", - "PO3KGfzGjPgd2nIGrYiX4umouDnpoy8XiOua/ZSN14mXYWOG3xaz+RQRkQ8pfjeS/7zY7b8gOPVwfWj7", - "AXhmN8PbOgjKD2XO5ZEGnlIUYUPjuFwmpsGUmd0N3xhgsarv4rkod6qUKBwZdr1rtQ3LoXv24NJJ5OvI", - "dXBpZAJrSJROoX+duyVnS380IcGP7NJ9radlV9wwjy92JeymA8dOLfSZOwE55Epv+/b2JX3ZtbETafbP", - "5mvUB7sjwQJSTm7aQoMBaeO5wgDsxbPFbGSCaYeacb7GBL8oyoKP5hl00OfDLn0l0CJaaQiFJmP2CcyX", - "XPI1Hfn46NhrWAtjXVz31oLUX8K9I+HeegteuURmZ7FlP69mj9+NM2Wn69vCgLavq+Po9sYdEs/sjWP+", - "2gd4O1DcUkhgcMiQzO1CvyQUMCmQSbgCzTDTiPSEhiLjCbiQMTWa+0sMmEjC5dYltFyCdIklsuSZH1Jp", - "Vkr6Ezmy5QVnFG/uQvfLBlA8xOnmKKk0rEqDmVDCUQ27Uvp9xOixgzMQCH/t490EPKQotT0URz584I59", - "zYKdZsARRSS3fCOEgnJ4eiVVwFePjlBSWSVFwgrQR2FJof2C/ZylKMI0S0t3sAzVRxPOf0gvq9JirqZc", - "I6KNVQig27V9He5OzN7DPq+2ZRJ/UL6rk0Ny67kkHjfnH85XGuAcJeE5mkDn+XL26/W826zV4tdOokWg", - "hb8YBqsVJJTQRAlglLXkEt59wmCVL5fwLAO9YM8vQW/ZSkBGu8iXRmWlhccucm1ECg6fqKi49Scia3EJ", - "Lh+pAI3opTAS18JucrAicRlrhqkrufhP+Z/ybAPMI92wJWTqilnNk/cEyekPz0//gQYCinQhsRtaQqDN", - "nF1tRLLBGf0xPPayZEEQNNwtk+V8y5awCIhwaXUmAO7cohwYJQAaVkqkqkwpA7o9b1HqQhlg/+///F+/", - "Vs6K0myY5XoN1iV/cbbKFFkx3LrTR03Zg8YfQBbKGIGM+B6gwBFWpUVLI4XECQ6+RIJ1CDWM+2Rj7kgY", - "qnMDJ0KZ5p7ruWSc5WLtNB9h9qJNIRcE4UUvfV1QErOSwBIQGbKKQ4W9UlWSm0wZCJoPf2E8o+ZcawGG", - "CYtS0JGOW70BmRq2VHbD8tK4fyMWcga/lTz7N8Z7mks/Qy27FuwNSJKaNJQwYX99Z0rEvNqIDND6BoGW", - "OlNZGtBCWwyJkqkHWyPZiixjupQofbtSNlEyKbXG2w9eDYPpDXSNh+eiYYKldO7uYR0wmM/V6+5cjxW5", - "AeabM2x+RBESTO++soFanYzmtSnpnJog0RUxiZKwYC/5lhlhUWddUWAYCVsHVkXu9RmpQ1M5yilNybOM", - "hjLsCrLMjyAsEkIKmSBpc7VRpoYePrhj/HgaYTxLDXh74zEMwlzeH2Eb3wC4pHsr1mbnKd8esoUdid27", - "cySOAhNyw3rZdR7Yc+MOFlEgoUO6EtpYlztrNl4BLtg/oLCslFZkDAjLKRSZ2qIiBBoGNygwDzMF8PcO", - "56ri9sX+0elewPvXnCtju8SIojsQJDekNYZo7EDwNM8PIwbsHCz0jIzs840q9aFU0Re5ndB12Ho6q9T4", - "X0wVuyB/D5WKNFeg50xzYby1hmRBO+7Vo7C1VRCc8mCDOU20YM8CzwrDFPbnTIJFA3POjCLNgeoRDEu4", - "ZEsK5Kwysd5YEiEyAacxtUajRMigXOiGSo+R4ZIvvH2NAilJoLCQuuRnrQoTC5JYSgkbmQukPYL2TSHj", - "yAcarN66rL6itBZZL17yEg0SJSPsbLip7KcMVpY07qnKC67JzibVz9dcSGMZcJ2JyDuIj37yxvXQW7JH", - "exXYCNVGVNhgjFpejmqzrnAc01eTjOSXkC9BPymKbDvoxrucLSv2CL8PzOOCInbbSgf8tnvyJcx5lTUf", - "JdD7u+hDPpkwLKeJzEYUwWYtDXFn0HV0e4j9nAtroVLEPtPUmTNGlRpdSg0YDGBSySPfud+j85GjgfOs", - "SHbcEtWF+fbY4Arx3c01pgTduhP39cmjf53vjJyUyxCG2K9ra2UegnrASetqhjB6BXO9ULrfWkmZtsQL", - "Hhia2fhlK5ONVlL83nbVSAIi6lFGOQ2PkpfEMKeYtEzQPiaXxceUfVQip16Na+otp6LASy8k3mJv0grQ", - "Ie2a58rJy3zBXji3xV9cJSk/R1kcDg7mIXXaL/Uv5AkGJnCmonSKg+XqkhwL8ri86PXaItSMQCErKSax", - "KrOM2Y1W5XrDStqEhr8dNIDl78GwQkMCKaAOUkH10ZLQyuXbrkswkBV+qqRFtzPkhYf4P/NHBZFoj2nx", - "m292UnEVJtyvm+m9H3ga4h+k110mWgReQLvdYJuVQqfTeXSt7DXkWfLh/GXDDWzZFWhnsFlnk1ZUh13q", - "cJnTuLiF8IEuC69bt6gIBlKbEq6Q5g35llufsnJFU6KPSVMH3buBzCNbGBcFUBkrMi49tT86+W6O3wNA", - "ZFGXyQb/Tz8TYUJrg759OD8kfOqvVg4ntr/cdSRywFHtg6//tc82fONGeE0EOXKk3goY4c/tg4HeJI3o", - "MHJM4VZ5MJ3jvtFT8rol9quC3+2rSwaY+8icaxqjzmq+WomEyIAy9TDsMuV4xtkyr8GCxImefyjcNo+F", - "XMOkIJH5c6WhDj6gYsf4Gw3LdBiXXQmZqivG14pYRtiqTcot9/lXmZJr0HVyaK+ij0/aRq2f0I7I97bW", - "txIfnAU9vK5cSWGVdqHvlGVqvf8iDylvMpCbqAfOuM9EDvVtgbBm9Kt9p8kn28aqoth/Eup08AHuYde0", - "nUJuRFMDUP7kbucRS+NKd1w7psb1pNO+Dk0OMmOfcMUceafrhzM7sir2PsYnbw3oejRCrQDdb8x57tb8", - "yttEUZUx+MATiyaKCQcPX6G9JHF3MzLjrKJApuWZWpPa+uvuU1iCZR6WMoQJZ1/3CP40hfTptu8cbfdl", - "nZ2ZeTuHqOyogfSDnVkFwjyr/a/h09WdgPgLT69EYksNb3XzBlupxZTVhNt+TQe002w0w0vUV/Pi1bWS", - "66qJxvfbjCU1UYPJrnI96M4lhKGHYKuu7Q3BNnHvg3E7JXsbmw7B4/JARnKN3XHe6+pEdCchCPOUSwlp", - "P1EK87Q+t+0pStCAvmobjTpvgTS0sP0ygXfQ6cFpvs+mpflGFuS+qWnTlLDv8nTbt4UD7t3ELKlbSEGo", - "QmTPYp0yAYKmGX3zbLcJxD0qcjNu7BtM872ZZoiN2An0UF23mk7OY+nd+6dsN4ytzty+BMZ0ch0zqQJb", - "TTKiqjzsOqPb4aqpUWoAY0Zp72YDMa3U7piMYiLZJZzMLd2Licf8DC/GhKU9A9sbenoWLqwdmQ0vIGVG", - "yHUGR5Vdr4GnC/ZSUHIOrU/IleZHvBDsIox+ETIJKM3DNauvwmk6HjTdcFikTlIoNCTkJ/jV94QUwPRW", - "hzpQE7XKa1SVkJCzTGMW8quws52YhR5psF0OFNGy9UUjqV8VvgnTz5n/4XfQir19++KZ8/wknqzvUorT", - "cujqAEsn++fS5YWXdYS3AXI4ePBZ1DWlN0BskvZ4jnjTtW3sge91aG74NFzEYaMONjoJ5beIk+n547c1", - "aQ/GOnp48uZgT1aake3ZS3tPTP4MPbpb1cwPv8V9Grij/RP+3ImLfFVdLjp26dgOM36K8C39636SbKgs", - "Whxtq0AQxtdBwxW6I+UqlBmOgELVwV02zZ5Cs0MOU02hG6Sbt8ymPenXdZ0qYf5Ie+swG2vMSDjj67ou", - "RI/3R98m4ZWvCaWhRzCNujcyRqV3XbR34h0Evo4L/VLUb9IlE4etqSqpWlXnCs/0pVHX70eu7oxOefNr", - "OtFV2l1Ybc69f+Woxi7WM89jeupsQRdDkwl3gmfQyU1Ptx3CqdNrLF/PqVwnpEF/UIbcYurF8H7++nyd", - "jTO+fo7HsyM7Qce3ZqICQ76mVCnLMkAprSR0yqz3a686Sn7WV1X+J1UH0fsqAY9jxS+iPcsO1PxNq7IY", - "kq55eFhlHxIV5h5pdMPNS6VheOvoXE8PAEwoY0vYKpfGHcgW0rgxnUr0b+i0xwF2752lgs4x0uulTdnA", - "Edreg4u5YRd1SfWLSuRUrHpL7Ex42ydq36LWnRfMcfR95IMh22QYh1QloGej8ee6TryjMpy84zRPsjrb", - "q6BJ+yAPZ3m3EeU/pDLFzc+7Dj5Z2B25PfA0NByyTT2gqc9PJ5zcNHNp/GFnfT424XSkeYzmFxnv3hid", - "1Lezhu6EvBm6EjJ0clH1DFz0tPcSyEB3dDYmVx5xsvrMZs96rigM9Knynn7oy2Af7vR6j4ooOf/wHwdW", - "J2mBN+/diREs15NHUFdobaNsjDRGC4HwfLqQDgPuFs40bBcmZDVISi3s9g2OCVGN20L8A7ZYApTAkbPH", - "sw3wFHRgrcez/3VELY/O6E5vDQL1rEoO/v2XszDKErgG/X3YuL//chbKiJNAoa/1OBtrSe1Mg+TViyOc", - "tQ+KqIzpU5pjAB4XEzm3fjm7AYsGxm3YASI2OXrxrB4ngIjbIHwlRCssKo4ZVuytIvPsyasXs/nsMkQG", - "ZyeLB4sTBEEVIHkhZo9nDxcni5MZPfWxoV085oj9Y1+GkX4qVF8qsXO5KKXXtfWXp5q1TRMupUK7jeUq", - "Re+Q/EAVSiejIzl7pYyNq3aGF+TA2Kcq3d5aJefhOrLXTcr34bpGNfSvTx7cKSBuqt5q2h69VaiAynif", - "DE1SQX2Mjeqa6rvaPojKg+9q+11U2Xu8LTaK5QXFZTuS4h1dL27y/Tu6UGzKPOd6W9FbTW6zYJq+mxHJ", - "zn7FeVrke/yxenHt2pFwBn2VL57R7zEx8xX9N8tC9dXK3qcrnClYnmz87R+8YezuNoO8FFpRO6ZhBRpk", - "fWUCywOjGwqGGbWyRw6YdKiTkPUdHau5NDxxCY502bTQYEBfYk5j3B/BcaGejTBW6S1OWgCnjOc6+z5l", - "NDkOF4oVd/nSIaXBmafR+3XxC54Dwfa6yXG1DxRwb7HWox7x4vchIEnpKumaLw257f89+cDtCuOSlbKi", - "lnQSV5A+P14qZY3VHEMJ7W8f3UtB18dLLsf4hUotmDi/eMnlgr2oamKPUhNZM2c001Mu9yYlB+NEOsJp", - "sKAF2eqL/eng0ZS2j/4AOniLa6Jbqzzv2XU8Geh9v0e/N42i0g4zIVX6yasX7D1sXTYwvSkQXsqQ63h/", - "/y1cX6eSHpniqZOLqOytKpNNr6Iv7R+z/5/f7j8d2/thrkZHdU++xi7ulpONLhi5ci0HcTzBcC88H6qj", - "f1ZM7/bj5mzvkENX9OmDq5zi9tXfnKCb0/UFCUqnZ9WNXrrgTE5uUzBwpsniQKWE9lOjFpGnm0mi4eaE", - "ckc+RKes/yT/YVA4Ncj0ruyZT1eSjRP0oCwLBs/jj7M12L6TD1tq6evzuNBMbdzXJwahRhN8KBSmsFVt", - "Eg10osmzHtP4b9Ah19PKALstyXZy+1Q7/IjDiOMZYSu8bfjflVj/BtaT6l+iJIXptnebelv+ad28NK7s", - "GL2Kc7zcHlWR/BCNGYihvDXVqzvm6fa5D1zfmSic+GrTJAF5cvegjZH7G/eiAt7k3roH09yLiygUwk7c", - "Kd3fMy27bXIPfIX1YVZX9XzWIC03idNXatmTNv2lnPumzdYTXn86uqzK4nyOFOk3p0mSe1DiR/f+2XVk", - "E/Sr7ZgQ34b36fZT226qe1DbtyDGPII+J1J5G5HITgoZinm5bxHV4Ke6RN4QCVUne/tRDA18LtJzpasS", - "RlRkdlK/6k2RqT3CE87T2/sXou+UpHuep9kt8vx72gVfC0l+pXc779YIfTil7cMb0n6X7AeOHd/9OnRy", - "2NHrwrhyTqyqjxXYw//g+MP949hEj9qMU3z14syhlG/ug7Y6b/lMpq/W4z1fiKvPW28/cDRGWh/Dxl/v", - "Jq6nVXbvYbR1D6RFzw7uSU0pXZ8zi7ukkHvyl++MmjyOhojJPXMyRkHuQZXZHe5/68mWHiL4IX6MxVQU", - "4fBYLThuRZ+Oq3Dq8ceqdMn1sa7qJg2tuUo+ehN6+VpL+zJQNevdclCzINRkLvLQfU58dGOu8aj0hzER", - "21S05DmnSgsbDZCGHAcXKo2O/CB1nhhWMZVrw6yau+OCUAxPpnWh54yve8OkZwTDHRJWNz1uMnHR6mjp", - "i3t0dwb3v8eMqyGMttn9O9riOAQzsNX03fjYpS+fp+JzPl/NdDvHWvQi5VURWgKBitm5OivDu1yHdPaP", - "gL/BfNs7FUB9lWAmR1vcLcfPWPb0B2KIWAJljFJgCGx7J7r543Gd6b3zqMaTo6+GmUbvRmhwFWi9+MHq", - "kDw/asqrqm6tK97NfUnHMDCjkpFUgj9RBe6rGiZnd6zzY8jf/vROdXoqjk0WfYQNj8hP9HzmED05v8G5", - "ToSSSaR+HJW0GrLSIkLyFbI+WUpqV/Daj5Q8LhafqCd8i0GVvNrH6STSjjG2WtVvyI2Kx6vGFUdOb1GE", - "NA6lfZJTN1UnNAkpO/ciNt+E59g+TWJvlWDbj9Zdwr8PgnyRnpH0bGBmkEOiR2vHHZMo4pv5B7ZjEyEM", - "xL6KPZH6ZyEzIQHfKskQqphtqPZyCqmnf38I/1fv36xEZgGviM2ZAa6TjatQnaiszCVdlkXj+FJwSpUE", - "y1zkPIArlBxgj7Dy/dnC9zz35R6u53v0cWvYs4/Sdr8ek08b6i73ceDQX/rrgDOHCuwvxw47NKSN6LyW", - "AOG3lhQ49nw7zTsIYqDL7CGPUehwjd0FYPGGFHGvr48yzpnPAjB3SJGD73tPJsrO6u8zfLEH6fQQSAf0", - "STTysa6Pcz1BbbhSdNUc+MRD9GaFzwV0D550ythp4OmcCZnCB9+n+drtMO2cxTV8DpPvd2/6NIr6TTd7", - "Gvfjvxxl9FD3WfRawV70fIzNj5NQJ2t3gIQqGciqgNbUmgZTCLeqr/DJU3C3EsR0YuZrV5bwCyX3X7RE", - "3DSq2pgDibqu1dNL1ad4Cla7tfyAuj1Tifp5KLhzOFVPMGz5+t6Iv1UmaRfx04Fju1YRbQ/IBD4Zi/ZP", - "zDR07utFcC+SD+ShNdUM2qkZemauOYW5QZyNvMSUQKD3kY+Iz9w94Xnk6rg3qfja92g4uftwnat3dPdc", - "d14vdQ83lK/PHV4O6hN817067eGJR53IFb8nudIqUbVHYCyiPAf4F6lyWz51B7UHypKPlq+vj1v14qaI", - "lZ7ymM7IrH0tLCNHT6dCis+lVXXk5j0yZHKgrClOmsUa70GsFNxu9hdB+8qGuKeXRPfE7H2lL/cyoyOi", - "cPXkvnD8bXB829Jt81okEEYkweWD47x6HvRLRZTbqIhSP7fq0FPf+Zxe0ESYZgGTT7IoyYHVR9hXlw/+", - "uoho0j+JeURPYh7V5Ohuqt+ggIy/5T60Pa9hLbB1vEG3f8usns/P46Z1EN30bnigFx2NicTjnzn+XMrf", - "jFwGc1vI+CHUNU0AHqdgrFbbI5S4QoKZHKyg2rn0wF4FHQUsnNCrjyWUZkbywmwUZiy9iYWcF0ckoDoS", - "NVVUSsVVe6DJyIVH2eUsM59i7Gu7sqQjXdgZ9sKFsVSBeyrZvZ1fZVIReIUme45OTXStW8KLyWNC0JPo", - "M4fE1xUO77o21E9qHNG1zohxcx8ccyti5bnWaD12DbEno4vOSyqQXFXMWsJKkV6m3QnpnDEy7op1XTAk", - "qSp4EQBMR/RxEAf7t5zN8Uf/104TJsm4htSd3nNprsg7IX/km5MHdNeZXitvPNy8YKTjnHXgJ2I5cPKG", - "koyLPH482szrHZizS8wQwN9CcaS6WAp3ZauFDm9xcm3FiifYzVtM3rwjIyYBKr6igeNj0Vnmcx9yZlXK", - "twt2Si+vG2Y2qsxSYu4UCpD1a9QV07JSWoGPk/MgACZYNv7x8kmWjW/rXnTv2Dd/9HVTbPtgStsHh9pC", - "FZWE1KsGVXu03KFlVHHDFMvobfw2/Z3bRX4eN+kfdOm+A02faH0eXoUPe6kBS/qKzNc6uc3yn5MA8p/+", - "xFU/747xfClQpetdirjwRlZin46py6bdAl+242yYpNlM7QzrIOttVRpU5cKVhqdie3P/Wna4rcMwTEXa", - "1QSdLyzLwFIqhL8rRLW7FuyJZCpLQSPu0tIRMDANl8J49wJT+AoLaVUYiVQGqTyu1+Cl/JgCeVIU2daT", - "b6jhdS9S5lZKctEgkSpD8JrlR01ZgDaQ/imrIN0dU9K212xYlQ7cW/1NZMr+CkqRMThs4GCgytPMPhEc", - "fG+9qggUhT5vEtGZvsefRvTHkj3aV0iN3vE/SArfwN6ZHxY2arlVtAjT8KMp/716Raum6dYj+2TqcSEN", - "uwBpQRdaGLiYs4QbOBLSgDQCfbbMRR0zXvlhboyEZzEOQ+SRjliCa9+a3PnpEwx5t6wD6DyOVLbeYfOw", - "fDKC71NgCofniCmsulNrpL5Md19uQnUH7l7UuJ/tUAX+vLowGK4pa2BmK5ONVhJf2vqitRtR1mBAQxtv", - "7szp7ui4eTfKae87VARulhEtQJae8a9o0QF2CgaJz4fcEyCUKAlRWIhuRN+7We3u6t0XQ7rZaP6bWtdu", - "KLMRRcBhwM+fws7+JNQN2dlIhf5hF0gDLj09xqjdh22rx97efQzPz/hCLNfz9kb+ovR7eoC4FeyqqNe0", - "KklQ1DWENv/+yxmZLn6WQaBwXt+mrm0R/ejulTV+CGfg179e//8BAA==", + "7H3tchw3kuCrIPo2YjwXzSZlyY61NvaHRMpjzVi2QqTWF6H1kWBVdjdWVUAZQJFqaxhxD3FPeE9ykQmg", + "CvXZ1c0Py1r9sakufCQS+Y1E4uMsUXmhJEhrZk8/zgqueQ4WNP3rshRZei5S/DsFk2hRWKHk7OnsZQrS", + "iqUAzdSS2TUwaruYzWcCvxfcrmfzmeQ5zJ7W48xnGn4rhYZ09tTqEuYzk6wh5zjBUumc29nTWVlSS7sp", + "sK+xWsjV7OZmXg1zrvS5hbzIuIUuaD/THzxjS5FZ0Oxy42BjooJ5zkL3xo9K17/zTHBTLee3EvSmu54G", + "IPFahmE3XYCPVZ7zAwOIewspy4SxiFUH9csTw6xiK7DMWG5LA4YtlUbQ4EORqRRmT5c8MzAOqhnFvbCQ", + "mwmbMJ/l/MNL1/jR0VH1nWvNcdJSit9K8A1wkpv5zNhNhm1w6FmFibCWXdFR4cAqJmSSlSlMRUU1Ze/K", + "/0XDcvZ09j8Oa4Y4dM3M4XOc+pS6z25aix5aoTlPSm2U7lkg/c402FJLSJFAkYEKDVdClcYtWIMplDTA", + "hGQXiQZExTm3/wz7ecHcVg2RqJ98AlGa80zkwnbhfMU/iLzMmSzzS8fnhCzEvIOdFaBZwVcwBIQbOIYh", + "hSUvMzt7+s3RvCY2Ie3jr2dEXDijp61cSP+vCuVCWliBJuCTrDQW9MuTKdLJNx6QT/VQtxNQhVb/BYmd", + "BpJvPABSPdTtQDJcppfqwzSQfOMBkOqhxkDqgmD5qjv5WRC0lq8YToBklawheT9ETDjM2MS5kD+CXNl1", + "TDBNMM65MWIlc5B2OtFr4OmG1T2ZVtdtJrB8NQR23fHHYW74dowZvv5mKy8013aXoseN6mRPhL07EkBt", + "uCdvyvh2/GEyCdez0qos7nILPhql7c0/PyK7GHse7cjNPz9avroJW8G+wpYHlq9WkM7Zm++PHz9+/N1P", + "XKq/jjCVOZ66U35lkzcpFcYKmVhkj122CJuPMAvyw51tkwGuk3XPNnEDB0IakEZYcQXMlJcOHcGuVLKS", + "XQv2LMvUNaQsWXPNEwvaMK6BXfCD3y/m7OLo4Dv83wL/c47/ObgYWrwHqCHZ+Icg2b59MkepbEFj1//9", + "jh/8fnTw3eL84Nf/+S+z+fi+IXF0F3qqtGVKp6CRiogWK9rE9bnOg9DimL27NPPkirPN5jOQuB3vWr9W", + "JJ34IeM/qc2vQ4si3bRdrQzoslsrE+D5NJ2KLYeAcIPcTsHjIKdZ2atiec5MVq4c6xmVXQ2yHDbbVa17", + "VE9Fg2s9iIpqsP2guA+Be8WzElDGVsBVonbB3gDaZ5AygXaxYdiHpQoMk8qynNtk7ZnptxLMLdVktcg/", + "p6lew1+Ul5lIugv43snUqiHu0pUw4lJkwm7YV0gI7N+Z6z5n5OWxf2c4iZY8G9Rvfr4e9F4qlQGXLfj2", + "UAduq1EbBNmDc5u5CyHQHzKtP4p0B8k/Rgoj4jxRWZlLmjcVGhL8tocEr93OthSPvjiB3W1aFmmzSfTD", + "sGQvzVSPDlsOCBM/yO3kKg2S3gUktwt73cxnQThRnOLJ0RH+L1HSgiQC4EWRiYQjfIf/ZRDIj9H4Y3GN", + "F1or7eZoLvI5T4Pgmt3MZ0+OHt3/nM9Ku0bUulEZuHY4+eP7n/x7pS9FmoJ0Mz65/xl/UpYtVSlTN+N3", + "9z/jsZLLTCS0o988BBWdgr4CXe/kNw9BRj8XoB0FCaeKRV5kkIO0kLqIiBsBJ3iW5kIi3b3W6kqkoF9r", + "tRQZBZULrQrQVjiug5yLrCsMXuDPjKepBmPYUqvcRZuUXIpVqSFlvLRrVvjhUUrIMsv4ZQZBEnQirIOC", + "x+s69uLr5yRzotg1DrxdpNVC6F0tmdzKamGsLtGuQUCGsGN+VOp9WdDa33ghMYywCiz3yza49gDnjTNt", + "B0FxSzW3inPnQoZ/Tgh69+La7LwqEvrd9RS+xeTw9dAc3Vh2C/BqpkHIj13k9JjU/+AOIBOcK73yR0lb", + "WcDHY3tPnqrjHWNxjIgJyH8VKeSFsiAtI5sELR/2BkqDJluSAKSGKZlt2PUaJDGryPPSDRXYthIfbuyE", + "ZxP4az4DmRZKSLvVf6ybntvM9NmkwYDYOpCPyZ4XWn3YnKcq50JOQrFV70FO8XRjeiCgooW2FhJGnUot", + "QwTe3P7d5FrUdxAMdIyfZyp5P0iwGrjXRi2NtlbXEr2q4Nkzbuhv14EJi2RziWMD2flR6ObRkRckk9Ht", + "oRhdh0fpsyoe+oeglQ7IXsql6pnVeQbPenwV6lWxKbMiB2N5XqAT7oOXUZC5AjjlFg6wcR8TLoUUZj06", + "n0KDYNuMc/Trw2CD029lM3IFR8JU9J1pyOiokwgLCQgBJQj4FRc0A4JQCfvuNP1wRCeUdHa52zGn6/QK", + "jOGrnnP277nISg0sdw2cQHV9kAkullxkkF7MmbJr0NfCALtAOC8mWEFt6qtoqLHB1brasA6S6GmFhz7K", + "8MDnvCggJW2ScrO+VFynLMkE4moROcG0SwjufObWinCgjjGmx7ltQIDGQpdVPjHa3THvY6t+/JMTIS2q", + "IrgeMtxKfeZHYUbkc8rt9JQEHApSGrYvJUHCB3s8HhC1ihXcmKDNsEeIhZJWpHwYhyykJ8QfOJ/KtQ3R", + "xN2wSItswDeMrlOfsDGMssuaWPrE7I+9qSOz+TQUt/l1m7XcBKZvWcev3x6rUvaw9/HrtyxR2uX2xGfx", + "s2bs9dsns/Fo63x24uJ3Z1GCVMsad3HJyaTWGvAZdu+jOVp/tb5OxLgLKXV4mU7yxxpmxDRTAORV+h+g", + "jVDTTGIn7zo/1yHrrpWuef7qsr1a2qPuak3Br2UvegY6WGV5diLM+1PxOwxMM7CoaJSrpCgnTdgn7gKp", + "1HsV1uwH7kI5b2jrKv4eEUcDFX1s0ktw/WTcbw3xHEzBE5iw7a1Vu0EnADUilKoQ/b4ctlXS1DP0Qho2", + "43lXzuA3ZsTv0JYzaEW8Es9Hxc1RH325QFzX7KdsvE68DBsz/LaYzaeIiHxI8buR/OfFdv8FwamH60Pb", + "D8Azux7e1kFQfihzLg808JSiCGsax+UyMQ2mzOx2+MYAi1V9F89FuVWlROHIsOtdq21YDj2wB5dOIl9H", + "roNLIxNYQ6J0Cv3r3C45W/qjCQl+ZFfuaz0tu+aGeXyxa2HXHTi2aqHP3AnIIVd607e3r+jLto2dSLN/", + "Nl+jPtgdCRaQcnLTFhoMSBvPFQZgL08Ws5EJph1qxvkaE/yiKAs+mmfQQZ8Pu/SVQItopSEUmozZJzBf", + "cclXdOTjo2NvYCWMdXHdOwtSfwn3joR76y147RKZncWW/bycPX03zpSdrm8LA9q+qY6j2xu3TzyzN475", + "ax/g7UBxSyGBwSFDMrcL/ZJQwKRAJuEaNMNMI9ITGoqMJ+BCxtRo7i8xYCIJlxuX0HIF0iWWyJJnfkil", + "WSnpT+TIlhecUby5C90va0DxEKebo6TSsCwNZkIJRzXsWun3EaPHDs5AIPyNj3cT8JCi1PZQHPjwgTv2", + "NQt2nAFHFJHc8o0QCsrh6ZVUAV89OkJJZZUUCStAH4QlhfYL9nOWogjTLC3dwTJUH004/yG9rEqLuZpy", + "hYg2ViGAbtd2dbg7MXsP+7zalkn8QfmuTg7JjeeSeNycfzhfaoBzlITnaAKd55ezX2/m3WatFr92Ei0C", + "LfzFMFguIaGEJkoAo6wll/DuEwarfLmEZxnoBXtxBXrDlgIy2kV+aVRWWnjqItdGpODwiYqKW38ishJX", + "4PKRCtCIXgojcS3sOgcrEpexZpi6lov/lP8pz9bAPNINu4RMXTOrefKeIDn+4cXxP9BAQJEuJHZDSwi0", + "mbPrtUjWOKM/hsdeliwIgoa7ZbKcb9glLAIiXFqdCYA7tygHRgmAhpUSqSpTyoBuz1uUulAG2P/7P//X", + "r5WzojRrZrlegXXJX5wtM0VWDLfu9FFT9qDxB5CFMkYgI74HKHCEZWnR0kghcYKDXyLBOoQaxn2yMXck", + "DNW5gROhTHPP9VwyznKxcpqPMHvRppALgvCil74uKIlZSWAJiAxZxaHCXqsqyU2mDATNh78wnlFzrrUA", + "w4RFKehIx63egEwNu1R2zfLSuH8jFnIGv5U8+zfGe5pLP0MtuxbsFCRJTRpKmLC/vjMlYl6vRQZofYNA", + "S52pLA1ooS2GRMnUg62RbEWWMV1KlL5dKZsomZRa4+0Hr4bB9Aa6xsNz0TDBUjp397D2GMzn6nV3rseK", + "XAPzzRk2P6AICaZ3X9tArU5G89qUdE5NkOiKmERJWLBXfMOMsKizrikwjIStA6si9/qM1KGpHOWUpuRZ", + "RkMZdg1Z5kcQFgkhhUyQtLleK1NDDx/cMX48jTCepQa8vfEYBmEu74+wjW8AXNG9FWuz85Rv9tnCjsTu", + "3TkSR4EJuWG97DoP7Ll2B4sokNAhXQptrMudNWuvABfsH1BYVkorMgaE5RSKTG1QEQINgxsUmIeZAvh7", + "h3NVcfti9+h0L+D9a86VsV1iRNEdCJIb0hpDNLYneJrn+xEDdg4WekZG9vlalXpfquiL3E7oOmw9nVVq", + "/C+mil2Qv4dKRZpr0HOmuTDeWkOyoB336lHY2ioITnmwwZwmWrCTwLPCMIX9OZNg0cCcM6NIc6B6BMMS", + "LtklBXKWmVitLYkQmYDTmFqjUSJkUC50Q6XHyHDJF96+RoGUJFBYSF3ys1aFiQVJLKWEjcwF0h5B+6aQ", + "ceQDDVZvXFZfUVqLrBcv+RINEiUj7Ky5qeynDJaWNO6xyguuyc4m1c9XXEhjGXCdicg7iI9+8sb10Duy", + "R3sV2AjVRlTYYIxaXo5qs65wHNNXk4zkV5Bfgn5WFNlm0I13OVtW7BB+H5jHBUXsppUO+G335EuY8ypr", + "Pkqg93fRh3wyYVhOE5m1KILNWhrizqDr6PYQ+zkX1kKliH2mqTNnjCo1upQaMBjApJIHvnO/R+cjRwPn", + "WZHsuCOqC/PtsMEV4ruba0wJunUn7uujJ/863xo5KS9DGGK3rq2VeQjqASetqxnC6BXM9ULpfmslZdoS", + "L3hgaGbjl41M1lpJ8XvbVSMJiKhHGeU0PEpeEsOcYtIyQfuYXBYfU/ZRiZx6Na6pt5yKAi+9kHiLvUkr", + "QIe0a54rJy/zBXvp3BZ/cZWk/BxlcTg4mIfUab/Uv5AnGJjAmYrSKQ6WqytyLMjj8qLXa4tQMwKFrKSY", + "xLLMMmbXWpWrNStpExr+dtAAlr8HwwoNCaSAOkgF1UdLQiuXb7ouwUBW+LGSFt3OkBce4v/MHxVEoj2m", + "xW++2UrFVZhwt26m937gcYh/kF53mWgReAHtdo1tlgqdTufRtbLXkGfJh/OXDdewYdegncFmnU1aUR12", + "qcNlTuPiFsIHuiy8at2iIhhIbUq4Rpo35FtufMrKNU2JPiZNHXTvGjKPbGFcFEBlrMi49NT+5Oi7OX4P", + "AJFFXSZr/D/9TIQJrQ369vF8n/Cpv1o5nNj+atuRyB5HtY++/tc+2/DUjfCGCHLkSL0VMMKf2wcDvUka", + "0WHkmMKt8mA6x32jp+R1S+xXBb/bV5cMMPeROdc0Rp3VfLkUCZEBZeph2GXK8YyzZd6ABYkTvfhQuG0e", + "C7mGSUEi8+dKQx18QMWO8TcalukwLrsWMlXXjK8UsYywVZuUW+7zrzIlV6Dr5NBeRR+ftI1aP6Edke9d", + "rW8pPjgLenhduZLCKu1C3ynL1Gr3Re5T3mQgN1EPnHGfiRzq2wJhzehX+06TT7aNVUWx+yTUae8D3P2u", + "aTuF3IimBqD8yd3WI5bGle64dkyN60mnfR2aHGTGPuGKOfJO1w9ndmRV7H2MT94a0PVohFoBut+Y89yt", + "+bW3iaIqY/CBJxZNFBMOHr5Ce0ni7mZkxllFgUzLM7UitfXX7aewBMs8LGUIE86+7hH8aQrp803fOdr2", + "yzpbM/O2DlHZUQPpB1uzCoQ5qf2v4dPVrYD4C0+vRWJLDW918wZbqcWU1YTbfk0HtNNsNMNL1Ffz4tW1", + "kuuqicb324wlNVGDya5yPejWJYShh2Crru0NwTZx74NxOyV7G5sOwePyQEZyjd1x3pvqRHQrIQjznEsJ", + "aT9RCvO8PrftKUrQgL5qG406b4E0tLDdMoG30Oneab4n09J8Iwty19S0aUrYd3m+6dvCAfduYpbUHaQg", + "VCGyk1inTICgaUbfPtttAnGPityMG3uKab630wyxETuBHqrrVtPJeSy9e/eU7Yax1Znbl8CYTq5jJlVg", + "q0lGVJWHXWd0O1w1NUoNYMwo7d1sIKaV2h2TUUwk24STuaN7MfGYn+HFmLC0E7C9oaeTcGHtwKx5ASkz", + "Qq4yOKjseg08XbBXgpJzaH1CLjU/4IVgF2H0i5BJQGkerll9FU7T8aDphsMidZJCoSEhP8GvviekAKa3", + "OtSemqhVXqOqhIScZRqzkF+Fne3ELPRIg21zoIiWrS8aSf2q8E2Yfs78D7+DVuzt25cnzvOTeLK+TSlO", + "y6GrAyyd7J8rlxde1hHeBsjh4MFnUdeU3gCxSdrjOeJN17axB77Xvrnh03ARh4062OgklN8hTqbnj9/V", + "pD0Y6+jhyZuDPVlpRrZnJ+09Mfkz9OhuVTM//A73aeCO9k/4cycu8lV1uejQpWM7zPgpwrf0r7tJsqGy", + "aHG0rQJBGF8HDVfojpSrUGY4AgpVB7fZNDsKzQ45TDWFbpFu3jKbdqRf13WqhPkj7a39bKwxI+GMr+q6", + "ED3eH32bhFe+IpSGHsE06t7IGJXeddHeiXcQ+Cou9EtRv0mXTBy2pqqkalWdKzzTl0Zdvx+5ujM65e2v", + "6URXabdhtTn37pWjGrtYzzyP6amzBV0MTSbcCZ5BJzc93XQIp06vsXw1p3KdkAb9QRlyi6kXw/v56/N1", + "Ns746gUez47sBB3fmokKDPmaUqUsywCltJLQKbPer73qKPlZX1X5n1QdRO+rBDyOFb+I9ixbUPM3rcpi", + "SLrm4WGVXUhUmAek0TU3r5SG4a2jcz09ADChjF3CRrk07kC2kMaN6VSif0OnPQ6wfe8sFXSOkV4vbcoG", + "jtD2DlzMDbuoS6pfVCKnYtU7YmfC2y5R+xa1br1gjqPvIh8M2SbDOKQqAT0bjT/XdeIdleHkHad5ktXZ", + "XgVN2gd5OMu7iyj/PpUpbn/etffJwvbI7Z6noeGQbeoBTX1+OuHkpplL4w876/OxCacjzWM0v8h498bo", + "pL6dNXQn5HToSsjQyUXVM3DR895LIAPd0dmYXHnEyeozm530XFEY6FPlPf3Ql8E+3OnNDhVRcv7hP/as", + "TtICb967EyNYriePoK7Q2kbZGGmMFgLh+XQhHQbcLpxp2C5MyGqQlFrYzSmOCVGN20L8AzZYApTAkbOn", + "szXwFHRgraez/3VALQ/O6E5vDQL1rEoO/v2XszDKJXAN+vuwcX//5SyUESeBQl/rcdbWktqZBsnrlwc4", + "ax8UURnT5zTHADwuJnJu/XK2AxYNjNuwBURscvDypB4ngIjbIHwlRCssKo4ZVuytIvPs2euXs/nsKkQG", + "Z0eLR4sjBEEVIHkhZk9njxdHi6MZPfWxpl085Ij9Q1+GkX4qVF8qsXO5KKXXtfWXp5q1TRMupUK7jeUq", + "Re+Q/EAVSiejIzl7rYyNq3aGF+TA2Ocq3dxZJefhOrI3Tcr34bpGNfSvjx7dKyBuqt5q2h69VaiAyngf", + "DU1SQX2Ijeqa6tvaPorKg29r+11U2Xu8LTaK5QXFZTuS4h1dL27y/Tu6UGzKPOd6U9FbTW6zYJq+mxHJ", + "zn7FeVrke/ixenHtxpFwBn2VL07o95iY+ZL+m2Wh+mpl79MVzhQsT9b+9g/eMHZ3m0FeCa2oHdOwBA2y", + "vjKB5YHRDQXDjFraAwdMOtRJyPqOjtVcGp64BEe6bFpoMKCvMKcx7o/guFDPWhir9AYnLYBTxnOdfZ8y", + "mhyHC8WKu3zpkNLgzOPo/br4Bc+BYHvd5LDaBwq4t1jrSY948fsQkKR0lXTNLw257f89+cDtCuOSlbKi", + "lnQSV5A+P/zoXgO6ObzkcownqJyCiXOIL7lcsJdV3etRiiGL5Yxmes7lzuTiYJxIKzgNFq0ge3yx+14/", + "mdL2yR+w129xTXQzlec9O4vR/943evR70ygc7TAT0qGfvX7J3sPGZfzSuwHhNQy5ivf338IVdSrbkSme", + "OtmHCt2qMln3KvPS/jH7//nt/vOxvR/manRGd+Rr7OJuMtnoEpErybIXxxMMD8LzoQL6Z8X0bj9uz/YO", + "OXQNnz646ihuX/3tCLodXV+CoJR5Vt3apUvM5Mg2BQNnmqwKVDxoIzXqDXm6mSQabk8o9+QndEr3T/IR", + "BoVTg0zvy2b5dCXZOEEPyrJg1Dz9OFuB7TvdsKWWvgaPC7/UBnx9KhDqMMGHQmGaWtUm0UCnljzrMX//", + "Bh1yPa6MrLuSbEd3T7XDDzWMOJcRtsL7hf9difVvYD2p/iVKRNjDvvZdWj5o3bw0rrQYvXxzeLk5qKL1", + "IeIyECd5a6qXdczzzQsfnL43UTjxZaZJAvLo/kEbI/dT92oC3tbeuEfR3KuKKBTCTtwr3T8wLbttco94", + "hfVh5lb1RNYgLTeJ01dj2ZE2/cWbh6bN1jNdfzq6rErffI4U6TenSZI7UOJH98bZTWQT9KvtmBDfhjfo", + "dlPbbqoHUNt3IMY8gj4nUnkbkchWCmlQBn6qS90NkUl1QrcbVdDA5yI9V7oqRUTFYif1q94GmdojPMU8", + "vb1/6fleybbnmZntYs2/i13wlZDkO3rX8n4NzcdT2j6+JX13SXvg+PDdr0MngB3dLYwry8SqOleBBfwP", + "jgfcPw5N9DjNOMVXL8fsS/nmIWir8ybPZPpqPcLzhbj6PPL2Q0VjpPUxbPzNduJ6XmXp7kdbD0Ba9Hzg", + "jtSU0jU4s7hPCnkgn/jeqMnjaIiY3HMlYxTkHkaZ3eP+t55e6SGCH+JHVUxFEQ6P1YLjVvTpsAqZHn6s", + "SpDcHOqq/tHQmqskotPQy9dM2pWBqlnvl4OahZ0mc5GH7nPio1tzjUelP3CJ2KaiJc85VXrXaBA05Cq4", + "cGh0rAep87awGqlcGWbV3B0JhKJ2Mq0LNmd81RsKPSMY7pGwumluk4mLVkdLXzygSzO4/z1mXA1htM3u", + "39EWx2GWga2m78bHJ30ZPBWf5fmqpJs51pQXKa+KyRIIVJTO1UsZ3uU6bLN7lPsU82bvVQD1VXSZHFFx", + "txU/Y9nTH2whYgmUMUqBIXjtnejmj4d1xvbW4xhPjr6qZRq9/6DBVZL14gerPPL8oCmvqvqzrgg396UZ", + "w8CMSj9SKf1EFbivapic3dHNjyEP+9M7uempHDZZ9BE2PCI/0TOYffTk/BZnNxFKJpH6YVSaashKiwjJ", + "V7r6ZCmpXYlrN1LyuFh8op7wHQZV8mofp5NIO8bYalW/BTcqHq8bVxV5Hr3kT/kWLlmrk44TmoS0nAcR", + "m6fhWbVPk9hbpdR2o3WXuO+DIF+kZyQ9G5gZ5JDo8dlxxySK+Gb+oezYRAgDsa9iT6T+WchMSMA3RzKE", + "KmYbqqGcQurp3x+0/9X7N0uRWcCrXnNmgOtk7SpNJyorc0mXXtE4vhKc0iHBMhc5D+AKJQfYI6x8d7bw", + "Pc992Yab+Q593Bp27KO03a3H5NOGustDHDj0l/Da48yhAvvLscMWDWkjOq8lQPitJQUOPd9O8w6CGOgy", + "e8hVFDpcR3cBWLzpRNzr65yMc+ZJAOYeKXLwne7JRNlZ/UOGL3YgnR4C6YA+iUY+1nVubiaoDVdSrpoD", + "n2qI3p7w+X7u4ZJOOToNPJ0zIVP44Ps0X60dpp2zuBbPfvL9/k2fRnG+6WZP4577l6OMHuo+i14d2Ime", + "D7H5YRLqXW0PkFBFAlkVwppam2AK4VZ1Ej55Cu5WdJhOzHzlygt+oeT+C5OIm0Z1GrMnUdc1d3qp+hhP", + "wWq3lu9Rf2cqUb8IhXP2p+oJhi1fPRjxt8odbSN+OnBs1xyi7QGZwCdj0f6JmYbOfb0I7kXynjy0oto/", + "WzVDz8w1pzA3iLORLzHtD+id4wPiM3ffdx65Ou5tKb7yPRpO7i5c5+oW3T/XnddL3cEN5atzh5e9+gTf", + "dadOO3jiUSdyxR9IrrRKTe0QGIsozwH+RarclU/dQe2esuSj5aubw1bdtylipafMpTMya18Ly8HRE6iQ", + "4rNnVT24eY8MmRwoa4qTZtHFBxArBbfr3UXQrrIh7ukl0QMxe18Jy53M6IgoXF24Lxx/FxzftnTbvBYJ", + "hBFJcPXoMK+e+fxS2eQuKpvUz6Y69NT3OqcXJhGmWYjkkywusmcVEfbV1aO/LiKa9E9bHtDTlgc1Obrb", + "6LcoBONvsg9tzxtYCWwdb9Dd3ySr5/PzuGkdRLe9/x3oRUdjIvH454o/lzI2Ixe+3BYyvg91TROAhykY", + "q9XmACWukGAmByuoBi49lFdBRwELJ/TqYwmlmZG8MGuFGUunsZDz4ogEVEeiporKpbiKDjQZufAou5xl", + "5lOMfY1WlnSkCzvDXrgwlipwTx67N/CrTCoCr9Bkz9Gpia51S3j5eEwIehI9cUh8U+Hwvms8/aTGEV3r", + "jBg3D8ExdyJWXmiN1mPXEHs2uui8pELHVeWrS1gq0su0OyGdM0bGfbGuC4YkVSUuAoDpiD724mD/JrM5", + "/Oj/2mrCJBnXkLrTey7NNXkn5I98c/SI7jPTq+ONB5gXjHScsw78RCwHTt5QknGRx49Am3m9A3N2hRkC", + "+FsogFQXROGu/LTQ4U1Nrq1Y8gS7eYvJm3dkxCRABVY0cHz0Oct87kPOrEr5ZsGO6QV1w8xalVlKzJ1C", + "AbJ+VbpiWlZKK/CRcR4EwATLxj9CPsmy8W3dy+wd++aPvlKKbR9NaftoX1uoopKQetWgao+We7SMKm6Y", + "Yhm9jd+Yv3e7yM/jJv2DLtZ3oOkTrS/C6+5hLzVgaV6R+Xomd1nGcxJA/tOfuHrn/TGeL+mpdL1LERfe", + "ykrs0zF1abQ74Mt2nA2TNJupnWEdZL0tS4OqXLgS71RQb+5fvQ63dRiGqUi7mqDzhWUZWEqF8HeFqD7X", + "gj2TTGUpaMRdWjoCBqbhShjvXmAKX2EhrYofkcoglcf1CryUH1Mgz4oi23jyDXW6HkTK3EnZLRokUmUI", + "XrOMqCkL0AbSP2Wlo/tjStr2mg2r8oA7q7+JTNlfJSkyBocNHAxUeZrZJYKD76ZXVX+i0OdtIjrT9/jT", + "iP5Yskf7iqXRe/x7SeFb2Dvz/cJGLbeKFmEafjTlv1evYdU03Xosn0w9LqRhFyAt6EILAxdzlnADB0Ia", + "kEagz5a5qGPGKz/MjZHwLMZhiDzSEUtw7VuTOz99giHvlrUHnceRytZ7ah6WT0bwfQpM4fAcMYVV92qN", + "1JfpHspNqO7APYga97Ptq8BfVBcGwzVlDcxsZLLWSuKLWV+0diPKGgxoaOPNnTndHx0370Y57X2PisDN", + "MqIFyNIz/jUsOsBOwSDx+ZB7AoQSJSEKC9GN6Ac3q91dvYdiSDcbzX9b69oNZdaiCDgM+PlT2NmfhLoh", + "Oxup0D/QAmnApafHGLW7sG31aNu7j+EZGV+I5Wbe3shflH5PDwm3gl0V9ZpWJQmKuobQ5t9/OSPTxc8y", + "CBTO69vUtS2iH929ssYP4Qz85teb/z8A", } // decodeSpec returns the embedded OpenAPI spec as raw JSON bytes, diff --git a/packages/dashboard-api/internal/handlers/team_handlers_test.go b/packages/dashboard-api/internal/handlers/team_handlers_test.go index 3990368000..99a30ea844 100644 --- a/packages/dashboard-api/internal/handlers/team_handlers_test.go +++ b/packages/dashboard-api/internal/handlers/team_handlers_test.go @@ -9,12 +9,12 @@ import ( "github.com/gin-gonic/gin" "github.com/google/uuid" - authqueries "github.com/e2b-dev/infra/packages/db/pkg/auth/queries" - "github.com/e2b-dev/infra/packages/db/pkg/testutils" "github.com/stretchr/testify/require" "github.com/e2b-dev/infra/packages/auth/pkg/auth" authtypes "github.com/e2b-dev/infra/packages/auth/pkg/types" + authqueries "github.com/e2b-dev/infra/packages/db/pkg/auth/queries" + "github.com/e2b-dev/infra/packages/db/pkg/testutils" ) const testBaseTier = "base_v1" diff --git a/spec/openapi-dashboard.yml b/spec/openapi-dashboard.yml index b07fa60eaf..d959413264 100644 --- a/spec/openapi-dashboard.yml +++ b/spec/openapi-dashboard.yml @@ -1554,8 +1554,6 @@ paths: $ref: "#/components/responses/401" "500": $ref: "#/components/responses/500" - /admin/users/bootstrap: - /admin/teams/bootstrap: /admin/clusters: post: summary: Create a cluster