Skip to content

fix(frontend): a max_amount spend cap of 0 is silently erased when a group is edited #133

Closed

Description

@cristim

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.MaxAmount carries omitempty)

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

  1. An operator creates a group whose execute:purchases permission carries max_amount: 0 as a hard "this group may never spend" cap.
  2. A later admin opens that group to rename it. The Max Amount field shows blank, because 0 || '' erased it.
  3. They click Save. The request omits max_amount, and the group's spending constraint is dropped.
  4. 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 stored 0 renders as 0.
  • In collectPermissions, distinguish "field left blank" from "field set to 0": read the raw string, treat '' as absent and a parsed numeric 0 as a real value. Parse strictly rather than coercing, matching readServiceFilterControls in frontend/src/serviceFilters.ts:201-213.
  • While in there, check the round-trip end to end. APIPermissionConstraint.MaxAmount's omitempty means a 0 cap is dropped on the way out of the API as well, so a 0 may not currently survive a read even with the frontend fixed. If 0 is 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 leaving 0 ambiguous.

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_zero and feedback_no_silent_fallbacks in 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 of MaxPurchaseAmount are 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

  1. cristim commented on Oct 16, 2026

    @cristim
    MemberAuthor

    Claiming: working on this [claude-cc-impl]

  2. self-assigned this
    on Oct 16, 2026
  3. cristim commented on Oct 16, 2026

    @cristim
    MemberAuthor

    Checked against current main: the premise needs restating.

    PermissionConstraints.MaxPurchaseAmount is a plain float64 (omitempty), and 0 means no cap in every reader:

    • matchPurchaseAmountConstraint (internal/auth/service_group.go ~:469) only fails when permMax > 0 && reqMax > permMax.
    • amountCovers (group_ceiling.go ~:506) treats a held cap of 0 or less as covering anything.
    • The zero-commitment guard and the RI-exchange code rely on the same meaning.

    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 in collectPermissions, 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:

    • The UI refuses 0, negative and unparsable values ("must be greater than 0; leave blank for no limit").
    • validateRequestedPermissions rejects negative and non-finite amounts.
    • openapi gets exclusiveMinimum: 0.

    Parking with status/needs-info and releasing my claim until the owner decides. [claude-cc-impl]

  4. removed their assignment
    on Oct 16, 2026
  5. cristim commented on Oct 16, 2026

    @cristim
    MemberAuthor

    Released: premise restated above; waiting on the owner's decision on the fix shape [claude-cc-impl]

  6. cristim commented on Oct 16, 2026

    @cristim
    MemberAuthor

    Owner decision (2026-10-16):

    • Fix shape A: absent means no cap, as today. The UI refuses 0, negative and unparsable values.
    • The API also rejects an explicit max_amount of 0 (B), via a request-only nullable DTO, and rejects negative and non-finite amounts.
    • Ship without a production data check (B); the error message tells an admin with an existing negative cap what to fix.
      Removing status/needs-info. I will claim this when a slot frees. [claude-cc-impl]
  7. cristim commented on Oct 16, 2026

    @cristim
    MemberAuthor

    Claiming: working on this [claude-cc-impl]

  8. self-assigned this
    on Oct 16, 2026
  9. cristim commented on Oct 16, 2026

    @cristim
    MemberAuthor

    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]

  10. removed their assignment
    on Oct 16, 2026
  11. cristim commented on Oct 16, 2026

    @cristim
    MemberAuthor

    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.

  12. self-assigned this
    on Oct 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions