Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,22 @@ 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; the tier is
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
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
Expand Down
15 changes: 15 additions & 0 deletions packages/auth/pkg/auth/internal/service/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}

Expand All @@ -274,7 +281,15 @@ func (s *AuthService) InvalidateTeamMemberCache(ctx context.Context, userID uuid
// <api key hash> ApiKeyAuth
// <user id>-<id> 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)
Expand Down
272 changes: 137 additions & 135 deletions packages/dashboard-api/internal/api/api.gen.go

Large diffs are not rendered by default.

141 changes: 92 additions & 49 deletions packages/dashboard-api/internal/handlers/management_contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,76 +49,119 @@ 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 + `"}`
body := `{"name":"Acme","slug":"acme","email":"ops@acme.test","project_type":"enterprise_v3"}`

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)
}
var decoded api.ManagementProjectUpsertRequest
if err := json.Unmarshal([]byte(body), &decoded); err != nil {
t.Fatalf("decoding an upsert that still carries project_type: %v", err)
}

if decoded.ProjectType != projectType {
t.Errorf("ProjectType = %q, want %q", decoded.ProjectType, projectType)
}
want := api.ManagementProjectUpsertRequest{Name: "Acme", Slug: "acme", Email: "ops@acme.test"}
if decoded != want {
t.Errorf("decoded %+v, want %+v", decoded, want)
}
}

// 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) {
// 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()

reached := make(chan string, 1)
router := gin.New()
api.RegisterHandlers(router, &routeRecorder{reached: reached})

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")

router.ServeHTTP(recorder, request)

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)
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 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.
// 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")
}
Loading
Loading