Skip to content

fix(controls): compensating controls could not be created or applied - #354

Merged
0xmanhnv merged 1 commit into
developfrom
fix/compensating-controls-usable
Aug 3, 2026
Merged

fix(controls): compensating controls could not be created or applied#354
0xmanhnv merged 1 commit into
developfrom
fix/compensating-controls-usable

Conversation

@0xmanhnv

@0xmanhnv 0xmanhnv commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Pairs with openctemio/api#404. Merge that first — this PR sends the values it validates.

The defect

compensating_controls has 0 rows on every tenant, including tenants where the module is released and enabled. Not because nobody tried, but because the create form could not produce a valid row.

Field What the form sent What the column allows
control_type preventive | detective | corrective | compensating (default compensating) segmentation | identity | runtime | detection | other
reduction_factor a percent — default "20" DECIMAL(3,2) CHECK (>= 0 AND <= 1)

Zero overlap on the type, and 20 is 20x the ceiling on the factor. Every create hit a CHECK violation, came back as a 500, and the page showed "Failed to create control" because every catch was bare and discarded the server's message.

Three further faults in the same page:

  • The read path had the inverse unit bug. The list rendered {reduction_factor}%, so a stored 0.30 displayed as "0.3%". Read and write disagreed with each other as well as with the database.
  • The Test button never worked. It sent PATCH to /{id}/test, but the route is registered as POST — a 405, swallowed by the same bare catch.
  • There was no way to link a control to an asset. The asset link is the only thing that makes a control affect scoring, and no UI for it existed anywhere. A control that saved successfully still did nothing.

What changed

One source of truthsrc/features/controls/vocabulary.ts holds both vocabularies and the unit conversion, and every <Select> is rendered by mapping over it. The form can no longer offer a value the backend rejects, because there is no second list to drift from.

Asset linking — a new LinkAssetsDialog (searchable, multi-select, modelled on the existing bulk-add-assets-dialog) posting to POST /{id}/assets. This is the change that makes the feature do anything at all.

Honest errorsgetErrorMessage(error, fallback) replaces the bare catches, so the API's new 400s ("control_type must be one of: …") actually reach the user. This page was the outlier; 382 other call sites already did this.

Honest copy — the create dialog now says what a control actually does today:

A control caps the priority of findings on the assets you link to it — a protected asset is held at P2 rather than P1. The percentage is recorded and shown as the rationale; it does not currently scale the result further.

That last clause is deliberate. reduction_factor is presentational: the classifier is binary on IsProtected, so a 5% and a 95% control produce an identical P2. Rather than delete the field or quietly imply it does more than it does, the UI states the truth. Making the factor band the outcome would change risk scoring for every tenant and belongs in its own change.

The unit decision

The form speaks percent; the API keeps the 0–1 fraction. Converted at the single API boundary via percentToFactor / factorToPercent.

The wire format is not negotiable — it is what the column stores. Percent in the UI is right because it is how operators talk about risk reduction and it was already the field's label. The conversion is safe rather than a new source of drift: the column is DECIMAL(3,2), so whole percents map exactly onto the storable set. A test asserts the round trip is lossless for all 100 values and that every one satisfies the CHECK.

Also dropped the controlTypeColors map. Control type is a category, not a risk level, so a neutral badge is more honest — and it removes five hardcoded light/dark colour pairs from a page the UI review flagged for growing dark-mode drift.

Verification

Check Result
tsc --noEmit exit 0
vitest run (full suite) 55 files, 898 tests passed
npm run build (after a real npm ci) exit 0
bash scripts/check-palette-drift.sh origin/develop no new hardcoded palette classes
prettier --check / eslint clean

The drift guards were proven red. Reverting the page to the legacy default (control_type: 'compensating'), the raw percent, and the raw-fraction renderer fails exactly the three tests that should fail:

× sends a control_type the backend accepts and a 0-1 reduction factor
× never offers a control type the backend rejects
× renders a stored 0-1 factor as a percentage

AssertionError: expected [ 'segmentation', 'identity', …(3) ] to include 'compensating'
AssertionError: expected 0 to be greater than 0

The page test asserts the actual request body the form puts on the wire satisfies both CHECK constraints — not just that a constant matches — so it fails if the form ever regresses regardless of how the vocabulary is wired.

The create form sent values no row could hold, so the feature had zero rows
on every tenant.

- control_type offered preventive/detective/corrective/compensating. The
  database CHECK allows segmentation/identity/runtime/detection/other —
  zero overlap, so every create failed.
- reduction_factor was entered and sent as a percent ("20"), but the column
  is DECIMAL(3,2) CHECK (>= 0 AND <= 1). The list then rendered a stored
  0.30 as "0.3%", so read and write disagreed with each other as well as
  with the database.
- Recording a test sent PATCH to a route registered as POST, so the Test
  button returned 405 and never worked.
- Every catch was bare (`catch {}`), so the API's error message was
  discarded and the user saw only "Failed to create control".

There was also no way to link a control to an asset, and the asset link is
the only thing that makes a control affect scoring — so even a control that
saved successfully did nothing.

Both vocabularies and the percent<->fraction conversion now live in
src/features/controls/vocabulary.ts, and the Selects are rendered from it,
so the form cannot offer a value the backend rejects. The form speaks
percent (which is how operators think about it) and converts at the API
boundary; whole percents map exactly onto the two-decimal storable set, so
the round trip is lossless.

The create dialog states plainly what a control does today: it caps a
protected asset's findings at P2 rather than P1. The percentage is recorded
and shown as rationale but does not scale the result further, and the copy
no longer implies otherwise.

Control-type badges lost their hardcoded light/dark colour pairs — a
category is not a risk level, and a neutral badge keeps the page out of the
dark-mode drift the UI review flagged.
@0xmanhnv
0xmanhnv merged commit 602fc54 into develop Aug 3, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant