Repository navigation
Release 1.10.0: Access Control rework: entitlements, access policies and anonymization reports #158
MoonWorm
announced in
Announcements
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Overview
1.10.0 is a rework of the Access Control plugin, plus the principal resolution that feeds it. Three headline changes:
ANY_OF/ALL_OF/NONE_OFclauses. Scopes now use the same structure, which let SpEL — and thespring-expressiondependency — be dropped entirely. For rules no annotation can express, a newAccessPolicySPI decides in Java against a context carrying the principal, the operation, the request and the resource.GETreturns200with the restricted data left out — which a caller cannot tell apart from there being nothing to return. The new opt-in anonymization report closes that gap, adding anaccessControlmember tometaat one of four levels: that something was hidden, which paths, and which requirement refused each one.Object.JwtPrincipalResolver,SpringSecurityPrincipalResolver,QuarkusJwtPrincipalResolver— build the principal from token claims, sharing one claim-mapping engine. The Access Control plugin also works in a GraalVM native image, with the Quarkus extension registering your annotated classes at build time.This release contains breaking changes. They are listed first.
Breaking Changes
Access tiers are now entitlements
Tiers were ordered: holding
ADMINimplicitly satisfied a requirement forPARTNER. That ranking could not express "support staff and an admin", and it made every new tier a question about where it sat in the order. Entitlements are plain unordered labels — any string your principal source produces — matched by exact name.@AccessControlAccessTier@AccessControlEntitlements+@EntitlementsGroupAccessTier,AccessTierRegistry,DefaultAccessTierRegistryTierRootAdmin,TierAdmin,TierPartner,TierPublic,TierNoAccessDefaultEntitlements.ROOT_ADMIN/.ADMIN/.PARTNERPrincipal#authenticatedClientAccessTier()Principal#authenticatedClientEntitlements()X-Authenticated-Client-Access-TierheaderX-Authenticated-Client-Entitlements(space-separated)There is deliberately no label meaning "everyone" or "nobody" (the old
PUBLIC/NO_ACCESStiers). Omit the requirement to demand nothing; name an entitlement no principal carries to deny everyone.PUBLICandNO_ACCESShave no direct equivalent, and the ranking is gone: a principal holdingADMINno longer satisfies a requirement forPARTNER. Grant a principal every entitlement it needs rather than expecting a "higher" one to cover the others.Scopes dropped SpEL
@AccessControlScopes(requiredScopes = …)and its SpEL expression are replaced by@ScopesGroupclauses with the same modes as entitlements. Thespring-expressiondependency is gone from the AC plugin's POM.Sparse fieldsets now enumerate inherited fields
JSON:API requires
fields[]to address every field of a resource, including inherited ones. Previously only fields declared directly on the attributes class were enumerated, so an inherited field survived afields[]that should have excluded it. This changes responses for clients already usingfields[]against attributes classes that use inheritance.Access Control
Entitlements are composed, not ranked
A requirement is built from
@EntitlementsGroupclauses. Each clause quantifies over entitlement names;@AccessControlEntitlementsthen quantifies over the clauses, using the same three modes.ANY_OF(clause default)ALL_OFNONE_OFThat gives two levels:
which reads as (
ADMINandSUPPORT) or (PARTNERandINTEGRATOR).ADMINandPARTNERcome fromDefaultEntitlements;SUPPORTandINTEGRATORare plain strings.The simple case stays short — one clause, default
ANY_OF:descriptionis optional and quoted back when access is denied, so a log line explains why the requirement exists rather than only naming entitlements:An empty
@EntitlementsGroup({})is rejected at startup, so a requirement that can never be satisfied fails on boot rather than silently denying every request.Scopes use the same structure
@ScopesGroupclauses combined by@AccessControlScopes, same three modes, matched by exact name. The clause default isALL_OFrather than entitlements'ANY_OF, because a scope requirement normally asks for every scope it lists.Policies — deciding access in code
Each declarative requirement has a fixed shape. When a rule doesn't fit any of them — reading the caller's attributes, branching on the operation, comparing the caller against the resource — write it as a class:
A policy is ordinary code — unlimited nesting, unit-testable without a running app, reusable across as many annotations as you like. It is combined with the other requirements exactly as they are combined with each other: every declared requirement must pass. It runs last, after the cheaper declarative checks have had their chance to deny.
Instances are created once when the access control model is built, so a policy must be stateless, thread-safe, and expose a public no-argument constructor. A class that cannot be constructed fails at startup rather than on the first request that reaches it.
What a policy can see.
AccessControlContextfollows the vocabulary shared by XACML, AWS Cedar and AWS IAM — who is asking, what they are doing, to what, and under what circumstances:principal()null;attributes()is where a JWT-claims resolver puts its claimsoperation()OperationType,ResourceType,RelationshipNameresource()resource(Class)request()stage()INBOUNDbefore data is fetched,OUTBOUNDbefore it is sentoperation()is the dimension no other requirement can reach: it lets a rule turn on what the caller is doing rather than who they are — "internal bookkeeping is visible on a single-resource lookup, but never in a list response".A policy changes the decision for one annotated element. Replacing
AccessControlEvaluatorchanges it for the whole application — reach for that only when evaluation itself is unusual (an external authorization service, an audit record per decision, caching), and extendDefaultAccessControlEvaluatorrather than starting from scratch.Requirements follow the object, not the field
Requirements are now read from the type of the value actually present, at the moment the response is built. A rule declared on a class therefore applies wherever an instance of that class turns up — held directly, at any depth, inside a container, or behind a field declared as an interface, a supertype or
Object.New placements for
@AccessControl:A container is rebuilt only when one of its elements is replaced or dropped. A
Listcomes back as aList, aMapas aMap, an array with the same component type, and sorted types keep their comparator. Two consequences worth knowing: a container's length depends on the caller, and indices do not line up between callers.Hiding a field never modifies your object
The plugin builds a copy of the attributes object with the denied fields left
nulland puts the copy in the response, so the instance yourResourcereturned comes back untouched — an application is free to return a cached or shared attributes object. Copies are made only along the path to a hidden field, and only when something is actually hidden; when a caller may see everything, the original is passed straight through and nothing is allocated.Building the copy does not call your constructor, so constructors that validate, normalise, or take arguments in a different order than the fields are declared all work. Records are the one exception: their components cannot be written after construction, so a record is rebuilt through its canonical constructor.
The Sparse Fieldsets plugin now works the same way, through the shared
ObjectCopier.Telling a caller something was hidden
A denied
GETreturns200with the restricted data left out, which a caller cannot tell apart from there being nothing to return.jsonapi4j.ac.anonymizationReportcloses that gap by adding anaccessControlmember to the responsemeta:{ "meta": { "accessControl": { "anonymized": true } }, "data": { "id": "1", "type": "users", "attributes": { "fullName": "John Doe" }, "meta": { "accessControl": { "anonymized": true, "fields": [ { "path": "attributes.creditCardNumber", "reason": "INSUFFICIENT_SCOPES" }, { "path": "attributes.addresses[0].zip", "reason": "INSUFFICIENT_SCOPES" } ]}} } }Four levels, each saying strictly more than the one before:
NONEINDICATORFIELDSaddresses[0].zip.FIELDS_AND_REASONSThe top level of the document carries the indicator; each resource names its own hidden paths in its own
meta. The member appears only when something was actually hidden, so its absence alongside an emptydatameans there genuinely was nothing to return — which is the question the feature exists to answer.The report is written after anonymization and is not itself subject to it, so it survives on a resource whose
metawas withheld.This discloses. Saying that something was hidden confirms that something is there.
FIELDS_AND_REASONSgoes further and tells a caller which entitlement or scope would reveal each field — excellent for a partner integrating against your API, and a map of your authorization model for anyone probing it. Turn it on deliberately, and prefer the lowest level that answers your callers' question.Denials name what failed
For a
GET, a denied request returns200with the restricted data omitted — a403would fail the whole response including the parts the caller is allowed to see, and would break compound documents. For everything else, a denied request returns403with an error code naming what failed:entitlementsINSUFFICIENT_ENTITLEMENTSscopesINSUFFICIENT_SCOPESauthenticated,ownership,policyFORBIDDENEntitlements and scopes are named because they tell a caller different things — one needs different credentials, the other a differently-scoped token. Ownership stays generic deliberately: reporting "you are not the owner" would confirm that the resource exists and that somebody else holds it. A custom evaluator can report any
ErrorCodeit likes, including its own.Rules that cannot take effect
Several
@AccessControlplacements enforce nothing — a requirement on a primitive field (a primitive can't hold "absent"), on astaticfield, on a field that shadows an inherited one of the same name, or a bare@AccessControldeclaring no requirement at all. These are now reported once, when the access control model for a class is first built, naming the class and field involved.Set
jsonapi4j.ac.failOnMisconfiguration=trueand each raisesAccessControlMisconfigurationExceptioninstead of logging. Off by default, because turning a warning into a failure would break an application already running with a rule that quietly does nothing. It earns its keep in tests and CI.Despite the name, this is not startup validation: requirements on a resource, operation or relationship class are read when the plugin registers, but an attributes class is only known once a response carries one — so most are raised on the first request that touches the resource.
New properties
jsonapi4j.ac.failOnMisconfigurationfalsejsonapi4j.ac.anonymizationReportNONENONE,INDICATOR,FIELDS,FIELDS_AND_REASONSPerformance
Annotation-derived access control models are now cached per class rather than rebuilt per request, and
ReflectionUtilscaches more of its traversal. Both plugins allocate nothing when a caller may see everything.Principal Resolution
JsonApi4j never authenticates anyone — verifying credentials is the job of your web framework or gateway. A
PrincipalResolveranswers the narrower question: given a request that has already passed authentication, who is the caller and what are they allowed to do?1.10.0 ships three new resolvers alongside the header-based default:
DefaultPrincipalResolverX-Authenticated-*headersjsonapi4j-restJwtPrincipalResolverAuthorization: Bearer …jsonapi4j-restSpringSecurityPrincipalResolverSecurityContextHolderjsonapi4j-rest-springbootQuarkusJwtPrincipalResolverJsonWebTokenjsonapi4j-rest-quarkusDefaultPrincipalResolverremains active out of the box. The other three are opt-in: you declare one as a bean and it takes precedence. This is deliberate — auto-selecting a JWT resolver because a security library happens to be on the classpath would silently change behaviour for applications that authenticate at the gateway and forward headers.Principalcarries four thingsauthenticatedUserId()authenticatedClientEntitlements()authenticatedClientScopes()attributes()attributes()is new, and is what makes ABAC policies useful: it is where a JWT-claims resolver puts the claims a policy then branches on. The header-basedDefaultPrincipalResolverleaves it empty.Claim mapping
All three JWT resolvers share one engine,
ClaimsPrincipalMapper, so claim configuration behaves identically no matter which you use.authenticatedUserId()suboidoremail.authenticatedClientScopes()scope, falling back toscpscpcovers Azure ADauthenticatedClientEntitlements()entitlementsattributes()Nested claims work too: a name containing dots is looked up literally first, then treated as a path into nested objects — so both Keycloak's
realm_access.rolesand Auth0's namespacedhttps://api4.pro/rolesresolve correctly.Entitlements need an explicit claim
The most common cause of unexpected 403s after switching to a JWT resolver. JWT defines no standard claim for entitlements. JsonApi4j reads
entitlementsby convention, but no identity provider emits that claim on its own. If it is missing, every principal resolves no entitlements at all and every operation guarded by@AccessControl(entitlements = …)is denied — even for a perfectly valid token.The plugin now says so explicitly, once per process, distinguishing "carries no entitlement at all" from "carries entitlements that aren't the required ones":
Anonymous callers never trigger it: having no entitlement is the expected state for them. Because a misspelling on either side looks exactly like a legitimate denial, both sides are logged at
DEBUG. The same pair of diagnostics exists for scopes.Thread safety
The resolver is called once per request and a single instance serves every request, so implementations must be thread-safe and must resolve everything from the request argument rather than caching state on the resolver itself.
AuthenticatedPrincipalContextHoldernow clears the principal at the end of every request, so nothing carries over between requests served by the same pooled thread.Native Image (GraalVM / Quarkus)
Building the redacted copy instantiates your attributes class reflectively, which a native image allows only for classes registered ahead of time. The Quarkus extension now registers them for you by scanning for
@AccessControlat build time.One thing to know: Quarkus indexes your application module automatically but not its dependencies. If your attributes classes live in a separate jar, that jar has to carry a Jandex index or the registration finds nothing. Build one into it with the Jandex Maven plugin:
That keeps the concern with the module owning the classes, so every consuming application picks it up without configuration. If you cannot change the producing jar, index it from the consuming application instead:
If access control is enabled and no
@AccessControlis found in the index, the build logs a warning naming both options. A native-image smoke test now runs in CI.Sparse Fieldsets
fields[]applies inside collections, arrays and maps.fields[users]=addresses.zipnames a field of every element —zipis kept in each address, other fields of each address are dropped. Elements themselves are never removed (unlike access control, which drops a denied element).ObjectCopier.nullon this resource is still a valid path: whether it exists is decided by the type, not the data.Validation & Spec Conformance
Validation failures can carry their own HTTP status. JSON:API requires
409 Conflictwhen a request body is aimed at the wrong collection, and403 Forbiddenwhen a create request supplies its ownidand the server does not accept client-generated ids. Both are now enforced, via a newwithHttpStatuson the fluent assertions — available to your own validators too, for cases like a409on a duplicate.A failure carrying its own status is reported alone. Collecting exists to tell a client everything wrong at once, which works only while the errors share a status — a JSON:API document carries many error objects but one HTTP status. So such a failure short-circuits, and anything collected before it is dropped.
New error code
CLIENT_GENERATED_ID_NOT_SUPPORTED.withHttpStatusapplies to the assertion that follows it and is cleared once that assertion has run, exactly likewithErrorCodeandwithDetail— so order matters.Other Changes
application.yaml(Spring Boot),application.properties(Quarkus),jsonapi4j.yaml(Servlet). Copy one and delete what you don't need; every value shown is the default, so removing a line changes nothing.User → addressescreate and update, with tests.Migration Checklist
@AccessControlAccessTierwith@AccessControlEntitlements+@EntitlementsGroup; replaceTierXxxconstants withDefaultEntitlements.X-Authenticated-Client-Access-Tierheader toX-Authenticated-Client-Entitlementswherever your gateway sets it.@AccessControlScopes(requiredScopes = …)with@ScopesGroupclauses.fields[]against attributes classes with inheritance, re-check those responses — inherited fields are now excludable.ObjectCopiertojsonapi4j-base.jsonapi4j.ac.failOnMisconfiguration=true.Resources
All reactions