or run

npx @tessl/cli init
Log in

Version

Tile

Overview

Evals

Files

docs

authorization.mdcsrf-protection.mdexpression-handling.mdindex.mdmessage-matching.mdreactive-support.mdsecurity-context.md
tile.json

authorization.mddocs/

Message Authorization

Configure fine-grained access control for messaging applications with pattern-based rules, role checks, and custom authorization logic.

Capabilities

MessageMatcherDelegatingAuthorizationManager

Central authorization manager that delegates to specific authorization managers based on message matcher patterns. This is the primary API for configuring message-based security rules. Thread-safe for concurrent use.

/**
 * Authorization manager that delegates based on MessageMatcher patterns.
 * Use the builder() method to create instances with fluent configuration.
 * Thread-safe for concurrent authorization checks.
 *
 * @since 5.8
 */
public final class MessageMatcherDelegatingAuthorizationManager
    implements AuthorizationManager<Message<?>> {

    /**
     * Creates a new builder for configuring authorization rules.
     *
     * @return a Builder instance (never null)
     */
    public static Builder builder();

    /**
     * Performs authorization check on the message.
     * Rules are evaluated in order until a match is found.
     *
     * @param authentication supplier of authentication to check (must not be null)
     * @param message the message to authorize (must not be null)
     * @return authorization result (never null, granted or denied)
     * @throws IllegalArgumentException if authentication or message is null
     */
    public AuthorizationResult authorize(
        Supplier<? extends Authentication> authentication,
        Message<?> message
    );
}

Usage Example:

MessageMatcherDelegatingAuthorizationManager authManager =
    MessageMatcherDelegatingAuthorizationManager.builder()
        .nullDestMatcher().authenticated()
        .simpSubscribeDestMatchers("/user/queue/errors").permitAll()
        .simpDestMatchers("/app/**").hasRole("USER")
        .simpSubscribeDestMatchers("/topic/**", "/queue/**").authenticated()
        .simpTypeMatchers(SimpMessageType.MESSAGE, SimpMessageType.SUBSCRIBE)
            .denyAll()
        .anyMessage().denyAll()
        .build();

Error Handling:

try {
    AuthorizationResult result = authManager.authorize(
        () -> SecurityContextHolder.getContext().getAuthentication(),
        message
    );
    if (!result.isGranted()) {
        // Handle denied access
        logger.warn("Access denied for message: {}", message);
    }
} catch (IllegalArgumentException e) {
    // Handle null parameters
    logger.error("Invalid authorization parameters", e);
}

Builder Configuration API

The Builder provides a fluent API for configuring message authorization rules. Rules are evaluated in the order they are added.

public static final class Builder {

    /**
     * Matches any message. Should typically be last in chain as catch-all.
     *
     * @return Constraint for configuring authorization rule (never null)
     */
    public Constraint anyMessage();

    /**
     * Matches messages with null destination.
     * Useful for handling messages without explicit destinations.
     *
     * @return Constraint for configuring authorization rule (never null)
     */
    public Constraint nullDestMatcher();

    /**
     * Matches messages by STOMP message type (CONNECT, SUBSCRIBE, MESSAGE, etc.).
     *
     * @param typesToMatch the STOMP message types to match (must not be null or empty)
     * @return Constraint for configuring authorization rule (never null)
     * @throws IllegalArgumentException if typesToMatch is null or empty
     */
    public Constraint simpTypeMatchers(SimpMessageType... typesToMatch);

    /**
     * Matches messages by destination pattern for any message type.
     * Uses Ant-style path patterns: ? (single char), * (path segment), ** (multiple segments).
     *
     * @param patterns destination patterns (must not be null or empty)
     * @return Constraint for configuring authorization rule (never null)
     * @throws IllegalArgumentException if patterns is null or empty
     */
    public Constraint simpDestMatchers(String... patterns);

    /**
     * Matches MESSAGE type messages by destination pattern.
     * More specific than simpDestMatchers as it also checks message type.
     *
     * @param patterns destination patterns (must not be null or empty)
     * @return Constraint for configuring authorization rule (never null)
     * @throws IllegalArgumentException if patterns is null or empty
     */
    public Constraint simpMessageDestMatchers(String... patterns);

