Recipe: Hide a Sensitive Field
Problem: loan officers must work with loans all day, but the borrower’s SSN must never reach their browser — not hidden by the UI, never sent.
1. Declare the field
Section titled “1. Declare the field”iam: resources: definitions: - code: loans actions: [read, update, approve] fields: - code: ssn # camelCase ON PURPOSE: field codes must match the display-name: Borrower SSN # DTO's serialized JSON property names sensitive: true2. Annotate the endpoint
Section titled “2. Annotate the endpoint”@GetMapping@RequiresPermission(resource = "loans", action = "read")@FieldFiltered(resource = "loans")fun listLoans(): List<LoanDto> = loanService.findAll()@FieldFiltered masks hidden properties at serialization time, keyed on the response’s resource
DTO type — the DTO object itself is untouched, so immutable data classes (and plain Java records)
work as-is. Two things follow from the type-aware design:
- Masking is type-scoped, not name-matched. Only the resource’s own DTO fields are nulled, so a same-named field on a nested object of a different type is never touched (no over-redaction).
- It reaches the resource DTO wherever it appears — top-level, inside a
List/Page/Collection, or inside aResponseEntity<…>. A hand-rolled non-generic wrapper whose element type can’t be resolved statically is not unwrapped; return the DTO directly or viaPage/List/ResponseEntity(kept generic) so masking can find it.
3. Grant only the roles that may see it — the rest is default-deny
Section titled “3. Grant only the roles that may see it — the rest is default-deny”Because ssn is declared sensitive: true, it is hidden by default: a sensitive field
with no read ALLOW resolves to visible = false and is stripped. You do not write a row to
hide it — you write ONE row to reveal it:
| row | attach to | effect |
|---|---|---|
ALLOW (loans, read, ssn) |
roles that MAY see the SSN (e.g. LOAN_MANAGER) |
reveals the field |
| (nothing) | every other role | hidden by default-deny |
That is the whole recipe for the common case: declare the field sensitive, grant the readers, and the safe default takes care of everyone else.
# POST /api/iam/v1/permissions — the ALLOW that reveals ssn to readers{ "resourceId": "<loans-id>", "actionId": "<read-id>", "resourceFieldId": "<ssn-field-id>", "effect": "ALLOW" }# then attach it to the reader role:# POST /api/iam/v1/roles/{managerRoleId}/permissions/{permissionId}What you get
Section titled “What you get”The same endpoint, per caller:
// manager — holds the read ALLOW on ssn{ "id": "1", "borrowerName": "John Doe", "ssn": "123-45-6789", "status": "PENDING" }// officer — no ssn grant at all; stripped server-side by default-deny, before it reaches the wire{ "id": "1", "borrowerName": "John Doe", "ssn": null, "status": "PENDING" }Prove why it’s hidden
Section titled “Prove why it’s hidden”Ask the explain API (admin-only — it leaks policy structure by design). Two shapes, one per mechanism:
Default-deny — an officer with no ssn grant at all. considered is empty: nothing granted
it, so the sensitive declaration closes it:
curl -s -X POST localhost:8080/api/iam/v1/access-simulations \ -H "Authorization: Bearer $ADMIN" -H 'Content-Type: application/json' \ -d '{"userId":"<officer-id>","resource":"loans","action":"read","field":"ssn"}'{ "granted": false, "reason": "FIELD_NOT_GRANTED", "trace": { "considered": [], "summary": "no field-level grant for 'ssn' — absence denies" }}DENY beats a group ALLOW — the auditor inherits the Underwriters read ALLOW but her direct
role’s DENY wins. Both rows appear in considered:
curl -s -X POST localhost:8080/api/iam/v1/access-simulations \ -H "Authorization: Bearer $ADMIN" -H 'Content-Type: application/json' \ -d '{"userId":"<auditor-id>","resource":"loans","action":"read","field":"ssn"}'{ "granted": false, "reason": "FIELD_DENIED", "trace": { "considered": [ { "role": "UNDERWRITERS_GROUP_ROLE", "resource": "loans", "action": "read", "field": "ssn", "effect": "ALLOW", "sensitive": true }, { "role": "AUDITOR", "resource": "loans", "action": "update", "field": "ssn", "effect": "DENY", "sensitive": true } ], "summary": "field-level DENY under any action hides 'ssn' — most-specific wins" }}Every grant the engine weighed is in considered. Users can also ask about their own access
via POST /api/iam/v1/me/check-access with "explain": true.