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

On this page

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

    wherewho fills the parameterneeds
    Spring MVC handler methodsStitchMvcArgumentResolvernothing — registered by DacStitchAutoConfiguration in servlet apps
    Spring WebFlux handler methodsStitchWebFluxArgumentResolvernothing — registered in reactive apps
    public methods on any Spring bean, called through the proxyStitchAdvisor (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 typeanswered from
    UserDtocastUsers
    UserProfileDtocastUserProfiles
    UserEntitlementsDtocastUserEntitlements, filtered by filter()
    UserBootstrapDtocastUserBootstrap
    CastUserGrantscastUserGrants — the evaluated permission set

    Resolution rules

    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() { … }
    elementdefaultmeaning
    domainrequiredWhich DacGateway decides — matched by gateway.domain().
    feature / permission"" / "READ"What is being checked on each scope.
    scopenoneTemplates rendered against each returned value: {property} reads a getter or record accessor on the value. A template whose property is null is skipped.
    allScopesfalseanyScope (default) or allScopes policy.
    policynoneA DacPolicy bean class to use instead of the feature/permission rule.
    onDenyEMPTYSingle values: null/empty, or throw DacAccessDeniedException with FAIL.
    onDisabledDENYWhen the domain is off, no gateway exists or there is no caller: hide, or ALLOW through unguarded.

    What it understands

    return typebehaviour
    a single objectallowed → returned; replaced → the trimmed copy; denied → null or exception
    Optional<T>same, wrapped; denied → Optional.empty()
    List, Set, any Collectiondecided 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
    Important

    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.