    /**
     * Matches SUBSCRIBE type messages by destination pattern.
     * More specific than simpDestMatchers as it also checks message type.
     *
     * @param patterns destination patterns (must not be null or empty)
     * @return Constraint for configuring authorization rule (never null)
     * @throws IllegalArgumentException if patterns is null or empty
     */
    public Constraint simpSubscribeDestMatchers(String... patterns);

    /**
     * Matches using custom message matchers.
     * Allows for complex matching logic beyond built-in patterns.
     *
     * @param matchers custom MessageMatcher instances (must not be null or empty)
     * @return Constraint for configuring authorization rule (never null)
     * @throws IllegalArgumentException if matchers is null or empty
     */
    public Constraint matchers(MessageMatcher<?>... matchers);

    /**
     * Builds the MessageMatcherDelegatingAuthorizationManager.
     * Rules are evaluated in the order they were added.
     *
     * @return configured authorization manager (never null)
     * @throws IllegalStateException if no rules are configured
     */
    public MessageMatcherDelegatingAuthorizationManager build();
}

Builder Error Handling:

try {
    MessageMatcherDelegatingAuthorizationManager authManager =
        MessageMatcherDelegatingAuthorizationManager.builder()
            .simpDestMatchers("/app/**").hasRole("USER")
            .build();
} catch (IllegalStateException e) {
    // No rules configured - this shouldn't happen with at least one rule
    logger.error("Failed to build authorization manager", e);
}

Constraint Authorization Rules

After selecting a matcher, use Constraint methods to define the authorization requirement. Each method returns the Builder for method chaining.

public interface Constraint {

    /**
     * Requires user to have the specified role (ROLE_ prefix added automatically).
     * Checks if authentication has authority "ROLE_{role}".
     *
     * @param role the role name without ROLE_ prefix (must not be null or empty)
     * @return Builder for configuring additional rules (never null)
     * @throws IllegalArgumentException if role is null or empty
     */
    Builder hasRole(String role);

    /**
     * Requires user to have any of the specified roles.
     * Returns true if user has at least one of the roles.
     *
     * @param roles role names without ROLE_ prefix (must not be null or empty)
     * @return Builder for configuring additional rules (never null)
     * @throws IllegalArgumentException if roles is null or empty
     */
    Builder hasAnyRole(String... roles);

    /**
     * Requires user to have the specified authority.
     * Checks exact authority match (no prefix added).
     *
     * @param authority the authority name (must not be null or empty)
     * @return Builder for configuring additional rules (never null)
     * @throws IllegalArgumentException if authority is null or empty
     */
    Builder hasAuthority(String authority);

    /**
     * Requires user to have any of the specified authorities.
     * Returns true if user has at least one of the authorities.
     *
     * @param authorities authority names (must not be null or empty)
     * @return Builder for configuring additional rules (never null)
     * @throws IllegalArgumentException if authorities is null or empty
     */
    Builder hasAnyAuthority(String... authorities);

    /**
     * Permits all users (authenticated and anonymous).
     * No authentication check is performed.
     *
     * @return Builder for configuring additional rules (never null)
     */
    Builder permitAll();

    /**
     * Denies all users.
     * Always returns denied authorization result.
     *
     * @return Builder for configuring additional rules (never null)
     */
    Builder denyAll();

    /**
     * Requires user to be authenticated (including remember-me).
     * Anonymous users are denied.
     *
     * @return Builder for configuring additional rules (never null)
     */
    Builder authenticated();

    /**
     * Requires full authentication (not remember-me).
     * Remember-me authenticated users are denied.
     *
     * @return Builder for configuring additional rules (never null)
     */
    Builder fullyAuthenticated();

    /**
     * Requires remember-me authentication.
     * Only remember-me authenticated users are allowed.
     *
     * @return Builder for configuring additional rules (never null)
     */
    Builder rememberMe();

    /**
     * Requires anonymous authentication.
     * Authenticated users are denied.
     *
     * @return Builder for configuring additional rules (never null)
     */
    Builder anonymous();

