[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?