Skip to content

Commit 9619632

Browse files
authored
Fix ui.phone_input.allowlist minItems:1 blocking explicit allow-all override- #5842
Fix ui.phone_input.allowlist minItems:1 blocking explicit allow-all override
2 parents 1db609c + f6aac30 commit 9619632

12 files changed

Lines changed: 264 additions & 12 deletions

.claude/skills/update-feature-config/SKILL.md

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,76 @@ regression would silently produce the "right" value by accident and the test
6666
wouldn't catch it. See the existing `hook`/`collaborator`/`identity` cases in
6767
that file for the pattern.
6868

69+
## Schema/runtime consistency
70+
71+
Before adding a JSON schema constraint on a feature config field
72+
(`minItems`, `minLength`, `enum`, `required`, etc. in the
73+
`FeatureConfigSchema.Add(...)` block), check what the field's actual
74+
*consumer* code does with edge-case values (nil, empty, zero) — grep for
75+
where the field is read at runtime. A constraint that's stricter than the
76+
runtime semantics can silently make a legitimately meaningful value
77+
unreachable. Concrete case: `PhoneInputFeatureConfig.allowlist` had
78+
`"minItems": 1`, but `IntersectAllowlist` (`pkg/lib/config/utils.go`) already
79+
treated an empty allowlist as "no restriction" — the schema blocked the one
80+
input (`allowlist: []`) that would have cleanly expressed "clear this
81+
override," forcing an awkward, undiscoverable workaround
82+
(`phone_input: {}` with the field omitted) instead. Don't add a schema
83+
constraint "for safety" without confirming the runtime already needs it.
84+
85+
## Don't tag a scalar field `omitempty` if its zero value is a real default
86+
87+
`SetFieldDefaults` (`pkg/lib/config/default.go`) already makes every section
88+
pointer non-nil via its generic reflection walk, regardless of whether that
89+
section implements `SetDefaults()` — a section is never actually "absent" in
90+
a parsed/defaulted `FeatureConfig`. If a plain (non-pointer) `bool`/`string`/
91+
`int`/`float` field's zero value (`false`/`""`/`0`) *is* that field's real,
92+
intended default — not a stand-in for "not set" — tagging it `omitempty`
93+
doesn't skip anything meaningful when parsing, but it does hide that value
94+
from JSON *output*: `encoding/json` treats the zero value as "empty" and
95+
omits the key, so a fully-resolved section marshals as `{}` instead of e.g.
96+
`{"disabled": false}`. This makes the Site Admin API's
97+
`effective_plan_feature_config`/`effective_app_feature_config` (and any
98+
other JSON consumer of `FeatureConfig`) show a resolved section as if it
99+
were empty/unset. Fix: drop `omitempty` — plain `json:"disabled"`. This is
100+
always safe, because `Merge()` for these fields already operates on the
101+
*section's* pointer-nil-ness (see the field-level merge rule above), never
102+
the leaf scalar's zero value — removing `omitempty` never changes merge or
103+
validation behavior, only what the field looks like once marshaled.
104+
105+
This is a different situation from the slice case in "Schema/runtime
106+
consistency" above (`PhoneInputFeatureConfig.allowlist`): there, `nil` and an
107+
explicit empty slice are two *different* meaningful values (inherit vs.
108+
explicitly cleared), so the fix was `omitzero` (which only omits the true
109+
zero value, `nil`), not simply dropping the tag. A plain scalar only has one
110+
value to begin with, so just remove `omitempty` entirely — don't reach for
111+
`omitzero` there, it would be a no-op.
112+
113+
**Test with a real marshal, not `ShouldResemble` on parsed structs.** Every
114+
existing test in this package compares parsed Go *structs*, which can't tell
115+
`omitempty` apart from no tag at all — that's exactly why this went
116+
unnoticed for nine fields across five sections. Marshal with
117+
`encoding/json.Marshal` (or `sigs.k8s.io/yaml.Marshal`, which calls it
118+
internally — this is what `viewEffectiveResource`'s merge fold does) and
119+
assert on the resulting shape, e.g.
120+
`TestFeatureConfigDisabledFieldsSerializeExplicitly` in `feature_test.go`.
121+
122+
**When auditing for this, grep the whole package by field, not file by
123+
file.** A file having a `SetDefaults()` for one field doesn't mean every
124+
field in that file is covered — `feature_identity.go` has one for
125+
`BiometricFeatureConfig` (a pointer-scalar field) while
126+
`LoginIDPhoneFeatureConfig.Disabled` and
127+
`OAuthSSOProviderFeatureConfig.Disabled` (plain-bool, single-field sections
128+
in that same file) still had the bug. Use:
129+
130+
```
131+
grep -nE '^\s*[A-Z][A-Za-z0-9_]*\s+(bool|string|int|int32|int64|float32|float64)\s+`json:"[^"]*,omitempty"`' pkg/lib/config/feature_*.go
132+
```
133+
69134
## References
70135

71136
- `pkg/lib/config/feature.go` — top-level `FeatureConfig.Merge` dispatcher
72137
- `pkg/lib/config/feature_*.go` — per-section `Merge` implementations
73138
- `pkg/lib/config/testdata/merge_feature.yaml` — shared merge test fixture
74139
- `pkg/lib/config/testdata/parse_feature_tests.yaml` — schema validation test fixture
140+
- `pkg/lib/config/default.go``SetFieldDefaults`, the generic reflection walk
141+
- `pkg/lib/config/feature_test.go`'s `TestFeatureConfigDisabledFieldsSerializeExplicitly` — marshal-based test pattern for the omitempty rule above

pkg/lib/config/config_test.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,25 @@ func TestApplyFeatureConfigConstraints(t *testing.T) {
8282
So(appConfig.UI.PhoneInput.AllowList, ShouldBeNil)
8383
})
8484

85+
Convey("explicit empty feature allowlist means allow all, same as nil", func() {
86+
appConfig := &config.AppConfig{
87+
UI: &config.UIConfig{
88+
PhoneInput: &config.PhoneInputConfig{
89+
AllowList: []string{"SG", "MY", "TH"},
90+
},
91+
},
92+
}
93+
featureConfig := &config.FeatureConfig{
94+
UI: &config.UIFeatureConfig{
95+
PhoneInput: &config.PhoneInputFeatureConfig{
96+
AllowList: []string{}, // explicit empty, not nil -- an app override clearing a plan restriction
97+
},
98+
},
99+
}
100+
config.ApplyFeatureConfigConstraints(appConfig, featureConfig)
101+
So(appConfig.UI.PhoneInput.AllowList, ShouldResemble, []string{"SG", "MY", "TH"})
102+
})
103+
85104
Convey("does not panic when phone input config is absent", func() {
86105
So(func() {
87106
config.ApplyFeatureConfigConstraints(&config.AppConfig{}, &config.FeatureConfig{})

pkg/lib/config/feature_authentication.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,5 +72,7 @@ var _ = FeatureConfigSchema.Add("AuthenticatorOOBOTBSMSFeatureConfig", `
7272
`)
7373

7474
type AuthenticatorOOBOTBSMSFeatureConfig struct {
75-
Disabled bool `json:"disabled,omitempty"`
75+
// No omitempty: false is this field's real default, not an absence
76+
// (see the update-feature-config skill).
77+
Disabled bool `json:"disabled"`
7678
}

pkg/lib/config/feature_collaborator.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,13 @@ var _ = FeatureConfigSchema.Add("CollaboratorFeatureConfig", `
1212
`)
1313

1414
type CollaboratorFeatureConfig struct {
15+
// Maximum and SoftMaximum are deliberately left without a SetDefaults():
16+
// nil means "unlimited" and is the current, intended behavior. Both
17+
// pkg/portal/service/collaborator.go (checkQuotaInSend/checkQuotaInAccept)
18+
// and portal/src/graphql/portal/PortalAdminsSettings.tsx skip
19+
// quota enforcement/warnings entirely when these are nil/undefined. Giving
20+
// either field a concrete default would start enforcing a collaborator
21+
// cap (or showing a quota warning) that does not exist today.
1522
Maximum *int `json:"maximum,omitempty"`
1623
SoftMaximum *int `json:"soft_maximum,omitempty"`
1724
}

pkg/lib/config/feature_custom_domain.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ var _ = FeatureConfigSchema.Add("CustomDomainFeatureConfig", `
1111
`)
1212

1313
type CustomDomainFeatureConfig struct {
14-
Disabled bool `json:"disabled,omitempty"`
14+
// No omitempty: false is this field's real default, not an absence
15+
// (see the update-feature-config skill).
16+
Disabled bool `json:"disabled"`
1517
}
1618

1719
var _ MergeableFeatureConfig = &CustomDomainFeatureConfig{}

pkg/lib/config/feature_google_tag_manager.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ var _ = FeatureConfigSchema.Add("GoogleTagManagerFeatureConfig", `
1111
`)
1212

1313
type GoogleTagManagerFeatureConfig struct {
14-
Disabled bool `json:"disabled,omitempty"`
14+
// No omitempty: false is this field's real default, not an absence
15+
// (see the update-feature-config skill).
16+
Disabled bool `json:"disabled"`
1517
}
1618

1719
var _ MergeableFeatureConfig = &GoogleTagManagerFeatureConfig{}

pkg/lib/config/feature_identity.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,9 @@ var _ = FeatureConfigSchema.Add("LoginIDPhoneFeatureConfig", `
8686
`)
8787

8888
type LoginIDPhoneFeatureConfig struct {
89-
Disabled bool `json:"disabled,omitempty"`
89+
// No omitempty: false is this field's real default, not an absence
90+
// (see the update-feature-config skill).
91+
Disabled bool `json:"disabled"`
9092
}
9193

9294
var _ = FeatureConfigSchema.Add("OAuthSSOFeatureConfig", `
@@ -235,7 +237,9 @@ var _ = FeatureConfigSchema.Add("OAuthSSOProviderFeatureConfig", `
235237
`)
236238