    /**
     * Uses custom AuthorizationManager for authorization decision.
     * Provides full control over authorization logic.
     *
     * @param manager custom authorization manager (must not be null)
     * @return Builder for configuring additional rules (never null)
     * @throws IllegalArgumentException if manager is null
     */
    Builder access(AuthorizationManager<MessageAuthorizationContext<?>> manager);
}

Usage Examples:

// Role-based authorization
.simpDestMatchers("/app/admin/**").hasRole("ADMIN")

// Multiple roles
.simpDestMatchers("/app/moderator/**").hasAnyRole("ADMIN", "MODERATOR")

// Authority-based
.simpDestMatchers("/app/write/**").hasAuthority("WRITE")

// Permit all
.simpSubscribeDestMatchers("/topic/public").permitAll()

// Authenticated users only
.simpDestMatchers("/app/**").authenticated()

// Custom authorization logic
.simpDestMatchers("/app/sensitive/**").access(customAuthManager)

Edge Cases:

// Handle null authentication gracefully
AuthorizationManager<MessageAuthorizationContext<?>> safeManager = (auth, context) -> {
    Authentication authentication = auth.get();
    if (authentication == null || !authentication.isAuthenticated()) {
        return new AuthorizationDecision(false);
    }
    // Perform authorization check...
    return new AuthorizationDecision(true);
};

// Handle missing path variables
AuthorizationManager<MessageAuthorizationContext<?>> variableManager = (auth, context) -> {
    Map<String, String> variables = context.getVariables();
    String userId = variables.get("userId");
    if (userId == null) {
        // Path variable not extracted - deny access
        return new AuthorizationDecision(false);
    }
    // Use userId for authorization...
    return new AuthorizationDecision(true);
};

AuthorizationChannelInterceptor

Channel interceptor that enforces authorization on messages using an AuthorizationManager. Throws AccessDeniedException if authorization fails.

/**
 * ChannelInterceptor that performs authorization on messages before they are sent.
 * Thread-safe for concurrent use.
 *
 * @since 5.8
 */
public final class AuthorizationChannelInterceptor implements ChannelInterceptor {

    /**
     * Creates interceptor with the specified authorization manager.
     *
     * @param preSendAuthorizationManager authorization manager for pre-send checks (must not be null)
     * @throws IllegalArgumentException if preSendAuthorizationManager is null
     */
    public AuthorizationChannelInterceptor(
        AuthorizationManager<Message<?>> preSendAuthorizationManager
    );

    /**
     * Intercepts message before sending and performs authorization check.
     * Called before message is sent to channel.
     *
     * @param message the message to authorize (must not be null)
     * @param channel the channel the message will be sent to (must not be null)
     * @return the message if authorized (never null)
     * @throws AccessDeniedException if authorization fails
     * @throws IllegalArgumentException if message or channel is null
     */
    public Message<?> preSend(Message<?> message, MessageChannel channel);

    /**
     * Sets the SecurityContextHolderStrategy for obtaining authentication.
     * Allows customization of how security context is stored/retrieved.
     *
     * @param securityContextHolderStrategy the strategy to use (must not be null)
     * @throws IllegalArgumentException if strategy is null
     */
    public void setSecurityContextHolderStrategy(
        SecurityContextHolderStrategy securityContextHolderStrategy
    );

    /**
     * Sets the publisher for authorization events.
     * Allows publishing authorization events for auditing/monitoring.
     *
     * @param eventPublisher the event publisher (can be null to disable events)
     */
    public void setAuthorizationEventPublisher(
        AuthorizationEventPublisher eventPublisher
    );
}

Usage Example:

import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.ChannelRegistration;
import org.springframework.security.config.annotation.web.socket.AbstractSecurityWebSocketMessageBrokerConfigurer;
import org.springframework.security.access.AccessDeniedException;

@Configuration
public class WebSocketSecurityConfig extends AbstractSecurityWebSocketMessageBrokerConfigurer {

