Skip to content

feat(console): mark immutable resource fields as read-only in edit forms#6

Draft
lexfrei wants to merge 9 commits into
mainfrom
claude/magical-meitner-7db476
Draft

feat(console): mark immutable resource fields as read-only in edit forms#6
lexfrei wants to merge 9 commits into
mainfrom
claude/magical-meitner-7db476

Conversation

@lexfrei
Copy link
Copy Markdown
Contributor

@lexfrei lexfrei commented May 12, 2026

Summary

Resource fields declared immutable in the schema via x-kubernetes-validations: [{rule: "self == oldSelf"}] are now:

  • Rendered grey-out with the helper text "This field cannot be changed after creation." on edit screens.
  • Overlaid with the persisted value before PUT so the admission server observes no delta on those paths and the CEL rule is never tripped — defence-in-depth against the YAML editor, devtools, or RJSF bugs bypassing the read-only state.

Create flows are untouched.

Acceptance criterion

The companion cozystack/cozystack#2639 marks MariaDB storageClass immutable (itself dependent on cozystack/cozyvalues-gen#24). With all three PRs in place, opening an existing MariaDB instance for edit grays out storageClass, shows the helper text, keeps the rest editable, and the outgoing PUT carries the original storageClass value.

What's in this PR

  • New helpers in apps/console/src/lib/immutable-paths.ts: findImmutablePaths walks the schema tree (properties, items, additionalProperties, oneOf/anyOf/allOf) and returns every path carrying the CEL rule. overlayImmutable copies the original spec's values along those paths into the submitted body. Wildcard semantics:
    • *-last with an array source: replace target with a clone of source (whole array immutable).
    • *-last with an object-map source: replace target with a clone of source (whole map immutable).
    • *-not-last with an array source: walk the user's array; restore the nested immutable subfield from source at shared indices, keep user-added entries verbatim, respect user deletions.
    • *-not-last with an object-map source: walk the user's keys; restore the nested immutable subfield from source at shared keys, keep user-added keys verbatim, respect user-deleted keys.
  • apps/console/src/lib/prepare-update.ts — pure wrapper combining the two; logs a console warning when the schema fails to parse; short-circuits when the persisted spec is null/undefined to avoid blanking immutable fields with undefined.
  • SchemaForm accepts an immutableMode prop ("enforce" | "off", default "off"). When enforcing, walks the schema and emits ui:disabled + ui:help per immutable path. Wildcard * maps to RJSF's items for arrays. For object maps (additionalProperties) the field itself is marked disabled — per-entry-value-immutable semantics is deliberately deferred until a real schema asks for it.
  • sanitizeSchema strips x-kubernetes-validations once it has been harvested from the raw schema. AJV has no CEL evaluator, so the rule was pure noise.
  • ApplicationOrderPage and BackupResourceEditPage opt in to enforcement on edit and call prepareUpdateSpec before mutating.

Known limitations (pinned with FIXME tests)

  • The walker does not recurse into properties beneath oneOf/anyOf/allOf branches; it only treats the parent as immutable when any branch carries the rule itself.
  • Overlay does not materialise immutable leaves whose ancestor is missing in the submitted body (e.g. YAML editor strips the parent object) — the leaf is silently dropped from the PUT.
  • Overlay aligns array entries by index; if the user reorders an array with a per-element immutable field, source values follow indices rather than identities.

Each is covered by an explicit test pinning the current behaviour so a future contributor can iterate them.

Test infrastructure

vitest + jsdom + @testing-library/react are added as part of this PR. 53 unit and component tests cover the schema walker, sanitiser, submit overlay, and form behaviour (greyed inputs, helper text, array-items rendering, etc.). pnpm test runs them all.

Tested manually against a hand-edited MariaDB values.schema.json carrying the rule on storageClass:

  • Edit form: storageClass greyed out, helper text below, every other field editable.
  • YAML editor bypass + Save → PUT body keeps the original storageClass value.
  • Create flow: storageClass fully editable.
  • Resources without the CEL rule: behaviour identical to master.

No-op until cozystack ships the schema annotation

When no schema carries the rule, findImmutablePaths returns [] everywhere, all walkers are no-ops, the overlay is identity, edit pages behave exactly as today. The UI lights up resource-by-resource as upstream annotates immutable fields.

