Skip to content

Restrict a field to specific roles

A user’s phoneNumber should be visible to admins only, but writable by every user (for self-service settings). A handler-level role check is too coarse, the check needs to happen at the field, not the call.

  • You’ve read Auth and permissions so the three-layer model is clear.
  • Your feature already declares roles.

Field-level access lives on the field definition itself:

src/features/users/schema/user.ts
import { createEntity, createTextField } from "@cosmicdrift/kumiko-framework/engine";
export const userEntity = createEntity({
fields: {
email: createTextField({ required: true, personal: "self", find: "exact" }),
displayName: createTextField({ required: true, personal: "self", find: "fuzzy" }),
phoneNumber: createTextField({
personal: "self",
find: "none",
access: {
read: ["Admin", "SystemAdmin"],
write: ["Admin", "User"],
},
}),
internalNotes: createTextField({
personal: "self",
find: "none",
access: {
read: ["Admin"],
write: ["Admin"],
},
}),
},
});

Two independent rules per field, one for reads, one for writes. Read restricts who sees the value in list and detail responses; write restricts who can change it.

On reads, the pipeline strips fields the caller cannot see, so the field does not appear in the response. Entity-bound list and detail results are filtered after the handler returns. A custom query hook must apply the same rule to fields it adds itself.

On writes, the pipeline rejects payloads that try to set a forbidden field. The access-denied error carries details.reason: "field_access_denied" and the field name (for example details.field: "internalNotes"), so clients see exactly which field they cannot write.

The split between read and write matters. A caller can write a field only when the write role rule permits it and, for ownership rules, both the old and new row match. Two rules, one field, one source of truth.

An edit form seen by a user without the role: the restricted sections render a permission notice instead of their fields
  • Anonymous callers count as roles: ["anonymous"]. A field with read: ["User"] is invisible to anonymous reads. If a field should appear on a public page, include "anonymous" in the read roles.
  • No declaration means visible. A field without an access block is available to callers who can use the surrounding handler. There is no useful access: {} deny-all shorthand; use explicit role or ownership rules.
  • Boot validation catches typos. Kumiko knows a role when a handler access rule uses it. A field role like "admin" (lowercase), when your handlers only use "Admin", fails at boot, not at runtime.

Two fields on an employee entity demonstrating both axes, salary with split read/write rules, internalNotes admin-only on both sides:

export const employeeEntity = createEntity({
table: "read_sample_employees",
fields: {
name: createTextField({ personal: { of: "id" }, find: "none", required: true }),
email: createTextField({ personal: { of: "id" }, find: "none", required: true }),
salary: createNumberField({
access: { read: ["Admin", "Accounting"], write: ["Admin"] },
}),
internalNotes: createTextField({
personal: { of: "id" },
find: "none",
access: { read: ["Admin"], write: ["Admin"] },
}),
},
});

Full source: samples/recipes/field-access.