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
38 changes: 38 additions & 0 deletions controlplane/configstore/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package configstore

import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"hash/fnv"
Expand Down Expand Up @@ -260,6 +262,42 @@ func HashPassword(password string) (string, error) {
return string(hash), nil
}

// GeneratePassword returns a cryptographically random 32-byte URL-safe password.
func GeneratePassword() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("generate password: %w", err)
}
return base64.RawURLEncoding.EncodeToString(b), nil
}

// CreateOrgUser creates a new user for the given org.
func (cs *ConfigStore) CreateOrgUser(orgID, username, passwordHash string) error {
user := OrgUser{
OrgID: orgID,
Username: username,
Password: passwordHash,
}
return cs.db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "org_id"}, {Name: "username"}},
DoUpdates: clause.AssignmentColumns([]string{"password", "updated_at"}),
}).Create(&user).Error
}

// UpdateOrgUserPassword updates the password hash for an existing user.
func (cs *ConfigStore) UpdateOrgUserPassword(orgID, username, passwordHash string) error {
result := cs.db.Model(&OrgUser{}).
Where("org_id = ? AND username = ?", orgID, username).
Update("password", passwordHash)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return fmt.Errorf("user %q not found in org %q", username, orgID)
}
return nil
}

// OnChange registers a callback that fires when the config snapshot changes.
func (cs *ConfigStore) OnChange(fn func(old, new *Snapshot)) {
cs.mu.Lock()
Expand Down
102 changes: 95 additions & 7 deletions controlplane/provisioning/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ import (
// Store defines the config store operations needed by the provisioning API.
type Store interface {
GetManagedWarehouse(orgID string) (*configstore.ManagedWarehouse, error)
GetOrg(orgID string) (*configstore.Org, error)
CreatePendingWarehouse(orgID, databaseName string, warehouse *configstore.ManagedWarehouse) error
CreateOrgUser(orgID, username, passwordHash string) error
UpdateOrgUserPassword(orgID, username, passwordHash string) error
SetWarehouseDeleting(orgID string, expectedState configstore.ManagedWarehouseProvisioningState) error
IsDatabaseNameAvailable(name string) (bool, error)
}
Expand All @@ -24,6 +27,7 @@ func RegisterAPI(r *gin.RouterGroup, store Store) {
r.POST("/orgs/:id/provision", h.provisionWarehouse)
r.POST("/orgs/:id/deprovision", h.deprovisionWarehouse)
r.GET("/orgs/:id/warehouse/status", h.getWarehouseStatus)
r.POST("/orgs/:id/reset-password", h.resetPassword)
r.GET("/database-name/check", h.checkDatabaseName)
}

Expand All @@ -32,17 +36,27 @@ type handler struct {
}

// warehouseStatusResponse is the public-facing view of warehouse state.
// Only exposes lifecycle status — no infrastructure secrets or internal config.
// Exposes lifecycle status and, when ready, connection details (without password).
type warehouseStatusResponse struct {
OrgID string `json:"org_id"`
OrgID string `json:"org_id"`
State configstore.ManagedWarehouseProvisioningState `json:"state"`
StatusMessage string `json:"status_message"`
StatusMessage string `json:"status_message"`
S3State configstore.ManagedWarehouseProvisioningState `json:"s3_state"`
MetadataStoreState configstore.ManagedWarehouseProvisioningState `json:"metadata_store_state"`
IdentityState configstore.ManagedWarehouseProvisioningState `json:"identity_state"`
SecretsState configstore.ManagedWarehouseProvisioningState `json:"secrets_state"`
ReadyAt *time.Time `json:"ready_at,omitempty"`
FailedAt *time.Time `json:"failed_at,omitempty"`
ReadyAt *time.Time `json:"ready_at,omitempty"`
FailedAt *time.Time `json:"failed_at,omitempty"`
Connection *connectionDetails `json:"connection,omitempty"`
}

// connectionDetails is returned in status (without password) and in provision/reset-password (with password).
type connectionDetails struct {
Host string `json:"host"`
Port int `json:"port"`
Database string `json:"database"`
Username string `json:"username"`
Password string `json:"password,omitempty"`
}

type provisionRequest struct {
Expand Down Expand Up @@ -89,7 +103,29 @@ func (h *handler) provisionWarehouse(c *gin.Context) {
return
}

c.JSON(http.StatusAccepted, gin.H{"status": "provisioning started", "org": orgID})
// Create root user with a generated password. The plaintext is returned
// in this response only — it is never stored, only the bcrypt hash is persisted.
plainPassword, err := configstore.GeneratePassword()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate password"})
return
}
hash, err := configstore.HashPassword(plainPassword)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"})
return
}
if err := h.store.CreateOrgUser(orgID, "root", hash); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create root user"})
return
}