    @Override
    protected void configureInbound(ChannelRegistration registration) {
        MessageMatcherDelegatingAuthorizationManager authManager =
            MessageMatcherDelegatingAuthorizationManager.builder()
                .nullDestMatcher().authenticated()
                .simpDestMatchers("/app/**").hasRole("USER")
                .anyMessage().denyAll()
                .build();

        AuthorizationChannelInterceptor interceptor =
            new AuthorizationChannelInterceptor(authManager);

        // Optional: Configure event publisher for auditing
        interceptor.setAuthorizationEventPublisher(authorizationEventPublisher());

        registration.interceptors(interceptor);
    }

    @Bean
    public AuthorizationEventPublisher authorizationEventPublisher() {
        return new DefaultAuthorizationEventPublisher();
    }
}

Error Handling:

// Global exception handler for AccessDeniedException
@ControllerAdvice
public class WebSocketExceptionHandler {

    @MessageExceptionHandler(AccessDeniedException.class)
    public void handleAccessDenied(AccessDeniedException ex) {
        logger.warn("Access denied: {}", ex.getMessage());
        // Send error message to client
    }
}

MessageAuthorizationContext

Context object that holds a message along with extracted path variables for use in authorization decisions. Immutable and thread-safe.

/**
 * Holds a Message and extracted path variables for authorization.
 * Immutable and thread-safe.
 *
 * @param <T> the message payload type
 * @since 5.8
 */
public final class MessageAuthorizationContext<T> {

    /**
     * Creates context with message only.
     * Variables map will be empty.
     *
     * @param message the message (must not be null)
     * @throws IllegalArgumentException if message is null
     */
    public MessageAuthorizationContext(Message<T> message);

    /**
     * Creates context with message and path variables.
     *
     * @param message the message (must not be null)
     * @param variables path variables extracted from destination (can be null, treated as empty map)
     * @throws IllegalArgumentException if message is null
     */
    public MessageAuthorizationContext(
        Message<T> message,
        Map<String, String> variables
    );

    /**
     * Returns the message.
     *
     * @return the message (never null)
     */
    public Message<T> getMessage();

    /**
     * Returns extracted path variables from destination pattern matching.
     * Variables are extracted when using path patterns like /app/user/{userId}/**.
     *
     * @return map of variable names to values (never null, may be empty)
     */
    public Map<String, String> getVariables();
}

Usage Example:

// Custom authorization manager using path variables
AuthorizationManager<MessageAuthorizationContext<?>> customManager = (authentication, context) -> {
    Map<String, String> variables = context.getVariables();
    String userId = variables.get("userId");

    // Handle missing variable
    if (userId == null) {
        logger.warn("Missing userId in path variables");
        return new AuthorizationDecision(false);
    }

    // Check if authenticated user can access this userId
    Authentication auth = authentication.get();
    if (auth == null || !auth.isAuthenticated()) {
        return new AuthorizationDecision(false);
    }

    String authenticatedUsername = auth.getName();
    boolean granted = authenticatedUsername.equals(userId);
    
    return new AuthorizationDecision(granted);
};

// Use with destination pattern containing path variable
MessageMatcherDelegatingAuthorizationManager authManager =
    MessageMatcherDelegatingAuthorizationManager.builder()
        .simpDestMatchers("/app/user/{userId}/**").access(customManager)
        .build();

Multiple Path Variables:

AuthorizationManager<MessageAuthorizationContext<?>> multiVariableManager = (auth, context) -> {
    Map<String, String> variables = context.getVariables();
    String teamId = variables.get("teamId");
    String memberId = variables.get("memberId");

    if (teamId == null || memberId == null) {
        return new AuthorizationDecision(false);
    }

    Authentication authentication = auth.get();
    // Complex authorization logic using both variables
    return new AuthorizationDecision(checkTeamMemberAccess(authentication, teamId, memberId));
};

Configuration Patterns

