From fbbb8b50266c61efa2aa2555d3c57a6933d22138 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Wed, 29 Jul 2026 16:44:24 +0200 Subject: [PATCH 1/9] feat(dashboard-api): project upsert, member sync and user purge Implements the remaining /v1/management operations except project deletion, which stays 501 with the reason recorded in the handler. Membership writes live in a new internal/management package together with their cache evictions: auth caches a copy of the team per member, and the sweep that would find those keys reads users_teams, so a removal has to name its evictions itself. Adds an optional email to the project upsert contract. It is required to create a project and optional to reconcile one, which a single operation cannot express in the schema. --- docs/ARCHITECTURE.md | 15 + .../dashboard-api/internal/api/api.gen.go | 256 +++++++------ .../handlers/management_contract_test.go | 125 ++++-- .../handlers/management_members_test.go | 262 +++++++++++++ .../handlers/management_project_delete.go | 13 +- .../management_project_member_delete.go | 30 +- .../management_project_member_upsert.go | 44 ++- .../management_project_members_batch.go | 85 ++++- .../handlers/management_project_upsert.go | 214 ++++++++++- .../management_project_upsert_test.go | 268 +++++++++++++ .../handlers/management_user_purge.go | 25 +- .../dashboard-api/internal/handlers/store.go | 3 + .../internal/handlers/utils_management.go | 28 +- .../internal/management/service.go | 197 ++++++++++ .../internal/management/service_test.go | 361 ++++++++++++++++++ .../db/pkg/auth/queries/purge_user.sql.go | 61 +++ .../pkg/auth/queries/team_management.sql.go | 121 ++++++ .../auth/queries/team_membership_sync.sql.go | 90 +++++ .../auth/queries/upsert_public_users.sql.go | 25 ++ .../sql_queries/teams/team_management.sql | 35 ++ .../teams/team_membership_sync.sql | 26 ++ .../pkg/auth/sql_queries/users/purge_user.sql | 15 + .../sql_queries/users/upsert_public_users.sql | 6 + packages/db/pkg/dberrors/dberrors.go | 13 + spec/openapi-dashboard.yml | 17 + 25 files changed, 2166 insertions(+), 169 deletions(-) create mode 100644 packages/dashboard-api/internal/handlers/management_members_test.go create mode 100644 packages/dashboard-api/internal/handlers/management_project_upsert_test.go create mode 100644 packages/dashboard-api/internal/management/service.go create mode 100644 packages/dashboard-api/internal/management/service_test.go create mode 100644 packages/db/pkg/auth/queries/purge_user.sql.go create mode 100644 packages/db/pkg/auth/queries/team_management.sql.go create mode 100644 packages/db/pkg/auth/queries/team_membership_sync.sql.go create mode 100644 packages/db/pkg/auth/queries/upsert_public_users.sql.go create mode 100644 packages/db/pkg/auth/sql_queries/teams/team_management.sql create mode 100644 packages/db/pkg/auth/sql_queries/teams/team_membership_sync.sql create mode 100644 packages/db/pkg/auth/sql_queries/users/purge_user.sql create mode 100644 packages/db/pkg/auth/sql_queries/users/upsert_public_users.sql diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b0615f8066..1cb115d2bc 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -211,6 +211,21 @@ OpenAPI security scheme accepts only short-lived service JWTs verified against t the same config shape as `AUTH_PROVIDER_CONFIG`. Talks to Postgres and ClickHouse; never talks to orchestrators. +The `/v1/management` operations are the cluster's half of a contract the workspace residency owns: +project upsert (a project is a `public.teams` row created from a caller-supplied UUID on the +`base_v1` tier), member sync (granular and batched, over opaque user UUIDs in `users_teams`), +limit sync (into `project_limits`, which `team_limits` reads in preference to `tiers`), and user +purge (memberships and access tokens; the `public.users` row survives). All are idempotent, because +the caller is level-triggered and retries. Membership writes live in `internal/management` with +their cache evictions rather than in the handlers: auth caches a copy of the team per member, and +the sweep that would find those keys reads `users_teams`, so a removal has to name them itself. + +`DELETE /v1/management/projects/{teamID}` is declared and answers 501. `envs`, `snapshots` and +`volumes` reference `teams` with `ON DELETE NO ACTION` and templates are only soft-deleted, so a +project that ever built one pins its team row — and releasing it needs the API service's +orchestrator connections, which this service does not have. Projects are not deleted from control +planes today. + ### Docker reverse proxy (`packages/docker-reverse-proxy`) A Docker Registry v2 auth gateway (port 5000). Users `docker push` template base images with E2B diff --git a/packages/dashboard-api/internal/api/api.gen.go b/packages/dashboard-api/internal/api/api.gen.go index 1dcf60da41..4369af8615 100644 --- a/packages/dashboard-api/internal/api/api.gen.go +++ b/packages/dashboard-api/internal/api/api.gen.go @@ -348,8 +348,11 @@ type ManagementMemberUpsertRequest struct { // ManagementProject defines model for ManagementProject. type ManagementProject struct { - Id openapi_types.UUID `json:"id"` - Name string `json:"name"` + // Email Contact address recorded on the project. Optional, because a caller that does not track one has nothing to send. + // Omitting it means different things on the two paths. On a create there is nothing to preserve, so the address is stored empty. On a reconcile it means leave the stored address alone: blanking one a project already has would discard something the caller never asked to change. + Email *string `json:"email,omitempty"` + Id openapi_types.UUID `json:"id"` + Name string `json:"name"` // ProjectType The caller's name for the project's tier. Recorded, not interpreted: limits arrive separately and in full through upsertProjectLimits, so nothing here derives behaviour from this value. // Deliberately unconstrained. The caller owns the vocabulary and the catalog behind it, and enumerating it here would make adding a tier a cross-repo change to a field this side only stores. @@ -380,7 +383,10 @@ type ManagementProjectType = string // ManagementProjectUpsertRequest defines model for ManagementProjectUpsertRequest. type ManagementProjectUpsertRequest struct { - Name string `json:"name"` + // Email Contact address recorded on the project. Optional, because a caller that does not track one has nothing to send. + // Omitting it means different things on the two paths. On a create there is nothing to preserve, so the address is stored empty. On a reconcile it means leave the stored address alone: blanking one a project already has would discard something the caller never asked to change. + Email *string `json:"email,omitempty"` + Name string `json:"name"` // ProjectType The caller's name for the project's tier. Recorded, not interpreted: limits arrive separately and in full through upsertProjectLimits, so nothing here derives behaviour from this value. // Deliberately unconstrained. The caller owns the vocabulary and the catalog behind it, and enumerating it here would make adding a tier a cross-repo change to a field this side only stores. @@ -2027,125 +2033,131 @@ 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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", + "wTwPKVGxN36TrEUM0Gj80OgnNG4mkUgzwYFrNTm5mWRU0hQ0SPzXImdJfMli898xqEiyTDPBJyeTlzFw", + "zZYMJBFLotdAsO18Mp0w83tG9XoynXCawuSk7Gc6kfBrziTEkxMtc5hOVLSGlJoBlkKmVE9OJnmOLfU2", + "M98qLRlfTW5vp0U3l0JeakizhGpokvYT/gdNyJIlGiRZbC1thBU0T4n/vPJHIcu/04RRVUzn1xzktjmf", + "CiHhXLppV02Cz0Sa0pkCw3sNMUmY0oarluqX54poQVagidJU5woUWQppSINPWSJimJwsaaKgn1TVy3um", + "IVUjFmE6Semnl7bxo+Pj4ncqJTWD5pz9moNrYAa5nU6U3iamjel6UnDCz2VXdhQ80IIwHiV5DGNZUQzZ", + "OvM/SFhOTib/46jcEEe2mTo6NUO/xc/NDCqT7pqhuoxyqYRsmSD+nUjQueQQG4CaDZRJ2DCRKzthCSoT", + "XAFhnHyMJBhWXFL9L7+eH4ldqi6IusFHgFJdJixluknnK/qJpXlKeJ4u7D5HZhnOW9pJBpJkdAVdRNiO", + "QxpiWNI80ZOTZ8fTEmyM6yePJwguM6LDVsq4+1fBcsY1rEAi8YryeCE+vTwfI51c4w75VHbVt0ma/NN0", + "1Rz8wosQTVfEDGAYFq0huupik+mmb+CU8R+Ar/Q6ZEWVjEuqFFvxFLgev5wSaLwl5ZdEiuv68mq66iK7", + "/PCH7nX+c98yP342uMrVud3nprK92l0VcO+etlad7tGL0r8c/7bdZuazkiLP7nMJbpSQ+vZfN2a7KH0Z", + "rMjtv240Xd36pSDfmJYzTVcriKfkzXdnT548+fZHysWfejaVOhu7Um5moxcpZkozHmmzPXZZItO8Z7OY", + "/XBvy6SAymjdskxUwYxxBVwxzTZAVL6w7PAak+CF7JqT50kiriEm0ZpKGhnNkFAJ5COd/fZxSj4ez741", + "/zc3/3Np/mf2sWvyjqCKZKOfvGT789OpkcoapPn0/76ns9+OZ9/OL2cf/ucfJtP+dTPgaE70rZCaCBmD", + "NChCLBbYNPOzH3dSa/psXaWJg6sZzage3CzH+9pfC0hHrsvwP7HNh65J4dk0fKx0nGV3PkyApuPOVNOy", + "iwjbyd3UfdPJ2yRvPWJpSlSSr+zWUyLZdG4502zXY92xeiwbbOtOVhSd7UfFIQTuhiY5GBlbEFeI2jl5", + "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", + "7ze8/XD73wEAAP//", } // decodeSpec returns the embedded OpenAPI spec as raw JSON bytes, diff --git a/packages/dashboard-api/internal/handlers/management_contract_test.go b/packages/dashboard-api/internal/handlers/management_contract_test.go index 103497137c..2b2bd30d88 100644 --- a/packages/dashboard-api/internal/handlers/management_contract_test.go +++ b/packages/dashboard-api/internal/handlers/management_contract_test.go @@ -74,51 +74,118 @@ func TestProjectUpsertAcceptsTheCallersOwnTierNames(t *testing.T) { } } -// A batch route sitting beside /members/{userId} is the arrangement where a -// router can read "batch" as a user id and hand the request to the wrong -// operation. Registering it and driving a request through proves which handler -// the path reaches. -func TestBatchMemberRouteIsNotShadowedByTheMemberParameter(t *testing.T) { +// email arrived after the callers did, so the shape they send has no such +// field. Declaring it required would break every one of them at its next spec +// sync; the create-only rule is enforced in the handler for that reason. +func TestProjectUpsertAcceptsAPayloadWithoutAnEmail(t *testing.T) { t.Parallel() - reached := make(chan string, 1) - router := gin.New() - api.RegisterHandlers(router, &routeRecorder{reached: reached}) + var decoded api.ManagementProjectUpsertRequest + if err := json.Unmarshal([]byte(`{"name":"Acme","slug":"acme","project_type":"base_v1"}`), &decoded); err != nil { + t.Fatalf("decoding an upsert without an email: %v", err) + } - teamID := uuid.New() - recorder := httptest.NewRecorder() - request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, - "/v1/management/projects/"+teamID.String()+"/members/batch", - strings.NewReader(`[{"user_id":"`+uuid.New().String()+`","present":true}]`)) - request.Header.Set("Content-Type", "application/json") + if decoded.Email != nil { + t.Errorf("Email = %v, want nil so an absent address stays distinguishable from a blank one", *decoded.Email) + } - router.ServeHTTP(recorder, request) + if err := json.Unmarshal([]byte(`{"name":"Acme","slug":"acme","project_type":"base_v1","email":"ops@acme.test"}`), &decoded); err != nil { + t.Fatalf("decoding an upsert with an email: %v", err) + } - select { - case got := <-reached: - if got != "batch" { - t.Fatalf("request reached %q, want the batch handler", got) - } - default: - t.Fatalf("no handler was reached; status %d", recorder.Code) + if decoded.Email == nil || *decoded.Email != "ops@acme.test" { + t.Errorf("Email = %v, want ops@acme.test", decoded.Email) } } -// routeRecorder answers the two member operations and reports which one ran. -// Embedding the generated interface leaves every other operation nil, which is -// fine: reaching one would panic, and that is the failure this test is for. +// Every declared operation has to reach its own handler. Only another +// repository's generated client exercises this surface, so a route registered +// against the wrong path fails first in an integration nobody runs here. +// +// The batch route is why this is a table: it sits beside /members/{userId}, +// exactly where a router reads "batch" as a user id and dispatches wrongly. +func TestEveryManagementRouteReachesItsHandler(t *testing.T) { + t.Parallel() + + teamID, userID := uuid.New().String(), uuid.New().String() + project := "/v1/management/projects/" + teamID + + for _, tt := range []struct { + operation string + method string + path string + body string + }{ + {"upsertProject", http.MethodPut, project, `{"name":"a","slug":"a","project_type":"base_v1"}`}, + {"deleteProject", http.MethodDelete, project, ""}, + {"upsertMember", http.MethodPut, project + "/members/" + userID, `{}`}, + {"deleteMember", http.MethodDelete, project + "/members/" + userID, ""}, + {"batchMembers", http.MethodPost, project + "/members/batch", `[]`}, + {"upsertLimits", http.MethodPut, project + "/limits", `{}`}, + {"purgeUser", http.MethodDelete, "/v1/management/users/" + userID, ""}, + } { + t.Run(tt.operation, func(t *testing.T) { + t.Parallel() + + reached := make(chan string, 1) + router := gin.New() + api.RegisterHandlers(router, &routeRecorder{reached: reached}) + + recorder := httptest.NewRecorder() + request := httptest.NewRequestWithContext(t.Context(), tt.method, tt.path, strings.NewReader(tt.body)) + request.Header.Set("Content-Type", "application/json") + + router.ServeHTTP(recorder, request) + + select { + case got := <-reached: + if got != tt.operation { + t.Fatalf("request reached %q, want %q", got, tt.operation) + } + default: + t.Fatalf("no handler was reached; status %d", recorder.Code) + } + }) + } +} + +// routeRecorder reports which operation ran. Embedding the generated interface +// leaves the rest nil: reaching one panics, which is the failure under test. type routeRecorder struct { api.ServerInterface reached chan string } -func (r *routeRecorder) ManagementBatchSyncProjectMembers(c *gin.Context, _ api.TeamID) { - r.reached <- "batch" +func (r *routeRecorder) report(c *gin.Context, operation string) { + r.reached <- operation c.Status(http.StatusNoContent) } +func (r *routeRecorder) ManagementUpsertProject(c *gin.Context, _ api.TeamID) { + r.report(c, "upsertProject") +} + +func (r *routeRecorder) ManagementDeleteProject(c *gin.Context, _ api.TeamID) { + r.report(c, "deleteProject") +} + func (r *routeRecorder) ManagementUpsertProjectMember(c *gin.Context, _ api.TeamID, _ api.UserId) { - r.reached <- "single" - c.Status(http.StatusNoContent) + r.report(c, "upsertMember") +} + +func (r *routeRecorder) ManagementDeleteProjectMember(c *gin.Context, _ api.TeamID, _ api.UserId) { + r.report(c, "deleteMember") +} + +func (r *routeRecorder) ManagementBatchSyncProjectMembers(c *gin.Context, _ api.TeamID) { + r.report(c, "batchMembers") +} + +func (r *routeRecorder) ManagementUpsertProjectLimits(c *gin.Context, _ api.TeamID) { + r.report(c, "upsertLimits") +} + +func (r *routeRecorder) ManagementPurgeUser(c *gin.Context, _ api.UserId) { + r.report(c, "purgeUser") } diff --git a/packages/dashboard-api/internal/handlers/management_members_test.go b/packages/dashboard-api/internal/handlers/management_members_test.go new file mode 100644 index 0000000000..7bb05745c5 --- /dev/null +++ b/packages/dashboard-api/internal/handlers/management_members_test.go @@ -0,0 +1,262 @@ +package handlers + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/stretchr/testify/require" + + "github.com/e2b-dev/infra/packages/dashboard-api/internal/api" + "github.com/e2b-dev/infra/packages/dashboard-api/internal/management" + "github.com/e2b-dev/infra/packages/db/pkg/testutils" +) + +// The three routes share an implementation, so what differs is only what each +// turns a failure into. +func TestMembershipRoutesReportAnUnknownProject(t *testing.T) { + t.Parallel() + + db := testutils.SetupDatabase(t) + store := newMembershipStore(db) + unknown, userID := uuid.New(), uuid.New() + + upsert := callMemberUpsert(t, store, unknown, userID, `{}`) + require.Equal(t, http.StatusNotFound, upsert.Code, upsert.Body.String()) + + // 404 on the delete too, though an absent project arguably satisfies it + // already. The caller reads that as convergence when deleting. + remove := callMemberDelete(t, store, unknown, userID) + require.Equal(t, http.StatusNotFound, remove.Code, remove.Body.String()) + + batch := callMemberBatch(t, store, unknown, + `[{"user_id":"`+userID.String()+`","present":true}]`) + require.Equal(t, http.StatusNotFound, batch.Code, batch.Body.String()) +} + +func TestMemberRoutesConvergeOnTheStatedPresence(t *testing.T) { + t.Parallel() + + db := testutils.SetupDatabase(t) + teamID := testutils.CreateTestTeam(t, db) + store := newMembershipStore(db) + userID := uuid.New() + + require.Equal(t, http.StatusNoContent, callMemberUpsert(t, store, teamID, userID, `{}`).Code) + require.Equal(t, http.StatusNoContent, callMemberUpsert(t, store, teamID, userID, `{}`).Code) + require.Equal(t, http.StatusNoContent, callMemberDelete(t, store, teamID, userID).Code) + require.Equal(t, http.StatusNoContent, callMemberDelete(t, store, teamID, userID).Code) +} + +// The only field this handler reads out of a body, and sent rarely enough that +// a parsing mistake would go unnoticed. +func TestMemberUpsertRecordsAddedBy(t *testing.T) { + t.Parallel() + + db := testutils.SetupDatabase(t) + teamID := testutils.CreateTestTeam(t, db) + store := newMembershipStore(db) + userID, actor := uuid.New(), uuid.New() + + recorder := callMemberUpsert(t, store, teamID, userID, `{"added_by":"`+actor.String()+`"}`) + require.Equal(t, http.StatusNoContent, recorder.Code, recorder.Body.String()) + + var addedBy *uuid.UUID + require.NoError(t, db.AuthDB.TestsRawSQLQuery(t.Context(), + "SELECT added_by FROM public.users_teams WHERE team_id = $1 AND user_id = $2", + func(rows pgx.Rows) error { + rows.Next() + + return rows.Scan(&addedBy) + }, teamID, userID)) + + require.NotNil(t, addedBy) + require.Equal(t, actor, *addedBy) +} + +// A caller sending no body is declining to name an actor, not sending a broken +// request. +func TestMemberUpsertAcceptsAnAbsentBody(t *testing.T) { + t.Parallel() + + db := testutils.SetupDatabase(t) + teamID := testutils.CreateTestTeam(t, db) + store := newMembershipStore(db) + + recorder := httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(recorder) + userID := uuid.New() + ginCtx.Request = httptest.NewRequestWithContext(t.Context(), http.MethodPut, + "/v1/management/projects/"+teamID.String()+"/members/"+userID.String(), nil) + + store.ManagementUpsertProjectMember(ginCtx, teamID, userID) + ginCtx.Writer.WriteHeaderNow() + + require.Equal(t, http.StatusNoContent, recorder.Code, recorder.Body.String()) +} + +func TestBatchAppliesBothDirections(t *testing.T) { + t.Parallel() + + db := testutils.SetupDatabase(t) + teamID := testutils.CreateTestTeam(t, db) + store := newMembershipStore(db) + leaving, joining := uuid.New(), uuid.New() + + require.Equal(t, http.StatusNoContent, callMemberUpsert(t, store, teamID, leaving, `{}`).Code) + + recorder := callMemberBatch(t, store, teamID, + `[{"user_id":"`+joining.String()+`","present":true},`+ + `{"user_id":"`+leaving.String()+`","present":false}]`) + require.Equal(t, http.StatusNoContent, recorder.Code, recorder.Body.String()) +} + +// Order-independence is untrue of a request stating a user is both present and +// absent, and picking a winner would hide the caller's bug. +func TestBatchRejectsContradictoryEntries(t *testing.T) { + t.Parallel() + + db := testutils.SetupDatabase(t) + teamID := testutils.CreateTestTeam(t, db) + store := newMembershipStore(db) + userID := uuid.New() + + recorder := callMemberBatch(t, store, teamID, + `[{"user_id":"`+userID.String()+`","present":true},`+ + `{"user_id":"`+userID.String()+`","present":false}]`) + require.Equal(t, http.StatusBadRequest, recorder.Code, recorder.Body.String()) +} + +func TestSplitBatchEntries(t *testing.T) { + t.Parallel() + + first, second, third := uuid.New(), uuid.New(), uuid.New() + + t.Run("partitions by stated presence", func(t *testing.T) { + t.Parallel() + + present, absent, err := splitBatchEntries(api.ManagementMemberBatchRequest{ + {UserId: first, Present: true}, + {UserId: second, Present: false}, + {UserId: third, Present: true}, + }) + + require.NoError(t, err) + require.Equal(t, []uuid.UUID{first, third}, present) + require.Equal(t, []uuid.UUID{second}, absent) + }) + + // Stated twice the same way says nothing new. Only disagreement is + // unanswerable. + t.Run("tolerates a consistent repeat", func(t *testing.T) { + t.Parallel() + + present, absent, err := splitBatchEntries(api.ManagementMemberBatchRequest{ + {UserId: first, Present: true}, + {UserId: first, Present: true}, + }) + + require.NoError(t, err) + require.Equal(t, []uuid.UUID{first}, present) + require.Empty(t, absent) + }) + + t.Run("refuses a contradiction", func(t *testing.T) { + t.Parallel() + + _, _, err := splitBatchEntries(api.ManagementMemberBatchRequest{ + {UserId: first, Present: true}, + {UserId: first, Present: false}, + }) + + require.ErrorIs(t, err, errContradictoryEntries) + }) + + t.Run("handles an empty request", func(t *testing.T) { + t.Parallel() + + present, absent, err := splitBatchEntries(api.ManagementMemberBatchRequest{}) + + require.NoError(t, err) + require.Empty(t, present) + require.Empty(t, absent) + }) +} + +// Most calls find nothing, which is success rather than a 404 — the contract +// does not declare one. +func TestPurgeUserSucceedsWithNothingToPurge(t *testing.T) { + t.Parallel() + + db := testutils.SetupDatabase(t) + store := newMembershipStore(db) + + recorder := httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(recorder) + userID := uuid.New() + ginCtx.Request = httptest.NewRequestWithContext(t.Context(), http.MethodDelete, + "/v1/management/users/"+userID.String(), nil) + + store.ManagementPurgeUser(ginCtx, userID) + ginCtx.Writer.WriteHeaderNow() + + require.Equal(t, http.StatusNoContent, recorder.Code, recorder.Body.String()) +} + +func newMembershipStore(db *testutils.Database) *APIStore { + auth := &recordingCacheAuthService{} + + return &APIStore{ + authDB: db.AuthDB, + authService: auth, + managementService: management.NewService(db.AuthDB, auth), + } +} + +func callMemberUpsert(t *testing.T, store *APIStore, teamID, userID uuid.UUID, body string) *httptest.ResponseRecorder { + t.Helper() + + recorder := httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(recorder) + ginCtx.Request = httptest.NewRequestWithContext(t.Context(), http.MethodPut, + "/v1/management/projects/"+teamID.String()+"/members/"+userID.String(), strings.NewReader(body)) + ginCtx.Request.Header.Set("Content-Type", "application/json") + + store.ManagementUpsertProjectMember(ginCtx, teamID, userID) + ginCtx.Writer.WriteHeaderNow() + + return recorder +} + +func callMemberDelete(t *testing.T, store *APIStore, teamID, userID uuid.UUID) *httptest.ResponseRecorder { + t.Helper() + + recorder := httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(recorder) + ginCtx.Request = httptest.NewRequestWithContext(t.Context(), http.MethodDelete, + "/v1/management/projects/"+teamID.String()+"/members/"+userID.String(), nil) + + store.ManagementDeleteProjectMember(ginCtx, teamID, userID) + ginCtx.Writer.WriteHeaderNow() + + return recorder +} + +func callMemberBatch(t *testing.T, store *APIStore, teamID uuid.UUID, body string) *httptest.ResponseRecorder { + t.Helper() + + recorder := httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(recorder) + ginCtx.Request = httptest.NewRequestWithContext(t.Context(), http.MethodPost, + "/v1/management/projects/"+teamID.String()+"/members/batch", strings.NewReader(body)) + ginCtx.Request.Header.Set("Content-Type", "application/json") + + store.ManagementBatchSyncProjectMembers(ginCtx, teamID) + ginCtx.Writer.WriteHeaderNow() + + return recorder +} diff --git a/packages/dashboard-api/internal/handlers/management_project_delete.go b/packages/dashboard-api/internal/handlers/management_project_delete.go index b3169ca740..51ce953683 100644 --- a/packages/dashboard-api/internal/handlers/management_project_delete.go +++ b/packages/dashboard-api/internal/handlers/management_project_delete.go @@ -6,7 +6,18 @@ import ( "github.com/e2b-dev/infra/packages/dashboard-api/internal/api" ) -// ManagementDeleteProject deletes a project and the control-plane state belonging to it. +// ManagementDeleteProject is declared by the contract and answers 501, which is +// a decision rather than a gap. +// +// envs, snapshots and volumes reference teams with ON DELETE NO ACTION and +// template deletion only stamps deleted_at, so any project that ever built one +// pins its team row. Releasing it means killing sandboxes, cancelling builds and +// reclaiming stored artifacts, all of which need the api service's orchestrator +// connections that this process does not have. +// +// Someone has to choose between a gateway to those teardown routes, moving the +// operation there, and asynchronous reconciliation. Until then, control planes +// do not delete projects. func (s *APIStore) ManagementDeleteProject(c *gin.Context, _ api.TeamID) { sendNotImplemented(c) } diff --git a/packages/dashboard-api/internal/handlers/management_project_member_delete.go b/packages/dashboard-api/internal/handlers/management_project_member_delete.go index 00ee602726..1fcf03beea 100644 --- a/packages/dashboard-api/internal/handlers/management_project_member_delete.go +++ b/packages/dashboard-api/internal/handlers/management_project_member_delete.go @@ -1,12 +1,36 @@ package handlers import ( + "net/http" + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "go.opentelemetry.io/otel/attribute" "github.com/e2b-dev/infra/packages/dashboard-api/internal/api" + "github.com/e2b-dev/infra/packages/dashboard-api/internal/management" + "github.com/e2b-dev/infra/packages/shared/pkg/telemetry" ) -// ManagementDeleteProjectMember removes a project member. -func (s *APIStore) ManagementDeleteProjectMember(c *gin.Context, _ api.TeamID, _ api.UserId) { - sendNotImplemented(c) +// ManagementDeleteProjectMember removes a project member. Removing one that is +// not there succeeds, because the state the request names already holds. +func (s *APIStore) ManagementDeleteProjectMember(c *gin.Context, teamID api.TeamID, userID api.UserId) { + ctx := c.Request.Context() + attrs := []attribute.KeyValue{ + telemetry.WithTeamID(teamID.String()), + telemetry.WithUserID(userID.String()), + } + + change := management.MemberChange{ + ProjectID: teamID, + Absent: []uuid.UUID{userID}, + } + + if err := s.managementService.SetProjectMembers(ctx, change); err != nil { + s.sendMembershipError(c, err, "delete project member failed", attrs...) + + return + } + + c.Status(http.StatusNoContent) } diff --git a/packages/dashboard-api/internal/handlers/management_project_member_upsert.go b/packages/dashboard-api/internal/handlers/management_project_member_upsert.go index 5e02583de2..b224027d30 100644 --- a/packages/dashboard-api/internal/handlers/management_project_member_upsert.go +++ b/packages/dashboard-api/internal/handlers/management_project_member_upsert.go @@ -1,12 +1,50 @@ package handlers import ( + "fmt" + "net/http" + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "go.opentelemetry.io/otel/attribute" "github.com/e2b-dev/infra/packages/dashboard-api/internal/api" + "github.com/e2b-dev/infra/packages/dashboard-api/internal/management" + "github.com/e2b-dev/infra/packages/shared/pkg/ginutils" + "github.com/e2b-dev/infra/packages/shared/pkg/telemetry" ) -// ManagementUpsertProjectMember reconciles an opaque user UUID as a member of a project. -func (s *APIStore) ManagementUpsertProjectMember(c *gin.Context, _ api.TeamID, _ api.UserId) { - sendNotImplemented(c) +// ManagementUpsertProjectMember reconciles an opaque user UUID as a member of a +// project. Idempotent: a repeated push reports the same success as the first. +func (s *APIStore) ManagementUpsertProjectMember(c *gin.Context, teamID api.TeamID, userID api.UserId) { + ctx := c.Request.Context() + attrs := []attribute.KeyValue{ + telemetry.WithTeamID(teamID.String()), + telemetry.WithUserID(userID.String()), + } + + // Optional in the contract: an absent body is a caller declining to name + // who added the member, not a malformed request. + body, err := ginutils.ParseOptionalBody[api.ManagementMemberUpsertRequest](ctx, c) + if err != nil { + telemetry.ReportErrorByCode(ctx, http.StatusBadRequest, "upsert project member failed", + fmt.Errorf("parse member upsert request: %w", err), attrs...) + s.sendAPIStoreError(c, http.StatusBadRequest, "Invalid request body") + + return + } + + change := management.MemberChange{ + ProjectID: teamID, + Present: []uuid.UUID{userID}, + AddedBy: body.AddedBy, + } + + if err := s.managementService.SetProjectMembers(ctx, change); err != nil { + s.sendMembershipError(c, err, "upsert project member failed", attrs...) + + return + } + + c.Status(http.StatusNoContent) } diff --git a/packages/dashboard-api/internal/handlers/management_project_members_batch.go b/packages/dashboard-api/internal/handlers/management_project_members_batch.go index e814bbecaa..7ea28e9a92 100644 --- a/packages/dashboard-api/internal/handlers/management_project_members_batch.go +++ b/packages/dashboard-api/internal/handlers/management_project_members_batch.go @@ -1,12 +1,91 @@ package handlers import ( + "errors" + "fmt" + "net/http" + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "go.opentelemetry.io/otel/attribute" "github.com/e2b-dev/infra/packages/dashboard-api/internal/api" + "github.com/e2b-dev/infra/packages/dashboard-api/internal/management" + "github.com/e2b-dev/infra/packages/shared/pkg/ginutils" + "github.com/e2b-dev/infra/packages/shared/pkg/telemetry" ) -// ManagementBatchSyncProjectMembers reconciles many project memberships in one call, for group and directory fan-outs. -func (s *APIStore) ManagementBatchSyncProjectMembers(c *gin.Context, _ api.TeamID) { - sendNotImplemented(c) +// errContradictoryEntries reports a user listed twice in one batch with +// opposing presence. +var errContradictoryEntries = errors.New("a user is listed as both present and absent") + +// ManagementBatchSyncProjectMembers reconciles many project memberships at +// once, for the group and directory fan-outs where the per-member routes would +// cost a request each. Shares their implementation, so the two cannot disagree +// about the same desired state. +func (s *APIStore) ManagementBatchSyncProjectMembers(c *gin.Context, teamID api.TeamID) { + ctx := c.Request.Context() + attrs := []attribute.KeyValue{telemetry.WithTeamID(teamID.String())} + + // maxItems is enforced by the request validator, so an oversized batch + // never arrives here. + entries, err := ginutils.ParseBody[api.ManagementMemberBatchRequest](ctx, c) + if err != nil { + telemetry.ReportErrorByCode(ctx, http.StatusBadRequest, "batch member sync failed", + fmt.Errorf("parse member batch request: %w", err), attrs...) + s.sendAPIStoreError(c, http.StatusBadRequest, "Invalid request body") + + return + } + + present, absent, err := splitBatchEntries(entries) + if err != nil { + telemetry.ReportErrorByCode(ctx, http.StatusBadRequest, "batch member sync failed", err, attrs...) + s.sendAPIStoreError(c, http.StatusBadRequest, "A user is listed as both present and absent") + + return + } + + change := management.MemberChange{ + ProjectID: teamID, + Present: present, + Absent: absent, + } + + if err := s.managementService.SetProjectMembers(ctx, change); err != nil { + s.sendMembershipError(c, err, "batch member sync failed", attrs...) + + return + } + + c.Status(http.StatusNoContent) +} + +// splitBatchEntries partitions stated presence into the two sets the change +// takes. A user listed twice with opposing presence is an error: the contract +// promises entries are independent of order, which such a request breaks, and +// picking a winner would hide the caller's bug behind a converged result. +func splitBatchEntries(entries api.ManagementMemberBatchRequest) (present, absent []uuid.UUID, err error) { + stated := make(map[uuid.UUID]bool, len(entries)) + + for _, entry := range entries { + previous, seen := stated[entry.UserId] + if seen && previous != entry.Present { + return nil, nil, fmt.Errorf("%w: %s", errContradictoryEntries, entry.UserId) + } + + if seen { + continue + } + + stated[entry.UserId] = entry.Present + + if entry.Present { + present = append(present, entry.UserId) + } else { + absent = append(absent, entry.UserId) + } + } + + return present, absent, nil } diff --git a/packages/dashboard-api/internal/handlers/management_project_upsert.go b/packages/dashboard-api/internal/handlers/management_project_upsert.go index 2bbd998355..e2464b6e92 100644 --- a/packages/dashboard-api/internal/handlers/management_project_upsert.go +++ b/packages/dashboard-api/internal/handlers/management_project_upsert.go @@ -1,12 +1,220 @@ package handlers import ( + "context" + "errors" + "fmt" + "net/http" + "github.com/gin-gonic/gin" + "go.opentelemetry.io/otel/attribute" + "go.uber.org/zap" "github.com/e2b-dev/infra/packages/dashboard-api/internal/api" + 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/ginutils" + "github.com/e2b-dev/infra/packages/shared/pkg/logger" + "github.com/e2b-dev/infra/packages/shared/pkg/telemetry" +) + +// managedProjectTier is where a project created through the management +// interface starts. project_type does not decide it: the caller names tiers +// this cluster has never heard of, so mapping it onto teams.tier would fail +// the foreign key on the first project that is not base. +const managedProjectTier = "base_v1" + +const teamSlugUniqueConstraint = "teams_slug_unique" + +var ( + // errProjectSlugImmutable reports a reconcile that would move a project to + // a different slug. + errProjectSlugImmutable = errors.New("project slug cannot change") + + // errProjectEmailRequired reports a create with no address to store. + errProjectEmailRequired = errors.New("email is required to create a project") ) -// ManagementUpsertProject creates or reconciles a project's control-plane state. -func (s *APIStore) ManagementUpsertProject(c *gin.Context, _ api.TeamID) { - sendNotImplemented(c) +// ManagementUpsertProject creates or reconciles a project from a +// caller-supplied id. +// +// One request serves both because the caller cannot tell them apart: it +// retries, and a retry after a response it never saw has to land on the same +// state as the original. +func (s *APIStore) ManagementUpsertProject(c *gin.Context, teamID api.TeamID) { + ctx := c.Request.Context() + attrs := []attribute.KeyValue{telemetry.WithTeamID(teamID.String())} + + body, err := ginutils.ParseBody[api.ManagementProjectUpsertRequest](ctx, c) + if err != nil { + telemetry.ReportErrorByCode(ctx, http.StatusBadRequest, "upsert project failed", + fmt.Errorf("parse project upsert request: %w", err), attrs...) + s.sendAPIStoreError(c, http.StatusBadRequest, "Invalid request body") + + return + } + + // Traced and not stored. The contract says project_type is recorded rather + // than interpreted, and nothing here reads it: no column, no behaviour, and + // limits that arrive already resolved. + attrs = append(attrs, attribute.String("project.type", body.ProjectType)) + telemetry.SetAttributes(ctx, attrs...) + + project, created, err := s.upsertManagedProject(ctx, teamID, body) + if err != nil { + s.sendProjectUpsertError(c, err, attrs...) + + return + } + + // A reconcile changes the team, so every cached copy of it is stale — its + // own entry, each API key's, each member's. Logged rather than returned: + // the row is committed, and a retry cannot improve on a stale cache. + if !created { + if err := s.authService.InvalidateTeamCache(ctx, teamID); err != nil { + logger.L().Error(ctx, "invalidating team cache after project reconcile", + logger.WithTeamID(teamID.String()), zap.Error(err)) + } + } + + c.JSON(upsertStatus(created), api.ManagementProject{ + Id: teamID, + Name: project.Name, + Slug: project.Slug, + ProjectType: body.ProjectType, + Email: optionalEmail(project.Email), + }) +} + +// managedProject is the part of a project both branches return. +type managedProject struct { + Name string + Slug string + Email string +} + +// upsertManagedProject inserts first and reconciles when the id is taken, so +// the common create path costs one statement. +// +// The address is required to create and optional to reconcile, which the +// contract cannot express on a single operation. A create has nowhere to get +// one from and the column does not accept none; a reconcile already has one +// stored, and blanking it because the caller stopped sending it would discard +// something nobody asked to change. +func (s *APIStore) upsertManagedProject( + ctx context.Context, + teamID api.TeamID, + body api.ManagementProjectUpsertRequest, +) (project managedProject, created bool, err error) { + txDB, tx, err := s.authDB.WithTx(ctx) + if err != nil { + return managedProject{}, false, fmt.Errorf("start project upsert transaction: %w", err) + } + defer func() { + _ = tx.Rollback(ctx) + }() + + if body.Email != nil { + inserted, insertErr := txDB.InsertManagedTeam(ctx, authqueries.InsertManagedTeamParams{ + ID: teamID, + Name: body.Name, + Slug: body.Slug, + Tier: managedProjectTier, + Email: *body.Email, + }) + + if insertErr == nil { + if err := tx.Commit(ctx); err != nil { + return managedProject{}, false, fmt.Errorf("commit project create: %w", err) + } + + return managedProject{Name: inserted.Name, Slug: inserted.Slug, Email: inserted.Email}, true, nil + } + + // No row means the id is taken, so this is a reconcile: DO NOTHING lets + // the insert double as the branch test, and a concurrent create resolve + // below instead of into a 409. Anything else is a real failure. + if !dberrors.IsNotFoundError(insertErr) { + return managedProject{}, false, fmt.Errorf("create project: %w", insertErr) + } + } + + existing, err := txDB.LockManagedTeam(ctx, teamID) + if err != nil { + // Only reachable without an address, since the insert above would + // otherwise have created the row. + if dberrors.IsNotFoundError(err) { + return managedProject{}, false, errProjectEmailRequired + } + + return managedProject{}, false, fmt.Errorf("lock project: %w", err) + } + + // The rule that a slug never moves is the caller's: it owns the region-wide + // namespace these labels address, and teams_slug_unique is only the backstop + // underneath it. Refusing also stops a reconcile adopting a team it does not + // own — caller-minted ids will not collide, but backfilling legacy teams + // into projects makes other ids reachable, and a mismatched slug is the + // signal that one of them is not the project being described. + if existing.Slug != body.Slug { + return managedProject{}, false, fmt.Errorf("%w: stored %q, requested %q", + errProjectSlugImmutable, existing.Slug, body.Slug) + } + + updated, err := txDB.UpdateManagedTeam(ctx, authqueries.UpdateManagedTeamParams{ + ID: teamID, + Name: body.Name, + Email: body.Email, + }) + if err != nil { + return managedProject{}, false, fmt.Errorf("reconcile project: %w", err) + } + + if err := tx.Commit(ctx); err != nil { + return managedProject{}, false, fmt.Errorf("commit project reconcile: %w", err) + } + + return managedProject{Name: updated.Name, Slug: updated.Slug, Email: updated.Email}, false, nil +} + +func (s *APIStore) sendProjectUpsertError(c *gin.Context, err error, attrs ...attribute.KeyValue) { + ctx := c.Request.Context() + + switch { + case errors.Is(err, errProjectEmailRequired): + telemetry.ReportErrorByCode(ctx, http.StatusBadRequest, "upsert project failed", err, attrs...) + s.sendAPIStoreError(c, http.StatusBadRequest, "Email is required to create a project") + + case errors.Is(err, errProjectSlugImmutable): + telemetry.ReportErrorByCode(ctx, http.StatusConflict, "upsert project failed", err, attrs...) + s.sendAPIStoreError(c, http.StatusConflict, "Project slug cannot change") + + // Slugs are unique cluster-wide, so the collision may be with a project the + // caller has never heard of. Only it can pick another name. + case dberrors.ConstraintName(err) == teamSlugUniqueConstraint: + telemetry.ReportErrorByCode(ctx, http.StatusConflict, "upsert project failed", err, attrs...) + s.sendAPIStoreError(c, http.StatusConflict, "Slug is already taken on this control plane") + + default: + telemetry.ReportCriticalError(ctx, "upsert project failed", err, attrs...) + s.sendAPIStoreError(c, http.StatusInternalServerError, "Error upserting project") + } +} + +// upsertStatus distinguishes a project this request created from one it found. +func upsertStatus(created bool) int { + if created { + return http.StatusCreated + } + + return http.StatusOK +} + +// optionalEmail reports an unset address as absent rather than blank. +func optionalEmail(email string) *string { + if email == "" { + return nil + } + + return &email } diff --git a/packages/dashboard-api/internal/handlers/management_project_upsert_test.go b/packages/dashboard-api/internal/handlers/management_project_upsert_test.go new file mode 100644 index 0000000000..fa7dd7fa79 --- /dev/null +++ b/packages/dashboard-api/internal/handlers/management_project_upsert_test.go @@ -0,0 +1,268 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/stretchr/testify/require" + + "github.com/e2b-dev/infra/packages/dashboard-api/internal/api" + "github.com/e2b-dev/infra/packages/db/pkg/testutils" +) + +// The caller cannot tell a create from a reconcile, so one request serves both +// and the status carries the answer. +func TestUpsertProjectCreatesThenReconciles(t *testing.T) { + t.Parallel() + + db := testutils.SetupDatabase(t) + store, cache := newUpsertStore(db) + project := newProjectFixture() + + created := callUpsertProject(t, store, project.id, project.request()) + require.Equal(t, http.StatusCreated, created.Code, created.Body.String()) + require.Empty(t, cache.invalidated, "a project that did not exist has nothing cached") + + renamed := project.request() + renamed.Name = "Acme Renamed" + + reconciled := callUpsertProject(t, store, project.id, renamed) + require.Equal(t, http.StatusOK, reconciled.Code, reconciled.Body.String()) + + require.Equal(t, "Acme Renamed", teamColumn(t, db, project.id, "name")) + // The team changed, so every cached copy of it is stale. + require.Equal(t, []uuid.UUID{project.id}, cache.invalidated) +} + +// A retry after a response the caller never saw has to land on the same state. +func TestUpsertProjectIsIdempotent(t *testing.T) { + t.Parallel() + + db := testutils.SetupDatabase(t) + store, _ := newUpsertStore(db) + project := newProjectFixture() + + require.Equal(t, http.StatusCreated, callUpsertProject(t, store, project.id, project.request()).Code) + + repeated := callUpsertProject(t, store, project.id, project.request()) + require.Equal(t, http.StatusOK, repeated.Code, repeated.Body.String()) + require.Equal(t, "Acme", teamColumn(t, db, project.id, "name")) +} + +// The slug is the project's DNS label, and the caller's region-wide namespace +// depends on it not moving. +func TestUpsertProjectRefusesAChangedSlug(t *testing.T) { + t.Parallel() + + db := testutils.SetupDatabase(t) + store, _ := newUpsertStore(db) + project := newProjectFixture() + + require.Equal(t, http.StatusCreated, callUpsertProject(t, store, project.id, project.request()).Code) + + moved := project.request() + moved.Slug += "-moved" + + movedResponse := callUpsertProject(t, store, project.id, moved) + require.Equal(t, http.StatusConflict, movedResponse.Code, movedResponse.Body.String()) + require.Equal(t, project.slug, teamColumn(t, db, project.id, "slug")) +} + +// Slugs are unique cluster-wide, so the collision may be with a project the +// caller has never heard of. +func TestUpsertProjectRejectsASlugTakenByAnotherProject(t *testing.T) { + t.Parallel() + + db := testutils.SetupDatabase(t) + store, _ := newUpsertStore(db) + incumbent := newProjectFixture() + + require.Equal(t, http.StatusCreated, callUpsertProject(t, store, incumbent.id, incumbent.request()).Code) + + challenger := newProjectFixture() + duplicate := challenger.request() + duplicate.Slug = incumbent.slug + + collision := callUpsertProject(t, store, challenger.id, duplicate) + require.Equal(t, http.StatusConflict, collision.Code, collision.Body.String()) +} + +// A routine name push must not undo an operator's decision, nor move a project +// off limits that arrived through their own route. +func TestUpsertProjectPreservesBlockedStateAndTier(t *testing.T) { + t.Parallel() + + db := testutils.SetupDatabase(t) + store, _ := newUpsertStore(db) + project := newProjectFixture() + + require.Equal(t, http.StatusCreated, callUpsertProject(t, store, project.id, project.request()).Code) + + // project_type names tiers this cluster has never heard of, which is why it + // is not mapped onto teams.tier — doing so would fail this very constraint. + require.NoError(t, db.AuthDB.TestsRawSQL(t.Context(), + `INSERT INTO public.tiers (id, name, disk_mb, concurrent_instances, max_length_hours, + max_vcpu, max_ram_mb, concurrent_template_builds, events_ttl_days, + default_free_disk_size_mb, max_disk_size_mb) + VALUES ('pro_v1', 'Pro', 20480, 100, 24, 8, 8192, 20, 30, 10240, 51200) + ON CONFLICT (id) DO NOTHING`)) + require.NoError(t, db.AuthDB.TestsRawSQL(t.Context(), + "UPDATE public.teams SET is_blocked = true, tier = 'pro_v1' WHERE id = $1", project.id)) + + reconcile := project.request() + reconcile.Name = "Acme Renamed" + reconcile.ProjectType = "enterprise_v3" + + renamed := callUpsertProject(t, store, project.id, reconcile) + require.Equal(t, http.StatusOK, renamed.Code, renamed.Body.String()) + + require.Equal(t, "true", teamColumn(t, db, project.id, "is_blocked::text")) + require.Equal(t, "pro_v1", teamColumn(t, db, project.id, "tier")) +} + +// Required to create, optional to reconcile — the asymmetry the contract cannot +// express on one operation. +func TestUpsertProjectRequiresAnEmailOnlyToCreate(t *testing.T) { + t.Parallel() + + db := testutils.SetupDatabase(t) + store, _ := newUpsertStore(db) + project := newProjectFixture() + withoutEmail := project.request() + withoutEmail.Email = nil + + refused := callUpsertProject(t, store, project.id, withoutEmail) + require.Equal(t, http.StatusBadRequest, refused.Code, refused.Body.String()) + + require.Equal(t, http.StatusCreated, callUpsertProject(t, store, project.id, project.request()).Code) + + // The project now has an address, so a caller that stopped sending one is + // omitting a value rather than clearing it. + require.Equal(t, http.StatusOK, callUpsertProject(t, store, project.id, withoutEmail).Code) + require.Equal(t, "ops@acme.test", teamColumn(t, db, project.id, "email")) +} + +func TestUpsertProjectUpdatesTheEmailWhenOneIsSent(t *testing.T) { + t.Parallel() + + db := testutils.SetupDatabase(t) + store, _ := newUpsertStore(db) + project := newProjectFixture() + + require.Equal(t, http.StatusCreated, callUpsertProject(t, store, project.id, project.request()).Code) + rebilled := project.request() + rebilled.Email = new("billing@acme.test") + + require.Equal(t, http.StatusOK, callUpsertProject(t, store, project.id, rebilled).Code) + + require.Equal(t, "billing@acme.test", teamColumn(t, db, project.id, "email")) +} + +func TestUpsertProjectEchoesTheStoredProject(t *testing.T) { + t.Parallel() + + db := testutils.SetupDatabase(t) + store, _ := newUpsertStore(db) + project := newProjectFixture() + + pro := project.request() + pro.ProjectType = "pro_v1" + + recorder := callUpsertProject(t, store, project.id, pro) + require.Equal(t, http.StatusCreated, recorder.Code) + + var decoded api.ManagementProject + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &decoded)) + require.Equal(t, project.id, decoded.Id) + require.Equal(t, "Acme", decoded.Name) + require.Equal(t, project.slug, decoded.Slug) + // Echoed from the request: there is no column for it. + require.Equal(t, "pro_v1", decoded.ProjectType) + require.NotNil(t, decoded.Email) + require.Equal(t, "ops@acme.test", *decoded.Email) +} + +// Deleting a project needs teardown this process cannot reach. If you are +// removing this test, read the handler comment first. +func TestDeleteProjectIsDeliberatelyNotImplemented(t *testing.T) { + t.Parallel() + + recorder := httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(recorder) + ginCtx.Request = httptest.NewRequestWithContext(t.Context(), http.MethodDelete, + "/v1/management/projects/"+uuid.NewString(), nil) + + (&APIStore{}).ManagementDeleteProject(ginCtx, uuid.New()) + + require.Equal(t, http.StatusNotImplemented, recorder.Code) +} + +// upsertFixture is a valid project with a slug unique to its test, so cases +// that care about one field can change that one and send the rest. +type projectFixture struct { + id uuid.UUID + slug string +} + +func newProjectFixture() projectFixture { + id := uuid.New() + + return projectFixture{id: id, slug: "acme-" + id.String()[:8]} +} + +func (p projectFixture) request() api.ManagementProjectUpsertRequest { + email := "ops@acme.test" + + return api.ManagementProjectUpsertRequest{ + Name: "Acme", + Slug: p.slug, + ProjectType: "base_v1", + Email: &email, + } +} + +func newUpsertStore(db *testutils.Database) (*APIStore, *recordingCacheAuthService) { + auth := &recordingCacheAuthService{} + + return &APIStore{authDB: db.AuthDB, authService: auth}, auth +} + +func callUpsertProject(t *testing.T, store *APIStore, projectID uuid.UUID, request api.ManagementProjectUpsertRequest) *httptest.ResponseRecorder { + t.Helper() + + body, err := json.Marshal(request) + require.NoError(t, err) + + recorder := httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(recorder) + ginCtx.Request = httptest.NewRequestWithContext(t.Context(), http.MethodPut, + "/v1/management/projects/"+projectID.String(), bytes.NewReader(body)) + ginCtx.Request.Header.Set("Content-Type", "application/json") + + store.ManagementUpsertProject(ginCtx, projectID) + ginCtx.Writer.WriteHeaderNow() + + return recorder +} + +func teamColumn(t *testing.T, db *testutils.Database, teamID uuid.UUID, column string) string { + t.Helper() + + var value string + err := db.AuthDB.TestsRawSQLQuery(t.Context(), + "SELECT "+column+" FROM public.teams WHERE id = $1", + func(rows pgx.Rows) error { + rows.Next() + + return rows.Scan(&value) + }, teamID) + require.NoError(t, err) + + return value +} diff --git a/packages/dashboard-api/internal/handlers/management_user_purge.go b/packages/dashboard-api/internal/handlers/management_user_purge.go index 35cfc73245..ff2c25a74e 100644 --- a/packages/dashboard-api/internal/handlers/management_user_purge.go +++ b/packages/dashboard-api/internal/handlers/management_user_purge.go @@ -1,12 +1,31 @@ package handlers import ( + "net/http" + "github.com/gin-gonic/gin" + "go.opentelemetry.io/otel/attribute" "github.com/e2b-dev/infra/packages/dashboard-api/internal/api" + "github.com/e2b-dev/infra/packages/shared/pkg/telemetry" ) -// ManagementPurgeUser purges shard-local membership and access-token state for an opaque user UUID. -func (s *APIStore) ManagementPurgeUser(c *gin.Context, _ api.UserId) { - sendNotImplemented(c) +// ManagementPurgeUser removes the membership and access-token state a user +// holds on this cluster. +// +// The caller fans this out to every control plane it knows, so a user with +// nothing here is ordinary rather than an error — hence no 404 in the contract +// and none returned. +func (s *APIStore) ManagementPurgeUser(c *gin.Context, userID api.UserId) { + ctx := c.Request.Context() + attrs := []attribute.KeyValue{telemetry.WithUserID(userID.String())} + + if err := s.managementService.PurgeUser(ctx, userID); err != nil { + telemetry.ReportCriticalError(ctx, "purge user failed", err, attrs...) + s.sendAPIStoreError(c, http.StatusInternalServerError, "Error purging user") + + return + } + + c.Status(http.StatusNoContent) } diff --git a/packages/dashboard-api/internal/handlers/store.go b/packages/dashboard-api/internal/handlers/store.go index 5c11ef8353..dd1965ce8d 100644 --- a/packages/dashboard-api/internal/handlers/store.go +++ b/packages/dashboard-api/internal/handlers/store.go @@ -13,6 +13,7 @@ import ( "github.com/e2b-dev/infra/packages/dashboard-api/internal/api" "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" @@ -30,6 +31,7 @@ type APIStore struct { authService sharedauth.Service identityService identity.Service provisioningService *provisioning.Service + managementService *management.Service } func NewAPIStore( @@ -49,6 +51,7 @@ func NewAPIStore( authService: authService, identityService: identityService, provisioningService: provisioning.New(authDB, identityService, teamProvisionSink), + managementService: management.NewService(authDB, authService), } } diff --git a/packages/dashboard-api/internal/handlers/utils_management.go b/packages/dashboard-api/internal/handlers/utils_management.go index 7fe56b76a5..bb9f40fdc0 100644 --- a/packages/dashboard-api/internal/handlers/utils_management.go +++ b/packages/dashboard-api/internal/handlers/utils_management.go @@ -1,19 +1,43 @@ package handlers import ( + "errors" "net/http" "github.com/gin-gonic/gin" + "go.opentelemetry.io/otel/attribute" "github.com/e2b-dev/infra/packages/dashboard-api/internal/api" + "github.com/e2b-dev/infra/packages/dashboard-api/internal/management" + "github.com/e2b-dev/infra/packages/shared/pkg/telemetry" ) // sendNotImplemented answers the management operations that are declared in -// the contract but not yet served. Each one is a distinct piece of work, so -// they land individually and this loses a caller each time. +// the contract but not served. func sendNotImplemented(c *gin.Context) { c.JSON(http.StatusNotImplemented, api.Error{ Code: http.StatusNotImplemented, Message: "operation is not implemented", }) } + +// sendMembershipError maps a membership change's failure onto the contract's +// status codes. Shared, so the caller's retry behaviour cannot depend on which +// route it used. +// +// An unknown project is 404 on every verb, deletes included. The caller reads +// that as convergence when deleting and as divergence otherwise, so answering +// uniformly gives it both without this side guessing which applies. +func (s *APIStore) sendMembershipError(c *gin.Context, err error, operation string, attrs ...attribute.KeyValue) { + ctx := c.Request.Context() + + if errors.Is(err, management.ErrProjectNotFound) { + telemetry.ReportErrorByCode(ctx, http.StatusNotFound, operation, err, attrs...) + s.sendAPIStoreError(c, http.StatusNotFound, "Project not found") + + return + } + + telemetry.ReportCriticalError(ctx, operation, err, attrs...) + s.sendAPIStoreError(c, http.StatusInternalServerError, "Error applying membership change") +} diff --git a/packages/dashboard-api/internal/management/service.go b/packages/dashboard-api/internal/management/service.go new file mode 100644 index 0000000000..e73aa538d6 --- /dev/null +++ b/packages/dashboard-api/internal/management/service.go @@ -0,0 +1,197 @@ +// Package management holds the state changes the control-plane management +// interface applies. They live outside the handlers so they are reachable +// without gin: what these operations get wrong is never the HTTP. +package management + +import ( + "context" + "errors" + "fmt" + "slices" + + "github.com/google/uuid" + + sharedauth "github.com/e2b-dev/infra/packages/auth/pkg/auth" + authdb "github.com/e2b-dev/infra/packages/db/pkg/auth" + authqueries "github.com/e2b-dev/infra/packages/db/pkg/auth/queries" + "github.com/e2b-dev/infra/packages/shared/pkg/logger" +) + +// ErrProjectNotFound reports a project unknown to this cluster. Returned rather +// than answered here, because each route reads it differently. +var ErrProjectNotFound = errors.New("project not found") + +// Service applies membership changes and evicts the cache entries each one +// invalidates. +// +// The two halves are inseparable. Auth caches a copy of the team per member +// under -, and InvalidateTeamCache finds those keys by reading +// users_teams — so a member already removed is one it cannot see. Writing and +// evicting from separate call sites leaves a revoked member authenticating +// until the entry expires, which is why there is no way to do only one. +type Service struct { + db *authdb.Client + cache sharedauth.Service +} + +func NewService(db *authdb.Client, cache sharedauth.Service) *Service { + return &Service{db: db, cache: cache} +} + +// MemberChange is the membership a push states for a project. Users it does not +// name keep whatever membership they have. +type MemberChange struct { + ProjectID uuid.UUID + Present []uuid.UUID + Absent []uuid.UUID + + // AddedBy records the actor behind the addition, when the caller names one. + AddedBy *uuid.UUID +} + +// namedUsers is every user the change states a presence for. +func (c MemberChange) namedUsers() []uuid.UUID { + return slices.Concat(c.Present, c.Absent) +} + +// anchoredUsers lists the user rows adding Present depends on: the members, and +// whoever is recorded as adding them. +func (c MemberChange) anchoredUsers() []uuid.UUID { + if c.AddedBy == nil { + return c.Present + } + + return append(slices.Clone(c.Present), *c.AddedBy) +} + +// SetProjectMembers reconciles a stated membership against a project in one +// transaction. +// +// Idempotent in both directions, because the caller retries, delivers at least +// once and lets its own pushes interleave. +// +// The rules the dashboard's member routes enforce — no removing the last +// member, no touching a default membership — are deliberately absent. The +// caller owns membership, and a rule here would make its pushes unrepeatable. +func (s *Service) SetProjectMembers(ctx context.Context, change MemberChange) error { + removed, err := s.applyMembers(ctx, change) + if err != nil { + return err + } + + // Evicting before the commit would let a concurrent read repopulate the + // entry with uncommitted state. + // + // Every user the change named, not only the rows that moved: a crash here + // leaves stale entries that only the caller's retry clears, and that retry + // finds the work already done. Repeating an eviction costs a cache delete; + // skipping one costs a revoked member's access. + for _, userID := range change.namedUsers() { + s.cache.InvalidateTeamMemberCache(ctx, userID, change.ProjectID.String()) + } + + // Costs the user a team rather than access: the signup path recreates a + // missing default on next login. Logged because backfilling legacy teams + // into projects is where it would start happening in bulk. + for _, row := range removed { + if row.IsDefault { + logger.L().Warn(ctx, "management removed a default team membership", + logger.WithTeamID(change.ProjectID.String()), logger.WithUserID(row.UserID.String())) + } + } + + return nil +} + +func (s *Service) applyMembers(ctx context.Context, change MemberChange) ([]authqueries.SyncTeamMembersAbsentRow, error) { + txDB, tx, err := s.db.WithTx(ctx) + if err != nil { + return nil, fmt.Errorf("start membership transaction: %w", err) + } + defer func() { + _ = tx.Rollback(ctx) + }() + + exists, err := txDB.TeamExists(ctx, change.ProjectID) + if err != nil { + return nil, fmt.Errorf("look up project: %w", err) + } + if !exists { + return nil, ErrProjectNotFound + } + + if len(change.Present) > 0 { + // users_teams still points at public.users for both the member and + // whoever added them, and the caller knows only opaque ids. + if err := txDB.UpsertPublicUsers(ctx, change.anchoredUsers()); err != nil { + return nil, fmt.Errorf("anchor users: %w", err) + } + + if err := txDB.SyncTeamMembersPresent(ctx, authqueries.SyncTeamMembersPresentParams{ + TeamID: change.ProjectID, + UserIds: change.Present, + AddedBy: change.AddedBy, + }); err != nil { + return nil, fmt.Errorf("add project members: %w", err) + } + } + + var removed []authqueries.SyncTeamMembersAbsentRow + if len(change.Absent) > 0 { + removed, err = txDB.SyncTeamMembersAbsent(ctx, authqueries.SyncTeamMembersAbsentParams{ + TeamID: change.ProjectID, + UserIds: change.Absent, + }) + if err != nil { + return nil, fmt.Errorf("remove project members: %w", err) + } + } + + if err := tx.Commit(ctx); err != nil { + return nil, fmt.Errorf("commit membership change: %w", err) + } + + return removed, nil +} + +// PurgeUser removes the memberships and access tokens a user holds here. +// +// public.users survives on purpose: addons.added_by would refuse the delete, +// and two created_by columns would quietly null out provenance. Deleting a +// user outright already belongs to the admin route. +func (s *Service) PurgeUser(ctx context.Context, userID uuid.UUID) error { + txDB, tx, err := s.db.WithTx(ctx) + if err != nil { + return fmt.Errorf("start purge transaction: %w", err) + } + defer func() { + _ = tx.Rollback(ctx) + }() + + // Read first, because afterwards nothing says which teams cached this user. + // That also bounds repair: a crash before the evictions below leaves entries + // a retry can no longer find, so they stand until they expire. Recording + // them would be a teardown log, which is a lot for a five-minute worst case. + teamIDs, err := txDB.ListUserTeamIDs(ctx, userID) + if err != nil { + return fmt.Errorf("list user projects: %w", err) + } + + if err := txDB.PurgeUserMemberships(ctx, userID); err != nil { + return fmt.Errorf("purge user memberships: %w", err) + } + + if err := txDB.PurgeUserAccessTokens(ctx, userID); err != nil { + return fmt.Errorf("purge user access tokens: %w", err) + } + + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("commit user purge: %w", err) + } + + for _, teamID := range teamIDs { + s.cache.InvalidateTeamMemberCache(ctx, userID, teamID.String()) + } + + return nil +} diff --git a/packages/dashboard-api/internal/management/service_test.go b/packages/dashboard-api/internal/management/service_test.go new file mode 100644 index 0000000000..91bbe1dbb7 --- /dev/null +++ b/packages/dashboard-api/internal/management/service_test.go @@ -0,0 +1,361 @@ +package management + +import ( + "context" + "testing" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/stretchr/testify/require" + + sharedauth "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/db/pkg/testutils" +) + +// The push is the only way membership reaches this side, so a repeated one must +// land on the same state rather than a duplicate row or a rejection. +func TestSetAddsAndIsIdempotent(t *testing.T) { + t.Parallel() + + db := testutils.SetupDatabase(t) + teamID := testutils.CreateTestTeam(t, db) + service, _ := newService(db) + userID := uuid.New() + + require.NoError(t, service.SetProjectMembers(t.Context(), MemberChange{ProjectID: teamID, Present: []uuid.UUID{userID}})) + require.NoError(t, service.SetProjectMembers(t.Context(), MemberChange{ProjectID: teamID, Present: []uuid.UUID{userID}})) + + require.Equal(t, []uuid.UUID{userID}, teamMembers(t, db, teamID)) +} + +// The caller knows only opaque ids, and users_teams still points at +// public.users, so without the anchor the first push for an unseen user fails. +func TestSetAnchorsUnknownUsers(t *testing.T) { + t.Parallel() + + db := testutils.SetupDatabase(t) + teamID := testutils.CreateTestTeam(t, db) + service, _ := newService(db) + userID := uuid.New() + addedBy := uuid.New() + + require.NoError(t, service.SetProjectMembers(t.Context(), MemberChange{ProjectID: teamID, Present: []uuid.UUID{userID}, AddedBy: &addedBy})) + + require.True(t, publicUserExists(t, db, userID)) + // added_by carries its own key, so an unknown actor fails the insert on a + // column nobody was looking at. + require.True(t, publicUserExists(t, db, addedBy)) +} + +// The assertion the type exists for: InvalidateTeamCache finds keys by reading +// users_teams, so a removed member is one it cannot see. Writing and +// invalidating separately would leave this user authenticating until expiry. +func TestSetEvictsRemovedMembersTheSweepCannotSee(t *testing.T) { + t.Parallel() + + db := testutils.SetupDatabase(t) + teamID := testutils.CreateTestTeam(t, db) + service, cache := newService(db) + userID := uuid.New() + + require.NoError(t, service.SetProjectMembers(t.Context(), MemberChange{ProjectID: teamID, Present: []uuid.UUID{userID}})) + cache.reset() + + require.NoError(t, service.SetProjectMembers(t.Context(), MemberChange{ProjectID: teamID, Absent: []uuid.UUID{userID}})) + + require.Empty(t, teamMembers(t, db, teamID)) + require.Equal(t, []memberKey{{userID: userID, teamID: teamID.String()}}, cache.members) +} + +// Membership does not change the team, so entries keyed by API key hold nothing +// this write invalidated. Sweeping them would evict every key on the project. +func TestSetLeavesTeamWideCacheEntriesAlone(t *testing.T) { + t.Parallel() + + db := testutils.SetupDatabase(t) + teamID := testutils.CreateTestTeam(t, db) + service, cache := newService(db) + + require.NoError(t, service.SetProjectMembers(t.Context(), MemberChange{ProjectID: teamID, Present: []uuid.UUID{uuid.New()}})) + + require.Empty(t, cache.teams) +} + +// Removing a membership that is not there is the desired state, not a failure — +// and the eviction still has to run. +// +// A crash before the eviction leaves a stale entry only a retry can clear, and +// that retry sees exactly this: nothing left to delete. Keying the eviction off +// what the statement touched would make the recovery path a no-op. +func TestSetRemovingAnAbsentMemberSucceedsAndStillEvicts(t *testing.T) { + t.Parallel() + + db := testutils.SetupDatabase(t) + teamID := testutils.CreateTestTeam(t, db) + service, cache := newService(db) + userID := uuid.New() + + require.NoError(t, service.SetProjectMembers(t.Context(), MemberChange{ProjectID: teamID, Absent: []uuid.UUID{userID}})) + + require.Equal(t, []memberKey{{userID: userID, teamID: teamID.String()}}, cache.members) +} + +// One call carries both directions so the caller never has to order them. +func TestSetAppliesBothDirectionsAtOnce(t *testing.T) { + t.Parallel() + + db := testutils.SetupDatabase(t) + teamID := testutils.CreateTestTeam(t, db) + service, _ := newService(db) + staying, leaving, joining := uuid.New(), uuid.New(), uuid.New() + + require.NoError(t, service.SetProjectMembers(t.Context(), MemberChange{ProjectID: teamID, Present: []uuid.UUID{staying, leaving}})) + require.NoError(t, service.SetProjectMembers(t.Context(), MemberChange{ProjectID: teamID, Present: []uuid.UUID{joining}, Absent: []uuid.UUID{leaving}})) + + require.ElementsMatch(t, []uuid.UUID{staying, joining}, teamMembers(t, db, teamID)) +} + +// A request states presence only for the users it lists, so a batch naming two +// members must not disturb a third. +func TestSetIgnoresUnlistedMembers(t *testing.T) { + t.Parallel() + + db := testutils.SetupDatabase(t) + teamID := testutils.CreateTestTeam(t, db) + service, _ := newService(db) + listed, unlisted := uuid.New(), uuid.New() + + require.NoError(t, service.SetProjectMembers(t.Context(), MemberChange{ProjectID: teamID, Present: []uuid.UUID{listed, unlisted}})) + require.NoError(t, service.SetProjectMembers(t.Context(), MemberChange{ProjectID: teamID, Absent: []uuid.UUID{listed}})) + + require.Equal(t, []uuid.UUID{unlisted}, teamMembers(t, db, teamID)) +} + +// A push naming an unknown project is divergence, and the caller decides what to +// do about it. Nothing may be written on the way to saying so. +func TestSetReportsAnUnknownProject(t *testing.T) { + t.Parallel() + + db := testutils.SetupDatabase(t) + service, cache := newService(db) + userID := uuid.New() + + err := service.SetProjectMembers(t.Context(), MemberChange{ProjectID: uuid.New(), Present: []uuid.UUID{userID}}) + + require.ErrorIs(t, err, ErrProjectNotFound) + require.False(t, publicUserExists(t, db, userID)) + require.Empty(t, cache.members) +} + +// Backfilled legacy teams carry default memberships, so a push can remove one. +// Allowed: the caller owns membership, and signup recreates a missing default. +func TestSetRemovesADefaultMembership(t *testing.T) { + t.Parallel() + + db := testutils.SetupDatabase(t) + teamID := testutils.CreateTestTeam(t, db) + service, _ := newService(db) + userID := uuid.New() + + require.NoError(t, service.SetProjectMembers(t.Context(), MemberChange{ProjectID: teamID, Present: []uuid.UUID{userID}})) + require.NoError(t, db.AuthDB.TestsRawSQL(t.Context(), + "UPDATE public.users_teams SET is_default = true WHERE team_id = $1 AND user_id = $2", teamID, userID)) + + require.NoError(t, service.SetProjectMembers(t.Context(), MemberChange{ProjectID: teamID, Absent: []uuid.UUID{userID}})) + require.Empty(t, teamMembers(t, db, teamID)) +} + +func TestPurgeUserClearsMembershipsAndTokensAcrossProjects(t *testing.T) { + t.Parallel() + + db := testutils.SetupDatabase(t) + first := testutils.CreateTestTeam(t, db) + second := testutils.CreateTestTeam(t, db) + service, cache := newService(db) + userID, other := uuid.New(), uuid.New() + + require.NoError(t, service.SetProjectMembers(t.Context(), MemberChange{ProjectID: first, Present: []uuid.UUID{userID, other}})) + require.NoError(t, service.SetProjectMembers(t.Context(), MemberChange{ProjectID: second, Present: []uuid.UUID{userID}})) + createAccessToken(t, db, userID) + createAccessToken(t, db, other) + cache.reset() + + require.NoError(t, service.PurgeUser(t.Context(), userID)) + + require.Equal(t, []uuid.UUID{other}, teamMembers(t, db, first)) + require.Empty(t, teamMembers(t, db, second)) + require.Zero(t, accessTokenCount(t, db, userID)) + require.Equal(t, 1, accessTokenCount(t, db, other)) + require.ElementsMatch(t, []memberKey{ + {userID: userID, teamID: first.String()}, + {userID: userID, teamID: second.String()}, + }, cache.members) +} + +// The row anchors keys that outlive the user's access: addons.added_by refuses +// the delete, and two created_by columns would null out provenance. +func TestPurgeUserLeavesTheUserRowStanding(t *testing.T) { + t.Parallel() + + db := testutils.SetupDatabase(t) + teamID := testutils.CreateTestTeam(t, db) + service, _ := newService(db) + userID := uuid.New() + + require.NoError(t, service.SetProjectMembers(t.Context(), MemberChange{ProjectID: teamID, Present: []uuid.UUID{userID}})) + require.NoError(t, service.PurgeUser(t.Context(), userID)) + + require.True(t, publicUserExists(t, db, userID)) +} + +// The caller fans a purge out to every control plane, so most hold nothing. +func TestPurgeUserWithNothingToPurgeSucceeds(t *testing.T) { + t.Parallel() + + db := testutils.SetupDatabase(t) + service, _ := newService(db) + + require.NoError(t, service.PurgeUser(t.Context(), uuid.New())) + require.NoError(t, service.PurgeUser(t.Context(), uuid.New())) +} + +func newService(db *testutils.Database) (*Service, *recordingCache) { + cache := &recordingCache{} + + return NewService(db.AuthDB, cache), cache +} + +func teamMembers(t *testing.T, db *testutils.Database, teamID uuid.UUID) []uuid.UUID { + t.Helper() + + var members []uuid.UUID + err := db.AuthDB.TestsRawSQLQuery(t.Context(), + "SELECT user_id FROM public.users_teams WHERE team_id = $1 ORDER BY user_id", + func(rows pgx.Rows) error { + for rows.Next() { + var userID uuid.UUID + if err := rows.Scan(&userID); err != nil { + return err + } + members = append(members, userID) + } + + return nil + }, teamID) + require.NoError(t, err) + + return members +} + +func publicUserExists(t *testing.T, db *testutils.Database, userID uuid.UUID) bool { + t.Helper() + + var exists bool + err := db.AuthDB.TestsRawSQLQuery(t.Context(), + "SELECT EXISTS (SELECT 1 FROM public.users WHERE id = $1)", + func(rows pgx.Rows) error { + rows.Next() + + return rows.Scan(&exists) + }, userID) + require.NoError(t, err) + + return exists +} + +func createAccessToken(t *testing.T, db *testutils.Database, userID uuid.UUID) { + t.Helper() + + require.NoError(t, db.AuthDB.TestsRawSQL(t.Context(), + `INSERT INTO public.users (id) VALUES ($1) ON CONFLICT (id) DO NOTHING`, userID)) + require.NoError(t, db.AuthDB.TestsRawSQL(t.Context(), + `INSERT INTO public.access_tokens (id, user_id, access_token_hash, access_token_prefix, + access_token_length, access_token_mask_prefix, access_token_mask_suffix, name) + VALUES ($1, $2, $3, 'e2b_', 8, 'e2b_', 'aaaa', 'test')`, + uuid.New(), userID, uuid.NewString())) +} + +func accessTokenCount(t *testing.T, db *testutils.Database, userID uuid.UUID) int { + t.Helper() + + var count int + err := db.AuthDB.TestsRawSQLQuery(t.Context(), + "SELECT count(*) FROM public.access_tokens WHERE user_id = $1", + func(rows pgx.Rows) error { + rows.Next() + + return rows.Scan(&count) + }, userID) + require.NoError(t, err) + + return count +} + +type memberKey struct { + userID uuid.UUID + teamID string +} + +// recordingCache captures the evictions, which are the half of these operations +// with no trace in the database. +type recordingCache struct { + noopAuthService + + members []memberKey + teams []uuid.UUID +} + +func (c *recordingCache) reset() { + c.members = nil + c.teams = nil +} + +func (c *recordingCache) InvalidateTeamMemberCache(_ context.Context, userID uuid.UUID, teamID string) { + c.members = append(c.members, memberKey{userID: userID, teamID: teamID}) +} + +func (c *recordingCache) InvalidateTeamCache(_ context.Context, teamID uuid.UUID) error { + c.teams = append(c.teams, teamID) + + return nil +} + +// noopAuthService covers what these tests never reach, so recordingCache only +// implements the two methods it asserts on. +type noopAuthService struct{} + +var _ sharedauth.Service = noopAuthService{} + +func (noopAuthService) ValidateAPIKey(context.Context, *gin.Context, string) (*authtypes.Team, *sharedauth.APIError) { + return nil, nil +} + +func (noopAuthService) ValidateAccessToken(context.Context, *gin.Context, string) (uuid.UUID, *sharedauth.APIError) { + return uuid.Nil, nil +} + +func (noopAuthService) ValidateAuthProviderToken(context.Context, *gin.Context, string) (uuid.UUID, *sharedauth.APIError) { + return uuid.Nil, nil +} + +func (noopAuthService) ValidateAuthProviderTeam(context.Context, *gin.Context, string) (*authtypes.Team, *sharedauth.APIError) { + return nil, nil +} + +func (noopAuthService) GetTeamByID(context.Context, uuid.UUID) (*authtypes.Team, error) { + return nil, nil +} + +func (noopAuthService) InvalidateTeamMemberCache(context.Context, uuid.UUID, string) {} + +func (noopAuthService) InvalidateAPIKeyCache(context.Context, string) {} + +func (noopAuthService) InvalidateTeamCache(context.Context, uuid.UUID) error { + return nil +} + +func (noopAuthService) Close(context.Context) error { + return nil +} diff --git a/packages/db/pkg/auth/queries/purge_user.sql.go b/packages/db/pkg/auth/queries/purge_user.sql.go new file mode 100644 index 0000000000..a4a2b74de2 --- /dev/null +++ b/packages/db/pkg/auth/queries/purge_user.sql.go @@ -0,0 +1,61 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: purge_user.sql + +package authqueries + +import ( + "context" + + "github.com/google/uuid" +) + +const listUserTeamIDs = `-- name: ListUserTeamIDs :many + +SELECT team_id FROM public.users_teams +WHERE user_id = $1::uuid +` + +// Cluster-local teardown of one user's state, for the management interface's +// purge route. public.users is deliberately left standing. +// Read before the delete: afterwards nothing says which teams cached the user. +func (q *Queries) ListUserTeamIDs(ctx context.Context, userID uuid.UUID) ([]uuid.UUID, error) { + rows, err := q.db.Query(ctx, listUserTeamIDs, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []uuid.UUID + for rows.Next() { + var team_id uuid.UUID + if err := rows.Scan(&team_id); err != nil { + return nil, err + } + items = append(items, team_id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const purgeUserAccessTokens = `-- name: PurgeUserAccessTokens :exec +DELETE FROM public.access_tokens +WHERE user_id = $1::uuid +` + +func (q *Queries) PurgeUserAccessTokens(ctx context.Context, userID uuid.UUID) error { + _, err := q.db.Exec(ctx, purgeUserAccessTokens, userID) + return err +} + +const purgeUserMemberships = `-- name: PurgeUserMemberships :exec +DELETE FROM public.users_teams +WHERE user_id = $1::uuid +` + +func (q *Queries) PurgeUserMemberships(ctx context.Context, userID uuid.UUID) error { + _, err := q.db.Exec(ctx, purgeUserMemberships, userID) + return err +} diff --git a/packages/db/pkg/auth/queries/team_management.sql.go b/packages/db/pkg/auth/queries/team_management.sql.go new file mode 100644 index 0000000000..d967008eda --- /dev/null +++ b/packages/db/pkg/auth/queries/team_management.sql.go @@ -0,0 +1,121 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: team_management.sql + +package authqueries + +import ( + "context" + + "github.com/google/uuid" +) + +const insertManagedTeam = `-- name: InsertManagedTeam :one + +INSERT INTO public.teams (id, name, slug, tier, email, is_blocked) +VALUES ( + $1::uuid, + $2::text, + $3::text, + $4::text, + $5::text, + false +) +ON CONFLICT (id) DO NOTHING +RETURNING id, name, slug, email +` + +type InsertManagedTeamParams struct { + ID uuid.UUID + Name string + Slug string + Tier string + Email string +} + +type InsertManagedTeamRow struct { + ID uuid.UUID + Name string + Slug string + Email string +} + +// Project reconciliation for the control-plane management interface. The caller +// supplies the id, so create and reconcile are one request and these are its +// branches. +// Yields no row when the id is taken, which is the signal to reconcile. DO +// NOTHING rather than DO UPDATE, so a slug collision still surfaces as the +// distinct violation it is. +func (q *Queries) InsertManagedTeam(ctx context.Context, arg InsertManagedTeamParams) (InsertManagedTeamRow, error) { + row := q.db.QueryRow(ctx, insertManagedTeam, + arg.ID, + arg.Name, + arg.Slug, + arg.Tier, + arg.Email, + ) + var i InsertManagedTeamRow + err := row.Scan( + &i.ID, + &i.Name, + &i.Slug, + &i.Email, + ) + return i, err +} + +const lockManagedTeam = `-- name: LockManagedTeam :one +SELECT id, slug FROM public.teams +WHERE id = $1::uuid +FOR UPDATE +` + +type LockManagedTeamRow struct { + ID uuid.UUID + Slug string +} + +func (q *Queries) LockManagedTeam(ctx context.Context, id uuid.UUID) (LockManagedTeamRow, error) { + row := q.db.QueryRow(ctx, lockManagedTeam, id) + var i LockManagedTeamRow + err := row.Scan(&i.ID, &i.Slug) + return i, err +} + +const updateManagedTeam = `-- name: UpdateManagedTeam :one +UPDATE public.teams +SET + name = $1::text, + email = COALESCE($2::text, email) +WHERE id = $3::uuid +RETURNING id, name, slug, email +` + +type UpdateManagedTeamParams struct { + Name string + Email *string + ID uuid.UUID +} + +type UpdateManagedTeamRow struct { + ID uuid.UUID + Name string + Slug string + Email string +} + +// Touches only what a reconcile may change. Tier stays because limits arrive +// through their own route; is_blocked stays because an operator's decision must +// outlive a routine name push. +func (q *Queries) UpdateManagedTeam(ctx context.Context, arg UpdateManagedTeamParams) (UpdateManagedTeamRow, error) { + row := q.db.QueryRow(ctx, updateManagedTeam, arg.Name, arg.Email, arg.ID) + var i UpdateManagedTeamRow + err := row.Scan( + &i.ID, + &i.Name, + &i.Slug, + &i.Email, + ) + return i, err +} diff --git a/packages/db/pkg/auth/queries/team_membership_sync.sql.go b/packages/db/pkg/auth/queries/team_membership_sync.sql.go new file mode 100644 index 0000000000..9053a4b068 --- /dev/null +++ b/packages/db/pkg/auth/queries/team_membership_sync.sql.go @@ -0,0 +1,90 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: team_membership_sync.sql + +package authqueries + +import ( + "context" + + "github.com/google/uuid" +) + +const syncTeamMembersAbsent = `-- name: SyncTeamMembersAbsent :many +DELETE FROM public.users_teams +WHERE team_id = $1::uuid + AND user_id = ANY($2::uuid[]) +RETURNING user_id, is_default +` + +type SyncTeamMembersAbsentParams struct { + TeamID uuid.UUID + UserIds []uuid.UUID +} + +type SyncTeamMembersAbsentRow struct { + UserID uuid.UUID + IsDefault bool +} + +// Returns what it removed, because afterwards the rows are gone — the same +// reason InvalidateTeamCache cannot find them either. +func (q *Queries) SyncTeamMembersAbsent(ctx context.Context, arg SyncTeamMembersAbsentParams) ([]SyncTeamMembersAbsentRow, error) { + rows, err := q.db.Query(ctx, syncTeamMembersAbsent, arg.TeamID, arg.UserIds) + if err != nil { + return nil, err + } + defer rows.Close() + var items []SyncTeamMembersAbsentRow + for rows.Next() { + var i SyncTeamMembersAbsentRow + if err := rows.Scan(&i.UserID, &i.IsDefault); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const syncTeamMembersPresent = `-- name: SyncTeamMembersPresent :exec +INSERT INTO public.users_teams (user_id, team_id, is_default, added_by) +SELECT + candidate, + $1::uuid, + false, + $2::uuid +FROM unnest($3::uuid[]) AS candidate +ON CONFLICT (team_id, user_id) DO NOTHING +` + +type SyncTeamMembersPresentParams struct { + TeamID uuid.UUID + AddedBy *uuid.UUID + UserIds []uuid.UUID +} + +func (q *Queries) SyncTeamMembersPresent(ctx context.Context, arg SyncTeamMembersPresentParams) error { + _, err := q.db.Exec(ctx, syncTeamMembersPresent, arg.TeamID, arg.AddedBy, arg.UserIds) + return err +} + +const teamExists = `-- name: TeamExists :one + +SELECT EXISTS ( + SELECT 1 FROM public.teams WHERE id = $1::uuid +)::boolean +` + +// Membership reconciliation for the control-plane management interface. Unlike +// the dashboard's member routes these enforce no team-side rules: the caller +// owns membership, and a rule here would make its pushes unrepeatable. +func (q *Queries) TeamExists(ctx context.Context, teamID uuid.UUID) (bool, error) { + row := q.db.QueryRow(ctx, teamExists, teamID) + var column_1 bool + err := row.Scan(&column_1) + return column_1, err +} diff --git a/packages/db/pkg/auth/queries/upsert_public_users.sql.go b/packages/db/pkg/auth/queries/upsert_public_users.sql.go new file mode 100644 index 0000000000..87ba5b55c0 --- /dev/null +++ b/packages/db/pkg/auth/queries/upsert_public_users.sql.go @@ -0,0 +1,25 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: upsert_public_users.sql + +package authqueries + +import ( + "context" + + "github.com/google/uuid" +) + +const upsertPublicUsers = `-- name: UpsertPublicUsers :exec +INSERT INTO public.users (id) +SELECT candidate FROM unnest($1::uuid[]) AS candidate +ON CONFLICT (id) DO NOTHING +` + +// Bulk form of UpsertPublicUser: anchors rows so memberships referencing them +// satisfy the foreign keys on users_teams. +func (q *Queries) UpsertPublicUsers(ctx context.Context, ids []uuid.UUID) error { + _, err := q.db.Exec(ctx, upsertPublicUsers, ids) + return err +} diff --git a/packages/db/pkg/auth/sql_queries/teams/team_management.sql b/packages/db/pkg/auth/sql_queries/teams/team_management.sql new file mode 100644 index 0000000000..8beb33031c --- /dev/null +++ b/packages/db/pkg/auth/sql_queries/teams/team_management.sql @@ -0,0 +1,35 @@ +-- Project reconciliation for the control-plane management interface. The caller +-- supplies the id, so create and reconcile are one request and these are its +-- branches. + +-- Yields no row when the id is taken, which is the signal to reconcile. DO +-- NOTHING rather than DO UPDATE, so a slug collision still surfaces as the +-- distinct violation it is. +-- name: InsertManagedTeam :one +INSERT INTO public.teams (id, name, slug, tier, email, is_blocked) +VALUES ( + sqlc.arg(id)::uuid, + sqlc.arg(name)::text, + sqlc.arg(slug)::text, + sqlc.arg(tier)::text, + sqlc.arg(email)::text, + false +) +ON CONFLICT (id) DO NOTHING +RETURNING id, name, slug, email; + +-- name: LockManagedTeam :one +SELECT id, slug FROM public.teams +WHERE id = sqlc.arg(id)::uuid +FOR UPDATE; + +-- Touches only what a reconcile may change. Tier stays because limits arrive +-- through their own route; is_blocked stays because an operator's decision must +-- outlive a routine name push. +-- name: UpdateManagedTeam :one +UPDATE public.teams +SET + name = sqlc.arg(name)::text, + email = COALESCE(sqlc.narg(email)::text, email) +WHERE id = sqlc.arg(id)::uuid +RETURNING id, name, slug, email; diff --git a/packages/db/pkg/auth/sql_queries/teams/team_membership_sync.sql b/packages/db/pkg/auth/sql_queries/teams/team_membership_sync.sql new file mode 100644 index 0000000000..4c1000ce44 --- /dev/null +++ b/packages/db/pkg/auth/sql_queries/teams/team_membership_sync.sql @@ -0,0 +1,26 @@ +-- Membership reconciliation for the control-plane management interface. Unlike +-- the dashboard's member routes these enforce no team-side rules: the caller +-- owns membership, and a rule here would make its pushes unrepeatable. + +-- name: TeamExists :one +SELECT EXISTS ( + SELECT 1 FROM public.teams WHERE id = sqlc.arg(team_id)::uuid +)::boolean; + +-- name: SyncTeamMembersPresent :exec +INSERT INTO public.users_teams (user_id, team_id, is_default, added_by) +SELECT + candidate, + sqlc.arg(team_id)::uuid, + false, + sqlc.narg(added_by)::uuid +FROM unnest(sqlc.arg(user_ids)::uuid[]) AS candidate +ON CONFLICT (team_id, user_id) DO NOTHING; + +-- Returns what it removed, because afterwards the rows are gone — the same +-- reason InvalidateTeamCache cannot find them either. +-- name: SyncTeamMembersAbsent :many +DELETE FROM public.users_teams +WHERE team_id = sqlc.arg(team_id)::uuid + AND user_id = ANY(sqlc.arg(user_ids)::uuid[]) +RETURNING user_id, is_default; diff --git a/packages/db/pkg/auth/sql_queries/users/purge_user.sql b/packages/db/pkg/auth/sql_queries/users/purge_user.sql new file mode 100644 index 0000000000..2ab23d52ac --- /dev/null +++ b/packages/db/pkg/auth/sql_queries/users/purge_user.sql @@ -0,0 +1,15 @@ +-- Cluster-local teardown of one user's state, for the management interface's +-- purge route. public.users is deliberately left standing. + +-- Read before the delete: afterwards nothing says which teams cached the user. +-- name: ListUserTeamIDs :many +SELECT team_id FROM public.users_teams +WHERE user_id = sqlc.arg(user_id)::uuid; + +-- name: PurgeUserMemberships :exec +DELETE FROM public.users_teams +WHERE user_id = sqlc.arg(user_id)::uuid; + +-- name: PurgeUserAccessTokens :exec +DELETE FROM public.access_tokens +WHERE user_id = sqlc.arg(user_id)::uuid; diff --git a/packages/db/pkg/auth/sql_queries/users/upsert_public_users.sql b/packages/db/pkg/auth/sql_queries/users/upsert_public_users.sql new file mode 100644 index 0000000000..5f2f80174e --- /dev/null +++ b/packages/db/pkg/auth/sql_queries/users/upsert_public_users.sql @@ -0,0 +1,6 @@ +-- Bulk form of UpsertPublicUser: anchors rows so memberships referencing them +-- satisfy the foreign keys on users_teams. +-- name: UpsertPublicUsers :exec +INSERT INTO public.users (id) +SELECT candidate FROM unnest(sqlc.arg(ids)::uuid[]) AS candidate +ON CONFLICT (id) DO NOTHING; diff --git a/packages/db/pkg/dberrors/dberrors.go b/packages/db/pkg/dberrors/dberrors.go index 07a1c05f4f..cd7720eda1 100644 --- a/packages/db/pkg/dberrors/dberrors.go +++ b/packages/db/pkg/dberrors/dberrors.go @@ -41,3 +41,16 @@ func IsCheckViolation(err error) bool { return false } + +// ConstraintName names the constraint an error violated, or "" when the error +// is not a constraint violation. A statement carrying more than one constraint +// needs the name to say which rule the request actually broke, and those rules +// rarely map to the same response. +func ConstraintName(err error) string { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + return pgErr.ConstraintName + } + + return "" +} diff --git a/spec/openapi-dashboard.yml b/spec/openapi-dashboard.yml index 9e752a127f..41fdd4f86e 100644 --- a/spec/openapi-dashboard.yml +++ b/spec/openapi-dashboard.yml @@ -1189,6 +1189,17 @@ components: maxLength: 63 project_type: $ref: "#/components/schemas/ManagementProjectType" + email: + type: string + maxLength: 255 + description: >- + Contact address recorded on the project. Required to create one and + optional to reconcile it, which is why it is not listed as required + here — a single operation cannot say that. + + A create has nowhere else to get an address from. A reconcile + already has one stored, so omitting it leaves that value alone + rather than blanking it. ManagementProject: allOf: @@ -1988,6 +1999,12 @@ paths: delete: operationId: managementDeleteProject summary: Delete a project and its control-plane state (v1). + description: >- + Declared, and answered with 501 by every control plane. Deleting a + project means reclaiming templates, snapshots, volumes, running + sandboxes and their stored artifacts, and no single service can reach + all of them today. Callers should not depend on this operation until + that changes. tags: [control-plane-management] security: - AdminJWTAuth: [] From 17128ae64411730c6a2b1ceed61c5b70dfc59fbf Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Wed, 29 Jul 2026 17:14:11 +0200 Subject: [PATCH 2/9] fix(dashboard-api): drive purge evictions from the delete, not a prior read Under READ COMMITTED the pre-read and the unconditional delete take different statement snapshots, so a membership committed between them was removed without its cache entry being evicted. DELETE ... RETURNING makes the eviction set the deleted set by construction, matching what the membership sync already does. --- .../internal/management/service.go | 17 ++++--- .../internal/management/service_test.go | 4 ++ .../db/pkg/auth/queries/purge_user.sql.go | 45 +++++++++---------- .../pkg/auth/sql_queries/users/purge_user.sql | 15 ++++--- 4 files changed, 40 insertions(+), 41 deletions(-) diff --git a/packages/dashboard-api/internal/management/service.go b/packages/dashboard-api/internal/management/service.go index e73aa538d6..5669283992 100644 --- a/packages/dashboard-api/internal/management/service.go +++ b/packages/dashboard-api/internal/management/service.go @@ -168,16 +168,11 @@ func (s *Service) PurgeUser(ctx context.Context, userID uuid.UUID) error { _ = tx.Rollback(ctx) }() - // Read first, because afterwards nothing says which teams cached this user. - // That also bounds repair: a crash before the evictions below leaves entries - // a retry can no longer find, so they stand until they expire. Recording - // them would be a teardown log, which is a lot for a five-minute worst case. - teamIDs, err := txDB.ListUserTeamIDs(ctx, userID) + // Driven by what the delete removed, not by a read taken before it. Under + // READ COMMITTED those are different snapshots, and a membership committed + // between them would be deleted here but never evicted below. + teamIDs, err := txDB.PurgeUserMemberships(ctx, userID) if err != nil { - return fmt.Errorf("list user projects: %w", err) - } - - if err := txDB.PurgeUserMemberships(ctx, userID); err != nil { return fmt.Errorf("purge user memberships: %w", err) } @@ -189,6 +184,10 @@ func (s *Service) PurgeUser(ctx context.Context, userID uuid.UUID) error { return fmt.Errorf("commit user purge: %w", err) } + // A crash before this leaves entries a retry cannot find, since the rows it + // would evict from are already gone. They stand until they expire; carrying + // them across the gap would need a teardown log, which is a lot of + // machinery for a five-minute worst case on a crash. for _, teamID := range teamIDs { s.cache.InvalidateTeamMemberCache(ctx, userID, teamID.String()) } diff --git a/packages/dashboard-api/internal/management/service_test.go b/packages/dashboard-api/internal/management/service_test.go index 91bbe1dbb7..9d83eda302 100644 --- a/packages/dashboard-api/internal/management/service_test.go +++ b/packages/dashboard-api/internal/management/service_test.go @@ -167,6 +167,10 @@ func TestSetRemovesADefaultMembership(t *testing.T) { require.Empty(t, teamMembers(t, db, teamID)) } +// The evicted set has to equal the deleted set: exactly the projects the purge +// removed the user from, and no others. Deriving it from the delete rather than +// a preceding read is what keeps that true when a membership is committed while +// the purge is running. func TestPurgeUserClearsMembershipsAndTokensAcrossProjects(t *testing.T) { t.Parallel() diff --git a/packages/db/pkg/auth/queries/purge_user.sql.go b/packages/db/pkg/auth/queries/purge_user.sql.go index a4a2b74de2..21b6cd7645 100644 --- a/packages/db/pkg/auth/queries/purge_user.sql.go +++ b/packages/db/pkg/auth/queries/purge_user.sql.go @@ -11,17 +11,32 @@ import ( "github.com/google/uuid" ) -const listUserTeamIDs = `-- name: ListUserTeamIDs :many +const purgeUserAccessTokens = `-- name: PurgeUserAccessTokens :exec +DELETE FROM public.access_tokens +WHERE user_id = $1::uuid +` + +func (q *Queries) PurgeUserAccessTokens(ctx context.Context, userID uuid.UUID) error { + _, err := q.db.Exec(ctx, purgeUserAccessTokens, userID) + return err +} -SELECT team_id FROM public.users_teams +const purgeUserMemberships = `-- name: PurgeUserMemberships :many + +DELETE FROM public.users_teams WHERE user_id = $1::uuid +RETURNING team_id ` // Cluster-local teardown of one user's state, for the management interface's // purge route. public.users is deliberately left standing. -// Read before the delete: afterwards nothing says which teams cached the user. -func (q *Queries) ListUserTeamIDs(ctx context.Context, userID uuid.UUID) ([]uuid.UUID, error) { - rows, err := q.db.Query(ctx, listUserTeamIDs, userID) +// Returns the projects it touched, so the caller evicts exactly what it +// removed. Reading them beforehand instead would be a second statement +// snapshot under READ COMMITTED, and would miss a membership committed between +// the read and this delete — which this statement removes but the caller would +// then never evict. +func (q *Queries) PurgeUserMemberships(ctx context.Context, userID uuid.UUID) ([]uuid.UUID, error) { + rows, err := q.db.Query(ctx, purgeUserMemberships, userID) if err != nil { return nil, err } @@ -39,23 +54,3 @@ func (q *Queries) ListUserTeamIDs(ctx context.Context, userID uuid.UUID) ([]uuid } return items, nil } - -const purgeUserAccessTokens = `-- name: PurgeUserAccessTokens :exec -DELETE FROM public.access_tokens -WHERE user_id = $1::uuid -` - -func (q *Queries) PurgeUserAccessTokens(ctx context.Context, userID uuid.UUID) error { - _, err := q.db.Exec(ctx, purgeUserAccessTokens, userID) - return err -} - -const purgeUserMemberships = `-- name: PurgeUserMemberships :exec -DELETE FROM public.users_teams -WHERE user_id = $1::uuid -` - -func (q *Queries) PurgeUserMemberships(ctx context.Context, userID uuid.UUID) error { - _, err := q.db.Exec(ctx, purgeUserMemberships, userID) - return err -} diff --git a/packages/db/pkg/auth/sql_queries/users/purge_user.sql b/packages/db/pkg/auth/sql_queries/users/purge_user.sql index 2ab23d52ac..41798eafbc 100644 --- a/packages/db/pkg/auth/sql_queries/users/purge_user.sql +++ b/packages/db/pkg/auth/sql_queries/users/purge_user.sql @@ -1,14 +1,15 @@ -- Cluster-local teardown of one user's state, for the management interface's -- purge route. public.users is deliberately left standing. --- Read before the delete: afterwards nothing says which teams cached the user. --- name: ListUserTeamIDs :many -SELECT team_id FROM public.users_teams -WHERE user_id = sqlc.arg(user_id)::uuid; - --- name: PurgeUserMemberships :exec +-- Returns the projects it touched, so the caller evicts exactly what it +-- removed. Reading them beforehand instead would be a second statement +-- snapshot under READ COMMITTED, and would miss a membership committed between +-- the read and this delete — which this statement removes but the caller would +-- then never evict. +-- name: PurgeUserMemberships :many DELETE FROM public.users_teams -WHERE user_id = sqlc.arg(user_id)::uuid; +WHERE user_id = sqlc.arg(user_id)::uuid +RETURNING team_id; -- name: PurgeUserAccessTokens :exec DELETE FROM public.access_tokens From 4fb338b70126626e371a8b74536cadc0c63c0a79 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Wed, 29 Jul 2026 18:03:57 +0200 Subject: [PATCH 3/9] refactor(dashboard-api): require email, drop project_type, branch on existence The caller synchronizes every property in the upsert body and sends all of them on every push, so email is required rather than optional and a reconcile is a complete statement instead of a patch. project_type leaves the contract. This side has no column for it and no opinion about the caller's plan vocabulary: the tier is assigned once at creation from a local default, and the limits that matter arrive absolute through upsertProjectLimits. Nothing sets additionalProperties, so a caller still sending the field has it ignored rather than rejected. The handler now branches on whether the project exists rather than on an insert failing, which is what makes 'create assigns the tier, reconcile never touches it' visible in the code. --- docs/ARCHITECTURE.md | 5 +- .../dashboard-api/internal/api/api.gen.go | 278 +++++++++--------- .../handlers/management_contract_test.go | 54 +--- .../handlers/management_project_upsert.go | 145 +++++---- .../management_project_upsert_test.go | 47 +-- .../pkg/auth/queries/team_management.sql.go | 27 +- .../sql_queries/teams/team_management.sql | 27 +- spec/openapi-dashboard.yml | 35 +-- 8 files changed, 279 insertions(+), 339 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1cb115d2bc..ccd3cff33c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -212,8 +212,9 @@ the same config shape as `AUTH_PROVIDER_CONFIG`. Talks to Postgres and ClickHous orchestrators. The `/v1/management` operations are the cluster's half of a contract the workspace residency owns: -project upsert (a project is a `public.teams` row created from a caller-supplied UUID on the -`base_v1` tier), member sync (granular and batched, over opaque user UUIDs in `users_teams`), +project upsert (a project is a `public.teams` row created from a caller-supplied UUID; the tier is +assigned once at creation from a local default and no push moves it), member sync (granular and +batched, over opaque user UUIDs in `users_teams`), limit sync (into `project_limits`, which `team_limits` reads in preference to `tiers`), and user purge (memberships and access tokens; the `public.users` row survives). All are idempotent, because the caller is level-triggered and retries. Membership writes live in `internal/management` with diff --git a/packages/dashboard-api/internal/api/api.gen.go b/packages/dashboard-api/internal/api/api.gen.go index 4369af8615..d4de53cc95 100644 --- a/packages/dashboard-api/internal/api/api.gen.go +++ b/packages/dashboard-api/internal/api/api.gen.go @@ -348,16 +348,11 @@ type ManagementMemberUpsertRequest struct { // ManagementProject defines model for ManagementProject. type ManagementProject struct { - // Email Contact address recorded on the project. Optional, because a caller that does not track one has nothing to send. - // Omitting it means different things on the two paths. On a create there is nothing to preserve, so the address is stored empty. On a reconcile it means leave the stored address alone: blanking one a project already has would discard something the caller never asked to change. - Email *string `json:"email,omitempty"` + // Email Contact address recorded on the project. + Email string `json:"email"` Id openapi_types.UUID `json:"id"` Name string `json:"name"` - - // ProjectType The caller's name for the project's tier. Recorded, not interpreted: limits arrive separately and in full through upsertProjectLimits, so nothing here derives behaviour from this value. - // Deliberately unconstrained. The caller owns the vocabulary and the catalog behind it, and enumerating it here would make adding a tier a cross-repo change to a field this side only stores. - ProjectType ManagementProjectType `json:"project_type"` - Slug string `json:"slug"` + Slug string `json:"slug"` } // ManagementProjectLimits A project's effective limits, already resolved by the caller. Every field is absolute: this side stores what it is given and performs no arithmetic of its own. @@ -377,21 +372,14 @@ type ManagementProjectLimits struct { MaxVcpu int32 `json:"max_vcpu"` } -// ManagementProjectType The caller's name for the project's tier. Recorded, not interpreted: limits arrive separately and in full through upsertProjectLimits, so nothing here derives behaviour from this value. -// Deliberately unconstrained. The caller owns the vocabulary and the catalog behind it, and enumerating it here would make adding a tier a cross-repo change to a field this side only stores. -type ManagementProjectType = string - -// ManagementProjectUpsertRequest defines model for ManagementProjectUpsertRequest. +// ManagementProjectUpsertRequest The properties of a project this side stores. Every one is synchronized by the caller and sent on every push, so a reconcile is a complete statement of the project rather than a patch. +// +// A project's tier is not among them. It is assigned once, at creation, from this side's own default, and no push moves it — limits arrive separately and in full through upsertProjectLimits, which takes precedence over the tier anyway. type ManagementProjectUpsertRequest struct { - // Email Contact address recorded on the project. Optional, because a caller that does not track one has nothing to send. - // Omitting it means different things on the two paths. On a create there is nothing to preserve, so the address is stored empty. On a reconcile it means leave the stored address alone: blanking one a project already has would discard something the caller never asked to change. - Email *string `json:"email,omitempty"` - Name string `json:"name"` - - // ProjectType The caller's name for the project's tier. Recorded, not interpreted: limits arrive separately and in full through upsertProjectLimits, so nothing here derives behaviour from this value. - // Deliberately unconstrained. The caller owns the vocabulary and the catalog behind it, and enumerating it here would make adding a tier a cross-repo change to a field this side only stores. - ProjectType ManagementProjectType `json:"project_type"` - Slug string `json:"slug"` + // Email Contact address recorded on the project. + Email string `json:"email"` + Name string `json:"name"` + Slug string `json:"slug"` } // MemoryMB Memory for the sandbox in MiB @@ -2033,131 +2021,127 @@ 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{ - "7H1tcxu58edXQfFSlc0VSclPqVtd5YUlebNO1rsuW85eldcngzNNEtEMMAtgKHMVVd2HuE/4/yT/QgOY", - "wTwPKVGxN36TrEUM0Gj80OgnNG4mkUgzwYFrNTm5mWRU0hQ0SPzXImdJfMli898xqEiyTDPBJyeTlzFw", - "zZYMJBFLotdAsO18Mp0w83tG9XoynXCawuSk7Gc6kfBrziTEkxMtc5hOVLSGlJoBlkKmVE9OJnmOLfU2", - "M98qLRlfTW5vp0U3l0JeakizhGpokvYT/gdNyJIlGiRZbC1thBU0T4n/vPJHIcu/04RRVUzn1xzktjmf", - "CiHhXLppV02Cz0Sa0pkCw3sNMUmY0oarluqX54poQVagidJU5woUWQppSINPWSJimJwsaaKgn1TVy3um", - "IVUjFmE6Semnl7bxo+Pj4ncqJTWD5pz9moNrYAa5nU6U3iamjel6UnDCz2VXdhQ80IIwHiV5DGNZUQzZ", - "OvM/SFhOTib/46jcEEe2mTo6NUO/xc/NDCqT7pqhuoxyqYRsmSD+nUjQueQQG4CaDZRJ2DCRKzthCSoT", - "XAFhnHyMJBhWXFL9L7+eH4ldqi6IusFHgFJdJixluknnK/qJpXlKeJ4u7D5HZhnOW9pJBpJkdAVdRNiO", - "QxpiWNI80ZOTZ8fTEmyM6yePJwguM6LDVsq4+1fBcsY1rEAi8YryeCE+vTwfI51c4w75VHbVt0ma/NN0", - "1Rz8wosQTVfEDGAYFq0huupik+mmb+CU8R+Ar/Q6ZEWVjEuqFFvxFLgev5wSaLwl5ZdEiuv68mq66iK7", - "/PCH7nX+c98yP342uMrVud3nprK92l0VcO+etlad7tGL0r8c/7bdZuazkiLP7nMJbpSQ+vZfN2a7KH0Z", - "rMjtv240Xd36pSDfmJYzTVcriKfkzXdnT548+fZHysWfejaVOhu7Um5moxcpZkozHmmzPXZZItO8Z7OY", - "/XBvy6SAymjdskxUwYxxBVwxzTZAVL6w7PAak+CF7JqT50kiriEm0ZpKGhnNkFAJ5COd/fZxSj4ez741", - "/zc3/3Np/mf2sWvyjqCKZKOfvGT789OpkcoapPn0/76ns9+OZ9/OL2cf/ucfJtP+dTPgaE70rZCaCBmD", - "NChCLBbYNPOzH3dSa/psXaWJg6sZzage3CzH+9pfC0hHrsvwP7HNh65J4dk0fKx0nGV3PkyApuPOVNOy", - "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", - "7ze8/XD73wEAAP//", + "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", } // decodeSpec returns the embedded OpenAPI spec as raw JSON bytes, diff --git a/packages/dashboard-api/internal/handlers/management_contract_test.go b/packages/dashboard-api/internal/handlers/management_contract_test.go index 2b2bd30d88..e2683cc325 100644 --- a/packages/dashboard-api/internal/handlers/management_contract_test.go +++ b/packages/dashboard-api/internal/handlers/management_contract_test.go @@ -49,52 +49,28 @@ func TestBatchMemberRequestMatchesTheShapeCallersSend(t *testing.T) { } } -// project_type carried an enum of deployment environments from the scaffolding -// that predated any caller. The caller that arrived sends tier names, so every -// upsert failed validation client-side, before a request was ever made. +// project_type is gone from the contract. It named the caller's plan +// vocabulary, which this side never had a column for or an opinion about: the +// tier is assigned once at creation from a local default, and the limits that +// actually matter arrive absolute through upsertProjectLimits. // -// Nothing on this side reads the value — there is no column for it, and limits -// arrive in full through upsertProjectLimits — so the contract has no business -// enumerating it. This pins that: a tier name decodes, which it cannot do if -// someone reintroduces a closed set that guesses at the caller's vocabulary. -func TestProjectUpsertAcceptsTheCallersOwnTierNames(t *testing.T) { +// Removing it is safe to ship ahead of the callers. Nothing declares +// additionalProperties: false, so a caller still sending the field has it +// ignored rather than rejected — the break is at their next codegen, not at +// runtime. +func TestProjectUpsertIgnoresARetiredProjectType(t *testing.T) { t.Parallel() - for _, projectType := range []string{"base_v1", "pro_v1", "enterprise_v3"} { - body := `{"name":"Acme","slug":"acme","project_type":"` + projectType + `"}` - - var decoded api.ManagementProjectUpsertRequest - if err := json.Unmarshal([]byte(body), &decoded); err != nil { - t.Fatalf("decoding an upsert with project_type %q: %v", projectType, err) - } - - if decoded.ProjectType != projectType { - t.Errorf("ProjectType = %q, want %q", decoded.ProjectType, projectType) - } - } -} - -// email arrived after the callers did, so the shape they send has no such -// field. Declaring it required would break every one of them at its next spec -// sync; the create-only rule is enforced in the handler for that reason. -func TestProjectUpsertAcceptsAPayloadWithoutAnEmail(t *testing.T) { - t.Parallel() + body := `{"name":"Acme","slug":"acme","email":"ops@acme.test","project_type":"enterprise_v3"}` var decoded api.ManagementProjectUpsertRequest - if err := json.Unmarshal([]byte(`{"name":"Acme","slug":"acme","project_type":"base_v1"}`), &decoded); err != nil { - t.Fatalf("decoding an upsert without an email: %v", err) - } - - if decoded.Email != nil { - t.Errorf("Email = %v, want nil so an absent address stays distinguishable from a blank one", *decoded.Email) - } - - if err := json.Unmarshal([]byte(`{"name":"Acme","slug":"acme","project_type":"base_v1","email":"ops@acme.test"}`), &decoded); err != nil { - t.Fatalf("decoding an upsert with an email: %v", err) + if err := json.Unmarshal([]byte(body), &decoded); err != nil { + t.Fatalf("decoding an upsert that still carries project_type: %v", err) } - if decoded.Email == nil || *decoded.Email != "ops@acme.test" { - t.Errorf("Email = %v, want ops@acme.test", decoded.Email) + want := api.ManagementProjectUpsertRequest{Name: "Acme", Slug: "acme", Email: "ops@acme.test"} + if decoded != want { + t.Errorf("decoded %+v, want %+v", decoded, want) } } diff --git a/packages/dashboard-api/internal/handlers/management_project_upsert.go b/packages/dashboard-api/internal/handlers/management_project_upsert.go index e2464b6e92..9eea1b471d 100644 --- a/packages/dashboard-api/internal/handlers/management_project_upsert.go +++ b/packages/dashboard-api/internal/handlers/management_project_upsert.go @@ -19,9 +19,10 @@ import ( ) // managedProjectTier is where a project created through the management -// interface starts. project_type does not decide it: the caller names tiers -// this cluster has never heard of, so mapping it onto teams.tier would fail -// the foreign key on the first project that is not base. +// interface starts, and the only tier this side ever assigns. Nothing in the +// contract names one: the caller's plan vocabulary is its own, and the limits +// it actually wants arrive absolute through upsertProjectLimits, which +// team_limits reads in preference to the tier. const managedProjectTier = "base_v1" const teamSlugUniqueConstraint = "teams_slug_unique" @@ -31,8 +32,9 @@ var ( // a different slug. errProjectSlugImmutable = errors.New("project slug cannot change") - // errProjectEmailRequired reports a create with no address to store. - errProjectEmailRequired = errors.New("email is required to create a project") + // errProjectRaced reports an id that appeared between the existence check + // and the insert. + errProjectRaced = errors.New("project was created concurrently") ) // ManagementUpsertProject creates or reconciles a project from a @@ -44,6 +46,7 @@ var ( func (s *APIStore) ManagementUpsertProject(c *gin.Context, teamID api.TeamID) { ctx := c.Request.Context() attrs := []attribute.KeyValue{telemetry.WithTeamID(teamID.String())} + telemetry.SetAttributes(ctx, attrs...) body, err := ginutils.ParseBody[api.ManagementProjectUpsertRequest](ctx, c) if err != nil { @@ -54,12 +57,6 @@ func (s *APIStore) ManagementUpsertProject(c *gin.Context, teamID api.TeamID) { return } - // Traced and not stored. The contract says project_type is recorded rather - // than interpreted, and nothing here reads it: no column, no behaviour, and - // limits that arrive already resolved. - attrs = append(attrs, attribute.String("project.type", body.ProjectType)) - telemetry.SetAttributes(ctx, attrs...) - project, created, err := s.upsertManagedProject(ctx, teamID, body) if err != nil { s.sendProjectUpsertError(c, err, attrs...) @@ -78,11 +75,10 @@ func (s *APIStore) ManagementUpsertProject(c *gin.Context, teamID api.TeamID) { } c.JSON(upsertStatus(created), api.ManagementProject{ - Id: teamID, - Name: project.Name, - Slug: project.Slug, - ProjectType: body.ProjectType, - Email: optionalEmail(project.Email), + Id: teamID, + Name: project.Name, + Slug: project.Slug, + Email: project.Email, }) } @@ -93,14 +89,10 @@ type managedProject struct { Email string } -// upsertManagedProject inserts first and reconciles when the id is taken, so -// the common create path costs one statement. -// -// The address is required to create and optional to reconcile, which the -// contract cannot express on a single operation. A create has nowhere to get -// one from and the column does not accept none; a reconcile already has one -// stored, and blanking it because the caller stopped sending it would discard -// something nobody asked to change. +// upsertManagedProject branches on whether the project already exists, because +// the two cases differ in what they are allowed to set. A create assigns the +// tier; a reconcile writes only the properties the caller synchronizes and +// leaves the tier where it is. func (s *APIStore) upsertManagedProject( ctx context.Context, teamID api.TeamID, @@ -114,42 +106,62 @@ func (s *APIStore) upsertManagedProject( _ = tx.Rollback(ctx) }() - if body.Email != nil { - inserted, insertErr := txDB.InsertManagedTeam(ctx, authqueries.InsertManagedTeamParams{ - ID: teamID, - Name: body.Name, - Slug: body.Slug, - Tier: managedProjectTier, - Email: *body.Email, - }) - - if insertErr == nil { - if err := tx.Commit(ctx); err != nil { - return managedProject{}, false, fmt.Errorf("commit project create: %w", err) - } - - return managedProject{Name: inserted.Name, Slug: inserted.Slug, Email: inserted.Email}, true, nil - } + existing, err := txDB.LockManagedTeam(ctx, teamID) - // No row means the id is taken, so this is a reconcile: DO NOTHING lets - // the insert double as the branch test, and a concurrent create resolve - // below instead of into a 409. Anything else is a real failure. - if !dberrors.IsNotFoundError(insertErr) { - return managedProject{}, false, fmt.Errorf("create project: %w", insertErr) - } + switch { + case err == nil: + project, err = reconcileManagedProject(ctx, txDB, teamID, existing, body) + case dberrors.IsNotFoundError(err): + project, err = createManagedProject(ctx, txDB, teamID, body) + created = true + default: + return managedProject{}, false, fmt.Errorf("look up project: %w", err) } - existing, err := txDB.LockManagedTeam(ctx, teamID) if err != nil { - // Only reachable without an address, since the insert above would - // otherwise have created the row. - if dberrors.IsNotFoundError(err) { - return managedProject{}, false, errProjectEmailRequired - } + return managedProject{}, false, err + } - return managedProject{}, false, fmt.Errorf("lock project: %w", err) + if err := tx.Commit(ctx); err != nil { + return managedProject{}, false, fmt.Errorf("commit project upsert: %w", err) } + return project, created, nil +} + +func createManagedProject( + ctx context.Context, + txDB *authqueries.Queries, + teamID api.TeamID, + body api.ManagementProjectUpsertRequest, +) (managedProject, error) { + inserted, err := txDB.InsertManagedTeam(ctx, authqueries.InsertManagedTeamParams{ + ID: teamID, + Name: body.Name, + Slug: body.Slug, + Tier: managedProjectTier, + Email: body.Email, + }) + + switch { + // ON CONFLICT DO NOTHING yields no row, which here means another request + // inserted this id between the lock finding nothing and this statement. + case dberrors.IsNotFoundError(err): + return managedProject{}, errProjectRaced + case err != nil: + return managedProject{}, fmt.Errorf("create project: %w", err) + } + + return managedProject{Name: inserted.Name, Slug: inserted.Slug, Email: inserted.Email}, nil +} + +func reconcileManagedProject( + ctx context.Context, + txDB *authqueries.Queries, + teamID api.TeamID, + existing authqueries.LockManagedTeamRow, + body api.ManagementProjectUpsertRequest, +) (managedProject, error) { // The rule that a slug never moves is the caller's: it owns the region-wide // namespace these labels address, and teams_slug_unique is only the backstop // underneath it. Refusing also stops a reconcile adopting a team it does not @@ -157,7 +169,7 @@ func (s *APIStore) upsertManagedProject( // into projects makes other ids reachable, and a mismatched slug is the // signal that one of them is not the project being described. if existing.Slug != body.Slug { - return managedProject{}, false, fmt.Errorf("%w: stored %q, requested %q", + return managedProject{}, fmt.Errorf("%w: stored %q, requested %q", errProjectSlugImmutable, existing.Slug, body.Slug) } @@ -167,28 +179,24 @@ func (s *APIStore) upsertManagedProject( Email: body.Email, }) if err != nil { - return managedProject{}, false, fmt.Errorf("reconcile project: %w", err) + return managedProject{}, fmt.Errorf("reconcile project: %w", err) } - if err := tx.Commit(ctx); err != nil { - return managedProject{}, false, fmt.Errorf("commit project reconcile: %w", err) - } - - return managedProject{Name: updated.Name, Slug: updated.Slug, Email: updated.Email}, false, nil + return managedProject{Name: updated.Name, Slug: updated.Slug, Email: updated.Email}, nil } func (s *APIStore) sendProjectUpsertError(c *gin.Context, err error, attrs ...attribute.KeyValue) { ctx := c.Request.Context() switch { - case errors.Is(err, errProjectEmailRequired): - telemetry.ReportErrorByCode(ctx, http.StatusBadRequest, "upsert project failed", err, attrs...) - s.sendAPIStoreError(c, http.StatusBadRequest, "Email is required to create a project") - case errors.Is(err, errProjectSlugImmutable): telemetry.ReportErrorByCode(ctx, http.StatusConflict, "upsert project failed", err, attrs...) s.sendAPIStoreError(c, http.StatusConflict, "Project slug cannot change") + case errors.Is(err, errProjectRaced): + telemetry.ReportErrorByCode(ctx, http.StatusConflict, "upsert project failed", err, attrs...) + s.sendAPIStoreError(c, http.StatusConflict, "Project was created concurrently, retry") + // Slugs are unique cluster-wide, so the collision may be with a project the // caller has never heard of. Only it can pick another name. case dberrors.ConstraintName(err) == teamSlugUniqueConstraint: @@ -209,12 +217,3 @@ func upsertStatus(created bool) int { return http.StatusOK } - -// optionalEmail reports an unset address as absent rather than blank. -func optionalEmail(email string) *string { - if email == "" { - return nil - } - - return &email -} diff --git a/packages/dashboard-api/internal/handlers/management_project_upsert_test.go b/packages/dashboard-api/internal/handlers/management_project_upsert_test.go index fa7dd7fa79..f40543cb4e 100644 --- a/packages/dashboard-api/internal/handlers/management_project_upsert_test.go +++ b/packages/dashboard-api/internal/handlers/management_project_upsert_test.go @@ -104,8 +104,6 @@ func TestUpsertProjectPreservesBlockedStateAndTier(t *testing.T) { require.Equal(t, http.StatusCreated, callUpsertProject(t, store, project.id, project.request()).Code) - // project_type names tiers this cluster has never heard of, which is why it - // is not mapped onto teams.tier — doing so would fail this very constraint. require.NoError(t, db.AuthDB.TestsRawSQL(t.Context(), `INSERT INTO public.tiers (id, name, disk_mb, concurrent_instances, max_length_hours, max_vcpu, max_ram_mb, concurrent_template_builds, events_ttl_days, @@ -117,38 +115,32 @@ func TestUpsertProjectPreservesBlockedStateAndTier(t *testing.T) { reconcile := project.request() reconcile.Name = "Acme Renamed" - reconcile.ProjectType = "enterprise_v3" renamed := callUpsertProject(t, store, project.id, reconcile) require.Equal(t, http.StatusOK, renamed.Code, renamed.Body.String()) require.Equal(t, "true", teamColumn(t, db, project.id, "is_blocked::text")) + // The tier is this side's to assign, once, at creation. No push moves it. require.Equal(t, "pro_v1", teamColumn(t, db, project.id, "tier")) } -// Required to create, optional to reconcile — the asymmetry the contract cannot -// express on one operation. -func TestUpsertProjectRequiresAnEmailOnlyToCreate(t *testing.T) { +// Assigned once, at creation, and never moved by a push. The caller names no +// tier at all, so there is nothing here that could move it. +func TestUpsertProjectCreatesOnTheDefaultTier(t *testing.T) { t.Parallel() db := testutils.SetupDatabase(t) store, _ := newUpsertStore(db) project := newProjectFixture() - withoutEmail := project.request() - withoutEmail.Email = nil - - refused := callUpsertProject(t, store, project.id, withoutEmail) - require.Equal(t, http.StatusBadRequest, refused.Code, refused.Body.String()) require.Equal(t, http.StatusCreated, callUpsertProject(t, store, project.id, project.request()).Code) - // The project now has an address, so a caller that stopped sending one is - // omitting a value rather than clearing it. - require.Equal(t, http.StatusOK, callUpsertProject(t, store, project.id, withoutEmail).Code) - require.Equal(t, "ops@acme.test", teamColumn(t, db, project.id, "email")) + require.Equal(t, "base_v1", teamColumn(t, db, project.id, "tier")) } -func TestUpsertProjectUpdatesTheEmailWhenOneIsSent(t *testing.T) { +// Every property in the contract is synchronized by the caller and sent on +// every push, so a reconcile is a complete statement rather than a patch. +func TestUpsertProjectReconcilesSynchronizedProperties(t *testing.T) { t.Parallel() db := testutils.SetupDatabase(t) @@ -157,10 +149,12 @@ func TestUpsertProjectUpdatesTheEmailWhenOneIsSent(t *testing.T) { require.Equal(t, http.StatusCreated, callUpsertProject(t, store, project.id, project.request()).Code) rebilled := project.request() - rebilled.Email = new("billing@acme.test") + rebilled.Name = "Acme Renamed" + rebilled.Email = "billing@acme.test" require.Equal(t, http.StatusOK, callUpsertProject(t, store, project.id, rebilled).Code) + require.Equal(t, "Acme Renamed", teamColumn(t, db, project.id, "name")) require.Equal(t, "billing@acme.test", teamColumn(t, db, project.id, "email")) } @@ -171,10 +165,7 @@ func TestUpsertProjectEchoesTheStoredProject(t *testing.T) { store, _ := newUpsertStore(db) project := newProjectFixture() - pro := project.request() - pro.ProjectType = "pro_v1" - - recorder := callUpsertProject(t, store, project.id, pro) + recorder := callUpsertProject(t, store, project.id, project.request()) require.Equal(t, http.StatusCreated, recorder.Code) var decoded api.ManagementProject @@ -182,10 +173,7 @@ func TestUpsertProjectEchoesTheStoredProject(t *testing.T) { require.Equal(t, project.id, decoded.Id) require.Equal(t, "Acme", decoded.Name) require.Equal(t, project.slug, decoded.Slug) - // Echoed from the request: there is no column for it. - require.Equal(t, "pro_v1", decoded.ProjectType) - require.NotNil(t, decoded.Email) - require.Equal(t, "ops@acme.test", *decoded.Email) + require.Equal(t, "ops@acme.test", decoded.Email) } // Deleting a project needs teardown this process cannot reach. If you are @@ -217,13 +205,10 @@ func newProjectFixture() projectFixture { } func (p projectFixture) request() api.ManagementProjectUpsertRequest { - email := "ops@acme.test" - return api.ManagementProjectUpsertRequest{ - Name: "Acme", - Slug: p.slug, - ProjectType: "base_v1", - Email: &email, + Name: "Acme", + Slug: p.slug, + Email: "ops@acme.test", } } diff --git a/packages/db/pkg/auth/queries/team_management.sql.go b/packages/db/pkg/auth/queries/team_management.sql.go index d967008eda..6d81753fba 100644 --- a/packages/db/pkg/auth/queries/team_management.sql.go +++ b/packages/db/pkg/auth/queries/team_management.sql.go @@ -12,7 +12,6 @@ import ( ) const insertManagedTeam = `-- name: InsertManagedTeam :one - INSERT INTO public.teams (id, name, slug, tier, email, is_blocked) VALUES ( $1::uuid, @@ -41,12 +40,10 @@ type InsertManagedTeamRow struct { Email string } -// Project reconciliation for the control-plane management interface. The caller -// supplies the id, so create and reconcile are one request and these are its -// branches. -// Yields no row when the id is taken, which is the signal to reconcile. DO -// NOTHING rather than DO UPDATE, so a slug collision still surfaces as the -// distinct violation it is. +// Tier is assigned here and only here. DO NOTHING covers the case where the +// lock above found nothing and another request inserted the same id before +// this ran: no row means that happened. A slug collision is a different +// constraint and still raises. func (q *Queries) InsertManagedTeam(ctx context.Context, arg InsertManagedTeamParams) (InsertManagedTeamRow, error) { row := q.db.QueryRow(ctx, insertManagedTeam, arg.ID, @@ -66,6 +63,7 @@ func (q *Queries) InsertManagedTeam(ctx context.Context, arg InsertManagedTeamPa } const lockManagedTeam = `-- name: LockManagedTeam :one + SELECT id, slug FROM public.teams WHERE id = $1::uuid FOR UPDATE @@ -76,6 +74,11 @@ type LockManagedTeamRow struct { Slug string } +// Project reconciliation for the control-plane management interface. The caller +// supplies the id, so create and reconcile are one request and these are its +// branches. +// Taken first, so the branch is decided by whether the project exists rather +// than by an insert failing. func (q *Queries) LockManagedTeam(ctx context.Context, id uuid.UUID) (LockManagedTeamRow, error) { row := q.db.QueryRow(ctx, lockManagedTeam, id) var i LockManagedTeamRow @@ -87,14 +90,14 @@ const updateManagedTeam = `-- name: UpdateManagedTeam :one UPDATE public.teams SET name = $1::text, - email = COALESCE($2::text, email) + email = $2::text WHERE id = $3::uuid RETURNING id, name, slug, email ` type UpdateManagedTeamParams struct { Name string - Email *string + Email string ID uuid.UUID } @@ -105,9 +108,9 @@ type UpdateManagedTeamRow struct { Email string } -// Touches only what a reconcile may change. Tier stays because limits arrive -// through their own route; is_blocked stays because an operator's decision must -// outlive a routine name push. +// Touches only the properties the caller synchronizes. Tier stays because it is +// this side's to assign; is_blocked stays because an operator's decision must +// outlive a routine push. func (q *Queries) UpdateManagedTeam(ctx context.Context, arg UpdateManagedTeamParams) (UpdateManagedTeamRow, error) { row := q.db.QueryRow(ctx, updateManagedTeam, arg.Name, arg.Email, arg.ID) var i UpdateManagedTeamRow diff --git a/packages/db/pkg/auth/sql_queries/teams/team_management.sql b/packages/db/pkg/auth/sql_queries/teams/team_management.sql index 8beb33031c..d6483c9614 100644 --- a/packages/db/pkg/auth/sql_queries/teams/team_management.sql +++ b/packages/db/pkg/auth/sql_queries/teams/team_management.sql @@ -2,9 +2,17 @@ -- supplies the id, so create and reconcile are one request and these are its -- branches. --- Yields no row when the id is taken, which is the signal to reconcile. DO --- NOTHING rather than DO UPDATE, so a slug collision still surfaces as the --- distinct violation it is. +-- Taken first, so the branch is decided by whether the project exists rather +-- than by an insert failing. +-- name: LockManagedTeam :one +SELECT id, slug FROM public.teams +WHERE id = sqlc.arg(id)::uuid +FOR UPDATE; + +-- Tier is assigned here and only here. DO NOTHING covers the case where the +-- lock above found nothing and another request inserted the same id before +-- this ran: no row means that happened. A slug collision is a different +-- constraint and still raises. -- name: InsertManagedTeam :one INSERT INTO public.teams (id, name, slug, tier, email, is_blocked) VALUES ( @@ -18,18 +26,13 @@ VALUES ( ON CONFLICT (id) DO NOTHING RETURNING id, name, slug, email; --- name: LockManagedTeam :one -SELECT id, slug FROM public.teams -WHERE id = sqlc.arg(id)::uuid -FOR UPDATE; - --- Touches only what a reconcile may change. Tier stays because limits arrive --- through their own route; is_blocked stays because an operator's decision must --- outlive a routine name push. +-- Touches only the properties the caller synchronizes. Tier stays because it is +-- this side's to assign; is_blocked stays because an operator's decision must +-- outlive a routine push. -- name: UpdateManagedTeam :one UPDATE public.teams SET name = sqlc.arg(name)::text, - email = COALESCE(sqlc.narg(email)::text, email) + email = sqlc.arg(email)::text WHERE id = sqlc.arg(id)::uuid RETURNING id, name, slug, email; diff --git a/spec/openapi-dashboard.yml b/spec/openapi-dashboard.yml index 41fdd4f86e..5b596590d5 100644 --- a/spec/openapi-dashboard.yml +++ b/spec/openapi-dashboard.yml @@ -1162,22 +1162,19 @@ components: slug: type: string - ManagementProjectType: - type: string - minLength: 1 + ManagementProjectUpsertRequest: + type: object description: >- - The caller's name for the project's tier. Recorded, not interpreted: - limits arrive separately and in full through upsertProjectLimits, so - nothing here derives behaviour from this value. + The properties of a project this side stores. Every one is synchronized + by the caller and sent on every push, so a reconcile is a complete + statement of the project rather than a patch. - Deliberately unconstrained. The caller owns the vocabulary and the - catalog behind it, and enumerating it here would make adding a tier a - cross-repo change to a field this side only stores. - example: base_v1 - ManagementProjectUpsertRequest: - type: object - required: [name, slug, project_type] + A project's tier is not among them. It is assigned once, at creation, + from this side's own default, and no push moves it — limits arrive + separately and in full through upsertProjectLimits, which takes + precedence over the tier anyway. + required: [name, slug, email] properties: name: type: string @@ -1187,19 +1184,11 @@ components: type: string minLength: 1 maxLength: 63 - project_type: - $ref: "#/components/schemas/ManagementProjectType" email: type: string + minLength: 1 maxLength: 255 - description: >- - Contact address recorded on the project. Required to create one and - optional to reconcile it, which is why it is not listed as required - here — a single operation cannot say that. - - A create has nowhere else to get an address from. A reconcile - already has one stored, so omitting it leaves that value alone - rather than blanking it. + description: Contact address recorded on the project. ManagementProject: allOf: From 1f635867f1564b600ab4f2aea5c8762ae23c6dcf Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Wed, 29 Jul 2026 18:59:10 +0200 Subject: [PATCH 4/9] refactor(dashboard-api): move project upsert behind the management service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The upsert grew past what belonged in a handler: three functions, a type, three sentinels and a tier constant, none of them touching gin. Keeping it in the web layer meant that layer importing dberrors and authqueries, and knowing a Postgres constraint name to tell a taken slug from a real failure. The service now reports outcomes as sentinel errors and owns the cache eviction its own write invalidates, the same rule the membership operations already followed. The handler parses, calls, maps sentinels to status codes and responds. Splits the package by feature — service.go for the type, members.go and project.go for the operations — with the tests following the same split. --- .../handlers/management_project_upsert.go | 190 +++------------ .../management_project_upsert_test.go | 3 +- .../internal/management/members.go | 174 ++++++++++++++ .../internal/management/members_test.go | 221 ++++++++++++++++++ .../internal/management/project.go | 159 +++++++++++++ .../internal/management/service.go | 182 +-------------- .../internal/management/service_test.go | 211 ----------------- 7 files changed, 590 insertions(+), 550 deletions(-) create mode 100644 packages/dashboard-api/internal/management/members.go create mode 100644 packages/dashboard-api/internal/management/members_test.go create mode 100644 packages/dashboard-api/internal/management/project.go diff --git a/packages/dashboard-api/internal/handlers/management_project_upsert.go b/packages/dashboard-api/internal/handlers/management_project_upsert.go index 9eea1b471d..777aada18f 100644 --- a/packages/dashboard-api/internal/handlers/management_project_upsert.go +++ b/packages/dashboard-api/internal/handlers/management_project_upsert.go @@ -1,48 +1,21 @@ package handlers import ( - "context" "errors" "fmt" "net/http" "github.com/gin-gonic/gin" "go.opentelemetry.io/otel/attribute" - "go.uber.org/zap" "github.com/e2b-dev/infra/packages/dashboard-api/internal/api" - 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/dashboard-api/internal/management" "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" ) -// managedProjectTier is where a project created through the management -// interface starts, and the only tier this side ever assigns. Nothing in the -// contract names one: the caller's plan vocabulary is its own, and the limits -// it actually wants arrive absolute through upsertProjectLimits, which -// team_limits reads in preference to the tier. -const managedProjectTier = "base_v1" - -const teamSlugUniqueConstraint = "teams_slug_unique" - -var ( - // errProjectSlugImmutable reports a reconcile that would move a project to - // a different slug. - errProjectSlugImmutable = errors.New("project slug cannot change") - - // errProjectRaced reports an id that appeared between the existence check - // and the insert. - errProjectRaced = errors.New("project was created concurrently") -) - // ManagementUpsertProject creates or reconciles a project from a -// caller-supplied id. -// -// One request serves both because the caller cannot tell them apart: it -// retries, and a retry after a response it never saw has to land on the same -// state as the original. +// caller-supplied id, answering 201 or 200 to say which happened. func (s *APIStore) ManagementUpsertProject(c *gin.Context, teamID api.TeamID) { ctx := c.Request.Context() attrs := []attribute.KeyValue{telemetry.WithTeamID(teamID.String())} @@ -57,156 +30,53 @@ func (s *APIStore) ManagementUpsertProject(c *gin.Context, teamID api.TeamID) { return } - project, created, err := s.upsertManagedProject(ctx, teamID, body) - if err != nil { - s.sendProjectUpsertError(c, err, attrs...) - - return - } - - // A reconcile changes the team, so every cached copy of it is stale — its - // own entry, each API key's, each member's. Logged rather than returned: - // the row is committed, and a retry cannot improve on a stale cache. - if !created { - if err := s.authService.InvalidateTeamCache(ctx, teamID); err != nil { - logger.L().Error(ctx, "invalidating team cache after project reconcile", - logger.WithTeamID(teamID.String()), zap.Error(err)) - } - } - - c.JSON(upsertStatus(created), api.ManagementProject{ - Id: teamID, - Name: project.Name, - Slug: project.Slug, - Email: project.Email, - }) -} - -// managedProject is the part of a project both branches return. -type managedProject struct { - Name string - Slug string - Email string -} - -// upsertManagedProject branches on whether the project already exists, because -// the two cases differ in what they are allowed to set. A create assigns the -// tier; a reconcile writes only the properties the caller synchronizes and -// leaves the tier where it is. -func (s *APIStore) upsertManagedProject( - ctx context.Context, - teamID api.TeamID, - body api.ManagementProjectUpsertRequest, -) (project managedProject, created bool, err error) { - txDB, tx, err := s.authDB.WithTx(ctx) - if err != nil { - return managedProject{}, false, fmt.Errorf("start project upsert transaction: %w", err) - } - defer func() { - _ = tx.Rollback(ctx) - }() - - existing, err := txDB.LockManagedTeam(ctx, teamID) - - switch { - case err == nil: - project, err = reconcileManagedProject(ctx, txDB, teamID, existing, body) - case dberrors.IsNotFoundError(err): - project, err = createManagedProject(ctx, txDB, teamID, body) - created = true - default: - return managedProject{}, false, fmt.Errorf("look up project: %w", err) - } - - if err != nil { - return managedProject{}, false, err - } - - if err := tx.Commit(ctx); err != nil { - return managedProject{}, false, fmt.Errorf("commit project upsert: %w", err) - } - - return project, created, nil -} - -func createManagedProject( - ctx context.Context, - txDB *authqueries.Queries, - teamID api.TeamID, - body api.ManagementProjectUpsertRequest, -) (managedProject, error) { - inserted, err := txDB.InsertManagedTeam(ctx, authqueries.InsertManagedTeamParams{ + stored, created, err := s.managementService.UpsertProject(ctx, management.Project{ ID: teamID, Name: body.Name, Slug: body.Slug, - Tier: managedProjectTier, Email: body.Email, }) + if err != nil { + s.sendProjectUpsertError(c, err, attrs...) - switch { - // ON CONFLICT DO NOTHING yields no row, which here means another request - // inserted this id between the lock finding nothing and this statement. - case dberrors.IsNotFoundError(err): - return managedProject{}, errProjectRaced - case err != nil: - return managedProject{}, fmt.Errorf("create project: %w", err) - } - - return managedProject{Name: inserted.Name, Slug: inserted.Slug, Email: inserted.Email}, nil -} - -func reconcileManagedProject( - ctx context.Context, - txDB *authqueries.Queries, - teamID api.TeamID, - existing authqueries.LockManagedTeamRow, - body api.ManagementProjectUpsertRequest, -) (managedProject, error) { - // The rule that a slug never moves is the caller's: it owns the region-wide - // namespace these labels address, and teams_slug_unique is only the backstop - // underneath it. Refusing also stops a reconcile adopting a team it does not - // own — caller-minted ids will not collide, but backfilling legacy teams - // into projects makes other ids reachable, and a mismatched slug is the - // signal that one of them is not the project being described. - if existing.Slug != body.Slug { - return managedProject{}, fmt.Errorf("%w: stored %q, requested %q", - errProjectSlugImmutable, existing.Slug, body.Slug) + return } - updated, err := txDB.UpdateManagedTeam(ctx, authqueries.UpdateManagedTeamParams{ - ID: teamID, - Name: body.Name, - Email: body.Email, + c.JSON(upsertStatus(created), api.ManagementProject{ + Id: stored.ID, + Name: stored.Name, + Slug: stored.Slug, + Email: stored.Email, }) - if err != nil { - return managedProject{}, fmt.Errorf("reconcile project: %w", err) - } - - return managedProject{Name: updated.Name, Slug: updated.Slug, Email: updated.Email}, nil } func (s *APIStore) sendProjectUpsertError(c *gin.Context, err error, attrs ...attribute.KeyValue) { ctx := c.Request.Context() - switch { - case errors.Is(err, errProjectSlugImmutable): + if message := projectConflictMessage(err); message != "" { telemetry.ReportErrorByCode(ctx, http.StatusConflict, "upsert project failed", err, attrs...) - s.sendAPIStoreError(c, http.StatusConflict, "Project slug cannot change") + s.sendAPIStoreError(c, http.StatusConflict, message) - case errors.Is(err, errProjectRaced): - telemetry.ReportErrorByCode(ctx, http.StatusConflict, "upsert project failed", err, attrs...) - s.sendAPIStoreError(c, http.StatusConflict, "Project was created concurrently, retry") + return + } - // Slugs are unique cluster-wide, so the collision may be with a project the - // caller has never heard of. Only it can pick another name. - case dberrors.ConstraintName(err) == teamSlugUniqueConstraint: - telemetry.ReportErrorByCode(ctx, http.StatusConflict, "upsert project failed", err, attrs...) - s.sendAPIStoreError(c, http.StatusConflict, "Slug is already taken on this control plane") + telemetry.ReportCriticalError(ctx, "upsert project failed", err, attrs...) + s.sendAPIStoreError(c, http.StatusInternalServerError, "Error upserting project") +} - default: - telemetry.ReportCriticalError(ctx, "upsert project failed", err, attrs...) - s.sendAPIStoreError(c, http.StatusInternalServerError, "Error upserting project") +// projectConflictMessage names the conflict an upsert lost to, or "" when the +// failure is not one the caller can resolve by changing the request. +func projectConflictMessage(err error) string { + switch { + case errors.Is(err, management.ErrProjectSlugImmutable): + return "Project slug cannot change" + case errors.Is(err, management.ErrProjectSlugTaken): + return "Slug is already taken on this control plane" + case errors.Is(err, management.ErrProjectRaced): + return "Project was created concurrently, retry" } + + return "" } // upsertStatus distinguishes a project this request created from one it found. diff --git a/packages/dashboard-api/internal/handlers/management_project_upsert_test.go b/packages/dashboard-api/internal/handlers/management_project_upsert_test.go index f40543cb4e..4b3d778311 100644 --- a/packages/dashboard-api/internal/handlers/management_project_upsert_test.go +++ b/packages/dashboard-api/internal/handlers/management_project_upsert_test.go @@ -13,6 +13,7 @@ import ( "github.com/stretchr/testify/require" "github.com/e2b-dev/infra/packages/dashboard-api/internal/api" + "github.com/e2b-dev/infra/packages/dashboard-api/internal/management" "github.com/e2b-dev/infra/packages/db/pkg/testutils" ) @@ -215,7 +216,7 @@ func (p projectFixture) request() api.ManagementProjectUpsertRequest { func newUpsertStore(db *testutils.Database) (*APIStore, *recordingCacheAuthService) { auth := &recordingCacheAuthService{} - return &APIStore{authDB: db.AuthDB, authService: auth}, auth + return &APIStore{managementService: management.NewService(db.AuthDB, auth)}, auth } func callUpsertProject(t *testing.T, store *APIStore, projectID uuid.UUID, request api.ManagementProjectUpsertRequest) *httptest.ResponseRecorder { diff --git a/packages/dashboard-api/internal/management/members.go b/packages/dashboard-api/internal/management/members.go new file mode 100644 index 0000000000..ed96431326 --- /dev/null +++ b/packages/dashboard-api/internal/management/members.go @@ -0,0 +1,174 @@ +package management + +import ( + "context" + "errors" + "fmt" + "slices" + + "github.com/google/uuid" + + authqueries "github.com/e2b-dev/infra/packages/db/pkg/auth/queries" + "github.com/e2b-dev/infra/packages/shared/pkg/logger" +) + +// ErrProjectNotFound reports a project unknown to this cluster. Returned rather +// than answered here, because each route reads it differently. +var ErrProjectNotFound = errors.New("project not found") + +// MemberChange is the membership a push states for a project. Users it does not +// name keep whatever membership they have. +type MemberChange struct { + ProjectID uuid.UUID + Present []uuid.UUID + Absent []uuid.UUID + + // AddedBy records the actor behind the addition, when the caller names one. + AddedBy *uuid.UUID +} + +// namedUsers is every user the change states a presence for. +func (c MemberChange) namedUsers() []uuid.UUID { + return slices.Concat(c.Present, c.Absent) +} + +// anchoredUsers lists the user rows adding Present depends on: the members, and +// whoever is recorded as adding them. +func (c MemberChange) anchoredUsers() []uuid.UUID { + if c.AddedBy == nil { + return c.Present + } + + return append(slices.Clone(c.Present), *c.AddedBy) +} + +// SetProjectMembers reconciles a stated membership against a project in one +// transaction. +// +// Idempotent in both directions, because the caller retries, delivers at least +// once and lets its own pushes interleave. +// +// The rules the dashboard's member routes enforce — no removing the last +// member, no touching a default membership — are deliberately absent. The +// caller owns membership, and a rule here would make its pushes unrepeatable. +func (s *Service) SetProjectMembers(ctx context.Context, change MemberChange) error { + removed, err := s.applyMembers(ctx, change) + if err != nil { + return err + } + + // Evicting before the commit would let a concurrent read repopulate the + // entry with uncommitted state. + // + // Every user the change named, not only the rows that moved: a crash here + // leaves stale entries that only the caller's retry clears, and that retry + // finds the work already done. Repeating an eviction costs a cache delete; + // skipping one costs a revoked member's access. + for _, userID := range change.namedUsers() { + s.cache.InvalidateTeamMemberCache(ctx, userID, change.ProjectID.String()) + } + + // Costs the user a team rather than access: the signup path recreates a + // missing default on next login. Logged because backfilling legacy teams + // into projects is where it would start happening in bulk. + for _, row := range removed { + if row.IsDefault { + logger.L().Warn(ctx, "management removed a default team membership", + logger.WithTeamID(change.ProjectID.String()), logger.WithUserID(row.UserID.String())) + } + } + + return nil +} + +func (s *Service) applyMembers(ctx context.Context, change MemberChange) ([]authqueries.SyncTeamMembersAbsentRow, error) { + txDB, tx, err := s.db.WithTx(ctx) + if err != nil { + return nil, fmt.Errorf("start membership transaction: %w", err) + } + defer func() { + _ = tx.Rollback(ctx) + }() + + exists, err := txDB.TeamExists(ctx, change.ProjectID) + if err != nil { + return nil, fmt.Errorf("look up project: %w", err) + } + if !exists { + return nil, ErrProjectNotFound + } + + if len(change.Present) > 0 { + // users_teams still points at public.users for both the member and + // whoever added them, and the caller knows only opaque ids. + if err := txDB.UpsertPublicUsers(ctx, change.anchoredUsers()); err != nil { + return nil, fmt.Errorf("anchor users: %w", err) + } + + if err := txDB.SyncTeamMembersPresent(ctx, authqueries.SyncTeamMembersPresentParams{ + TeamID: change.ProjectID, + UserIds: change.Present, + AddedBy: change.AddedBy, + }); err != nil { + return nil, fmt.Errorf("add project members: %w", err) + } + } + + var removed []authqueries.SyncTeamMembersAbsentRow + if len(change.Absent) > 0 { + removed, err = txDB.SyncTeamMembersAbsent(ctx, authqueries.SyncTeamMembersAbsentParams{ + TeamID: change.ProjectID, + UserIds: change.Absent, + }) + if err != nil { + return nil, fmt.Errorf("remove project members: %w", err) + } + } + + if err := tx.Commit(ctx); err != nil { + return nil, fmt.Errorf("commit membership change: %w", err) + } + + return removed, nil +} + +// PurgeUser removes the memberships and access tokens a user holds here. +// +// public.users survives on purpose: addons.added_by would refuse the delete, +// and two created_by columns would quietly null out provenance. Deleting a +// user outright already belongs to the admin route. +func (s *Service) PurgeUser(ctx context.Context, userID uuid.UUID) error { + txDB, tx, err := s.db.WithTx(ctx) + if err != nil { + return fmt.Errorf("start purge transaction: %w", err) + } + defer func() { + _ = tx.Rollback(ctx) + }() + + // Driven by what the delete removed, not by a read taken before it. Under + // READ COMMITTED those are different snapshots, and a membership committed + // between them would be deleted here but never evicted below. + teamIDs, err := txDB.PurgeUserMemberships(ctx, userID) + if err != nil { + return fmt.Errorf("purge user memberships: %w", err) + } + + if err := txDB.PurgeUserAccessTokens(ctx, userID); err != nil { + return fmt.Errorf("purge user access tokens: %w", err) + } + + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("commit user purge: %w", err) + } + + // A crash before this leaves entries a retry cannot find, since the rows it + // would evict from are already gone. They stand until they expire; carrying + // them across the gap would need a teardown log, which is a lot of + // machinery for a five-minute worst case on a crash. + for _, teamID := range teamIDs { + s.cache.InvalidateTeamMemberCache(ctx, userID, teamID.String()) + } + + return nil +} diff --git a/packages/dashboard-api/internal/management/members_test.go b/packages/dashboard-api/internal/management/members_test.go new file mode 100644 index 0000000000..3bd9043ebe --- /dev/null +++ b/packages/dashboard-api/internal/management/members_test.go @@ -0,0 +1,221 @@ +package management + +import ( + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/e2b-dev/infra/packages/db/pkg/testutils" +) + +// The push is the only way membership reaches this side, so a repeated one must +// land on the same state rather than a duplicate row or a rejection. +func TestSetAddsAndIsIdempotent(t *testing.T) { + t.Parallel() + + db := testutils.SetupDatabase(t) + teamID := testutils.CreateTestTeam(t, db) + service, _ := newService(db) + userID := uuid.New() + + require.NoError(t, service.SetProjectMembers(t.Context(), MemberChange{ProjectID: teamID, Present: []uuid.UUID{userID}})) + require.NoError(t, service.SetProjectMembers(t.Context(), MemberChange{ProjectID: teamID, Present: []uuid.UUID{userID}})) + + require.Equal(t, []uuid.UUID{userID}, teamMembers(t, db, teamID)) +} + +// The caller knows only opaque ids, and users_teams still points at +// public.users, so without the anchor the first push for an unseen user fails. +func TestSetAnchorsUnknownUsers(t *testing.T) { + t.Parallel() + + db := testutils.SetupDatabase(t) + teamID := testutils.CreateTestTeam(t, db) + service, _ := newService(db) + userID := uuid.New() + addedBy := uuid.New() + + require.NoError(t, service.SetProjectMembers(t.Context(), MemberChange{ProjectID: teamID, Present: []uuid.UUID{userID}, AddedBy: &addedBy})) + + require.True(t, publicUserExists(t, db, userID)) + // added_by carries its own key, so an unknown actor fails the insert on a + // column nobody was looking at. + require.True(t, publicUserExists(t, db, addedBy)) +} + +// The assertion the type exists for: InvalidateTeamCache finds keys by reading +// users_teams, so a removed member is one it cannot see. Writing and +// invalidating separately would leave this user authenticating until expiry. +func TestSetEvictsRemovedMembersTheSweepCannotSee(t *testing.T) { + t.Parallel() + + db := testutils.SetupDatabase(t) + teamID := testutils.CreateTestTeam(t, db) + service, cache := newService(db) + userID := uuid.New() + + require.NoError(t, service.SetProjectMembers(t.Context(), MemberChange{ProjectID: teamID, Present: []uuid.UUID{userID}})) + cache.reset() + + require.NoError(t, service.SetProjectMembers(t.Context(), MemberChange{ProjectID: teamID, Absent: []uuid.UUID{userID}})) + + require.Empty(t, teamMembers(t, db, teamID)) + require.Equal(t, []memberKey{{userID: userID, teamID: teamID.String()}}, cache.members) +} + +// Membership does not change the team, so entries keyed by API key hold nothing +// this write invalidated. Sweeping them would evict every key on the project. +func TestSetLeavesTeamWideCacheEntriesAlone(t *testing.T) { + t.Parallel() + + db := testutils.SetupDatabase(t) + teamID := testutils.CreateTestTeam(t, db) + service, cache := newService(db) + + require.NoError(t, service.SetProjectMembers(t.Context(), MemberChange{ProjectID: teamID, Present: []uuid.UUID{uuid.New()}})) + + require.Empty(t, cache.teams) +} + +// Removing a membership that is not there is the desired state, not a failure — +// and the eviction still has to run. +// +// A crash before the eviction leaves a stale entry only a retry can clear, and +// that retry sees exactly this: nothing left to delete. Keying the eviction off +// what the statement touched would make the recovery path a no-op. +func TestSetRemovingAnAbsentMemberSucceedsAndStillEvicts(t *testing.T) { + t.Parallel() + + db := testutils.SetupDatabase(t) + teamID := testutils.CreateTestTeam(t, db) + service, cache := newService(db) + userID := uuid.New() + + require.NoError(t, service.SetProjectMembers(t.Context(), MemberChange{ProjectID: teamID, Absent: []uuid.UUID{userID}})) + + require.Equal(t, []memberKey{{userID: userID, teamID: teamID.String()}}, cache.members) +} + +// One call carries both directions so the caller never has to order them. +func TestSetAppliesBothDirectionsAtOnce(t *testing.T) { + t.Parallel() + + db := testutils.SetupDatabase(t) + teamID := testutils.CreateTestTeam(t, db) + service, _ := newService(db) + staying, leaving, joining := uuid.New(), uuid.New(), uuid.New() + + require.NoError(t, service.SetProjectMembers(t.Context(), MemberChange{ProjectID: teamID, Present: []uuid.UUID{staying, leaving}})) + require.NoError(t, service.SetProjectMembers(t.Context(), MemberChange{ProjectID: teamID, Present: []uuid.UUID{joining}, Absent: []uuid.UUID{leaving}})) + + require.ElementsMatch(t, []uuid.UUID{staying, joining}, teamMembers(t, db, teamID)) +} + +// A request states presence only for the users it lists, so a batch naming two +// members must not disturb a third. +func TestSetIgnoresUnlistedMembers(t *testing.T) { + t.Parallel() + + db := testutils.SetupDatabase(t) + teamID := testutils.CreateTestTeam(t, db) + service, _ := newService(db) + listed, unlisted := uuid.New(), uuid.New() + + require.NoError(t, service.SetProjectMembers(t.Context(), MemberChange{ProjectID: teamID, Present: []uuid.UUID{listed, unlisted}})) + require.NoError(t, service.SetProjectMembers(t.Context(), MemberChange{ProjectID: teamID, Absent: []uuid.UUID{listed}})) + + require.Equal(t, []uuid.UUID{unlisted}, teamMembers(t, db, teamID)) +} + +// A push naming an unknown project is divergence, and the caller decides what to +// do about it. Nothing may be written on the way to saying so. +func TestSetReportsAnUnknownProject(t *testing.T) { + t.Parallel() + + db := testutils.SetupDatabase(t) + service, cache := newService(db) + userID := uuid.New() + + err := service.SetProjectMembers(t.Context(), MemberChange{ProjectID: uuid.New(), Present: []uuid.UUID{userID}}) + + require.ErrorIs(t, err, ErrProjectNotFound) + require.False(t, publicUserExists(t, db, userID)) + require.Empty(t, cache.members) +} + +// Backfilled legacy teams carry default memberships, so a push can remove one. +// Allowed: the caller owns membership, and signup recreates a missing default. +func TestSetRemovesADefaultMembership(t *testing.T) { + t.Parallel() + + db := testutils.SetupDatabase(t) + teamID := testutils.CreateTestTeam(t, db) + service, _ := newService(db) + userID := uuid.New() + + require.NoError(t, service.SetProjectMembers(t.Context(), MemberChange{ProjectID: teamID, Present: []uuid.UUID{userID}})) + require.NoError(t, db.AuthDB.TestsRawSQL(t.Context(), + "UPDATE public.users_teams SET is_default = true WHERE team_id = $1 AND user_id = $2", teamID, userID)) + + require.NoError(t, service.SetProjectMembers(t.Context(), MemberChange{ProjectID: teamID, Absent: []uuid.UUID{userID}})) + require.Empty(t, teamMembers(t, db, teamID)) +} + +// The evicted set has to equal the deleted set: exactly the projects the purge +// removed the user from, and no others. Deriving it from the delete rather than +// a preceding read is what keeps that true when a membership is committed while +// the purge is running. +func TestPurgeUserClearsMembershipsAndTokensAcrossProjects(t *testing.T) { + t.Parallel() + + db := testutils.SetupDatabase(t) + first := testutils.CreateTestTeam(t, db) + second := testutils.CreateTestTeam(t, db) + service, cache := newService(db) + userID, other := uuid.New(), uuid.New() + + require.NoError(t, service.SetProjectMembers(t.Context(), MemberChange{ProjectID: first, Present: []uuid.UUID{userID, other}})) + require.NoError(t, service.SetProjectMembers(t.Context(), MemberChange{ProjectID: second, Present: []uuid.UUID{userID}})) + createAccessToken(t, db, userID) + createAccessToken(t, db, other) + cache.reset() + + require.NoError(t, service.PurgeUser(t.Context(), userID)) + + require.Equal(t, []uuid.UUID{other}, teamMembers(t, db, first)) + require.Empty(t, teamMembers(t, db, second)) + require.Zero(t, accessTokenCount(t, db, userID)) + require.Equal(t, 1, accessTokenCount(t, db, other)) + require.ElementsMatch(t, []memberKey{ + {userID: userID, teamID: first.String()}, + {userID: userID, teamID: second.String()}, + }, cache.members) +} + +// The row anchors keys that outlive the user's access: addons.added_by refuses +// the delete, and two created_by columns would null out provenance. +func TestPurgeUserLeavesTheUserRowStanding(t *testing.T) { + t.Parallel() + + db := testutils.SetupDatabase(t) + teamID := testutils.CreateTestTeam(t, db) + service, _ := newService(db) + userID := uuid.New() + + require.NoError(t, service.SetProjectMembers(t.Context(), MemberChange{ProjectID: teamID, Present: []uuid.UUID{userID}})) + require.NoError(t, service.PurgeUser(t.Context(), userID)) + + require.True(t, publicUserExists(t, db, userID)) +} + +// The caller fans a purge out to every control plane, so most hold nothing. +func TestPurgeUserWithNothingToPurgeSucceeds(t *testing.T) { + t.Parallel() + + db := testutils.SetupDatabase(t) + service, _ := newService(db) + + require.NoError(t, service.PurgeUser(t.Context(), uuid.New())) + require.NoError(t, service.PurgeUser(t.Context(), uuid.New())) +} diff --git a/packages/dashboard-api/internal/management/project.go b/packages/dashboard-api/internal/management/project.go new file mode 100644 index 0000000000..7ae04dbfae --- /dev/null +++ b/packages/dashboard-api/internal/management/project.go @@ -0,0 +1,159 @@ +package management + +import ( + "context" + "errors" + "fmt" + + "github.com/google/uuid" + "go.uber.org/zap" + + 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/logger" +) + +// defaultProjectTier is the only tier this side ever assigns, and it assigns it +// once. The contract names none: the caller's plan vocabulary is its own, and +// the limits it actually wants arrive absolute through the limits route, which +// team_limits reads in preference to the tier. +const defaultProjectTier = "base_v1" + +const teamSlugUniqueConstraint = "teams_slug_unique" + +var ( + // ErrProjectSlugImmutable reports a reconcile that would move a project to + // a different slug. + ErrProjectSlugImmutable = errors.New("project slug cannot change") + + // ErrProjectSlugTaken reports a slug already held on this cluster, possibly + // by a project the caller has never heard of. + ErrProjectSlugTaken = errors.New("project slug is already taken") + + // ErrProjectRaced reports an id inserted between the existence check and + // this request's own insert. + ErrProjectRaced = errors.New("project was created concurrently") +) + +// Project is the set of properties the caller synchronizes. It sends all of +// them on every push, so a reconcile is a complete statement rather than a +// patch. +type Project struct { + ID uuid.UUID + Name string + Slug string + Email string +} + +// UpsertProject creates a project or reconciles an existing one, reporting +// which happened. +// +// One operation serves both because the caller cannot tell them apart: it +// retries, and a retry after a response it never saw has to land on the same +// state as the original. +func (s *Service) UpsertProject(ctx context.Context, project Project) (stored Project, created bool, err error) { + stored, created, err = s.writeProject(ctx, project) + if err != nil { + return Project{}, false, err + } + + // A reconcile changes the team, so every cached copy of it is stale — its + // own entry, each API key's, each member's. A create has nothing cached. + // Logged rather than returned: the row is committed, and a retry cannot + // improve on a stale cache. + if !created { + if err := s.cache.InvalidateTeamCache(ctx, project.ID); err != nil { + logger.L().Error(ctx, "invalidating team cache after project reconcile", + logger.WithTeamID(project.ID.String()), zap.Error(err)) + } + } + + return stored, created, nil +} + +// writeProject branches on whether the project exists, because the two cases +// differ in what they may set: a create assigns the tier, a reconcile writes +// only what the caller synchronizes and leaves the tier alone. +func (s *Service) writeProject(ctx context.Context, project Project) (stored Project, created bool, err error) { + txDB, tx, err := s.db.WithTx(ctx) + if err != nil { + return Project{}, false, fmt.Errorf("start project upsert transaction: %w", err) + } + defer func() { + _ = tx.Rollback(ctx) + }() + + existing, err := txDB.LockManagedTeam(ctx, project.ID) + + switch { + case err == nil: + stored, err = reconcileProject(ctx, txDB, existing, project) + case dberrors.IsNotFoundError(err): + stored, err = createProject(ctx, txDB, project) + created = true + default: + return Project{}, false, fmt.Errorf("look up project: %w", err) + } + + if err != nil { + return Project{}, false, err + } + + if err := tx.Commit(ctx); err != nil { + return Project{}, false, fmt.Errorf("commit project upsert: %w", err) + } + + return stored, created, nil +} + +func createProject(ctx context.Context, txDB *authqueries.Queries, project Project) (Project, error) { + inserted, err := txDB.InsertManagedTeam(ctx, authqueries.InsertManagedTeamParams{ + ID: project.ID, + Name: project.Name, + Slug: project.Slug, + Tier: defaultProjectTier, + Email: project.Email, + }) + + switch { + // ON CONFLICT DO NOTHING yields no row, which here means another request + // inserted this id between the lock finding nothing and this statement. + case dberrors.IsNotFoundError(err): + return Project{}, ErrProjectRaced + case dberrors.ConstraintName(err) == teamSlugUniqueConstraint: + return Project{}, fmt.Errorf("%w: %q", ErrProjectSlugTaken, project.Slug) + case err != nil: + return Project{}, fmt.Errorf("create project: %w", err) + } + + return Project{ID: project.ID, Name: inserted.Name, Slug: inserted.Slug, Email: inserted.Email}, nil +} + +func reconcileProject( + ctx context.Context, + txDB *authqueries.Queries, + existing authqueries.LockManagedTeamRow, + project Project, +) (Project, error) { + // The rule that a slug never moves is the caller's: it owns the region-wide + // namespace these labels address, and teams_slug_unique is only the backstop + // underneath it. Refusing also stops a reconcile adopting a team it does not + // own — caller-minted ids will not collide, but backfilling legacy teams + // into projects makes other ids reachable, and a mismatched slug is the + // signal that one of them is not the project being described. + if existing.Slug != project.Slug { + return Project{}, fmt.Errorf("%w: stored %q, requested %q", + ErrProjectSlugImmutable, existing.Slug, project.Slug) + } + + updated, err := txDB.UpdateManagedTeam(ctx, authqueries.UpdateManagedTeamParams{ + ID: project.ID, + Name: project.Name, + Email: project.Email, + }) + if err != nil { + return Project{}, fmt.Errorf("reconcile project: %w", err) + } + + return Project{ID: project.ID, Name: updated.Name, Slug: updated.Slug, Email: updated.Email}, nil +} diff --git a/packages/dashboard-api/internal/management/service.go b/packages/dashboard-api/internal/management/service.go index 5669283992..d802ec80b8 100644 --- a/packages/dashboard-api/internal/management/service.go +++ b/packages/dashboard-api/internal/management/service.go @@ -1,34 +1,17 @@ // Package management holds the state changes the control-plane management // interface applies. They live outside the handlers so they are reachable // without gin: what these operations get wrong is never the HTTP. +// +// Each one owns the cache evictions its own write invalidates, and reports +// failures as sentinel errors rather than database ones, so the routes above +// map outcomes to status codes without knowing what backs them. package management import ( - "context" - "errors" - "fmt" - "slices" - - "github.com/google/uuid" - sharedauth "github.com/e2b-dev/infra/packages/auth/pkg/auth" authdb "github.com/e2b-dev/infra/packages/db/pkg/auth" - authqueries "github.com/e2b-dev/infra/packages/db/pkg/auth/queries" - "github.com/e2b-dev/infra/packages/shared/pkg/logger" ) -// ErrProjectNotFound reports a project unknown to this cluster. Returned rather -// than answered here, because each route reads it differently. -var ErrProjectNotFound = errors.New("project not found") - -// Service applies membership changes and evicts the cache entries each one -// invalidates. -// -// The two halves are inseparable. Auth caches a copy of the team per member -// under -, and InvalidateTeamCache finds those keys by reading -// users_teams — so a member already removed is one it cannot see. Writing and -// evicting from separate call sites leaves a revoked member authenticating -// until the entry expires, which is why there is no way to do only one. type Service struct { db *authdb.Client cache sharedauth.Service @@ -37,160 +20,3 @@ type Service struct { func NewService(db *authdb.Client, cache sharedauth.Service) *Service { return &Service{db: db, cache: cache} } - -// MemberChange is the membership a push states for a project. Users it does not -// name keep whatever membership they have. -type MemberChange struct { - ProjectID uuid.UUID - Present []uuid.UUID - Absent []uuid.UUID - - // AddedBy records the actor behind the addition, when the caller names one. - AddedBy *uuid.UUID -} - -// namedUsers is every user the change states a presence for. -func (c MemberChange) namedUsers() []uuid.UUID { - return slices.Concat(c.Present, c.Absent) -} - -// anchoredUsers lists the user rows adding Present depends on: the members, and -// whoever is recorded as adding them. -func (c MemberChange) anchoredUsers() []uuid.UUID { - if c.AddedBy == nil { - return c.Present - } - - return append(slices.Clone(c.Present), *c.AddedBy) -} - -// SetProjectMembers reconciles a stated membership against a project in one -// transaction. -// -// Idempotent in both directions, because the caller retries, delivers at least -// once and lets its own pushes interleave. -// -// The rules the dashboard's member routes enforce — no removing the last -// member, no touching a default membership — are deliberately absent. The -// caller owns membership, and a rule here would make its pushes unrepeatable. -func (s *Service) SetProjectMembers(ctx context.Context, change MemberChange) error { - removed, err := s.applyMembers(ctx, change) - if err != nil { - return err - } - - // Evicting before the commit would let a concurrent read repopulate the - // entry with uncommitted state. - // - // Every user the change named, not only the rows that moved: a crash here - // leaves stale entries that only the caller's retry clears, and that retry - // finds the work already done. Repeating an eviction costs a cache delete; - // skipping one costs a revoked member's access. - for _, userID := range change.namedUsers() { - s.cache.InvalidateTeamMemberCache(ctx, userID, change.ProjectID.String()) - } - - // Costs the user a team rather than access: the signup path recreates a - // missing default on next login. Logged because backfilling legacy teams - // into projects is where it would start happening in bulk. - for _, row := range removed { - if row.IsDefault { - logger.L().Warn(ctx, "management removed a default team membership", - logger.WithTeamID(change.ProjectID.String()), logger.WithUserID(row.UserID.String())) - } - } - - return nil -} - -func (s *Service) applyMembers(ctx context.Context, change MemberChange) ([]authqueries.SyncTeamMembersAbsentRow, error) { - txDB, tx, err := s.db.WithTx(ctx) - if err != nil { - return nil, fmt.Errorf("start membership transaction: %w", err) - } - defer func() { - _ = tx.Rollback(ctx) - }() - - exists, err := txDB.TeamExists(ctx, change.ProjectID) - if err != nil { - return nil, fmt.Errorf("look up project: %w", err) - } - if !exists { - return nil, ErrProjectNotFound - } - - if len(change.Present) > 0 { - // users_teams still points at public.users for both the member and - // whoever added them, and the caller knows only opaque ids. - if err := txDB.UpsertPublicUsers(ctx, change.anchoredUsers()); err != nil { - return nil, fmt.Errorf("anchor users: %w", err) - } - - if err := txDB.SyncTeamMembersPresent(ctx, authqueries.SyncTeamMembersPresentParams{ - TeamID: change.ProjectID, - UserIds: change.Present, - AddedBy: change.AddedBy, - }); err != nil { - return nil, fmt.Errorf("add project members: %w", err) - } - } - - var removed []authqueries.SyncTeamMembersAbsentRow - if len(change.Absent) > 0 { - removed, err = txDB.SyncTeamMembersAbsent(ctx, authqueries.SyncTeamMembersAbsentParams{ - TeamID: change.ProjectID, - UserIds: change.Absent, - }) - if err != nil { - return nil, fmt.Errorf("remove project members: %w", err) - } - } - - if err := tx.Commit(ctx); err != nil { - return nil, fmt.Errorf("commit membership change: %w", err) - } - - return removed, nil -} - -// PurgeUser removes the memberships and access tokens a user holds here. -// -// public.users survives on purpose: addons.added_by would refuse the delete, -// and two created_by columns would quietly null out provenance. Deleting a -// user outright already belongs to the admin route. -func (s *Service) PurgeUser(ctx context.Context, userID uuid.UUID) error { - txDB, tx, err := s.db.WithTx(ctx) - if err != nil { - return fmt.Errorf("start purge transaction: %w", err) - } - defer func() { - _ = tx.Rollback(ctx) - }() - - // Driven by what the delete removed, not by a read taken before it. Under - // READ COMMITTED those are different snapshots, and a membership committed - // between them would be deleted here but never evicted below. - teamIDs, err := txDB.PurgeUserMemberships(ctx, userID) - if err != nil { - return fmt.Errorf("purge user memberships: %w", err) - } - - if err := txDB.PurgeUserAccessTokens(ctx, userID); err != nil { - return fmt.Errorf("purge user access tokens: %w", err) - } - - if err := tx.Commit(ctx); err != nil { - return fmt.Errorf("commit user purge: %w", err) - } - - // A crash before this leaves entries a retry cannot find, since the rows it - // would evict from are already gone. They stand until they expire; carrying - // them across the gap would need a teardown log, which is a lot of - // machinery for a five-minute worst case on a crash. - for _, teamID := range teamIDs { - s.cache.InvalidateTeamMemberCache(ctx, userID, teamID.String()) - } - - return nil -} diff --git a/packages/dashboard-api/internal/management/service_test.go b/packages/dashboard-api/internal/management/service_test.go index 9d83eda302..1765c9b727 100644 --- a/packages/dashboard-api/internal/management/service_test.go +++ b/packages/dashboard-api/internal/management/service_test.go @@ -14,217 +14,6 @@ import ( "github.com/e2b-dev/infra/packages/db/pkg/testutils" ) -// The push is the only way membership reaches this side, so a repeated one must -// land on the same state rather than a duplicate row or a rejection. -func TestSetAddsAndIsIdempotent(t *testing.T) { - t.Parallel() - - db := testutils.SetupDatabase(t) - teamID := testutils.CreateTestTeam(t, db) - service, _ := newService(db) - userID := uuid.New() - - require.NoError(t, service.SetProjectMembers(t.Context(), MemberChange{ProjectID: teamID, Present: []uuid.UUID{userID}})) - require.NoError(t, service.SetProjectMembers(t.Context(), MemberChange{ProjectID: teamID, Present: []uuid.UUID{userID}})) - - require.Equal(t, []uuid.UUID{userID}, teamMembers(t, db, teamID)) -} - -// The caller knows only opaque ids, and users_teams still points at -// public.users, so without the anchor the first push for an unseen user fails. -func TestSetAnchorsUnknownUsers(t *testing.T) { - t.Parallel() - - db := testutils.SetupDatabase(t) - teamID := testutils.CreateTestTeam(t, db) - service, _ := newService(db) - userID := uuid.New() - addedBy := uuid.New() - - require.NoError(t, service.SetProjectMembers(t.Context(), MemberChange{ProjectID: teamID, Present: []uuid.UUID{userID}, AddedBy: &addedBy})) - - require.True(t, publicUserExists(t, db, userID)) - // added_by carries its own key, so an unknown actor fails the insert on a - // column nobody was looking at. - require.True(t, publicUserExists(t, db, addedBy)) -} - -// The assertion the type exists for: InvalidateTeamCache finds keys by reading -// users_teams, so a removed member is one it cannot see. Writing and -// invalidating separately would leave this user authenticating until expiry. -func TestSetEvictsRemovedMembersTheSweepCannotSee(t *testing.T) { - t.Parallel() - - db := testutils.SetupDatabase(t) - teamID := testutils.CreateTestTeam(t, db) - service, cache := newService(db) - userID := uuid.New() - - require.NoError(t, service.SetProjectMembers(t.Context(), MemberChange{ProjectID: teamID, Present: []uuid.UUID{userID}})) - cache.reset() - - require.NoError(t, service.SetProjectMembers(t.Context(), MemberChange{ProjectID: teamID, Absent: []uuid.UUID{userID}})) - - require.Empty(t, teamMembers(t, db, teamID)) - require.Equal(t, []memberKey{{userID: userID, teamID: teamID.String()}}, cache.members) -} - -// Membership does not change the team, so entries keyed by API key hold nothing -// this write invalidated. Sweeping them would evict every key on the project. -func TestSetLeavesTeamWideCacheEntriesAlone(t *testing.T) { - t.Parallel() - - db := testutils.SetupDatabase(t) - teamID := testutils.CreateTestTeam(t, db) - service, cache := newService(db) - - require.NoError(t, service.SetProjectMembers(t.Context(), MemberChange{ProjectID: teamID, Present: []uuid.UUID{uuid.New()}})) - - require.Empty(t, cache.teams) -} - -// Removing a membership that is not there is the desired state, not a failure — -// and the eviction still has to run. -// -// A crash before the eviction leaves a stale entry only a retry can clear, and -// that retry sees exactly this: nothing left to delete. Keying the eviction off -// what the statement touched would make the recovery path a no-op. -func TestSetRemovingAnAbsentMemberSucceedsAndStillEvicts(t *testing.T) { - t.Parallel() - - db := testutils.SetupDatabase(t) - teamID := testutils.CreateTestTeam(t, db) - service, cache := newService(db) - userID := uuid.New() - - require.NoError(t, service.SetProjectMembers(t.Context(), MemberChange{ProjectID: teamID, Absent: []uuid.UUID{userID}})) - - require.Equal(t, []memberKey{{userID: userID, teamID: teamID.String()}}, cache.members) -} - -// One call carries both directions so the caller never has to order them. -func TestSetAppliesBothDirectionsAtOnce(t *testing.T) { - t.Parallel() - - db := testutils.SetupDatabase(t) - teamID := testutils.CreateTestTeam(t, db) - service, _ := newService(db) - staying, leaving, joining := uuid.New(), uuid.New(), uuid.New() - - require.NoError(t, service.SetProjectMembers(t.Context(), MemberChange{ProjectID: teamID, Present: []uuid.UUID{staying, leaving}})) - require.NoError(t, service.SetProjectMembers(t.Context(), MemberChange{ProjectID: teamID, Present: []uuid.UUID{joining}, Absent: []uuid.UUID{leaving}})) - - require.ElementsMatch(t, []uuid.UUID{staying, joining}, teamMembers(t, db, teamID)) -} - -// A request states presence only for the users it lists, so a batch naming two -// members must not disturb a third. -func TestSetIgnoresUnlistedMembers(t *testing.T) { - t.Parallel() - - db := testutils.SetupDatabase(t) - teamID := testutils.CreateTestTeam(t, db) - service, _ := newService(db) - listed, unlisted := uuid.New(), uuid.New() - - require.NoError(t, service.SetProjectMembers(t.Context(), MemberChange{ProjectID: teamID, Present: []uuid.UUID{listed, unlisted}})) - require.NoError(t, service.SetProjectMembers(t.Context(), MemberChange{ProjectID: teamID, Absent: []uuid.UUID{listed}})) - - require.Equal(t, []uuid.UUID{unlisted}, teamMembers(t, db, teamID)) -} - -// A push naming an unknown project is divergence, and the caller decides what to -// do about it. Nothing may be written on the way to saying so. -func TestSetReportsAnUnknownProject(t *testing.T) { - t.Parallel() - - db := testutils.SetupDatabase(t) - service, cache := newService(db) - userID := uuid.New() - - err := service.SetProjectMembers(t.Context(), MemberChange{ProjectID: uuid.New(), Present: []uuid.UUID{userID}}) - - require.ErrorIs(t, err, ErrProjectNotFound) - require.False(t, publicUserExists(t, db, userID)) - require.Empty(t, cache.members) -} - -// Backfilled legacy teams carry default memberships, so a push can remove one. -// Allowed: the caller owns membership, and signup recreates a missing default. -func TestSetRemovesADefaultMembership(t *testing.T) { - t.Parallel() - - db := testutils.SetupDatabase(t) - teamID := testutils.CreateTestTeam(t, db) - service, _ := newService(db) - userID := uuid.New() - - require.NoError(t, service.SetProjectMembers(t.Context(), MemberChange{ProjectID: teamID, Present: []uuid.UUID{userID}})) - require.NoError(t, db.AuthDB.TestsRawSQL(t.Context(), - "UPDATE public.users_teams SET is_default = true WHERE team_id = $1 AND user_id = $2", teamID, userID)) - - require.NoError(t, service.SetProjectMembers(t.Context(), MemberChange{ProjectID: teamID, Absent: []uuid.UUID{userID}})) - require.Empty(t, teamMembers(t, db, teamID)) -} - -// The evicted set has to equal the deleted set: exactly the projects the purge -// removed the user from, and no others. Deriving it from the delete rather than -// a preceding read is what keeps that true when a membership is committed while -// the purge is running. -func TestPurgeUserClearsMembershipsAndTokensAcrossProjects(t *testing.T) { - t.Parallel() - - db := testutils.SetupDatabase(t) - first := testutils.CreateTestTeam(t, db) - second := testutils.CreateTestTeam(t, db) - service, cache := newService(db) - userID, other := uuid.New(), uuid.New() - - require.NoError(t, service.SetProjectMembers(t.Context(), MemberChange{ProjectID: first, Present: []uuid.UUID{userID, other}})) - require.NoError(t, service.SetProjectMembers(t.Context(), MemberChange{ProjectID: second, Present: []uuid.UUID{userID}})) - createAccessToken(t, db, userID) - createAccessToken(t, db, other) - cache.reset() - - require.NoError(t, service.PurgeUser(t.Context(), userID)) - - require.Equal(t, []uuid.UUID{other}, teamMembers(t, db, first)) - require.Empty(t, teamMembers(t, db, second)) - require.Zero(t, accessTokenCount(t, db, userID)) - require.Equal(t, 1, accessTokenCount(t, db, other)) - require.ElementsMatch(t, []memberKey{ - {userID: userID, teamID: first.String()}, - {userID: userID, teamID: second.String()}, - }, cache.members) -} - -// The row anchors keys that outlive the user's access: addons.added_by refuses -// the delete, and two created_by columns would null out provenance. -func TestPurgeUserLeavesTheUserRowStanding(t *testing.T) { - t.Parallel() - - db := testutils.SetupDatabase(t) - teamID := testutils.CreateTestTeam(t, db) - service, _ := newService(db) - userID := uuid.New() - - require.NoError(t, service.SetProjectMembers(t.Context(), MemberChange{ProjectID: teamID, Present: []uuid.UUID{userID}})) - require.NoError(t, service.PurgeUser(t.Context(), userID)) - - require.True(t, publicUserExists(t, db, userID)) -} - -// The caller fans a purge out to every control plane, so most hold nothing. -func TestPurgeUserWithNothingToPurgeSucceeds(t *testing.T) { - t.Parallel() - - db := testutils.SetupDatabase(t) - service, _ := newService(db) - - require.NoError(t, service.PurgeUser(t.Context(), uuid.New())) - require.NoError(t, service.PurgeUser(t.Context(), uuid.New())) -} - func newService(db *testutils.Database) (*Service, *recordingCache) { cache := &recordingCache{} From 5b83d2a90b1ebf98e30ff65c60e9b9c63cb6b4d9 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Wed, 29 Jul 2026 20:51:12 +0200 Subject: [PATCH 5/9] fix(auth): detach team cache invalidation from request cancellation RedisCache.Delete documents that its caller must pass a context detached from request cancellation when the delete must not be skipped, and InvalidateAPIKeyCache already does. The two team invalidators did not, so an invalidation running after its write had committed was skipped whenever the client had gone away. That matters most for the management purge route, whose eviction set comes from the delete's own RETURNING: a retry finds the rows already gone and evicts nothing, leaving a purged user authenticating until the entry expires. The caller times out by design before retrying, so the cancelled context is the expected path rather than a rare one. Also fixes the same latent gap in the dashboard's own member routes, the limits push, and the api service's team-kill route. --- .../auth/pkg/auth/internal/service/service.go | 15 +++++++++++++++ .../dashboard-api/internal/management/members.go | 9 +++++---- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/packages/auth/pkg/auth/internal/service/service.go b/packages/auth/pkg/auth/internal/service/service.go index 66c0e1f2c6..a41370a8b2 100644 --- a/packages/auth/pkg/auth/internal/service/service.go +++ b/packages/auth/pkg/auth/internal/service/service.go @@ -259,7 +259,14 @@ func (s *AuthService) ValidateAuthProviderTeam(ctx context.Context, ginCtx *gin. // InvalidateTeamMemberCache removes the cached auth entry for a specific user-team pair. // This should be called when team membership changes (member added or removed). +// +// Detached for the same reason as InvalidateAPIKeyCache: the invalidation runs +// after the membership change has committed, and skipping it because the client +// disconnected leaves a removed member authenticating until the cache TTL. func (s *AuthService) InvalidateTeamMemberCache(ctx context.Context, userID uuid.UUID, teamID string) { + ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), invalidateTimeout) + defer cancel() + s.teamCache.Invalidate(ctx, teamMemberCacheKey(userID, teamID)) } @@ -274,7 +281,15 @@ func (s *AuthService) InvalidateTeamMemberCache(ctx context.Context, userID uuid // ApiKeyAuth // - AuthProviderBearerAuth + AuthProviderTeamAuth, the // browser session path, one entry per member +// +// Detached from the caller's cancellation, and bounded, because it runs after +// the change it reflects has committed. One budget covers the whole sweep: the +// reads below decide which keys to drop, so a cancelled context part-way +// through would leave an arbitrary subset of them stale. func (s *AuthService) InvalidateTeamCache(ctx context.Context, teamID uuid.UUID) error { + ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), invalidateTimeout) + defer cancel() + s.teamCache.Invalidate(ctx, teamCacheKey(teamID)) hashes, err := s.store.GetTeamAPIKeyHashes(ctx, teamID) diff --git a/packages/dashboard-api/internal/management/members.go b/packages/dashboard-api/internal/management/members.go index ed96431326..abf98a0c20 100644 --- a/packages/dashboard-api/internal/management/members.go +++ b/packages/dashboard-api/internal/management/members.go @@ -162,10 +162,11 @@ func (s *Service) PurgeUser(ctx context.Context, userID uuid.UUID) error { return fmt.Errorf("commit user purge: %w", err) } - // A crash before this leaves entries a retry cannot find, since the rows it - // would evict from are already gone. They stand until they expire; carrying - // them across the gap would need a teardown log, which is a lot of - // machinery for a five-minute worst case on a crash. + // Losing the process here leaves entries a retry cannot find, since the rows + // it would evict from are already gone. They stand until they expire; + // carrying them across the gap would need a teardown log, which is a lot of + // machinery for a five-minute worst case on a crash. Cancellation is not + // part of that gap — the invalidation detaches from the request context. for _, teamID := range teamIDs { s.cache.InvalidateTeamMemberCache(ctx, userID, teamID.String()) } From caa87cc1eef6499bfd9fd8e29ab6926adc05fadb Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Wed, 29 Jul 2026 21:22:49 +0200 Subject: [PATCH 6/9] docs(dashboard-api): give the real reason a project slug cannot move The comment credited the rule to the caller's DNS namespace, which is the M5 reason and implies this side would accept a rename if the caller allowed one. It would not: register_build stamps the team slug onto every template alias it claims and names render as slug/alias, so a rename without rewriting those rows orphans every template the team owns. --- .../internal/management/project.go | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/packages/dashboard-api/internal/management/project.go b/packages/dashboard-api/internal/management/project.go index 7ae04dbfae..36d5c3ef08 100644 --- a/packages/dashboard-api/internal/management/project.go +++ b/packages/dashboard-api/internal/management/project.go @@ -135,12 +135,19 @@ func reconcileProject( existing authqueries.LockManagedTeamRow, project Project, ) (Project, error) { - // The rule that a slug never moves is the caller's: it owns the region-wide - // namespace these labels address, and teams_slug_unique is only the backstop - // underneath it. Refusing also stops a reconcile adopting a team it does not - // own — caller-minted ids will not collide, but backfilling legacy teams - // into projects makes other ids reachable, and a mismatched slug is the - // signal that one of them is not the project being described. + // A slug is not a display property, and this side has its own reason to + // refuse moving one. Template aliases are namespaced by it: register_build + // stamps the team's slug onto every alias it claims, and a template's name + // renders as "/". Accepting a new slug without rewriting every + // one of those rows would leave the team's templates addressed under a name + // that no longer exists. The caller has its own reason too — the slug is the + // DNS label projects are reached at — but neither is satisfied by a rename + // here alone. + // + // Refusing also stops a reconcile adopting a team it does not own: + // caller-minted ids will not collide, but backfilling legacy teams into + // projects makes other ids reachable, and a mismatched slug is the signal + // that one of them is not the project being described. if existing.Slug != project.Slug { return Project{}, fmt.Errorf("%w: stored %q, requested %q", ErrProjectSlugImmutable, existing.Slug, project.Slug) From e498f2ee0577c4d1a905dd3ef272c1b6704dc9c7 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Wed, 29 Jul 2026 21:26:35 +0200 Subject: [PATCH 7/9] feat(dashboard-api): allow a project rename, carrying its template names A slug was refused on reconcile because template names are stored as '/' and a rename would have addressed the project's templates under a slug it no longer had. Repointing env_aliases.namespace in the same transaction removes that reason, so renames are allowed. Two costs stay with the caller and are noted in the contract: the names its users already type change, and the api service resolves aliases through a cache, so the old ones answer for up to its TTL. Aliases predating the namespace column keep their null, which is how they are still resolved. teams_slug_unique now applies on the way out as well as in, so a rename onto a taken slug is the same 409 a create would get. --- docs/ARCHITECTURE.md | 2 +- .../dashboard-api/internal/api/api.gen.go | 252 +++++++++--------- .../handlers/management_project_upsert.go | 2 - .../management_project_upsert_test.go | 60 ++++- .../internal/management/project.go | 37 ++- .../pkg/auth/queries/team_management.sql.go | 33 ++- .../sql_queries/teams/team_management.sql | 10 + spec/openapi-dashboard.yml | 5 + 8 files changed, 246 insertions(+), 155 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ccd3cff33c..13aaf7ee7b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -213,7 +213,7 @@ orchestrators. The `/v1/management` operations are the cluster's half of a contract the workspace residency owns: project upsert (a project is a `public.teams` row created from a caller-supplied UUID; the tier is -assigned once at creation from a local default and no push moves it), member sync (granular and +assigned once at creation from a local default and no push moves it; a changed slug renames the project and repoints its template namespaces), member sync (granular and batched, over opaque user UUIDs in `users_teams`), limit sync (into `project_limits`, which `team_limits` reads in preference to `tiers`), and user purge (memberships and access tokens; the `public.users` row survives). All are idempotent, because diff --git a/packages/dashboard-api/internal/api/api.gen.go b/packages/dashboard-api/internal/api/api.gen.go index d4de53cc95..e6f06de238 100644 --- a/packages/dashboard-api/internal/api/api.gen.go +++ b/packages/dashboard-api/internal/api/api.gen.go @@ -352,7 +352,9 @@ type ManagementProject struct { Email string `json:"email"` Id openapi_types.UUID `json:"id"` Name string `json:"name"` - Slug string `json:"slug"` + + // Slug Changing it renames the project. The project's template names move with it, since they read "/", so the names its users already type change too. A slug already held on this control plane is a 409, on a rename as much as on a create. + Slug string `json:"slug"` } // ManagementProjectLimits A project's effective limits, already resolved by the caller. Every field is absolute: this side stores what it is given and performs no arithmetic of its own. @@ -379,7 +381,9 @@ type ManagementProjectUpsertRequest struct { // Email Contact address recorded on the project. Email string `json:"email"` Name string `json:"name"` - Slug string `json:"slug"` + + // Slug Changing it renames the project. The project's template names move with it, since they read "/", so the names its users already type change too. A slug already held on this control plane is a 409, on a rename as much as on a create. + Slug string `json:"slug"` } // MemoryMB Memory for the sandbox in MiB @@ -2021,127 +2025,129 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options // const string: with thousands of chunks the chained `+` fold is several // times slower for the Go compiler than parsing a slice literal. var swaggerSpec = []string{ - "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", } // decodeSpec returns the embedded OpenAPI spec as raw JSON bytes, diff --git a/packages/dashboard-api/internal/handlers/management_project_upsert.go b/packages/dashboard-api/internal/handlers/management_project_upsert.go index 777aada18f..299ba0b4d3 100644 --- a/packages/dashboard-api/internal/handlers/management_project_upsert.go +++ b/packages/dashboard-api/internal/handlers/management_project_upsert.go @@ -68,8 +68,6 @@ func (s *APIStore) sendProjectUpsertError(c *gin.Context, err error, attrs ...at // failure is not one the caller can resolve by changing the request. func projectConflictMessage(err error) string { switch { - case errors.Is(err, management.ErrProjectSlugImmutable): - return "Project slug cannot change" case errors.Is(err, management.ErrProjectSlugTaken): return "Slug is already taken on this control plane" case errors.Is(err, management.ErrProjectRaced): diff --git a/packages/dashboard-api/internal/handlers/management_project_upsert_test.go b/packages/dashboard-api/internal/handlers/management_project_upsert_test.go index 4b3d778311..730ebedbdf 100644 --- a/packages/dashboard-api/internal/handlers/management_project_upsert_test.go +++ b/packages/dashboard-api/internal/handlers/management_project_upsert_test.go @@ -56,9 +56,9 @@ func TestUpsertProjectIsIdempotent(t *testing.T) { require.Equal(t, "Acme", teamColumn(t, db, project.id, "name")) } -// The slug is the project's DNS label, and the caller's region-wide namespace -// depends on it not moving. -func TestUpsertProjectRefusesAChangedSlug(t *testing.T) { +// Template names are stored as "/", so a rename that left them +// behind would address the project's templates under a slug it no longer has. +func TestUpsertProjectRenameCarriesTemplateNames(t *testing.T) { t.Parallel() db := testutils.SetupDatabase(t) @@ -67,12 +67,41 @@ func TestUpsertProjectRefusesAChangedSlug(t *testing.T) { require.Equal(t, http.StatusCreated, callUpsertProject(t, store, project.id, project.request()).Code) + templateID := testutils.CreateTestTemplate(t, db, project.id) + namespaced := testutils.CreateTestTemplateAliasWithNamespace(t, db, templateID, &project.slug) + legacy := testutils.CreateTestTemplateAliasWithNamespace(t, db, templateID, nil) + moved := project.request() moved.Slug += "-moved" - movedResponse := callUpsertProject(t, store, project.id, moved) - require.Equal(t, http.StatusConflict, movedResponse.Code, movedResponse.Body.String()) - require.Equal(t, project.slug, teamColumn(t, db, project.id, "slug")) + response := callUpsertProject(t, store, project.id, moved) + require.Equal(t, http.StatusOK, response.Code, response.Body.String()) + + require.Equal(t, moved.Slug, teamColumn(t, db, project.id, "slug")) + require.Equal(t, moved.Slug, aliasNamespace(t, db, namespaced)) + // Aliases predating the namespace column stay null, which is how they are + // still resolved. + require.Empty(t, aliasNamespace(t, db, legacy)) +} + +// The slug is unique cluster-wide on the way in and on the way out, so a rename +// can collide just as a create can. +func TestUpsertProjectRenameRejectsATakenSlug(t *testing.T) { + t.Parallel() + + db := testutils.SetupDatabase(t) + store, _ := newUpsertStore(db) + incumbent, mover := newProjectFixture(), newProjectFixture() + + require.Equal(t, http.StatusCreated, callUpsertProject(t, store, incumbent.id, incumbent.request()).Code) + require.Equal(t, http.StatusCreated, callUpsertProject(t, store, mover.id, mover.request()).Code) + + collide := mover.request() + collide.Slug = incumbent.slug + + response := callUpsertProject(t, store, mover.id, collide) + require.Equal(t, http.StatusConflict, response.Code, response.Body.String()) + require.Equal(t, mover.slug, teamColumn(t, db, mover.id, "slug")) } // Slugs are unique cluster-wide, so the collision may be with a project the @@ -237,6 +266,25 @@ func callUpsertProject(t *testing.T, store *APIStore, projectID uuid.UUID, reque return recorder } +func aliasNamespace(t *testing.T, db *testutils.Database, alias string) string { + t.Helper() + + var namespace *string + require.NoError(t, db.AuthDB.TestsRawSQLQuery(t.Context(), + "SELECT namespace FROM public.env_aliases WHERE alias = $1", + func(rows pgx.Rows) error { + rows.Next() + + return rows.Scan(&namespace) + }, alias)) + + if namespace == nil { + return "" + } + + return *namespace +} + func teamColumn(t *testing.T, db *testutils.Database, teamID uuid.UUID, column string) string { t.Helper() diff --git a/packages/dashboard-api/internal/management/project.go b/packages/dashboard-api/internal/management/project.go index 36d5c3ef08..9154e76a9b 100644 --- a/packages/dashboard-api/internal/management/project.go +++ b/packages/dashboard-api/internal/management/project.go @@ -22,10 +22,6 @@ const defaultProjectTier = "base_v1" const teamSlugUniqueConstraint = "teams_slug_unique" var ( - // ErrProjectSlugImmutable reports a reconcile that would move a project to - // a different slug. - ErrProjectSlugImmutable = errors.New("project slug cannot change") - // ErrProjectSlugTaken reports a slug already held on this cluster, possibly // by a project the caller has never heard of. ErrProjectSlugTaken = errors.New("project slug is already taken") @@ -135,30 +131,31 @@ func reconcileProject( existing authqueries.LockManagedTeamRow, project Project, ) (Project, error) { - // A slug is not a display property, and this side has its own reason to - // refuse moving one. Template aliases are namespaced by it: register_build - // stamps the team's slug onto every alias it claims, and a template's name - // renders as "/". Accepting a new slug without rewriting every - // one of those rows would leave the team's templates addressed under a name - // that no longer exists. The caller has its own reason too — the slug is the - // DNS label projects are reached at — but neither is satisfied by a rename - // here alone. - // - // Refusing also stops a reconcile adopting a team it does not own: - // caller-minted ids will not collide, but backfilling legacy teams into - // projects makes other ids reachable, and a mismatched slug is the signal - // that one of them is not the project being described. + // A rename carries the team's template names with it: they are stored as + // "/", so leaving them behind would address templates under a + // slug the project no longer has. Two costs stay with the caller — the names + // its users already type change, and the api service resolves aliases + // through a cache, so the old ones answer for up to its TTL. if existing.Slug != project.Slug { - return Project{}, fmt.Errorf("%w: stored %q, requested %q", - ErrProjectSlugImmutable, existing.Slug, project.Slug) + if err := txDB.RepointTeamAliasNamespace(ctx, authqueries.RepointTeamAliasNamespaceParams{ + TeamID: project.ID, + Slug: project.Slug, + }); err != nil { + return Project{}, fmt.Errorf("repoint template namespace: %w", err) + } } updated, err := txDB.UpdateManagedTeam(ctx, authqueries.UpdateManagedTeamParams{ ID: project.ID, Name: project.Name, + Slug: project.Slug, Email: project.Email, }) - if err != nil { + + switch { + case dberrors.ConstraintName(err) == teamSlugUniqueConstraint: + return Project{}, fmt.Errorf("%w: %q", ErrProjectSlugTaken, project.Slug) + case err != nil: return Project{}, fmt.Errorf("reconcile project: %w", err) } diff --git a/packages/db/pkg/auth/queries/team_management.sql.go b/packages/db/pkg/auth/queries/team_management.sql.go index 6d81753fba..13598c19e4 100644 --- a/packages/db/pkg/auth/queries/team_management.sql.go +++ b/packages/db/pkg/auth/queries/team_management.sql.go @@ -86,17 +86,39 @@ func (q *Queries) LockManagedTeam(ctx context.Context, id uuid.UUID) (LockManage return i, err } +const repointTeamAliasNamespace = `-- name: RepointTeamAliasNamespace :exec +UPDATE public.env_aliases +SET namespace = $1::text +WHERE namespace IS NOT NULL + AND env_id IN (SELECT id FROM public.envs WHERE team_id = $2::uuid) +` + +type RepointTeamAliasNamespaceParams struct { + Slug string + TeamID uuid.UUID +} + +// Template names are stored as "/" with the namespace copied +// from the team's slug, so a rename has to carry them with it. Aliases predating +// the namespace column are left null, which is how they are still resolved. +func (q *Queries) RepointTeamAliasNamespace(ctx context.Context, arg RepointTeamAliasNamespaceParams) error { + _, err := q.db.Exec(ctx, repointTeamAliasNamespace, arg.Slug, arg.TeamID) + return err +} + const updateManagedTeam = `-- name: UpdateManagedTeam :one UPDATE public.teams SET name = $1::text, - email = $2::text -WHERE id = $3::uuid + slug = $2::text, + email = $3::text +WHERE id = $4::uuid RETURNING id, name, slug, email ` type UpdateManagedTeamParams struct { Name string + Slug string Email string ID uuid.UUID } @@ -112,7 +134,12 @@ type UpdateManagedTeamRow struct { // this side's to assign; is_blocked stays because an operator's decision must // outlive a routine push. func (q *Queries) UpdateManagedTeam(ctx context.Context, arg UpdateManagedTeamParams) (UpdateManagedTeamRow, error) { - row := q.db.QueryRow(ctx, updateManagedTeam, arg.Name, arg.Email, arg.ID) + row := q.db.QueryRow(ctx, updateManagedTeam, + arg.Name, + arg.Slug, + arg.Email, + arg.ID, + ) var i UpdateManagedTeamRow err := row.Scan( &i.ID, diff --git a/packages/db/pkg/auth/sql_queries/teams/team_management.sql b/packages/db/pkg/auth/sql_queries/teams/team_management.sql index d6483c9614..e4f7af44c6 100644 --- a/packages/db/pkg/auth/sql_queries/teams/team_management.sql +++ b/packages/db/pkg/auth/sql_queries/teams/team_management.sql @@ -33,6 +33,16 @@ RETURNING id, name, slug, email; UPDATE public.teams SET name = sqlc.arg(name)::text, + slug = sqlc.arg(slug)::text, email = sqlc.arg(email)::text WHERE id = sqlc.arg(id)::uuid RETURNING id, name, slug, email; + +-- Template names are stored as "/" with the namespace copied +-- from the team's slug, so a rename has to carry them with it. Aliases predating +-- the namespace column are left null, which is how they are still resolved. +-- name: RepointTeamAliasNamespace :exec +UPDATE public.env_aliases +SET namespace = sqlc.arg(slug)::text +WHERE namespace IS NOT NULL + AND env_id IN (SELECT id FROM public.envs WHERE team_id = sqlc.arg(team_id)::uuid); diff --git a/spec/openapi-dashboard.yml b/spec/openapi-dashboard.yml index 5b596590d5..539332c1ab 100644 --- a/spec/openapi-dashboard.yml +++ b/spec/openapi-dashboard.yml @@ -1184,6 +1184,11 @@ components: type: string minLength: 1 maxLength: 63 + description: >- + Changing it renames the project. The project's template names move + with it, since they read "/", so the names its users + already type change too. A slug already held on this control plane + is a 409, on a rename as much as on a create. email: type: string minLength: 1 From 3b2e1c591821aeb3bc900ceecb62a2950a860580 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Wed, 29 Jul 2026 21:47:31 +0200 Subject: [PATCH 8/9] fix(dashboard-api): claim the slug before repointing template names Template names are unique on (alias, namespace), so repointing them first meant a rename onto a taken slug collided there rather than on teams_slug_unique, reporting a conflict the contract calls 409 as a 500. Only when both projects held a template of the same name, which is why the existing test missed it. Claiming the slug first makes the repoint collision-free by construction: no other project can hold the namespace once the slug is ours, and a team's aliases all share one namespace. --- .../management_project_upsert_test.go | 8 +++++ .../internal/management/project.go | 32 +++++++++++-------- 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/packages/dashboard-api/internal/handlers/management_project_upsert_test.go b/packages/dashboard-api/internal/handlers/management_project_upsert_test.go index 730ebedbdf..6dff25a968 100644 --- a/packages/dashboard-api/internal/handlers/management_project_upsert_test.go +++ b/packages/dashboard-api/internal/handlers/management_project_upsert_test.go @@ -96,6 +96,14 @@ func TestUpsertProjectRenameRejectsATakenSlug(t *testing.T) { require.Equal(t, http.StatusCreated, callUpsertProject(t, store, incumbent.id, incumbent.request()).Code) require.Equal(t, http.StatusCreated, callUpsertProject(t, store, mover.id, mover.request()).Code) + // Both hold a template of the same name. Template names are unique on + // (alias, namespace), so a rename that repointed them before claiming the + // slug would collide there first and report this as a server error. + testutils.CreateTestTemplateAliasWithName(t, db, + testutils.CreateTestTemplate(t, db, incumbent.id), "web", &incumbent.slug) + testutils.CreateTestTemplateAliasWithName(t, db, + testutils.CreateTestTemplate(t, db, mover.id), "web", &mover.slug) + collide := mover.request() collide.Slug = incumbent.slug diff --git a/packages/dashboard-api/internal/management/project.go b/packages/dashboard-api/internal/management/project.go index 9154e76a9b..887682652f 100644 --- a/packages/dashboard-api/internal/management/project.go +++ b/packages/dashboard-api/internal/management/project.go @@ -131,20 +131,6 @@ func reconcileProject( existing authqueries.LockManagedTeamRow, project Project, ) (Project, error) { - // A rename carries the team's template names with it: they are stored as - // "/", so leaving them behind would address templates under a - // slug the project no longer has. Two costs stay with the caller — the names - // its users already type change, and the api service resolves aliases - // through a cache, so the old ones answer for up to its TTL. - if existing.Slug != project.Slug { - if err := txDB.RepointTeamAliasNamespace(ctx, authqueries.RepointTeamAliasNamespaceParams{ - TeamID: project.ID, - Slug: project.Slug, - }); err != nil { - return Project{}, fmt.Errorf("repoint template namespace: %w", err) - } - } - updated, err := txDB.UpdateManagedTeam(ctx, authqueries.UpdateManagedTeamParams{ ID: project.ID, Name: project.Name, @@ -159,5 +145,23 @@ func reconcileProject( return Project{}, fmt.Errorf("reconcile project: %w", err) } + // A rename carries the team's template names with it: they are stored as + // "/", so leaving them behind would address templates under a + // slug the project no longer has. Two costs stay with the caller — the names + // its users already type change, and the api service resolves aliases + // through a cache, so the old ones answer for up to its TTL. + // + // After the slug is claimed, not before. Template names are unique on + // (alias, namespace), so repointing into a namespace another project still + // holds collides there first and reports a taken slug as a server error. + if existing.Slug != project.Slug { + if err := txDB.RepointTeamAliasNamespace(ctx, authqueries.RepointTeamAliasNamespaceParams{ + TeamID: project.ID, + Slug: project.Slug, + }); err != nil { + return Project{}, fmt.Errorf("repoint template namespace: %w", err) + } + } + return Project{ID: project.ID, Name: updated.Name, Slug: updated.Slug, Email: updated.Email}, nil } From 116c341debf8e01f13e0c3e3ab26791dbfb1a837 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Wed, 29 Jul 2026 21:56:18 +0200 Subject: [PATCH 9/9] refactor(dashboard-api): rename a project without touching template names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repointing env_aliases.namespace on a rename broke every template reference a user had already scripted, to fix an inconsistency that is merely untidy: names embed the slug they were built under, so a renamed project keeps its existing template names and only new ones carry the new slug. Dropping the side effect also drops what it dragged in — the ordering constraint against the alias unique index, and the read of the stored slug that decided whether to repoint. The upsert writes the row and nothing else. teams_slug_unique still applies on rename, so a taken slug is the same 409 a create would get. --- docs/ARCHITECTURE.md | 2 +- .../dashboard-api/internal/api/api.gen.go | 250 +++++++++--------- .../management_project_upsert_test.go | 25 +- .../internal/management/project.go | 34 +-- .../pkg/auth/queries/team_management.sql.go | 34 +-- .../sql_queries/teams/team_management.sql | 11 +- spec/openapi-dashboard.yml | 9 +- 7 files changed, 151 insertions(+), 214 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 13aaf7ee7b..0c0c0e60e8 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -213,7 +213,7 @@ orchestrators. The `/v1/management` operations are the cluster's half of a contract the workspace residency owns: project upsert (a project is a `public.teams` row created from a caller-supplied UUID; the tier is -assigned once at creation from a local default and no push moves it; a changed slug renames the project and repoints its template namespaces), member sync (granular and +assigned once at creation from a local default and no push moves it; a changed slug renames the project, and nothing else follows it), member sync (granular and batched, over opaque user UUIDs in `users_teams`), limit sync (into `project_limits`, which `team_limits` reads in preference to `tiers`), and user purge (memberships and access tokens; the `public.users` row survives). All are idempotent, because diff --git a/packages/dashboard-api/internal/api/api.gen.go b/packages/dashboard-api/internal/api/api.gen.go index e6f06de238..488d7061b7 100644 --- a/packages/dashboard-api/internal/api/api.gen.go +++ b/packages/dashboard-api/internal/api/api.gen.go @@ -353,7 +353,7 @@ type ManagementProject struct { Id openapi_types.UUID `json:"id"` Name string `json:"name"` - // Slug Changing it renames the project. The project's template names move with it, since they read "/", so the names its users already type change too. A slug already held on this control plane is a 409, on a rename as much as on a create. + // Slug Changing it renames the project, and nothing follows it. Template names embed the slug they were built under, so a renamed project keeps its existing template names and only new ones carry the new slug. A slug already held on this control plane is a 409, on a rename as much as on a create. Slug string `json:"slug"` } @@ -382,7 +382,7 @@ type ManagementProjectUpsertRequest struct { Email string `json:"email"` Name string `json:"name"` - // Slug Changing it renames the project. The project's template names move with it, since they read "/", so the names its users already type change too. A slug already held on this control plane is a 409, on a rename as much as on a create. + // Slug Changing it renames the project, and nothing follows it. Template names embed the slug they were built under, so a renamed project keeps its existing template names and only new ones carry the new slug. A slug already held on this control plane is a 409, on a rename as much as on a create. Slug string `json:"slug"` } @@ -2025,129 +2025,129 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options // const string: with thousands of chunks the chained `+` fold is several // times slower for the Go compiler than parsing a slice literal. var swaggerSpec = []string{ - "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", } // decodeSpec returns the embedded OpenAPI spec as raw JSON bytes, diff --git a/packages/dashboard-api/internal/handlers/management_project_upsert_test.go b/packages/dashboard-api/internal/handlers/management_project_upsert_test.go index 6dff25a968..9d1bebc39f 100644 --- a/packages/dashboard-api/internal/handlers/management_project_upsert_test.go +++ b/packages/dashboard-api/internal/handlers/management_project_upsert_test.go @@ -56,9 +56,10 @@ func TestUpsertProjectIsIdempotent(t *testing.T) { require.Equal(t, "Acme", teamColumn(t, db, project.id, "name")) } -// Template names are stored as "/", so a rename that left them -// behind would address the project's templates under a slug it no longer has. -func TestUpsertProjectRenameCarriesTemplateNames(t *testing.T) { +// A rename moves the slug and nothing else: template names embed the slug they +// were built under, and rewriting them would break references users already +// have. +func TestUpsertProjectRenameLeavesTemplateNames(t *testing.T) { t.Parallel() db := testutils.SetupDatabase(t) @@ -68,8 +69,7 @@ func TestUpsertProjectRenameCarriesTemplateNames(t *testing.T) { require.Equal(t, http.StatusCreated, callUpsertProject(t, store, project.id, project.request()).Code) templateID := testutils.CreateTestTemplate(t, db, project.id) - namespaced := testutils.CreateTestTemplateAliasWithNamespace(t, db, templateID, &project.slug) - legacy := testutils.CreateTestTemplateAliasWithNamespace(t, db, templateID, nil) + alias := testutils.CreateTestTemplateAliasWithNamespace(t, db, templateID, &project.slug) moved := project.request() moved.Slug += "-moved" @@ -78,10 +78,7 @@ func TestUpsertProjectRenameCarriesTemplateNames(t *testing.T) { require.Equal(t, http.StatusOK, response.Code, response.Body.String()) require.Equal(t, moved.Slug, teamColumn(t, db, project.id, "slug")) - require.Equal(t, moved.Slug, aliasNamespace(t, db, namespaced)) - // Aliases predating the namespace column stay null, which is how they are - // still resolved. - require.Empty(t, aliasNamespace(t, db, legacy)) + require.Equal(t, project.slug, aliasNamespace(t, db, alias)) } // The slug is unique cluster-wide on the way in and on the way out, so a rename @@ -96,14 +93,6 @@ func TestUpsertProjectRenameRejectsATakenSlug(t *testing.T) { require.Equal(t, http.StatusCreated, callUpsertProject(t, store, incumbent.id, incumbent.request()).Code) require.Equal(t, http.StatusCreated, callUpsertProject(t, store, mover.id, mover.request()).Code) - // Both hold a template of the same name. Template names are unique on - // (alias, namespace), so a rename that repointed them before claiming the - // slug would collide there first and report this as a server error. - testutils.CreateTestTemplateAliasWithName(t, db, - testutils.CreateTestTemplate(t, db, incumbent.id), "web", &incumbent.slug) - testutils.CreateTestTemplateAliasWithName(t, db, - testutils.CreateTestTemplate(t, db, mover.id), "web", &mover.slug) - collide := mover.request() collide.Slug = incumbent.slug @@ -229,7 +218,7 @@ func TestDeleteProjectIsDeliberatelyNotImplemented(t *testing.T) { require.Equal(t, http.StatusNotImplemented, recorder.Code) } -// upsertFixture is a valid project with a slug unique to its test, so cases +// projectFixture is a valid project with a slug unique to its test, so cases // that care about one field can change that one and send the rest. type projectFixture struct { id uuid.UUID diff --git a/packages/dashboard-api/internal/management/project.go b/packages/dashboard-api/internal/management/project.go index 887682652f..f8f466faff 100644 --- a/packages/dashboard-api/internal/management/project.go +++ b/packages/dashboard-api/internal/management/project.go @@ -34,6 +34,11 @@ var ( // Project is the set of properties the caller synchronizes. It sends all of // them on every push, so a reconcile is a complete statement rather than a // patch. +// +// Slug moves like the others, and nothing follows it. Template names embed the +// slug they were built under, so a renamed project keeps its existing template +// names and only new ones carry the new slug. Rewriting them would break every +// reference a user has already scripted, which is worse than the inconsistency. type Project struct { ID uuid.UUID Name string @@ -79,11 +84,11 @@ func (s *Service) writeProject(ctx context.Context, project Project) (stored Pro _ = tx.Rollback(ctx) }() - existing, err := txDB.LockManagedTeam(ctx, project.ID) + _, err = txDB.LockManagedTeam(ctx, project.ID) switch { case err == nil: - stored, err = reconcileProject(ctx, txDB, existing, project) + stored, err = reconcileProject(ctx, txDB, project) case dberrors.IsNotFoundError(err): stored, err = createProject(ctx, txDB, project) created = true @@ -125,12 +130,7 @@ func createProject(ctx context.Context, txDB *authqueries.Queries, project Proje return Project{ID: project.ID, Name: inserted.Name, Slug: inserted.Slug, Email: inserted.Email}, nil } -func reconcileProject( - ctx context.Context, - txDB *authqueries.Queries, - existing authqueries.LockManagedTeamRow, - project Project, -) (Project, error) { +func reconcileProject(ctx context.Context, txDB *authqueries.Queries, project Project) (Project, error) { updated, err := txDB.UpdateManagedTeam(ctx, authqueries.UpdateManagedTeamParams{ ID: project.ID, Name: project.Name, @@ -145,23 +145,5 @@ func reconcileProject( return Project{}, fmt.Errorf("reconcile project: %w", err) } - // A rename carries the team's template names with it: they are stored as - // "/", so leaving them behind would address templates under a - // slug the project no longer has. Two costs stay with the caller — the names - // its users already type change, and the api service resolves aliases - // through a cache, so the old ones answer for up to its TTL. - // - // After the slug is claimed, not before. Template names are unique on - // (alias, namespace), so repointing into a namespace another project still - // holds collides there first and reports a taken slug as a server error. - if existing.Slug != project.Slug { - if err := txDB.RepointTeamAliasNamespace(ctx, authqueries.RepointTeamAliasNamespaceParams{ - TeamID: project.ID, - Slug: project.Slug, - }); err != nil { - return Project{}, fmt.Errorf("repoint template namespace: %w", err) - } - } - return Project{ID: project.ID, Name: updated.Name, Slug: updated.Slug, Email: updated.Email}, nil } diff --git a/packages/db/pkg/auth/queries/team_management.sql.go b/packages/db/pkg/auth/queries/team_management.sql.go index 13598c19e4..086d83f581 100644 --- a/packages/db/pkg/auth/queries/team_management.sql.go +++ b/packages/db/pkg/auth/queries/team_management.sql.go @@ -64,46 +64,20 @@ func (q *Queries) InsertManagedTeam(ctx context.Context, arg InsertManagedTeamPa const lockManagedTeam = `-- name: LockManagedTeam :one -SELECT id, slug FROM public.teams +SELECT id FROM public.teams WHERE id = $1::uuid FOR UPDATE ` -type LockManagedTeamRow struct { - ID uuid.UUID - Slug string -} - // Project reconciliation for the control-plane management interface. The caller // supplies the id, so create and reconcile are one request and these are its // branches. // Taken first, so the branch is decided by whether the project exists rather // than by an insert failing. -func (q *Queries) LockManagedTeam(ctx context.Context, id uuid.UUID) (LockManagedTeamRow, error) { +func (q *Queries) LockManagedTeam(ctx context.Context, id uuid.UUID) (uuid.UUID, error) { row := q.db.QueryRow(ctx, lockManagedTeam, id) - var i LockManagedTeamRow - err := row.Scan(&i.ID, &i.Slug) - return i, err -} - -const repointTeamAliasNamespace = `-- name: RepointTeamAliasNamespace :exec -UPDATE public.env_aliases -SET namespace = $1::text -WHERE namespace IS NOT NULL - AND env_id IN (SELECT id FROM public.envs WHERE team_id = $2::uuid) -` - -type RepointTeamAliasNamespaceParams struct { - Slug string - TeamID uuid.UUID -} - -// Template names are stored as "/" with the namespace copied -// from the team's slug, so a rename has to carry them with it. Aliases predating -// the namespace column are left null, which is how they are still resolved. -func (q *Queries) RepointTeamAliasNamespace(ctx context.Context, arg RepointTeamAliasNamespaceParams) error { - _, err := q.db.Exec(ctx, repointTeamAliasNamespace, arg.Slug, arg.TeamID) - return err + err := row.Scan(&id) + return id, err } const updateManagedTeam = `-- name: UpdateManagedTeam :one diff --git a/packages/db/pkg/auth/sql_queries/teams/team_management.sql b/packages/db/pkg/auth/sql_queries/teams/team_management.sql index e4f7af44c6..4bfb465277 100644 --- a/packages/db/pkg/auth/sql_queries/teams/team_management.sql +++ b/packages/db/pkg/auth/sql_queries/teams/team_management.sql @@ -5,7 +5,7 @@ -- Taken first, so the branch is decided by whether the project exists rather -- than by an insert failing. -- name: LockManagedTeam :one -SELECT id, slug FROM public.teams +SELECT id FROM public.teams WHERE id = sqlc.arg(id)::uuid FOR UPDATE; @@ -37,12 +37,3 @@ SET email = sqlc.arg(email)::text WHERE id = sqlc.arg(id)::uuid RETURNING id, name, slug, email; - --- Template names are stored as "/" with the namespace copied --- from the team's slug, so a rename has to carry them with it. Aliases predating --- the namespace column are left null, which is how they are still resolved. --- name: RepointTeamAliasNamespace :exec -UPDATE public.env_aliases -SET namespace = sqlc.arg(slug)::text -WHERE namespace IS NOT NULL - AND env_id IN (SELECT id FROM public.envs WHERE team_id = sqlc.arg(team_id)::uuid); diff --git a/spec/openapi-dashboard.yml b/spec/openapi-dashboard.yml index 539332c1ab..194c09f802 100644 --- a/spec/openapi-dashboard.yml +++ b/spec/openapi-dashboard.yml @@ -1185,10 +1185,11 @@ components: minLength: 1 maxLength: 63 description: >- - Changing it renames the project. The project's template names move - with it, since they read "/", so the names its users - already type change too. A slug already held on this control plane - is a 409, on a rename as much as on a create. + Changing it renames the project, and nothing follows it. Template + names embed the slug they were built under, so a renamed project + keeps its existing template names and only new ones carry the new + slug. A slug already held on this control plane is a 409, on a + rename as much as on a create. email: type: string minLength: 1