Skip to content

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

Draft
lexfrei wants to merge 6 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 6 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 and returns every path carrying the CEL rule. overlayImmutable copies the original spec's values along those paths into the submitted body. Wildcard semantics: whole-array/whole-map (*-last) → clone from source; per-element nested (*-not-last) → overlay shared indices/keys, keep user adds, respect deletes. Length shrink with a nested-immutable path bails out with a console.warn (admission stays authoritative). Top-level wildcard paths are handled like root-immutable: warn + clone source.
  • apps/console/src/lib/prepare-update.ts — pure wrapper; warns on malformed schema; short-circuits to a clone when persisted spec is null/undefined.
  • SchemaForm accepts immutableMode (enforce | off, default off). When enforcing, walks the schema and emits ui:disabled + ui:help per immutable path. For object maps (additionalProperties) the field itself is marked disabled — per-entry-value semantics is deliberately deferred.
  • sanitizeSchema strips x-kubernetes-validations once it has been harvested. AJV has no CEL evaluator, so the rule is pure noise to the form validator.
  • ApplicationOrderPage and BackupResourceEditPage opt in to enforcement on edit and anchor the overlay source to a mount-time snapshot so a background React-Query refetch can't sneak a different immutable value into the PUT.

Known limitations (pinned with FIXME tests + tracking issues)

Tests

The console workspace already had vitest + jsdom + @testing-library/react on main; this PR adds 7 new test files (immutable-paths walker, overlay edges, prepare-update, SchemaForm immutable rendering, AdditionalPropertiesField asymmetry, keys-order characterization) totalling 85 passing tests. 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.

@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: 3a4f42f3-822a-41f2-8922-081d12eff9f2

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 lexfrei self-assigned this May 13, 2026
@lexfrei lexfrei force-pushed the claude/magical-meitner-7db476 branch from 5727ac1 to 4416ec3 Compare May 14, 2026 11:53
lexfrei added 2 commits May 14, 2026 16:52
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. Wildcard
semantics dispatch on the runtime value shape:

  - *-last on an array: replace with a clone of source (whole-array
    immutable).
  - *-last on an object map: replace with a clone of source (whole-map
    immutable).
  - *-not-last on an array: iterate the user's array, restore the
    immutable nested subfield from source at shared indices, keep
    user-added entries verbatim. Length shrinks are detected and the
    overlay is skipped with a console.warn so admission stays the
    authoritative enforcer (index-aligned overlay would otherwise
    silently swap which element the user deleted).
  - *-not-last on an object map: freeze the whole map. The UI marks
    such fields ui:disabled (Add/Remove hidden); the overlay matches
    so YAML-editor and devtools bypasses can't add or rename keys.

A root-level immutable path or a top-level wildcard immutable path
(both pathological schema shapes) replaces submitted wholesale with a
clone of original and logs a warning.

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 alongside the new strip.

Widen keysOrderToUiSchema's parameter to
ReadonlyArray<ReadonlyArray<string>> so call sites can pass
immutable-path-style arrays without casts.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
@lexfrei lexfrei force-pushed the claude/magical-meitner-7db476 branch 2 times, most recently from b41f2aa to 55c8e1e Compare May 14, 2026 14:06
lexfrei added 4 commits May 14, 2026 17:24
Add an immutableMode prop ("enforce" | "off", default "off"). When
"enforce", every immutable schema path (as reported by
findImmutablePaths) receives ui:disabled=true plus a ui:help string
sourced from IMMUTABLE_HELP_TEXT. RJSF then renders the corresponding
inputs greyed out and surfaces the helper text underneath. The default
keeps every field editable so create flows are unaffected.

Wildcard segments map to RJSF's "items" key for arrays; for object
maps (additionalProperties) the field itself is marked disabled and
the existing AdditionalPropertiesField renderer propagates that to
the nested form, hiding Add/Remove controls.

Parse the raw schema once and derive both the sanitised RJSFSchema
and the immutable-path set from the shared parsed object so the JSON
string isn't decoded twice per render.

Add component-level tests covering:

  - enforce / off / omitted prop branches on scalar fields,
  - per-element-nested-immutable fields inside array items,
  - whole-array immutable shape,
  - the deliberate UI/overlay asymmetry on additionalProperties
    object maps (the whole map is frozen in both UI and overlay).

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 greyed out 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).

Both pages anchor the overlay source to a mount-time snapshot stored
in a ref so a background React-Query refetch can't sneak a different
immutable value into the PUT. The snapshot capture lives inside
useEffect to keep render side-effect-free.

prepareUpdateSpec is a thin pure wrapper around findImmutablePaths +
overlayImmutable. It short-circuits to a deep clone when the persisted
spec is null/undefined (avoids blanking immutable fields with
undefined) and logs a console.warn when the schema string fails to
parse instead of silently disabling enforcement.

BackupResourceEditPage.handleSubmit also refuses to save when the CRD
schema hasn't loaded yet, mirroring the existing !resource guard.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
The workspace already configures vitest + jsdom + @testing-library
in apps/console; the README mentioned only install/dev/build. Add a
one-line Test section pointing at the workspace-wide "pnpm test".

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
pnpm/action-setup@v4 fails when the workspace doesn't pin a pnpm
version anywhere. Declare it in the standard packageManager field on
the root package.json so both the Test workflow and any local
contributor's corepack pick up the same version we develop against.

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