c.JSON(http.StatusAccepted, gin.H{
"status": "provisioning started",
"org": orgID,
"username": "root",
"password": plainPassword,
})
}

func (h *handler) deprovisionWarehouse(c *gin.Context) {
Expand Down Expand Up @@ -131,7 +167,7 @@ func (h *handler) getWarehouseStatus(c *gin.Context) {
return
}

c.JSON(http.StatusOK, warehouseStatusResponse{
resp := warehouseStatusResponse{
OrgID: warehouse.OrgID,
State: warehouse.State,
StatusMessage: warehouse.StatusMessage,
Expand All @@ -141,6 +177,58 @@ func (h *handler) getWarehouseStatus(c *gin.Context) {
SecretsState: warehouse.SecretsState,
ReadyAt: warehouse.ReadyAt,
FailedAt: warehouse.FailedAt,
}

if warehouse.State == configstore.ManagedWarehouseStateReady {
org, err := h.store.GetOrg(orgID)
if err == nil {
resp.Connection = &connectionDetails{
Host: warehouse.WarehouseDatabase.Endpoint,
Port: warehouse.WarehouseDatabase.Port,
Database: org.DatabaseName,
Username: "root",
}
}
}

c.JSON(http.StatusOK, resp)
}

func (h *handler) resetPassword(c *gin.Context) {
orgID := c.Param("id")

warehouse, err := h.store.GetManagedWarehouse(orgID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "warehouse not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if warehouse.State != configstore.ManagedWarehouseStateReady {
c.JSON(http.StatusConflict, gin.H{"error": "warehouse must be in ready state to reset password"})
return
}

plainPassword, err := configstore.GeneratePassword()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate password"})
return
}
hash, err := configstore.HashPassword(plainPassword)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"})
return
}
if err := h.store.UpdateOrgUserPassword(orgID, "root", hash); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "root user not found"})
return
}

c.JSON(http.StatusOK, gin.H{
"username": "root",
"password": plainPassword,
})
}

