Skip to content

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.

application.yml
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: true
@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 a ResponseEntity<…>. A hand-rolled non-generic wrapper whose element type can’t be resolved statically is not unwrapped; return the DTO directly or via Page/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.

Terminal window
# 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}

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

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:

Terminal window
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:

Terminal window
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.