diff --git a/internal/dispatch/gcf/provisioner.go b/internal/dispatch/gcf/provisioner.go index f5b0a67dc..24eeacf96 100644 --- a/internal/dispatch/gcf/provisioner.go +++ b/internal/dispatch/gcf/provisioner.go @@ -410,6 +410,26 @@ func (p *Provisioner) EnsureOrgInMint(ctx context.Context, expectedURL string, o } allowedOrgs := trafficEnvVars["ALLOWED_ORGS"] + + // Defense-in-depth: if ALLOWED_ORGS is empty but ROLE_APP_IDS has + // role-only entries, the mint has been bootstrapped and should have + // enrolled orgs. Empty ALLOWED_ORGS in this state indicates env var + // data loss (e.g., stale read from a diverged revision), not a first + // enrollment. Abort to prevent silently unenrolling all existing orgs. + if allowedOrgs == "" { + var roleAppIDMap map[string]string + if raw := trafficEnvVars["ROLE_APP_IDS"]; raw != "" { + _ = json.Unmarshal([]byte(raw), &roleAppIDMap) + } + roleOnly := mintcore.RoleOnlyAppIDs(roleAppIDMap) + if len(roleOnly) > 0 { + return fmt.Errorf( + "data inconsistency: ALLOWED_ORGS is empty but ROLE_APP_IDS has %d configured roles; "+ + "this suggests env var data loss — run 'fullsend mint status --project=%s' to investigate", + len(roleOnly), p.cfg.ProjectID) + } + } + orgPresent := false for _, o := range strings.Split(allowedOrgs, ",") { if strings.EqualFold(strings.TrimSpace(o), org) { diff --git a/internal/dispatch/gcf/provisioner_test.go b/internal/dispatch/gcf/provisioner_test.go index ec3a233c6..55212f5ed 100644 --- a/internal/dispatch/gcf/provisioner_test.go +++ b/internal/dispatch/gcf/provisioner_test.go @@ -2416,6 +2416,66 @@ func TestEnsureOrgInMint_ProceedsOnFirstEnrollment(t *testing.T) { assert.Equal(t, "new-org", fake.lastUpdateServiceEnvVars["ALLOWED_ORGS"]) } +func TestEnsureOrgInMint_DataInconsistencyGuard(t *testing.T) { + // When ALLOWED_ORGS is empty but ROLE_APP_IDS has role-only entries, + // the mint has been bootstrapped — empty ALLOWED_ORGS is data loss. + fake := newFakeGCFClient() + fake.functionInfo = &FunctionInfo{ + URI: "https://mint.example.com", + EnvVars: map[string]string{}, + } + fake.trafficEnvVars = map[string]string{ + "ALLOWED_ORGS": "", + "ROLE_APP_IDS": `{"coder":"111","reviewer":"222"}`, + } + + p := NewProvisioner(Config{ProjectID: "proj1", Region: "us-central1"}, fake) + err := p.EnsureOrgInMint(context.Background(), "https://mint.example.com", "new-org") + require.Error(t, err) + assert.Contains(t, err.Error(), "data inconsistency") + assert.Contains(t, err.Error(), "2 configured roles") + assert.Contains(t, err.Error(), "proj1") + assert.NotContains(t, fake.calls, "UpdateServiceEnvVars") +} + +func TestEnsureOrgInMint_DataInconsistencyGuard_NoRoleAppIDs(t *testing.T) { + // When ALLOWED_ORGS is empty and ROLE_APP_IDS is also empty, + // this is a genuine first enrollment — no error. + fake := newFakeGCFClient() + fake.functionInfo = &FunctionInfo{ + URI: "https://mint.example.com", + EnvVars: map[string]string{}, + } + fake.trafficEnvVars = map[string]string{ + "ALLOWED_ORGS": "", + "ROLE_APP_IDS": "", + } + + p := NewProvisioner(Config{ProjectID: "proj1", Region: "us-central1"}, fake) + err := p.EnsureOrgInMint(context.Background(), "https://mint.example.com", "new-org") + require.NoError(t, err) + assert.Contains(t, fake.calls, "UpdateServiceEnvVars") +} + +func TestEnsureOrgInMint_DataInconsistencyGuard_OnlyLegacyKeys(t *testing.T) { + // When ALLOWED_ORGS is empty and ROLE_APP_IDS has only legacy org/role + // keys (containing "/"), RoleOnlyAppIDs filters them out — no guard trigger. + fake := newFakeGCFClient() + fake.functionInfo = &FunctionInfo{ + URI: "https://mint.example.com", + EnvVars: map[string]string{}, + } + fake.trafficEnvVars = map[string]string{ + "ALLOWED_ORGS": "", + "ROLE_APP_IDS": `{"acme/coder":"111"}`, + } + + p := NewProvisioner(Config{ProjectID: "proj1", Region: "us-central1"}, fake) + err := p.EnsureOrgInMint(context.Background(), "https://mint.example.com", "new-org") + require.NoError(t, err) + assert.Contains(t, fake.calls, "UpdateServiceEnvVars") +} + func TestRegisterPerRepoWIF_AddsNewRepo(t *testing.T) { fake := newFakeGCFClient() fake.functionInfo = &FunctionInfo{ @@ -2993,12 +3053,14 @@ func TestMarshalRoleAppIDs_SortsKeys(t *testing.T) { } func TestEnsureOrgInMint_DerivesAllowedRolesWhenEmpty(t *testing.T) { + // ALLOWED_ORGS populated, ALLOWED_ROLES empty — should derive roles + // from ROLE_APP_IDS and proceed. fake := newFakeGCFClient() fake.functionInfo = &FunctionInfo{ URI: "https://mint.example.com", } fake.trafficEnvVars = map[string]string{ - "ALLOWED_ORGS": "", + "ALLOWED_ORGS": "existing-org", "ROLE_APP_IDS": `{"coder":"100","triage":"200"}`, }