From a79366a04a3e3ca968e7e855d968b83b6dc1ef7f Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Sun, 19 Apr 2026 01:53:12 -0700 Subject: [PATCH] fix(security): tenant CPProvisioner attaches CP bearer on all calls Completes the C1 integration (PR #50 on molecule-controlplane). The CP now requires Authorization: Bearer on all three /cp/workspaces/* endpoints; without this change the tenant-side Start/Stop/IsRunning calls would all 401 (or 404 when the CP's routes refused to mount) and every workspace provision from a SaaS tenant would silently fail. Reads MOLECULE_CP_SHARED_SECRET, falling back to PROVISION_SHARED_SECRET so operators can use one env-var name on both sides of the wire. Empty value is a no-op: self-hosted deployments with no CP or a CP that doesn't gate /cp/workspaces/* keep working as before. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../internal/provisioner/cp_provisioner.go | 36 +++++++++++++++---- 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/workspace-server/internal/provisioner/cp_provisioner.go b/workspace-server/internal/provisioner/cp_provisioner.go index ca224d99..44ae097a 100644 --- a/workspace-server/internal/provisioner/cp_provisioner.go +++ b/workspace-server/internal/provisioner/cp_provisioner.go @@ -18,9 +18,10 @@ import ( // // Auto-activated when MOLECULE_ORG_ID is set (SaaS tenant). type CPProvisioner struct { - baseURL string - orgID string - httpClient *http.Client + baseURL string + orgID string + sharedSecret string // bearer passed to CP's /cp/workspaces/* gate + httpClient *http.Client } // NewCPProvisioner creates a provisioner that delegates to the control plane. @@ -39,13 +40,33 @@ func NewCPProvisioner() (*CPProvisioner, error) { baseURL = "https://api.moleculesai.app" } + // CP gates /cp/workspaces/* behind a bearer check (C1). Without the + // shared secret the CP returns 401 — or 404 if the routes refused + // to mount on its side. Tenant operators should set this on the + // tenant env to the same value as the CP's PROVISION_SHARED_SECRET. + sharedSecret := os.Getenv("MOLECULE_CP_SHARED_SECRET") + if sharedSecret == "" { + // Fall back to PROVISION_SHARED_SECRET so a single env-var name + // works on both sides of the wire. + sharedSecret = os.Getenv("PROVISION_SHARED_SECRET") + } + return &CPProvisioner{ - baseURL: baseURL, - orgID: orgID, - httpClient: &http.Client{Timeout: 120 * time.Second}, + baseURL: baseURL, + orgID: orgID, + sharedSecret: sharedSecret, + httpClient: &http.Client{Timeout: 120 * time.Second}, }, nil } +// authHeader sets Authorization: Bearer on the outbound request. No-op +// when sharedSecret is empty so self-hosted / dev deployments still work. +func (p *CPProvisioner) authHeader(req *http.Request) { + if p.sharedSecret != "" { + req.Header.Set("Authorization", "Bearer "+p.sharedSecret) + } +} + type cpProvisionRequest struct { OrgID string `json:"org_id"` WorkspaceID string `json:"workspace_id"` @@ -84,6 +105,7 @@ func (p *CPProvisioner) Start(ctx context.Context, cfg WorkspaceConfig) (string, return "", fmt.Errorf("cp provisioner: create request: %w", err) } httpReq.Header.Set("Content-Type", "application/json") + p.authHeader(httpReq) resp, err := p.httpClient.Do(httpReq) if err != nil { @@ -111,6 +133,7 @@ func (p *CPProvisioner) Start(ctx context.Context, cfg WorkspaceConfig) (string, func (p *CPProvisioner) Stop(ctx context.Context, workspaceID string) error { url := fmt.Sprintf("%s/cp/workspaces/%s?instance_id=%s", p.baseURL, workspaceID, workspaceID) req, _ := http.NewRequestWithContext(ctx, "DELETE", url, nil) + p.authHeader(req) resp, err := p.httpClient.Do(req) if err != nil { return fmt.Errorf("cp provisioner: stop: %w", err) @@ -123,6 +146,7 @@ func (p *CPProvisioner) Stop(ctx context.Context, workspaceID string) error { func (p *CPProvisioner) IsRunning(ctx context.Context, workspaceID string) (bool, error) { url := fmt.Sprintf("%s/cp/workspaces/%s/status?instance_id=%s", p.baseURL, workspaceID, workspaceID) req, _ := http.NewRequestWithContext(ctx, "GET", url, nil) + p.authHeader(req) resp, err := p.httpClient.Do(req) if err != nil { return false, err