|
| 1 | +# Locked Fields (Server-Trusted State) |
| 2 | + |
| 3 | +Component state round-trips through the client on every dispatch. [State |
| 4 | +signing](STATE_SIGNING.md) guarantees *integrity* — the client cannot alter a |
| 5 | +signed blob — but it does not make state trustworthy: |
| 6 | + |
| 7 | +1. **Unsigned deployments have zero integrity.** If signing is not enabled, |
| 8 | + the client can freely edit any state field before echoing it back. |
| 9 | +2. **Even with signing, replay is possible.** A client can capture an *older* |
| 10 | + validly-signed state blob and replay it to roll fields back — e.g. a token |
| 11 | + from before their `is_admin` flag was revoked, a stale `user_id`, or an |
| 12 | + old price. |
| 13 | + |
| 14 | +Locked fields are the defense for values the **server owns**: fields the |
| 15 | +client must never influence, in either mode. |
| 16 | + |
| 17 | +## Declaring locked fields |
| 18 | + |
| 19 | +```python |
| 20 | +from typing import ClassVar |
| 21 | + |
| 22 | +from component_framework import Component, registry |
| 23 | + |
| 24 | + |
| 25 | +@registry.register("account_panel") |
| 26 | +class AccountPanel(Component): |
| 27 | + template_name = "account_panel.html" |
| 28 | + locked_fields: ClassVar[frozenset[str]] = frozenset({"is_admin", "user_id"}) |
| 29 | + |
| 30 | + def mount(self): |
| 31 | + super().mount() |
| 32 | + self.state["user_id"] = self.params["user"].id |
| 33 | + self.state["is_admin"] = self.params["user"].is_staff |
| 34 | + self.state["tab"] = "profile" |
| 35 | + |
| 36 | + def before_render(self): |
| 37 | + # Re-derive server-trusted values on EVERY request — hydrated |
| 38 | + # requests do not run mount(). |
| 39 | + user = self.params.get("user") |
| 40 | + self.state["is_admin"] = bool(user and user.is_staff) |
| 41 | +``` |
| 42 | + |
| 43 | +Any iterable of strings works (`frozenset`, `list`, `tuple`); it is |
| 44 | +normalized internally. The default is empty — existing components are |
| 45 | +unaffected. |
| 46 | + |
| 47 | +## Enforcement semantics |
| 48 | + |
| 49 | +Locked fields are enforced at the core lifecycle choke points, so every |
| 50 | +adapter (FastAPI, Flask, Litestar, Django FBV/CBV, WebSocket, SSE streaming) |
| 51 | +is covered without adapter changes: |
| 52 | + |
| 53 | +- **Outbound (`dehydrate()`):** locked fields are excluded from the |
| 54 | + serialized state — they never reach the client at all. `self.state` keeps |
| 55 | + them server-side for the rest of the request. |
| 56 | +- **Inbound (`hydrate()`):** locked fields present in client-supplied state |
| 57 | + are stripped *before* `self.state` is updated, and a warning is logged |
| 58 | + naming the fields (never the values). Because outbound state never |
| 59 | + contains locked fields, any inbound occurrence is anomalous — crafted |
| 60 | + state, or a replayed blob from before the field was locked. |
| 61 | + |
| 62 | +The inbound dict is sanitized **in place**, so `hydrate()` overrides that |
| 63 | +read the raw argument after `super().hydrate(state)` (e.g. |
| 64 | +`DjangoModelMixin.hydrate` reading `state["pk"]`) cannot see locked values |
| 65 | +either. |
| 66 | + |
| 67 | +## Re-establishing locked values |
| 68 | + |
| 69 | +Since locked values never round-trip, the component must (re)derive them |
| 70 | +server-side on every request. Pick whichever fits: |
| 71 | + |
| 72 | +| Pattern | When to use | |
| 73 | +|---------|-------------| |
| 74 | +| `before_render()` | Value only needed for rendering | |
| 75 | +| `hydrate()` override (set after `super().hydrate(state)`) | Value needed by event handlers (handlers run *before* `before_render()`) | |
| 76 | +| Server-supplied `params` (adapter/dependency injection) | Value comes from the request context (current user, tenant, …) | |
| 77 | +| Look up inside the handler | Value only needed by one handler | |
| 78 | + |
| 79 | +## Interaction with signing |
| 80 | + |
| 81 | +Locked fields work identically in both modes and complement signing: |
| 82 | + |
| 83 | +- **Signing disabled:** locked fields are the only integrity guarantee for |
| 84 | + the fields they cover. Everything else in state remains client-editable — |
| 85 | + enable signing for production. |
| 86 | +- **Signing enabled:** signing blocks tampering; locked fields additionally |
| 87 | + block **replay/rollback** of stale-but-validly-signed values for the |
| 88 | + covered fields. |
| 89 | + |
| 90 | +## Caveats |
| 91 | + |
| 92 | +- **Top-level keys only.** `locked_fields` matches top-level state keys. |
| 93 | + Nested values (e.g. one field inside `FormComponent`'s |
| 94 | + `state["form_data"]` dict) cannot be individually locked — keep |
| 95 | + server-trusted values in their own top-level keys, not inside form data. |
| 96 | +- **Event payloads are not covered.** Locked fields guard the *state* |
| 97 | + channel. Event payloads (including `FormComponent`'s `submit` payload, |
| 98 | + which writes `form_data` directly) are separate inputs — validate them |
| 99 | + with your Pydantic `schema` and authorization checks in handlers. |
| 100 | +- **`ModelFormComponent` / `DjangoModelComponent`:** the bound instance is |
| 101 | + loaded server-side, but `pk` round-trips by design so the mixin can reload |
| 102 | + the instance. Locking `pk` therefore breaks instance reloading — instead, |
| 103 | + authorize access in `get_instance()` (scope the queryset to the current |
| 104 | + user) rather than trusting the client-supplied `pk`. |
| 105 | +- **Don't put secrets in state.** Locked or not, state design guidance from |
| 106 | + [State Signing](STATE_SIGNING.md) still applies; locked fields are about |
| 107 | + *trust*, not confidentiality (in fact they never leave the server, which |
| 108 | + also helps confidentiality — but that is a side effect, not a contract). |
0 commit comments