lexfrei added 7 commits May 12, 2026 22:29
Wire up a minimal vitest + jsdom + @testing-library/react setup in
apps/console so the policy-bearing schema helpers and form behaviour
introduced in upcoming commits can be developed test-first.

- vitest.config.ts: jsdom env, test glob under src/, setup file.
- src/test-setup.ts: import jest-dom matchers.
- package.json: "test" and "test:watch" scripts.
- root package.json: aggregate "test" delegating to workspaces.
- tsconfig.json: pick up jest-dom matcher types.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
Introduce pure helpers used to recognise and enforce Kubernetes-style
immutability rules on resource schemas.

findImmutablePaths(schema) walks an OpenAPI/JSON-schema tree and
returns every path whose subschema carries an
x-kubernetes-validations entry with rule "self == oldSelf". Object
properties contribute named segments; array items and
additionalProperties contribute the wildcard segment "*". oneOf,
anyOf and allOf branches are considered when determining whether a
node is immutable.

overlayImmutable(submitted, original, paths) deep-clones the
submitted value, then for each path copies the value from original.
For wildcard segments the array length is aligned to original so an
immutable list cannot be reshaped client-side.

IMMUTABLE_HELP_TEXT centralises the user-facing string consumed by
later UI wiring.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
AJV has no CEL evaluator, so the rule does nothing for JSON-Schema
validation; the UI consults the raw schema once to harvest
immutability paths and then drops the extension so RJSF traversal
stays small.

Also add characterization tests for keysOrderToUiSchema and
sanitizeSchema so existing behaviour (int-or-string coercion,
preserve-unknown-fields → additionalProperties, "Chart Values" →
"Parameters") is locked in.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
When immutableMode='enforce' is passed, every immutable schema path
(as reported by findImmutablePaths) receives ui:readonly=true plus a
ui:help string sourced from IMMUTABLE_HELP_TEXT. RJSF then renders
the corresponding inputs read-only and surfaces the helper text
underneath. The default 'off' keeps every field editable so create
flows are unaffected.

Wildcard segments map to RJSF's 'items' key for arrays; for object
maps (additionalProperties) the readonly flag is set on the field
itself, to be propagated by the AdditionalPropertiesField renderer.

Add a RTL component test covering enforce / off / omitted paths,
plus afterEach(cleanup) in the shared test-setup so renders don't
leak between cases.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
The component already derives isReadonly from readonly || disabled and
forwards it to the inner <Form> and Add/Remove controls; the new tests
pin that contract by exercising it via SchemaForm + immutableMode.

When immutableMode is omitted the Add control and per-entry Remove
buttons render. With immutableMode='enforce' those controls disappear,
the inner inputs become disabled, and the canonical help text is
rendered alongside the map field.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
ApplicationOrderPage and BackupResourceEditPage now opt their
SchemaForm into immutableMode='enforce' when in edit flow so the
relevant fields render read-only with helper text.

On submit, prepareUpdateSpec walks the schema for immutable paths
and copies the original values from the persisted spec into the
outgoing body. The API server therefore observes no delta on those
paths and the CEL immutability check is not triggered, even if the
user managed to bypass the read-only UI (YAML editor, devtools,
RJSF bug).

prepareUpdateSpec is a thin pure wrapper around findImmutablePaths +
overlayImmutable so the schema-vs-no-schema and malformed-JSON edges
can be locked down without rendering full pages under jsdom.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
- Switch from ui:readonly to ui:disabled so immutable inputs render
  greyed out, matching the product decision. RJSF readonly only sets
  the HTML readonly attribute, which leaves the control visually
  identical to an editable one.
- overlayImmutable: warn and replace wholesale when a root-level
  immutable path is supplied (almost certainly a schema-authoring
  mistake worth surfacing in DevTools).
- overlayImmutable: when a wildcard segment is the last in the path
  the whole array is replaced from source; when nested fields are
  immutable the loop iterates the longer of source/target so the
  user can still add new array entries and they survive the overlay.
- prepareUpdateSpec: short-circuit to a deep clone when original is
  null/undefined to avoid blanking immutable fields with undefined.
  Log a console.warn when the schema string fails to parse instead
  of silently disabling immutability enforcement.
