[Proposal]: Add a New Field Property to Prevent Any Client-Side Write (Stronger Than Read Only)

In many Frappe/ERPNext implementations, developers rely on the “Read Only” field property expecting it to guarantee that the value cannot be modified by the user. However, this assumption is not accurate. The current Read Only behavior only affects the Form UI and can be completely bypassed from the client side.

This post aims to open a discussion on adding a new field-level property that enforces server-controlled write access, ensuring that field values cannot be changed by the client under any circumstance.

The Problem

1. Misconception About “Read Only”

Many developers assume that setting a field as Read Only makes it immutable.

But in the Frappe Framework, Read Only only prevents visual editing in the form it does not protect the field from programmatic modification on the client.

2. Client Can Easily Modify Values

Even if a field is Read Only, the client can still modify it using:

  • Browser Console
  • JavaScript injections
  • Client scripts

Example using Console:

cur_frm.set_value(“read_only_field”, “new value”)

The server accepts this change because Frappe currently trusts incoming field values unless additional validation is implemented.

3. Real Example: Employee Advance DocType

A clear real-world example is the Employee Advance DocType.

It contains several fields that are meant to be computed only by server-side logic, such as:

  • Paid Amount – updated only when a Payment Entry is submitted
  • Returned Amount – updated only when a Journal Entry is submitted
  • Claimed Amount – updated only when an Expense Claim is submitted

These fields are marked as Read Only because they should never be manually edited by the user.

However, due to the current behavior of Read Only:

The client can still modify these values through the browser console before submission.

The server accepts the modified values since it does not block client-side writes.

This means a user can submit an Employee Advance with incorrect financial values, for example, a manually set Paid Amount even though no Payment Entry exists.

This results in:

  • incorrect financial data
  • inconsistent reporting
  • behavior that contradicts the intended server-controlled logic

See the example

4. Existing Workarounds Are Not Practical

Developers currently rely on workarounds such as:

  • resetting values manually inside validate()
  • custom hooks to enforce restrictions
  • using db_set() (which bypasses validation entirely)
  • Change the field level (permlevel)

5. Limitations of Using Field Permission Level (permlevel) as a Workaround

Some developers attempt to restrict client-side modifications by increasing a field’s Permission Level (permlevel). However, this approach introduces behavior that is unexpected and inconsistent, and it does not provide a reliable mechanism for “server-only write”.

a. Server Can Modify the Field Only Inside Document Hooks

When a field is assigned a high permlevel (e.g., permlevel = 1) and the current user has only Read permission for that level:

  • Directly updating the field from a standard save operation does not work:
doc.field_with_high_permlevel = "new value"
doc.save()   # Frappe will reset the value to the database version

This happens because Frappe first clears the incoming client values for fields the user cannot write to, and then reloads the original database values before running the save process.

b. Updating the Field Inside Hooks Works Correctly

If the modification happens inside one of the server-side document hooks such as:

  • validate()

  • before_save()

  • on_submit()

then the update will succeed:

def validate(self):
    self.field_with_high_permlevel = "new value"

c. Why This Is a Problem

This inconsistent behavior produces several issues:

  • server-side updates only work inside hooks, not outside normal code paths

  • Background jobs, integration scripts, and API-based updates often need to update the field outside hooks

  • Developers cannot use doc.save() reliably to update controlled fields

  • Logic becomes fragmented because some updates must be forced into hooks unnecessarily

Summary

Using permlevel as a workaround to block client-side writes introduces several practical and technical limitations:

  • It behaves inconsistently (server updates work only inside hooks, but fail when using doc.save() outside them).

  • It complicates server-side logic and forces developers to restructure updates unnaturally just to bypass permission restrictions.

  • It requires granting unnecessary Read permissions for users just to make the field visible.

It becomes increasingly unmaintainable when:

  1. The DocType contains many sensitive or computed fields,
  2. Multiple developers contribute to the same module,
  3. or long-term maintenance and reliability are required.

