Field-level permissions
Return a record to everyone who may read it, and a sensitive field only to those who may see that field.
Some fields need more than read: the reason for leave, a salary, a medical note. Model each as its own action, read:<field>, and reveal the field per row with one batched check.
Declare the field permission
actions:
read:
scopes: [self, team, org_unit, global]
description: View leave requests
read:reason:
scopes: [self, team, global] # HR by org unit sees requests, not reasons
description: See the reason given for leave
sensitive: true # granting it globally needs a reasonThen give it to the roles that should see it: employees for their own requests (self), managers for their team (inherit).
employee:
permissions:
leave.request:read:reason: self
line_manager:
permissions:
leave.request:read:reason: inheritReveal it per row
The list endpoint filters rows with read, then asks once, for the whole page, which rows may show the field:
items := make([]access.CheckItem, len(rows))
for i, l := range rows {
items[i] = access.CheckItem{Resource: l.resource(), Permissions: []string{accessgen.LeaveRequestReadReason}}
}
res, err := a.CheckMany(ctx, s, items) // one Cerbos call
for i, l := range rows {
if err == nil && res[i].Allowed[accessgen.LeaveRequestReadReason] {
l.Reason = &l.reason
}
}const results = await access.checkMany(
s,
rows.map((r) => ({ resource: leaveResource(r), permissions: [P.LEAVE_REQUEST_READ_REASON] })),
);
return rows.map((r, i) => ({
...publicFields(r),
...(results[i]?.allowed[P.LEAVE_REQUEST_READ_REASON] ? { reason: r.reason } : {}),
}));Rules
- Absent, not empty. Leave the field out of the response (
omitempty, a missing key) when it isn't allowed. An empty string ornullreads as "no reason given". - On every endpoint that returns it: the list, the detail view, exports, search results, webhooks. Put the reveal in one function per kind and call it everywhere.
- Never in the list filter. Someone who may read a request but not its reason still sees the request.
- Errors hide the field. If the batched check fails, return the rows without it; don't fail the page, and don't show it.
- Test both sides: present for someone with the field permission, absent for someone with only
read.
In the UI, useCan(P.LEAVE_REQUEST_READ_REASON, id) can decide whether to show a "Reason" column, but the server's response is what keeps the field private.