Repository navigation
Description
Reviewed commit: be11bdcb5. Note: origin/main moved to 3e9660d06 during the review; re-verify against current main before changing code, since a finding may have been fixed or moved.
Severity: HIGH (money-path guard silently removed). Narrow trigger: it needs a permission whose max_amount is exactly 0, hence priority/p2 rather than p1.
Where
frontend/src/groups/groupModals.ts:172(populating the Max Amount input)frontend/src/groups/groupModals.ts:229-244(collectPermissions)- Wire contract:
internal/auth/service_api.go:169-192(max_amount<->PermissionConstraints.MaxPurchaseAmount,APIPermissionConstraint.MaxAmountcarriesomitempty)
What
The Max Amount field is populated with:
escapeHtml(String(permission?.constraints?.max_amount || ''))0 || '' is '', so a stored cap of 0 renders as a blank field. The admin sees no cap where a hard cap exists.
On save, collectPermissions gates the constraint block on if (providers || services || regions || maxAmount) and then on if (maxAmount). Both are falsy for '', so max_amount is omitted from the request entirely. The Go DTO round-trip maps max_amount to PermissionConstraints.MaxPurchaseAmount, and APIPermissionConstraint.MaxAmount carries omitempty, so the omitted field persists as 0 or as absent depending on the store path. Either way the intent recorded by the operator is gone and nothing in the UI signals it.
This is the feedback_nullable_not_zero shape on a spend cap: a real 0 is indistinguishable from "not set" at three separate layers (render, collect, serialize).
Failure scenario
- An operator creates a group whose
execute:purchasespermission carriesmax_amount: 0as a hard "this group may never spend" cap. - A later admin opens that group to rename it. The Max Amount field shows blank, because
0 || ''erased it. - They click Save. The request omits
max_amount, and the group's spending constraint is dropped. - A permission that was configured to block all purchases now carries no monetary limit at all, and the admin who removed it never saw a cap on screen.
Fix direction
- Populate with
?? ''(nullish coalescing) instead of|| '', so a stored0renders as0. - In
collectPermissions, distinguish "field left blank" from "field set to 0": read the raw string, treat''as absent and a parsed numeric0as a real value. Parse strictly rather than coercing, matchingreadServiceFilterControlsinfrontend/src/serviceFilters.ts:201-213. - While in there, check the round-trip end to end.
APIPermissionConstraint.MaxAmount'somitemptymeans a0cap is dropped on the way out of the API as well, so a0may not currently survive a read even with the frontend fixed. If0is meant to be expressible, the DTO field needs to be a pointer (or the semantics need to be "0 is not a valid cap, reject it at the boundary"). Pick one and make all three layers agree rather than leaving0ambiguous.
Regression test
Round-trip a permission with max_amount: 0: render the edit modal from it, change only the description, run collectPermissions, and assert the resulting payload still carries max_amount: 0. A test that uses a non-zero cap passes today and would pass with this bug present.
Related
feedback_nullable_not_zeroandfeedback_no_silent_fallbacksin the project memory garden.- LeanerCloud/cloud-commitments-cli#1522 (
hardening(auth): negative MaxPurchaseAmount constraint is treated as 'no constraint' instead of deny-all) - the same "a numerically meaningful cap value is read as no-cap" class on the backend side. Worth fixing in the same pass so the semantics ofMaxPurchaseAmountare decided once. - fix(exchange): make PurchaseHistoryRecord.MonthlyCost a *float64 to distinguish absent from zero cloud-commitments-cli#1081 (
make PurchaseHistoryRecord.MonthlyCost a *float64 to distinguish absent from zero) - same absent-vs-zero pattern elsewhere on the money path.
Findings from the 2026-09-02 codebase audit
Added by an automated audit of 3c0f8ac94048a2c36fce5ccddee54e6c4849a5cd (tip of origin/main). Each item below was reported by one reviewer and independently confirmed by a second that did not write it. Full report: docs/audits/codebase-audit-2026-09-02.md.
A11-004 (medium)
A second way the same field loses its cap in the same file. When the Max Amount input holds something parseFloat cannot turn into a finite non-negative value, frontend/src/groups/groupModals.ts:450-460 simply skips the assignment, so a permission that previously carried a cap is saved with no max_amount and the operator gets a success toast. The same file refuses to save at all when a list constraint cannot round-trip (unrepresentablePermissionErrors, :361), so the money dimension is the one dimension that fails open. Pushing a message into unrepresentablePermissionErrors when the box is non-empty but does not parse would make saveGroup refuse. Narrowing: the input is type="number" min="0" inside a form submitted by a real submit button, so native validation blocks negatives and non-numeric text; reaching the branch needs a value that validates yet fails Number.isFinite, such as an exponent overflow. Audit finding A11-004.
Activity
cristim commentedon Oct 16, 2026
Claiming: working on this [claude-cc-impl]
cristim commentedon Oct 16, 2026
Checked against current main: the premise needs restating.
PermissionConstraints.MaxPurchaseAmountis a plain float64 (omitempty), and 0 means no cap in every reader:matchPurchaseAmountConstraint(internal/auth/service_group.go~:469) only fails whenpermMax > 0 && reqMax > permMax.amountCovers(group_ceiling.go~:506) treats a held cap of 0 or less as covering anything.So a stored cap of 0 cannot exist, and the blank field on edit is accurate.
The real defect is the reverse, and it fails open on a money path. An admin who types 0 into Max Amount, meaning "may not spend", gets unlimited spend: the input allows min=0, and
'0'is truthy incollectPermissions, so it is sent and decoded as no cap. Negative values are likewise treated as no cap, and a non-numeric entry is silently dropped.Proposed fix, pending an owner decision:
validateRequestedPermissionsrejects negative and non-finite amounts.exclusiveMinimum: 0.Parking with status/needs-info and releasing my claim until the owner decides. [claude-cc-impl]
cristim commentedon Oct 16, 2026
Released: premise restated above; waiting on the owner's decision on the fix shape [claude-cc-impl]
cristim commentedon Oct 16, 2026
Owner decision (2026-10-16):
Removing status/needs-info. I will claim this when a slot frees. [claude-cc-impl]
cristim commentedon Oct 16, 2026
Claiming: working on this [claude-cc-impl]
cristim commentedon Oct 16, 2026
Released: pausing for p1 #950 (found during this plan's review). The revised plan, reviewed with changes, is saved; I'll re-claim when a slot frees [claude-cc-impl]
cristim commentedon Oct 16, 2026
Claiming: working on this [claude-cc-impl]. Re-scoped after #940 (12c2663), which already rejects and fails closed on negative/NaN caps at enforcement, the grant ceiling, and the group and API-key write paths. Remaining here: the UI refuses 0, negative and unparsable values; group writes reject an explicit max_amount of 0 (owner decision Q2=B); openapi documents it.
fix(auth): reject an explicit zero spend cap on group writes (#983)
fix(frontend): refuse unparsable Max Amount input
fix(api): reject an explicit max_amount of 0 on API-key create (#1406)