Expand Down
66 changes: 66 additions & 0 deletions controlplane/provisioning/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,41 @@ import (

type fakeStore struct {
orgs map[string]*configstore.Org
users map[configstore.OrgUserKey]string
warehouses map[string]*configstore.ManagedWarehouse
}

func newFakeStore() *fakeStore {
return &fakeStore{
orgs: make(map[string]*configstore.Org),
users: make(map[configstore.OrgUserKey]string),
warehouses: make(map[string]*configstore.ManagedWarehouse),
}
}

func (s *fakeStore) GetOrg(orgID string) (*configstore.Org, error) {
org, ok := s.orgs[orgID]
if !ok {
return nil, gorm.ErrRecordNotFound
}
return org, nil
}

func (s *fakeStore) CreateOrgUser(orgID, username, passwordHash string) error {
key := configstore.OrgUserKey{OrgID: orgID, Username: username}
s.users[key] = passwordHash
return nil
}

func (s *fakeStore) UpdateOrgUserPassword(orgID, username, passwordHash string) error {
key := configstore.OrgUserKey{OrgID: orgID, Username: username}
if _, exists := s.users[key]; !exists {
return fmt.Errorf("user %q not found in org %q", username, orgID)
}
s.users[key] = passwordHash
return nil
}

func (s *fakeStore) GetManagedWarehouse(orgID string) (*configstore.ManagedWarehouse, error) {
w, ok := s.warehouses[orgID]
if !ok {
Expand Down Expand Up @@ -195,6 +220,7 @@ func TestProvisionRejectsExistingNonTerminal(t *testing.T) {
func TestProvisionAllowsRetryAfterFailure(t *testing.T) {
store := newFakeStore()
store.orgs["analytics"] = &configstore.Org{Name: "analytics"}
store.users[configstore.OrgUserKey{OrgID: "analytics", Username: "root"}] = "old-hash"
store.warehouses["analytics"] = &configstore.ManagedWarehouse{
OrgID: "analytics",
State: configstore.ManagedWarehouseStateFailed,
Expand All @@ -215,6 +241,27 @@ func TestProvisionAllowsRetryAfterFailure(t *testing.T) {
}
}

func TestProvisionAllowsRetryAfterDeleted(t *testing.T) {
store := newFakeStore()
store.orgs["analytics"] = &configstore.Org{Name: "analytics"}
store.users[configstore.OrgUserKey{OrgID: "analytics", Username: "root"}] = "old-hash"
store.warehouses["analytics"] = &configstore.ManagedWarehouse{
OrgID: "analytics",
State: configstore.ManagedWarehouseStateDeleted,
}
router := newTestRouter(store)

body := []byte(`{"database_name": "analytics-db", "metadata_store": {"type": "aurora", "aurora": {"min_acu": 0, "max_acu": 2}}}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/orgs/analytics/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())
}
}

func TestDeprovisionReadyWarehouse(t *testing.T) {
store := newFakeStore()
store.orgs["analytics"] = &configstore.Org{Name: "analytics"}
Expand Down Expand Up @@ -339,3 +386,22 @@ func TestGetWarehouseNotFound(t *testing.T) {
t.Fatalf("status = %d, want %d: %s", rec.Code, http.StatusNotFound, rec.Body.String())
}
}

func TestResetPasswordRequiresReadyWarehouse(t *testing.T) {
store := newFakeStore()
store.orgs["analytics"] = &configstore.Org{Name: "analytics"}
store.users[configstore.OrgUserKey{OrgID: "analytics", Username: "root"}] = "old-hash"
store.warehouses["analytics"] = &configstore.ManagedWarehouse{
OrgID: "analytics",
State: configstore.ManagedWarehouseStateDeleted,
}
router := newTestRouter(store)

req := httptest.NewRequest(http.MethodPost, "/api/v1/orgs/analytics/reset-password", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)

if rec.Code != http.StatusConflict {
t.Fatalf("status = %d, want %d: %s", rec.Code, http.StatusConflict, rec.Body.String())
}
}
16 changes: 16 additions & 0 deletions controlplane/provisioning/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,22 @@ func NewGormStore(cs *configstore.ConfigStore) Store {
return &gormStore{cs: cs}
}

func (s *gormStore) GetOrg(orgID string) (*configstore.Org, error) {
var org configstore.Org
if err := s.cs.DB().First(&org, "name = ?", orgID).Error; err != nil {
return nil, err
}
return &org, nil
}

func (s *gormStore) CreateOrgUser(orgID, username, passwordHash string) error {
return s.cs.CreateOrgUser(orgID, username, passwordHash)
}

func (s *gormStore) UpdateOrgUserPassword(orgID, username, passwordHash string) error {
return s.cs.UpdateOrgUserPassword(orgID, username, passwordHash)
}

func (s *gormStore) GetManagedWarehouse(orgID string) (*configstore.ManagedWarehouse, error) {
var warehouse configstore.ManagedWarehouse
if err := s.cs.DB().First(&warehouse, "org_id = ?", orgID).Error; err != nil {
Expand Down
Loading