Proposed Solution: Add a New Field Property

These limitations highlight the need for a dedicated field property named for example, Ignore Client Write, Server Only Write or Disallow Client Write

which would:

  • block all client-side attempts to modify the field (UI, JS, Console, etc.),

  • allow unrestricted server-side updates from any context (hooks, controllers, background jobs, API scripts, or whitelisted methods),

  • and eliminate the need to adjust permission levels or implement custom validation workarounds.

A framework-level solution would provide a clean, predictable, and scalable way to ensure “server-only write” behavior across all DocTypes.

1 Like

Hi there,

If there’s a bug/inconsistency with permlevels, I think the right way forward is to fix that.

From a technical standpoint, I’m not sure how what you’re proposing would be possible. You want to be able to block “UI, JS, Console, etc.” but allow “hooks, controllers, background jobs, API scripts, or whitelisted methods”. The problem is that this distinction doesn’t really exist. The JS api, for example, is just a convenience interface for whitelisted methods. Everything that happens happens on the server. How would the document data model know what ultimately initiated the change?

1 Like

Thanks for your reply — let me clarify two important points.

1. Regarding permlevel

There is no bug in permlevels.

Permlevel simply does not cover this use case.

If a user only has Read access for a field with a higher permlevel, then:

  1. The client cannot modify it (which is correct),
  2. But the server itself also cannot update the field except inside document hooks (validate, before_save, etc.).
  3. If the server code tries to modify the field outside hooks like:
doc.field_with_high_permlevel = "new value"
doc.save()   # Frappe will reset the value to the database version

Frappe resets the value to the database value, because permission checks are applied before running hooks.

So permlevel does not have a bug — it just does not work for cases where the server must always be allowed to write, while the client must never be allowed.

We have a requirement that differs from PermLevel’s design.

2. Regarding the technical feasibility

You mentioned that the framework cannot distinguish between changes coming from UI/JS/API versus changes coming from server hooks/controllers.

This is true only at the level of the document model itself, but not at the level of the request pipeline.

Separation exists at the request layer, not within the Document model:

  • Requests coming from the client, and

  • Internal server execution

How do we know this:

Every external client request goes through the full request pipeline (e.g., application(request))

During this initialization:

  • frappe.local is reset

  • All unsafe flags from the client are wiped or rewritten

  • Client-provided values are sanitized before any whitelisted method or controller runs

This is exactly why, if the client injects something like:

cur_frm.doc.flags.ignore_permissions = true

Frappe removes that flag before running the whitelisted method.

This indicates that the request layer enforces a clear contextual boundary:

  • This data came from an external client request

  • This data was produced internally by server code

Server-side calls do not go through this pipeline

If server logic calls a whitelisted method directly:

from myapp.api import method_whitelist
method_whitelist(data)

or runs background jobs, hooks, or controllers,
The request initialization pipeline does not run.
meaning:

  • frappe.local is empty

  • No client payload exists

  • No flag sanitation happens

  • Execution is clearly internal

How this applies to the proposed feature

Because Frappe already sanitizes and rewrites flags based on the context (client vs server), it is possible to apply the same mechanism to field values.

The framework can:

  1. At request initialization, record which fields came from the client payload.

  2. Before the document enters validate/save, reset these values for fields marked with the new property (e.g., Ignore Client Write).

  3. Allow any server-side changes that occur after this stage (hooks, controllers, background jobs, whitelisted methods executed internally).

This logic must happen before reaching the Document Data Model — exactly like flag sanitization.

So the Document model does not need to “know what initiated the change.”
The request handler already knows that, because:

  • Client requests go through the full HTTP request lifecycle

  • Server-internal calls do not

This separation already exists inside Frappe.

Conclusion

  • Permlevel is not broken, but it does not solve this requirement.

  • The request lifecycle naturally separates external calls from internal server execution, because only client requests pass through application(request) and have their data sanitized.

I hope this clarifies the reasoning behind the proposal. Happy to discuss further.