- SchemaForm: parse openAPISchema once and derive the sanitised
  schema plus immutable path set from the shared object.
- SchemaForm: add a SchemaForm-level test for the array-items
  wildcard case so the rendering branch is covered end-to-end.
- keysOrderToUiSchema: accept ReadonlyArray<ReadonlyArray<string>>
  so call sites can hand in immutable-path-style arrays without
  casting.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 12, 2026

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 00b771c1-f740-4a36-9989-9e3d4807b44e

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/magical-meitner-7db476

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements immutability enforcement in the SchemaForm component by parsing x-kubernetes-validations and applying ui:disabled and ui:help to immutable paths. It introduces a prepareUpdateSpec utility to ensure immutable values are preserved during updates and adds Vitest for testing. The review feedback highlights improvements for the array overlay logic to prevent potential null-value issues, suggests consolidating utility functions like isPlainObject and deepClone for better consistency, and recommends cleaning up redundant code in the update preparation logic.

Comment thread apps/console/src/lib/immutable-paths.ts Outdated
Comment on lines +120 to +131
const sourceArr = Array.isArray(source) ? source : []
const targetArr = Array.isArray(target) ? target : []
const len = Math.max(sourceArr.length, targetArr.length)
const out: unknown[] = []
for (let i = 0; i < len; i++) {
if (i >= sourceArr.length) {
out[i] = deepClone(targetArr[i])
} else {
out[i] = overlayPath(targetArr[i], sourceArr[i], path, depth + 1)
}
}
return out
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The current array overlay logic can produce arrays with undefined elements (holes) when the source array is longer than the target array (e.g., when an item is deleted). When serialized to JSON, these holes become null, which may violate the schema or cause unexpected behavior on the API server. Using targetArr.map is a safer and more concise way to handle positional overlays while correctly respecting deletions and additions.

    const sourceArr = Array.isArray(source) ? source : []
    const targetArr = Array.isArray(target) ? target : []
    return targetArr.map((item, i) =>
      i < sourceArr.length
        ? overlayPath(item, sourceArr[i], path, depth + 1)
        : deepClone(item),
    )

Comment on lines +19 to +20
const isPlainObject = (v: unknown): v is Record<string, unknown> =>
v !== null && typeof v === "object" && !Array.isArray(v)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Export isPlainObject so it can be reused in SchemaForm.tsx and deepClone for better consistency and code reuse.

Suggested change
const isPlainObject = (v: unknown): v is Record<string, unknown> =>
v !== null && typeof v === "object" && !Array.isArray(v)
export const isPlainObject = (v: unknown): v is Record<string, unknown> =>
v !== null && typeof v === "object" && !Array.isArray(v)

Comment on lines +143 to +154
function deepClone<T>(value: T): T {
if (value === null || value === undefined) return value
if (Array.isArray(value)) return value.map(deepClone) as unknown as T
if (typeof value === "object") {
const out: Record<string, unknown> = {}
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
out[k] = deepClone(v)
}
return out as unknown as T
}
return value
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Export deepClone to allow its reuse in prepare-update.ts, and use the isPlainObject helper for a cleaner and more consistent implementation.

Suggested change
function deepClone<T>(value: T): T {
if (value === null || value === undefined) return value
if (Array.isArray(value)) return value.map(deepClone) as unknown as T
if (typeof value === "object") {
const out: Record<string, unknown> = {}
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
out[k] = deepClone(v)
}
return out as unknown as T
}
return value
}
export function deepClone<T>(value: T): T {
if (value === null || value === undefined) return value
if (Array.isArray(value)) return value.map(deepClone) as unknown as T
if (isPlainObject(value)) {
const out: Record<string, unknown> = {}
for (const [k, v] of Object.entries(value)) {
out[k] = deepClone(v)
}
return out as unknown as T
}
return value
}

Comment on lines +1 to +5
import {
findImmutablePaths,
overlayImmutable,
type ImmutablePath,
} from "./immutable-paths.ts"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Add deepClone to the imports from ./immutable-paths.ts to replace the redundant cloneShallowAsDeep function.

