Skip to content

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.

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: loanAmount

Field permissions are permissions like any other — (resource, action, field, ALLOW|DENY) — managed in the admin matrix. Two rules settle every field:

  • sensitive: true is 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.

@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.

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 }
}
}
}