Basic WebSocket Security

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig extends AbstractSecurityWebSocketMessageBrokerConfigurer {

    @Override
    protected void configureInbound(ChannelRegistration registration) {
        MessageMatcherDelegatingAuthorizationManager authManager =
            MessageMatcherDelegatingAuthorizationManager.builder()
                .nullDestMatcher().authenticated()
                .simpSubscribeDestMatchers("/user/queue/errors").permitAll()
                .simpDestMatchers("/app/**").authenticated()
                .simpSubscribeDestMatchers("/user/**", "/topic/**").authenticated()
                .anyMessage().denyAll()
                .build();

        registration.interceptors(new AuthorizationChannelInterceptor(authManager));
    }

    @Override
    protected boolean sameOriginDisabled() {
        return true; // Disable same-origin check if using CSRF token validation
    }
}

Role-Based Message Authorization

MessageMatcherDelegatingAuthorizationManager authManager =
    MessageMatcherDelegatingAuthorizationManager.builder()
        // Admin-only destinations (most specific first)
        .simpDestMatchers("/app/admin/**").hasRole("ADMIN")
        // Moderator or admin
        .simpDestMatchers("/app/moderate/**").hasAnyRole("ADMIN", "MODERATOR")
        // Authenticated users (less specific)
        .simpDestMatchers("/app/**").authenticated()
        // Public subscriptions
        .simpSubscribeDestMatchers("/topic/public/**").permitAll()
        // Private subscriptions require authentication
        .simpSubscribeDestMatchers("/topic/**", "/queue/**").authenticated()
        // Deny everything else (catch-all)
        .anyMessage().denyAll()
        .build();

Type-Based Authorization

MessageMatcherDelegatingAuthorizationManager authManager =
    MessageMatcherDelegatingAuthorizationManager.builder()
        // Allow CONNECT from anyone
        .simpTypeMatchers(SimpMessageType.CONNECT).permitAll()
        // Require auth for MESSAGE and SUBSCRIBE
        .simpTypeMatchers(SimpMessageType.MESSAGE).authenticated()
        .simpTypeMatchers(SimpMessageType.SUBSCRIBE).authenticated()
        // Deny UNSUBSCRIBE and DISCONNECT
        .simpTypeMatchers(SimpMessageType.UNSUBSCRIBE, SimpMessageType.DISCONNECT)
            .denyAll()
        .anyMessage().denyAll()
        .build();

Path Variable Authorization

// Custom manager that checks path variables
AuthorizationManager<MessageAuthorizationContext<?>> userAuthManager = (auth, context) -> {
    String userId = context.getVariables().get("userId");
    if (userId == null) {
        return new AuthorizationDecision(false);
    }

    Authentication authentication = auth.get();
    if (authentication == null || !authentication.isAuthenticated()) {
        return new AuthorizationDecision(false);
    }

    String authenticatedUser = authentication.getName();
    // User can only access their own resources
    return new AuthorizationDecision(authenticatedUser.equals(userId));
};

MessageMatcherDelegatingAuthorizationManager authManager =
    MessageMatcherDelegatingAuthorizationManager.builder()
        // Use path variable {userId} in pattern
        .simpDestMatchers("/app/user/{userId}/**").access(userAuthManager)
        .simpSubscribeDestMatchers("/user/{userId}/queue/**").access(userAuthManager)
        .anyMessage().denyAll()
        .build();

Complex Authorization with Business Logic

@Service
public class TeamAuthorizationService {

    public boolean canAccessTeam(String username, String teamId) {
        // Complex business logic to check team membership
        return teamRepository.isMember(username, teamId);
    }
}

@Configuration
public class ComplexAuthorizationConfig {

    @Autowired
    private TeamAuthorizationService teamService;

    @Bean
    public MessageMatcherDelegatingAuthorizationManager authorizationManager() {
        AuthorizationManager<MessageAuthorizationContext<?>> teamManager = (auth, context) -> {
            Map<String, String> variables = context.getVariables();
            String teamId = variables.get("teamId");
            String username = auth.get().getName();

            if (teamId == null) {
                return new AuthorizationDecision(false);
            }

            boolean canAccess = teamService.canAccessTeam(username, teamId);
            return new AuthorizationDecision(canAccess);
        };

        return MessageMatcherDelegatingAuthorizationManager.builder()
            .simpDestMatchers("/app/team/{teamId}/**").access(teamManager)
            .anyMessage().denyAll()
            .build();
    }
}