237239
type OAuthSSOProviderFeatureConfig struct {
238-
Disabled bool `json:"disabled,omitempty"`
240+
// No omitempty: false is this field's real default, not an absence
241+
// (see the update-feature-config skill).
242+
Disabled bool `json:"disabled"`
239243
}
240244

241245
var _ = FeatureConfigSchema.Add("BiometricFeatureConfig", `

pkg/lib/config/feature_rate_limits.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ var _ = FeatureConfigSchema.Add("RateLimitsFeatureConfig", `
1111
`)
1212

1313
type RateLimitsFeatureConfig struct {
14-
Disabled bool `json:"disabled,omitempty"`
14+
// No omitempty: false is this field's real default, not an absence
15+
// (see the update-feature-config skill).
16+
Disabled bool `json:"disabled"`
1517
}
1618

1719
var _ MergeableFeatureConfig = &RateLimitsFeatureConfig{}

pkg/lib/config/feature_test.go

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package config_test
22

33
import (
44
"context"
5+
"encoding/json"
56
"errors"
67
"io"
78

@@ -172,3 +173,129 @@ func TestFeatureConfigMigrate(t *testing.T) {
172173
}})
173174
})
174175
}
176+
177+
// This mirrors configsource/resources.go's viewEffectiveResource exactly
178+
// (fold layers via Merge, then yaml.Marshal, then re-parse) rather than the
179+
// "merge feature config" Convey block above, which stops at
180+
// SetFieldDefaults/Migrate on the in-memory merged struct and so never
181+
// exercises the yaml.Marshal step -- the one place a field tagged
182+
// `omitempty` (as opposed to `omitzero`) can silently turn an explicit
183+
// empty slice back into an absent/nil field once re-parsed.
184+
func TestFeatureConfigEffectiveResourceRoundTrip(t *testing.T) {
185+
Convey("an explicit empty phone_input.allowlist override survives the merge fold's marshal-and-reparse round trip", t, func() {
186+
ctx := context.Background()
187+
188+
planYAML := []byte(`
189+
ui:
190+
phone_input:
191+
allowlist:
192+
- US
193+
- GB
194+
`)
195+
appYAML := []byte(`
196+
ui:
197+
phone_input:
198+
allowlist: []
199+
`)
200+
201+
planCfg, err := config.ParseFeatureConfigWithoutDefaults(ctx, planYAML)
202+
So(err, ShouldBeNil)
203+
appCfg, err := config.ParseFeatureConfigWithoutDefaults(ctx, appYAML)
204+
So(err, ShouldBeNil)
205+
206+
mergedConfig := &config.FeatureConfig{}
207+
mergedConfig = mergedConfig.Merge(planCfg)
208+
mergedConfig = mergedConfig.Merge(appCfg)
209+
210+
mergedYAML, err := yaml.Marshal(mergedConfig)
211+
So(err, ShouldBeNil)
212+
213+
effective, err := config.ParseFeatureConfig(ctx, mergedYAML)
214+
So(err, ShouldBeNil)
215+
216+
So(effective.UI, ShouldNotBeNil)
217+
So(effective.UI.PhoneInput, ShouldNotBeNil)
218+
So(effective.UI.PhoneInput.AllowList, ShouldResemble, []string{})
219+
})
220+
221+
Convey("an app override that doesn't touch phone_input.allowlist inherits the plan's list", t, func() {
222+
ctx := context.Background()
223+
224+
planYAML := []byte(`
225+
ui:
226+
phone_input:
227+
allowlist:
228+
- US
229+
- GB
230+
`)
231+
// The app layer is present and non-empty, but never mentions
232+
// phone_input -- this is the "not set" case, distinct from the
233+
// explicit-empty case above, and must inherit the plan's list.
234+
appYAML := []byte(`
235+
collaborator:
236+
maximum: 5
237+
`)
238+
239+
planCfg, err := config.ParseFeatureConfigWithoutDefaults(ctx, planYAML)
240+
So(err, ShouldBeNil)
241+
appCfg, err := config.ParseFeatureConfigWithoutDefaults(ctx, appYAML)
242+
So(err, ShouldBeNil)
243+
244+
mergedConfig := &config.FeatureConfig{}
245+
mergedConfig = mergedConfig.Merge(planCfg)
246+
mergedConfig = mergedConfig.Merge(appCfg)
247+
248+
mergedYAML, err := yaml.Marshal(mergedConfig)
249+
So(err, ShouldBeNil)
250+
251+
effective, err := config.ParseFeatureConfig(ctx, mergedYAML)
252+
So(err, ShouldBeNil)
253+
254+
So(effective.UI, ShouldNotBeNil)
255+
So(effective.UI.PhoneInput, ShouldNotBeNil)
256+
So(effective.UI.PhoneInput.AllowList, ShouldResemble, []string{"US", "GB"})
257+
})
258+
}
259+
260+
// These single-field sections have `false` as their real, correct default
261+
// (not disabled) -- `omitempty` hid that from JSON output, making a
262+
// fully-resolved section marshal as an empty object indistinguishable from
263+
// one with no fields at all. Sections stay non-nil with a `false` value
264+
// regardless, from SetFieldDefaults's generic pointer-to-struct
265+
// initialization (default.go) -- this test is specifically about the
266+
// *serialized shape*, which is what the Site Admin API's feature-config UI
267+
// (and any other JSON consumer of FeatureConfig) actually sees.
268+
func TestFeatureConfigDisabledFieldsSerializeExplicitly(t *testing.T) {
269+
Convey("false-valued Disabled fields marshal explicitly, not as an empty object", t, func() {
270+
ctx := context.Background()
271+
cfg, err := config.ParseFeatureConfig(ctx, []byte(`{}`))
272+
So(err, ShouldBeNil)
273+
274+
data, err := json.Marshal(cfg)
275+
So(err, ShouldBeNil)
276+
277+
var raw map[string]any
278+
err = json.Unmarshal(data, &raw)
279+
So(err, ShouldBeNil)
280+
281+
So(raw["custom_domain"], ShouldResemble, map[string]any{"disabled": false})
282+
So(raw["google_tag_manager"], ShouldResemble, map[string]any{"disabled": false})
283+
So(raw["rate_limits"], ShouldResemble, map[string]any{"disabled": false})
284+
285+
ui, _ := raw["ui"].(map[string]any)
286+
So(ui["white_labeling"], ShouldResemble, map[string]any{"disabled": false})
287+
288+
authentication, _ := raw["authentication"].(map[string]any)
289+
secondaryAuthenticators, _ := authentication["secondary_authenticators"].(map[string]any)
290+
So(secondaryAuthenticators["oob_otp_sms"], ShouldResemble, map[string]any{"disabled": false})
291+
292+
identity, _ := raw["identity"].(map[string]any)
293+
loginID, _ := identity["login_id"].(map[string]any)
294+
types, _ := loginID["types"].(map[string]any)
295+
So(types["phone"], ShouldResemble, map[string]any{"disabled": false})
296+
297+
oauth, _ := identity["oauth"].(map[string]any)
298+
providers, _ := oauth["providers"].(map[string]any)
299+
So(providers["google"], ShouldResemble, map[string]any{"disabled": false})
300+
})
301+
}

pkg/lib/config/feature_ui.go

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,21 +51,33 @@ var _ = FeatureConfigSchema.Add("WhiteLabelingFeatureConfig", `
5151
`)
5252

5353
type WhiteLabelingFeatureConfig struct {
54-
Disabled bool `json:"disabled,omitempty"`
54+
// No omitempty: false is this field's real default, not an absence
55+
// (see the update-feature-config skill).
56+
Disabled bool `json:"disabled"`
5557
}
5658

5759
var _ = FeatureConfigSchema.Add("PhoneInputFeatureConfig", `
5860
{
5961
"type": "object",
6062
"additionalProperties": false,
6163
"properties": {
62-
"allowlist": { "type": "array", "items": { "$ref": "#/$defs/ISO31661Alpha2" }, "minItems": 1 }
64+
"allowlist": { "type": "array", "items": { "$ref": "#/$defs/ISO31661Alpha2" } }
6365
}
6466
}
6567
`)
6668

6769
type PhoneInputFeatureConfig struct {
68-
AllowList []string `json:"allowlist,omitempty"`
70+
// omitzero, not omitempty: nil (unset, inherit from plan) and an
71+
// explicit empty slice (allow all countries) are semantically distinct
72+
// here (see ApplyFeatureConfigConstraints/IntersectAllowlist). omitempty
73+
// treats both as "empty" and drops the field either way, which loses
74+
// the explicit-empty signal when this struct is re-marshaled during the
75+
// merge fold's final yaml.Marshal step (configsource/resources.go's
76+
// viewEffectiveResource) -- turning an explicit "allow all" override
77+
// back into an absent field, indistinguishable from "not set", once
78+
// re-parsed. omitzero only omits the true zero value (nil), preserving
79+
// AllowList: []string{} through that round trip.
80+
AllowList []string `json:"allowlist,omitzero"`
6981
}
7082

7183
var _ = FeatureConfigSchema.Add("ISO31661Alpha2", phone.JSONSchemaString)

0 commit comments

Comments
 (0)