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
2 changes: 1 addition & 1 deletion controlplane/provisioning/analytics_events_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ func TestProvisionEmitsAnalyticsEvent(t *testing.T) {
store := newFakeStore()
router := newTestRouter(store)

body := []byte(`{"database_name": "acme-db", "metadata_store": {"type": "cnpg-shard"}, "ducklake": {"enabled": true}}`)
body := []byte(`{"database_name": "acme-db", "default_team_id": "1", "metadata_store": {"type": "cnpg-shard"}, "ducklake": {"enabled": true}}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/orgs/acme/provision", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
Expand Down
15 changes: 12 additions & 3 deletions controlplane/provisioning/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,9 +136,11 @@ type connectionDetails struct {

type provisionRequest struct {
DatabaseName string `json:"database_name"`
// DefaultTeamID optionally links the org to its default PostHog team id.
// Optional and non-breaking: absent/empty ⇒ the org's default_team_id is
// left NULL (no error). Prerequisite for pull-based compute billing.
// DefaultTeamID links the org to its default PostHog team id. REQUIRED
// when the provision creates a NEW org (400 otherwise — every org carries
// its team id from birth; pull-based compute billing keys usage buckets by
// it). Optional on re-provision of an existing org: absent/empty keeps the
// stored value, never wipes it.
DefaultTeamID string `json:"default_team_id,omitempty"`
MetadataStore *provisionMetadataReq `json:"metadata_store,omitempty"`
DataStore *provisionDataStoreReq `json:"data_store,omitempty"`
Expand Down Expand Up @@ -327,6 +329,13 @@ func (h *handler) provisionWarehouse(c *gin.Context) {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
// Creating a NEW org requires default_team_id — a caller input
// problem, not a server failure. Decided in the store (only it knows
// whether the org exists), surfaced here as 400.
if errors.Is(err, ErrDefaultTeamIDRequired) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if isUniqueViolation(err) {
// Most likely: database_name already in use by another
// org. Map to 409 with a clear message; the underlying
Expand Down
66 changes: 50 additions & 16 deletions controlplane/provisioning/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,12 +108,15 @@ func (s *fakeStore) Provision(req ProvisionRequest) error {
shadowWarehouse := s.warehouses[req.OrgID]
shadowUserHash, hadUser := s.users[configstore.OrgUserKey{OrgID: req.OrgID, Username: "root"}]

// 1. Warehouse + Org
// 1. Warehouse + Org. Mirrors createPendingWarehouseTx: a NEW org
// requires default_team_id (rejected before any write); an existing org
// keeps its stored value when the field is omitted (set-only, never wipe).
if _, ok := s.orgs[req.OrgID]; !ok {
if req.DefaultTeamID == "" {
return ErrDefaultTeamIDRequired
}
s.orgs[req.OrgID] = &configstore.Org{Name: req.OrgID, DatabaseName: req.DatabaseName}
}
// default_team_id is optional/non-breaking: set it only when supplied,
// mirroring createPendingWarehouseTx (empty ⇒ leave NULL, never wipe).
if req.DefaultTeamID != "" {
teamID := req.DefaultTeamID
s.orgs[req.OrgID].DefaultTeamID = &teamID
Expand Down Expand Up @@ -212,7 +215,7 @@ func TestProvisionAutoCreatesOrg(t *testing.T) {
store := newFakeStore()
router := newTestRouter(store)

body := []byte(`{"database_name": "test-db", "metadata_store": {"type": "cnpg-shard"}, "ducklake": {"enabled": true}}`)
body := []byte(`{"database_name": "test-db", "default_team_id": "1", "metadata_store": {"type": "cnpg-shard"}, "ducklake": {"enabled": true}}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/orgs/new-org/provision", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
Expand All @@ -229,7 +232,7 @@ func TestProvisionAutoCreatesOrg(t *testing.T) {
}
}

// TestProvisionPersistsDefaultTeamID checks the optional default_team_id in the
// TestProvisionPersistsDefaultTeamID checks the default_team_id in the
// provision body is threaded through to the org record.
func TestProvisionPersistsDefaultTeamID(t *testing.T) {
store := newFakeStore()
Expand All @@ -256,9 +259,11 @@ func TestProvisionPersistsDefaultTeamID(t *testing.T) {
}
}

// TestProvisionWithoutDefaultTeamIDLeavesNull checks default_team_id is optional:
// an absent field leaves the org's default_team_id NULL with no error.
func TestProvisionWithoutDefaultTeamIDLeavesNull(t *testing.T) {
// TestProvisionNewOrgRequiresDefaultTeamID locks in the mandatory contract:
// provisioning a NEW org without default_team_id is rejected with 400 and
// creates nothing (no org, no warehouse) — every org carries its default team
// id from birth.
func TestProvisionNewOrgRequiresDefaultTeamID(t *testing.T) {
store := newFakeStore()
router := newTestRouter(store)

Expand All @@ -268,15 +273,41 @@ func TestProvisionWithoutDefaultTeamIDLeavesNull(t *testing.T) {
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)

if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d: %s", rec.Code, http.StatusBadRequest, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "default_team_id") {
t.Fatalf("error body should name default_team_id: %s", rec.Body.String())
}
if _, ok := store.orgs["noteam-org"]; ok {
t.Fatal("org must not be created when default_team_id is missing")
}
if store.warehouses["noteam-org"] != nil {
t.Fatal("warehouse must not be created when default_team_id is missing")
}
}

// TestReprovisionExistingOrgKeepsDefaultTeamID: re-provisioning an EXISTING org
// without default_team_id stays valid (not the new-org 400) and keeps the
// stored value — the field is set-only, never wiped by omission.
func TestReprovisionExistingOrgKeepsDefaultTeamID(t *testing.T) {
store := newFakeStore()
teamID := "777"
store.orgs["backfilled"] = &configstore.Org{Name: "backfilled", DefaultTeamID: &teamID}
router := newTestRouter(store)

body := []byte(`{"database_name": "backfilled-db", "metadata_store": {"type": "cnpg-shard"}, "ducklake": {"enabled": true}}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/orgs/backfilled/provision", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)

if rec.Code != http.StatusAccepted {
t.Fatalf("status = %d, want %d: %s", rec.Code, http.StatusAccepted, rec.Body.String())
}
org, ok := store.orgs["noteam-org"]
if !ok {
t.Fatal("expected org to be auto-created")
}
if org.DefaultTeamID != nil {
t.Fatalf("expected default_team_id to be nil (NULL), got %q", *org.DefaultTeamID)
org := store.orgs["backfilled"]
if org.DefaultTeamID == nil || *org.DefaultTeamID != teamID {
t.Fatalf("default_team_id must survive re-provision without the field, got %v", org.DefaultTeamID)
}
}

Expand Down Expand Up @@ -495,7 +526,7 @@ func TestProvisionTransactionRollsBackOnUserFailure(t *testing.T) {
store.setProvisionUserFailHook(errors.New("simulated DB write failure"))
router := newTestRouter(store)

body := []byte(`{"database_name": "team-7-db", "metadata_store": {"type": "cnpg-shard"}, "ducklake": {"enabled": true}}`)
body := []byte(`{"database_name": "team-7-db", "default_team_id": "7", "metadata_store": {"type": "cnpg-shard"}, "ducklake": {"enabled": true}}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/orgs/7/provision", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
Expand Down Expand Up @@ -617,6 +648,7 @@ func TestProvisionDuckLakeExternal(t *testing.T) {

body := []byte(`{
"database_name": "extdl-db",
"default_team_id": "1",
"metadata_store": {"type": "external", "external": {
"endpoint": "rds.example.us-east-1.rds.amazonaws.com",
"password_aws_secret": "duckling-example-rds-password",
Expand Down Expand Up @@ -667,6 +699,7 @@ func TestProvisionComputesS3BucketName(t *testing.T) {
wantBucket := "posthog-duckling-0194d6405db400006cde48d6114c0f99-mw-prod-us"
body := []byte(`{
"database_name": "db",
"default_team_id": "1",
"metadata_store": {"type": "cnpg-shard"},
"data_store": {"type": "s3bucket"},
"ducklake": {"enabled": true}
Expand Down Expand Up @@ -705,6 +738,7 @@ func TestProvisionNoBucketSuffixLeavesNameEmpty(t *testing.T) {

body := []byte(`{
"database_name": "db",
"default_team_id": "1",
"metadata_store": {"type": "cnpg-shard"},
"data_store": {"type": "s3bucket"},
"ducklake": {"enabled": true}
Expand Down Expand Up @@ -828,7 +862,7 @@ func TestProvisionRejectsOverlongSlugOrgID(t *testing.T) {
func TestProvisionAcceptsHyphenatedOrgID(t *testing.T) {
store := newFakeStore()
router := newTestRouter(store)
body := []byte(`{"database_name":"d","metadata_store":{"type":"cnpg-shard"},"ducklake":{"enabled":true}}`)
body := []byte(`{"database_name":"d","default_team_id":"1","metadata_store":{"type":"cnpg-shard"},"ducklake":{"enabled":true}}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/orgs/my-org-cnpg/provision", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
Expand Down
52 changes: 36 additions & 16 deletions controlplane/provisioning/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@ import (
// Deleted, then retry /provision.
var ErrWarehouseNonTerminal = errors.New("warehouse already exists in non-terminal state")

// ErrDefaultTeamIDRequired is returned by Provision when the request would
// create a NEW org without a default_team_id. Every org must carry its default
// PostHog team id from birth (pull-based compute billing keys usage buckets by
// it); all pre-existing orgs have been backfilled. Re-provisioning an EXISTING
// org without the field stays valid — the stored value is kept, never wiped.
// HTTP handlers map this to 400.
var ErrDefaultTeamIDRequired = errors.New("default_team_id is required when creating a new org")

// ProvisionRequest is the all-or-nothing input the Provision endpoint
// dispatches into a single configstore transaction. Warehouse + root
// user are always written.
Expand All @@ -29,9 +37,11 @@ var ErrWarehouseNonTerminal = errors.New("warehouse already exists in non-termin
type ProvisionRequest struct {
OrgID string
DatabaseName string
// DefaultTeamID optionally links the org to its default PostHog team id.
// Optional/non-breaking: empty ⇒ the org's default_team_id is left NULL
// (unset), never an error. Prerequisite for pull-based compute billing.
// DefaultTeamID links the org to its default PostHog team id. REQUIRED
// when the org does not exist yet (Provision returns
// ErrDefaultTeamIDRequired otherwise); optional on re-provision of an
// existing org, where empty keeps the stored value (never a wipe).
// Prerequisite for pull-based compute billing.
DefaultTeamID string
Warehouse *configstore.ManagedWarehouse
// RootUserHash is the bcrypt hash of the freshly-generated root
Expand Down Expand Up @@ -76,7 +86,9 @@ func (s *gormStore) GetManagedWarehouse(orgID string) (*configstore.ManagedWareh

func (s *gormStore) CreatePendingWarehouse(orgID, databaseName string, warehouse *configstore.ManagedWarehouse) error {
return s.cs.DB().Transaction(func(tx *gorm.DB) error {
// No default_team_id on this standalone path — leave the column as-is.
// No default_team_id on this standalone path — an existing org keeps
// its column as-is; creating a NEW org through here now fails with
// ErrDefaultTeamIDRequired (same invariant as Provision).
return createPendingWarehouseTx(tx, orgID, databaseName, "", warehouse)
})
}
Expand All @@ -91,15 +103,23 @@ func (s *gormStore) CreatePendingWarehouse(orgID, databaseName string, warehouse
// exists in non-terminal state") so HTTP handlers can map to 409
// without an extra error type.
func createPendingWarehouseTx(tx *gorm.DB, orgID, databaseName, defaultTeamID string, warehouse *configstore.ManagedWarehouse) error {
// Auto-create org if it doesn't exist (PostHog calls provision, duckgres creates everything)
org := configstore.Org{Name: orgID, DatabaseName: databaseName}
if defaultTeamID != "" {
// Set default_team_id on create. Optional/non-breaking: an empty value
// leaves the column NULL (unset), and re-provisioning without it does
// NOT wipe an existing value (see the explicit update below).
org.DefaultTeamID = &defaultTeamID
}
if err := tx.Where("name = ?", orgID).FirstOrCreate(&org).Error; err != nil {
// Auto-create org if it doesn't exist (PostHog calls provision, duckgres
// creates everything). A NEW org MUST carry default_team_id (pull-based
// compute billing keys usage buckets by it; all pre-existing orgs are
// backfilled) — creating one without it is rejected. Re-provisioning an
// existing org without it keeps the stored value (never a wipe).
var org configstore.Org
err := tx.Where("name = ?", orgID).First(&org).Error
switch {
case errors.Is(err, gorm.ErrRecordNotFound):
if defaultTeamID == "" {
return ErrDefaultTeamIDRequired
}
org = configstore.Org{Name: orgID, DatabaseName: databaseName, DefaultTeamID: &defaultTeamID}
if err := tx.Create(&org).Error; err != nil {
return err
}
case err != nil:
return err
}
// Update database name if org already existed with a different one
Expand All @@ -109,8 +129,8 @@ func createPendingWarehouseTx(tx *gorm.DB, orgID, databaseName, defaultTeamID st
}
}
// If a default_team_id was supplied, persist it even when the org already
// existed (FirstOrCreate only sets fields on insert). Only ever set, never
// cleared here, so an omitted value is a no-op rather than a wipe.
// existed (the create branch above only runs on insert). Only ever set,
// never cleared here, so an omitted value is a no-op rather than a wipe.
if defaultTeamID != "" {
if err := tx.Model(&org).Update("default_team_id", defaultTeamID).Error; err != nil {
return err
Expand All @@ -119,7 +139,7 @@ func createPendingWarehouseTx(tx *gorm.DB, orgID, databaseName, defaultTeamID st

// Check for existing warehouse in non-terminal state
var existing configstore.ManagedWarehouse
err := tx.First(&existing, "org_id = ?", orgID).Error
err = tx.First(&existing, "org_id = ?", orgID).Error
if err == nil {
if existing.State != configstore.ManagedWarehouseStateFailed &&
existing.State != configstore.ManagedWarehouseStateDeleted {
Expand Down
Loading
Loading