fix(workspace-server): send X-Molecule-Admin-Token on CP calls

controlplane #118 + #130 made /cp/workspaces/* require a per-tenant
admin_token header in addition to the platform-wide shared secret.
Without it, every workspace provision / deprovision / status call
now 401s.

ADMIN_TOKEN is already injected into the tenant container by the
controlplane's Secrets Manager bootstrap, so this is purely a
header-plumbing change — no new config required on the tenant side.

## Change

- CPProvisioner carries adminToken alongside sharedSecret
- New authHeaders method sets BOTH auth headers on every outbound
  request (old authHeader deleted — single call site was misleading
  once the semantics changed)
- Empty values on either header are no-ops so self-hosted / dev
  deployments without a real CP still work

## Tests

Renamed + expanded cp_provisioner_test cases:
- TestAuthHeaders_NoopWhenBothEmpty — self-hosted path
- TestAuthHeaders_SetsBothWhenBothProvided — prod happy path
- TestAuthHeaders_OnlyAdminTokenWhenSecretEmpty — transition window

Full workspace-server suite green.

## Rollout

Next tenant provision will ship an image with this commit merged.
Existing tenants (none in prod right now — hongming was the only
one and was purged earlier today) will auto-update via the 5-min
image-pull cron.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hongming Wang 2026-04-20 08:17:50 -07:00
parent 07ec90a23c
commit d3386ad620
2 changed files with 63 additions and 22 deletions

View File

@ -20,7 +20,8 @@ import (
type CPProvisioner struct {
baseURL string
orgID string
sharedSecret string // bearer passed to CP's /cp/workspaces/* gate
sharedSecret string // Authorization: Bearer — platform-wide gate
adminToken string // X-Molecule-Admin-Token — per-tenant identity (controlplane #118/#130)
httpClient *http.Client
}
@ -40,31 +41,48 @@ 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.
// CP gates /cp/workspaces/* behind two credentials now:
// 1. Shared secret (Authorization: Bearer) — gates the route at
// the router level, proves the caller is a tenant platform.
// 2. Admin token (X-Molecule-Admin-Token) — proves WHICH tenant.
// Introduced in controlplane #118/#130 to prevent cross-tenant
// provisioning when the shared secret leaks from one tenant.
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")
}
// ADMIN_TOKEN is injected into the tenant container at provision
// time by the control plane (see provisioner/ec2.go Secrets Manager
// bootstrap path). Without it, post-#118 CP rejects every
// /cp/workspaces/* call with 401.
adminToken := os.Getenv("ADMIN_TOKEN")
return &CPProvisioner{
baseURL: baseURL,
orgID: orgID,
sharedSecret: sharedSecret,
adminToken: adminToken,
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) {
// authHeaders sets both auth headers on the outbound request:
// - Authorization: Bearer <shared secret> — platform gate
// - X-Molecule-Admin-Token: <per-tenant token> — identity gate
//
// Either is a no-op when its value is empty so self-hosted / dev
// deployments without a real CP still work (those don't hit a CP that
// enforces either gate). In prod both are set by the controlplane
// bootstrap, so both headers land on every outbound call.
func (p *CPProvisioner) authHeaders(req *http.Request) {
if p.sharedSecret != "" {
req.Header.Set("Authorization", "Bearer "+p.sharedSecret)
}
if p.adminToken != "" {
req.Header.Set("X-Molecule-Admin-Token", p.adminToken)
}
}
type cpProvisionRequest struct {
@ -105,7 +123,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)
p.authHeaders(httpReq)
resp, err := p.httpClient.Do(httpReq)
if err != nil {
@ -140,7 +158,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)
p.authHeaders(req)
resp, err := p.httpClient.Do(req)
if err != nil {
return fmt.Errorf("cp provisioner: stop: %w", err)
@ -153,7 +171,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)
p.authHeaders(req)
resp, err := p.httpClient.Do(req)
if err != nil {
return false, err

View File

@ -39,27 +39,50 @@ func TestNewCPProvisioner_FallsBackToProvisionSharedSecret(t *testing.T) {
}
}
// TestAuthHeader_NoopWhenSecretEmpty — the self-hosted path that
// doesn't gate /cp/workspaces/* must not add a stray Authorization
// header (bearer-like content would be surprising to non-bearer
// intermediaries).
func TestAuthHeader_NoopWhenSecretEmpty(t *testing.T) {
p := &CPProvisioner{sharedSecret: ""}
// TestAuthHeaders_NoopWhenBothEmpty — the self-hosted path that
// doesn't gate /cp/workspaces/* must not add stray auth headers
// (bearer-like content would surprise non-bearer intermediaries).
func TestAuthHeaders_NoopWhenBothEmpty(t *testing.T) {
p := &CPProvisioner{sharedSecret: "", adminToken: ""}
req := httptest.NewRequest("GET", "http://x/", nil)
p.authHeader(req)
p.authHeaders(req)
if got := req.Header.Get("Authorization"); got != "" {
t.Errorf("Authorization set to %q with empty secret; want unset", got)
}
if got := req.Header.Get("X-Molecule-Admin-Token"); got != "" {
t.Errorf("X-Molecule-Admin-Token set to %q with empty token; want unset", got)
}
}
// TestAuthHeader_SetsBearerWhenSecretSet — happy path.
func TestAuthHeader_SetsBearerWhenSecretSet(t *testing.T) {
p := &CPProvisioner{sharedSecret: "the-secret"}
// TestAuthHeaders_SetsBothWhenBothProvided — happy path for SaaS
// tenants. Both the platform-wide shared secret and the per-tenant
// admin_token land on every outbound call.
func TestAuthHeaders_SetsBothWhenBothProvided(t *testing.T) {
p := &CPProvisioner{sharedSecret: "the-secret", adminToken: "tok-abc"}
req := httptest.NewRequest("GET", "http://x/", nil)
p.authHeader(req)
p.authHeaders(req)
if got := req.Header.Get("Authorization"); got != "Bearer the-secret" {
t.Errorf("Authorization = %q, want %q", got, "Bearer the-secret")
}
if got := req.Header.Get("X-Molecule-Admin-Token"); got != "tok-abc" {
t.Errorf("X-Molecule-Admin-Token = %q, want tok-abc", got)
}
}
// TestAuthHeaders_OnlyAdminTokenWhenSecretEmpty — in the transition
// window where the tenant has admin_token but PROVISION_SHARED_SECRET
// isn't set, still send the admin token. CP middleware decides whether
// the shared secret is required.
func TestAuthHeaders_OnlyAdminTokenWhenSecretEmpty(t *testing.T) {
p := &CPProvisioner{sharedSecret: "", adminToken: "tok-abc"}
req := httptest.NewRequest("GET", "http://x/", nil)
p.authHeaders(req)
if got := req.Header.Get("Authorization"); got != "" {
t.Errorf("Authorization = %q, want unset", got)
}
if got := req.Header.Get("X-Molecule-Admin-Token"); got != "tok-abc" {
t.Errorf("X-Molecule-Admin-Token = %q, want tok-abc", got)
}
}
// TestStart_HappyPath — Start posts to the stubbed CP, passes the