Suggested change
import {
findImmutablePaths,
overlayImmutable,
type ImmutablePath,
} from "./immutable-paths.ts"
import {
findImmutablePaths,
overlayImmutable,
deepClone,
type ImmutablePath,
} from "./immutable-paths.ts"

openAPISchema: string,
): T {
if (original === undefined || original === null) {
return cloneShallowAsDeep(submitted)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use the deepClone utility instead of cloneShallowAsDeep.

Suggested change
return cloneShallowAsDeep(submitted)
return deepClone(submitted)

Comment on lines +46 to +49
function cloneShallowAsDeep<T>(value: T): T {
if (value === null || value === undefined) return value
return JSON.parse(JSON.stringify(value)) as T
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This function is redundant as deepClone is already implemented in immutable-paths.ts. Additionally, the name is misleading (it performs a deep clone) and the implementation using JSON.stringify is less efficient and can strip undefined values from objects.

Comment on lines +6 to +10
import {
IMMUTABLE_HELP_TEXT,
findImmutablePaths,
type ImmutablePath,
} from "../lib/immutable-paths.ts"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Add isPlainObject to the imports from ../lib/immutable-paths.ts.

Suggested change
import {
IMMUTABLE_HELP_TEXT,
findImmutablePaths,
type ImmutablePath,
} from "../lib/immutable-paths.ts"
import {
IMMUTABLE_HELP_TEXT,
findImmutablePaths,
isPlainObject,
type ImmutablePath,
} from "../lib/immutable-paths.ts"

key: string,
): Record<string, unknown> {
const existing = uiNode[key]
if (existing && typeof existing === "object" && !Array.isArray(existing)) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use the isPlainObject helper for better readability and consistency.

Suggested change
if (existing && typeof existing === "object" && !Array.isArray(existing)) {
if (isPlainObject(existing)) {

lexfrei added 2 commits May 13, 2026 01:23
Second-pass review surfaced four defects in overlayImmutable that
produced data corruption in the PUT body for cases the schema walker
already documents as supported. Fix each, pin behaviour with tests.

1. Nested-field overlay with the submitted array shorter than original
   used to leave undefined slots ("holes"); the new loop walks the
   target length so deletions stick. Adds are still preserved because
   the overlay never touches indices past source.

2. additionalProperties map with *-last used to be a no-op (Array.isArray
   guarded the only branch). Now an object-source replaces target with a
   deep clone, so YAML-editor / devtools edits cannot smuggle a mutation
   past the form's read-only state.

3. additionalProperties map with *-not-last used to coerce the map to
   an empty array. New code handles object source/target separately:
   iterate shared keys, overlay the immutable nested subfield from
   source, keep user-added keys and respect user-deleted ones.

4. When the persisted spec has no value at the immutable path, the
   overlay used to write undefined into the body, silently erasing the
   user's input. Now keep the submitted value — the form already greys
   the field out so interactive edits are blocked, and a missing source
   value isn't an immutability violation to defend against.

Also surface two trade-offs reviewers asked about in plain comments:
- SchemaForm.parsedSchema useMemo identity (same string ⇒ same object).
- ApplicationOrderPage uses mount-time initialSpec; documented as a
  re-mount-to-refresh trade-off.

README gains a one-line 'pnpm test' section to surface the new suite.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
- Replace .hasOwnProperty(seg) with Object.prototype.hasOwnProperty.call
  so no-prototype-builtins doesn't regress. Add a test pinning the
  "neither side has the field" code path.
- Add three pinned-as-broken tests with FIXME notes for shapes not yet
  supported by the walker/overlay: oneOf/anyOf/allOf branch properties,
  intermediate-missing-parent during overlay, and index-aligned overlay
  on user-reordered arrays. These behaviours stay as-is in this PR.
- Add a component test pinning the deliberate UI/overlay asymmetry on
  additionalProperties when only nested values are immutable: the form
  freezes the whole map for simplicity while overlay enforces per-entry.
- Expand sanitizeSchema's doc block to mention x-kubernetes-validations
  (stripped here, harvested from the raw schema by immutable-paths).
- Tighten SchemaForm's walker doc-comment so "raw (sanitised)" is not
  self-contradicting.
- Spy on console.warn inside the malformed-schema prepareUpdateSpec
  test so the suite doesn't emit log noise.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
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