Rule Evaluation Order

Rules are evaluated in the order they are added to the builder. The first matching rule determines the authorization decision.

Important: Order matters! More specific rules should come before less specific ones.

// CORRECT: Specific before general
MessageMatcherDelegatingAuthorizationManager authManager =
    MessageMatcherDelegatingAuthorizationManager.builder()
        .simpDestMatchers("/app/admin/**").hasRole("ADMIN")  // Specific
        .simpDestMatchers("/app/**").authenticated()         // General
        .anyMessage().denyAll()                              // Catch-all
        .build();

// INCORRECT: General before specific (admin rule never reached)
MessageMatcherDelegatingAuthorizationManager wrongManager =
    MessageMatcherDelegatingAuthorizationManager.builder()
        .simpDestMatchers("/app/**").authenticated()         // General - matches first!
        .simpDestMatchers("/app/admin/**").hasRole("ADMIN")  // Never reached
        .anyMessage().denyAll()
        .build();

Performance Considerations

  • Rule evaluation: Rules are evaluated in order until first match. Place frequently matched rules first.
  • Path pattern matching: More specific patterns are faster than wildcards.
  • Custom managers: Can be expensive if they perform database queries. Consider caching.
  • Thread safety: All components are thread-safe, but custom managers must also be thread-safe.

Optimization Example:

// Optimize by ordering rules by frequency
MessageMatcherDelegatingAuthorizationManager optimizedManager =
    MessageMatcherDelegatingAuthorizationManager.builder()
        // Most frequent: public subscriptions
        .simpSubscribeDestMatchers("/topic/public/**").permitAll()
        // Second most frequent: user messages
        .simpDestMatchers("/app/**").authenticated()
        // Less frequent: admin operations
        .simpDestMatchers("/app/admin/**").hasRole("ADMIN")
        // Catch-all
        .anyMessage().denyAll()
        .build();

Troubleshooting

Issue: All messages are denied

Symptoms: All WebSocket messages fail with AccessDeniedException.

Causes:

  1. No matching rule configured
  2. Rules evaluated in wrong order
  3. Authentication not propagated to message thread

Solutions:

// Ensure catch-all rule is last
.anyMessage().denyAll()  // Must be last

// Ensure authentication is propagated
registration.interceptors(new SecurityContextPropagationChannelInterceptor());

// Add debug logging
@Bean
public AuthorizationEventPublisher debugEventPublisher() {
    return (authorizationEvent) -> {
        logger.debug("Authorization event: {}", authorizationEvent);
    };
}

Issue: Specific rule not matching

Symptoms: Rule configured but not applied.

Causes:

  1. Pattern doesn't match actual destination
  2. Rule order prevents matching
  3. Message type mismatch

Solutions:

// Use correct matcher for message type
.simpMessageDestMatchers("/app/**")      // Only MESSAGE type
.simpSubscribeDestMatchers("/topic/**")  // Only SUBSCRIBE type
.simpDestMatchers("/app/**")              // Any type

// Check actual destination in logs
logger.debug("Message destination: {}", message.getHeaders().get("simpDestination"));

Issue: Path variables not extracted

Symptoms: Variables map is empty in custom authorization manager.

Causes:

  1. Pattern doesn't use path variable syntax
  2. Using wrong matcher type

Solutions:

// Use path variable syntax in pattern
.simpDestMatchers("/app/user/{userId}/**")  // Correct: {userId} extracted

// Not: .simpDestMatchers("/app/user/*/**")  // Wrong: no variable extraction

Issue: Null authentication in custom manager

Symptoms: NullPointerException when accessing authentication.

Causes:

  1. Security context not propagated
  2. Anonymous authentication not configured

Solutions:

// Always check for null
AuthorizationManager<MessageAuthorizationContext<?>> safeManager = (auth, context) -> {
    Authentication authentication = auth.get();
    if (authentication == null || !authentication.isAuthenticated()) {
        return new AuthorizationDecision(false);
    }
    // Use authentication...
};

// Ensure security context propagation
registration.interceptors(new SecurityContextPropagationChannelInterceptor());