Thanks for your reply. I think my previous concern wasn’t quite clear. Specifically:

How would we do that?

There are several whitelisted methods that allow updating arbitrary fields, like frappe.client.set_value, frappe.client.update, and frappe.client.insert. They all take different syntaxes, and some allow multiple syntaxes. There are also an arbitrarily large number of document-specific whitelisted methods that enact field changes.

Are you suggesting that the http request handler should know how to parse all of these different calls to understand what fields are being triggered?

Let me explain the proposal more concretely by outlining the three execution paths for invoking whitelisted methods in Frappe. This helps clarify where it is technically feasible to intercept client-submitted field values, and where it is not.

This is not a final solution, but a technical direction based on how the framework currently routes requests.

1. First Path: Core Whitelisted Methods (save/update pipeline)

Examples: savedocs, set_value, and the internal save/update functions used by the Desk.

This is the main, stable pipeline that every DocType save follows when the user clicks Save or Update.

It is also the path used by most client calls, such as:

method: "frappe.desk.form.save.savedocs"

method: "frappe.client.set_value"

Because these methods are part of Frappe’s core and do not change frequently, we can reliably intercept their input before a Document instance is constructed.

What can be done here:

At the point where Frappe deserializes the incoming request payload and calls (e.g., savedocs), the framework can:

Inspect the incoming field values

For fields marked with a new property (e.g., “Ignore Client Write”)

Replace the client-supplied value with the database value before permission checks and before document hooks

This approach does not require parsing every whitelisted method — it only requires hooking into the stable, central save/update pipeline.

2. Second Path: Whitelisted Methods Inside DocType Classes

These methods require receiving the self from the client. On the client, they must be invoked with:

doc: frm.doc

because otherwise the method would not work. This makes the call signature predictable.

The call stack for these methods is also consistent:

StaticDataMiddleware
application
handle_rpc_call
execute_cmd
call
run_method
run_doc_method
composer
compose
run_method
validate_argument_types

Because this stack is stable, the framework can intercept execution before (e.g., run_doc_method), at a point where the framework already has:

the incoming request payload
the deserialized doc dict
and the DocType metadata

Feasible intervention:

The same client-value scrubbing (resetting disallowed fields) can be applied here just before constructing the document instance used by the whitelisted method.

Thus, Paths 1 and 2 both share locations in the request pipeline where the field scrubbing can safely occur.

3. Third Path: Whitelisted Methods Outside DocType Classes

These are API functions defined anywhere in the codebase, with arbitrary arguments and arbitrary shapes of input data.

Here, the framework does not know:

Which parameters represent a document

which fields the function intends to modify

or whether the arguments even belong to a DocType

Because of this variability, it is not technically feasible to scrub incoming field values universally and reliably for this execution path.

Therefore, the proposal limits the “Ignore Client Write” behavior to Paths 1 and 2, which cover:

  • all normal form saves
  • all client-side doc updates
  • all core CRUD whitelisted APIs
  • all DocType-bound whitelisted methods

This already covers the vast majority of field-modification mechanisms used by the client.

This read-only field should recalculate on every save, preventing manual or API changes. The calculation logic if present in UI, should also be present in server script.

The “recalculate on every save” approach does not address the underlying issue for several reasons:

  • Many server-controlled fields are not calculated fields
    Some fields are flags or state controls (e.g. party_not_required in Journal Entry) and have no calculation logic to re-run.

  • Values often originate outside the current document
    Many fields are updated by other DocTypes, background jobs, or server workflows, not by the document’s own save event.

  • Recalculating on save can overwrite valid server-written data
    Forcing recalculation may unintentionally destroy values that were correctly set by server logic elsewhere.

  • This is a framework-level write-authority problem, not a DocType-specific issue
    The Employee Advance example is provided for illustrative purposes only. Similar server-controlled fields exist across many DocTypes (e.g. party_not_required in Journal Entry), where values must be set by server logic and must never be modified by the client.

1 Like