@Stitch & @DacAuthorize
The most ergonomic way to use cached, access-controlled data is not to call a cache
at all. @Stitch lets a domain declare a parameter annotation that is filled from the
cache for the current caller — the way @AuthenticationPrincipal fills a user.
@DacAuthorize guards a method's result when the data does not come from a facade.
@Stitch — concept
@Stitch is a meta-annotation: it goes on another annotation, not on a parameter.
It names the StitchResolver that knows how to produce a value for that annotation. A
domain ships one annotation and one resolver; the library ships the machinery that applies them
everywhere:
| where | who fills the parameter | needs |
|---|---|---|
| Spring MVC handler methods | StitchMvcArgumentResolver | nothing — registered by DacStitchAutoConfiguration in servlet apps |
| Spring WebFlux handler methods | StitchWebFluxArgumentResolver | nothing — registered in reactive apps |
| public methods on any Spring bean, called through the proxy | StitchAdvisor (AOP) | spring-boot-starter-aop; callers pass null for the parameter |
The contract in three types:
@Target(ANNOTATION_TYPE) @Retention(RUNTIME)
public @interface Stitch {
Class<? extends StitchResolver<? extends Annotation>> resolver();
}
public interface StitchResolver<A extends Annotation> {
Class<A> annotationType();
boolean supports(A annotation, Class<?> parameterType);
Object resolve(A annotation, Class<?> parameterType, StitchContext context);
}
public record StitchContext(Optional<Principal> principal, Method method, Object[] arguments) { }
@CastAuthContext — the CAST stitch
The CAST domain declares @CastAuthContext and one resolver. The parameter type
picks the region that answers:
@Target(PARAMETER) @Retention(RUNTIME)
@Stitch(resolver = CastStitchResolver.class)
public @interface CastAuthContext {
String filter() default ""; // entitlement filter for UserEntitlementsDto
}
@GetMapping("/documents")
List<DocumentResource> documents(@CastAuthContext UserDto castUser) { … }
public void loadDocuments(String portfolio, @CastAuthContext CastUserGrants grants) {
if (!grants.allows(CastUserGrants.portfolioScope(portfolio), "EDOCS", "READ")) { … }
}
// caller
documentService.loadDocuments("PF-100", null);
| parameter type | answered from |
|---|---|
| UserDto | castUsers |
| UserProfileDto | castUserProfiles |
| UserEntitlementsDto | castUserEntitlements, filtered by filter() |
| UserBootstrapDto | castUserBootstrap |
| CastUserGrants | castUserGrants — the evaluated permission set |
Resolution rules
- Only
nullarguments are filled. An explicit non-null value is never touched — tests and special cases always win. - The principal comes from the domain's
PrincipalSource— for CAST, the application'sCastPrincipalSourcebean ("who is calling, as a jbUid"). That bean is the on-switch for the whole feature. dac.domains.cast.enabled=falseresolves every parameter tonull, so the call site's legacy fallback runs. Cache and transport flip together.- Proxies only. Private methods and
this.method()self-calls are invisible to the advisor — pass the value down normally there. - Every resolution logs under the MDC domain
cast, operationresolveCurrentUser.
Writing your own stitch
Two files in the domain, zero changes to the library:
@Target(PARAMETER) @Retention(RUNTIME)
@Stitch(resolver = TenantStitchResolver.class)
public @interface CurrentTenant { }
@Component
public class TenantStitchResolver implements StitchResolver<CurrentTenant> {
private final TenantCache tenants;
public TenantStitchResolver(TenantCache tenants) { this.tenants = tenants; }
@Override public Class<CurrentTenant> annotationType() { return CurrentTenant.class; }
@Override public boolean supports(CurrentTenant a, Class<?> type) { return type == TenantSnapshot.class; }
@Override public Object resolve(CurrentTenant a, Class<?> type, StitchContext ctx) {
return ctx.principal()
.map(p -> p.attributes().get("tenantId"))
.flatMap(tenants::get)
.orElse(null);
}
}
Register the resolver as a bean. StitchRegistry discovers every
StitchResolver bean, maps it to its annotation, and the advisor and argument resolvers
consult the registry per method. StitchContext.principal() is supplied by the
PrincipalSource beans in the context; a domain that needs a different notion of "who"
can read it from ctx.arguments() instead.
@DacAuthorize — guarding a method's result
When the data does not come through an AbstractDacCache — a repository query, a
remote call, a legacy service — annotate the method and let DacAuthorizer apply the
same gateway-and-policy decision to what it returns:
@DacAuthorize(domain = "cast", feature = "EDOCS", scope = "PF:{portfolioNumber}")
public List<DocumentMetadata> documentsFor(String portfolioNumber) {
return repository.findByPortfolioNumber(portfolioNumber);
}
@DacAuthorize(domain = "cast", feature = "EDOCS", scope = {"PF:{portfolioNumber}", "BR:{businessRelationship}"},
onDeny = DenyMode.FAIL)
public Optional<DocumentMetadata> document(String id) { … }
@DacAuthorize(domain = "cast", policy = ClosedPortfolioPolicy.class)
public Flux<PortfolioDto> portfolios() { … }
| element | default | meaning |
|---|---|---|
| domain | required | Which DacGateway decides — matched by gateway.domain(). |
| feature / permission | "" / "READ" | What is being checked on each scope. |
| scope | none | Templates rendered against each returned value: {property} reads a getter or record accessor on the value. A template whose property is null is skipped. |
| allScopes | false | anyScope (default) or allScopes policy. |
| policy | none | A DacPolicy bean class to use instead of the feature/permission rule. |
| onDeny | EMPTY | Single values: null/empty, or throw DacAccessDeniedException with FAIL. |
| onDisabled | DENY | When the domain is off, no gateway exists or there is no caller: hide, or ALLOW through unguarded. |
What it understands
| return type | behaviour |
|---|---|
| a single object | allowed → returned; replaced → the trimmed copy; denied → null or exception |
Optional<T> | same, wrapped; denied → Optional.empty() |
List, Set, any Collection | decided element by element; denied elements are dropped (a Set stays a Set) |
Mono<T> / Flux<T> | decided as elements are emitted; only when Reactor is on the classpath |
Guard the same data once. A value read from an AbstractDacCache and then returned from a @DacAuthorize method is decided twice — harmless, but a sign the design is confused. Facades for cached data, annotations for everything else.
Wiring
DacStitchAutoConfiguration (in cache-binding-spring, after the binding
auto-configuration) creates the StitchRegistry, StitchAdvisor,
DacAuthorizer and DacAuthorizeAdvisor beans — each
@ConditionalOnMissingBean — and registers the MVC or WebFlux argument resolver
depending on the web stack. The authorizer collects every DacGateway bean, keyed by
domain, and checks domain flags through DomainCacheAccess. Nothing needs to be declared
by the application beyond the resolver and gateway beans of the domains it uses, plus
spring-boot-starter-aop when the advisors should apply to service beans.