Field-Level Access
“Loan officers can read loans but must never see the borrower’s SSN.” That sentence is impossible with role checks alone — it needs the third permission tier.
Declare the fields
Section titled “Declare the fields”Via yml (with sensitivity flags), or implicitly via annotations:
iam: resources: definitions: - code: loans fields: - code: ssn # camelCase — a field code MUST match the DTO's serialized JSON name sensitive: true - code: loanAmountGrant field visibility
Section titled “Grant field visibility”Field permissions are permissions like any other — (resource, action, field, ALLOW|DENY) —
managed in the admin matrix. Two rules settle every field:
sensitive: trueis default-deny. A field declared sensitive is hidden unless a role holds an explicit read ALLOW on it — no permission row is needed to hide it, only to reveal it. Declaring a field sensitive that then leaked was the security-surprising default this closes; the safe state is closed.- DENY beats any ALLOW. A field DENY under any action is most-specific and hides the field outright — the way to override an ALLOW a role inherits (e.g. through a group).
A non-sensitive field with no field permission at all stays visible — declaring the field sensitive is what opts it into default-deny.
Filtered fields must be nullable & optional
Section titled “Filtered fields must be nullable & optional”// WRONG — ssn is filterable but typed non-nullable. IAM refuses to boot.data class LoanDto(val id: String, val borrowerName: String, val ssn: String)
// RIGHT — every field IAM may hide is nullable; id (never filtered) stays non-null.data class LoanDto(val id: String, val borrowerName: String?, val ssn: String?)In Java, a reference type is already nullable; just don’t annotate a filterable field @NotNull or
@Schema(requiredMode = REQUIRED), and never declare a primitive field (double) filterable —
box it to Double first. Typing the field nullable has a bonus: springdoc then generates it as
nullable: true and drops it from required, so your OpenAPI contract is honest automatically.
The guard is on by default; the escape hatch (you then own the contract-violation risk) is
iam.field-security.validate-nullability: false.
Enforce on the backend (the truth)
Section titled “Enforce on the backend (the truth)”@GetMapping("/{id}")@RequiresPermission(resource = "loans", action = "read")fun get(@PathVariable id: UUID): LoanDto { val loan = loanService.get(id) return if (permissions.isFieldVisible("loans", "ssn")) loan else loan.copy(ssn = null) // field stripped server-side}Response filtering can also be automated with @FieldFiltered(resource = "loans") on the handler.
It masks hidden fields at serialization time, scoped to the resource’s own DTO type — so a
same-named field on a nested object of a different type is never touched, and masking reaches the
DTO inside a List/Page/ResponseEntity (but not a non-generic hand-rolled wrapper — keep such
wrappers generic). See the hide-a-sensitive-field recipe.
Either way, the principle stands: the field never leaves the server for a user who can’t see it.
Render accordingly on the frontend
Section titled “Render accordingly on the frontend”The resolved permission object carries fieldAccess, and the React library reads it:
<FieldGuard resource="loans" field="ssn"> <SsnDisplay value={loan.ssn} /></FieldGuard>The guard controls rendering; the backend already controlled data. Both layers agree
because both read the same resolved object from /me/permissions:
{ "fieldAccess": { "loans": { "ssn": { "visible": false, "editable": false, "isSensitive": true }, "loanAmount": { "visible": true, "editable": true, "isSensitive": false } } }}