Skip to content

Role-based access control

Fluxzero allows you to restrict message handling based on the authenticated user’s roles. This access control happens before the message reaches the handler — similar to how payload validation is enforced.

There are several annotations for declaring user and role requirements.

Use this annotation to ensure that a handler is only invoked if the user has at least one of the specified roles.

@HandleCommand
@RequiresAnyRole({"admin", "editor"})
void handle(UpdateArticle command) { ... }
@RequiresAnyRole("admin")
public record DeleteAccount(String userId) {
}

This annotation prevents message handling if the user has any of the specified roles. With a nonempty role list, the default throwing policy also rejects an absent user with UnauthenticatedException. An empty list allows every authenticated user and silently skips anonymous users regardless of throwIfUnauthorized.

@ForbidsAnyRole("guest")
@HandleCommand
void handle(SensitiveOperation command) { ... }

Ensures that a message can only be handled if an authenticated user is present. If no user is found, the message is rejected with an UnauthenticatedException.

@RequiresUser
@HandleCommand
void handle(UpdateProfile command) { ... }

Allows a message to be processed even if no authenticated user is present — ideal for public APIs or health checks.

@NoUserRequired
@HandleCommand
void handle(SignUpUser command) { ... }

Prevents message handling if an authenticated user is present. This is useful for restricting certain flows to unauthenticated users — such as guest signups.

@ForbidsUser
@HandleCommand
void handle(SignUpAsGuest command) { ... }

Controlling behavior on unauthorized access

Section titled “Controlling behavior on unauthorized access”

Keep the default throwing behavior for ordinary access restrictions:

  • If a user is required but not present, an UnauthenticatedException is thrown.
  • If a user is present but lacks the required access, an UnauthorizedException is thrown.

With throwIfUnauthorized = false, a denied handler is skipped instead of throwing. Other eligible handlers can still process the request. On a command or query payload, denial skips every handler for that payload. A skipped request does not produce a successful null response; if no handler responds, the caller may eventually time out.

Authorization is evaluated for the current request’s user, including when handlers are prepared for reuse. For a query whose result varies by role, one authenticated handler with an explicit role branch is often the clearest option.


Role annotations support nesting and overrides

Section titled “Role annotations support nesting and overrides”

You can protect a command or query and the handler that processes it. For a separate command/query handler, both checks must allow access. Making one side public does not remove a requirement on the other side. These examples use the default throwing behavior on denial.

Consider OrderHandler.handle(ApproveOrder). Both types have their own package hierarchy. Read each column from the method or type up toward its parent packages: the search stops at the nearest authorization policy. Package names below are relative to com.example.

flowchart TB
    accTitle: Package policy selection for OrderHandler and ApproveOrder
    accDescr: OrderHandler.handle has no authorization annotation, nor does OrderHandler. The nearest handler package-info requires reviewer, replacing the parent package's RequiresUser. ApproveOrder has no type policy. Its nearest package-info requires sales, replacing its parent package's RequiresUser. The method handles ApproveOrder only when the user has both sales and reviewer roles.

    subgraph handler["Handler hierarchy"]
        HParent["`handlers
        package-info.java
        @RequiresUser
        Not reached`"]
        HOwn["`handlers.orders
        package-info.java
        @RequiresAnyRole("reviewer")
        3. Selected policy`"]
        HType["`OrderHandler
        2. No type policy`"]
        HMethod["`handle(ApproveOrder)
        1. No method policy`"]
        HParent -.- HOwn
        HOwn --- HType
        HType --- HMethod
    end

    subgraph payload["Payload hierarchy"]
        PParent["`api
        package-info.java
        @RequiresUser
        Not reached`"]
        POwn["`api.orders
        package-info.java
        @RequiresAnyRole("sales")
        2. Selected policy`"]
        PType["`ApproveOrder
        1. No type policy`"]
        PParent -.- POwn
        POwn --- PType
    end

    HMethod -->|Requires reviewer| access["`OrderHandler handles ApproveOrder
    Signed in AND reviewer AND sales`"]
    PType -->|Requires sales| access

    classDef selected stroke-width:3px
    classDef unused stroke-dasharray:5 5
    class HOwn,POwn,access selected
    class HParent,PParent unused

The package policies with a heavy solid border are selected; the dashed parent policies are not added to them. The handler requires reviewer and the payload requires sales, so a signed-in user needs both roles. Within one hierarchy the nearest policy wins; between payload and handler the two checks are combined with AND. If one policy lists several roles in @RequiresAnyRole, satisfying any one of those roles passes that check; the other check must still pass independently.

Use the first level with authorization annotations in each row. Each side uses its own type and package hierarchy.

CheckSearch order
Command or queryPayload type → own package → parent packages
HandlerHandler method → handler type → own package → parent packages

Packages provide defaults. A subpackage with authorization annotations replaces the broader package policy; requirements declared together at the selected level still apply together. Unrelated annotations do not stop the search. If no level defines authorization, that side allows access.

A public healthcheck in a protected package

Section titled “A public healthcheck in a protected package”

Suppose the web handlers live in a package annotated with @RequiresUser. Regular endpoints inherit that requirement. The healthcheck explicitly uses @NoUserRequired, so a monitoring probe can call it without logging in.

flowchart TB
    accTitle: Public healthcheck overrides the package default
    accDescr: The web package requires a user. Regular endpoint methods inherit this default. The healthcheck method declares NoUserRequired, replacing the package default only for that handler.
    packagePolicy["Web package: @RequiresUser"] -->|Inherited default| regular["Regular endpoint: login required"]
    packagePolicy -->|Explicit method override| health["GET /health: @NoUserRequired"]

In Java, declare the default in com/myapp/web/package-info.java:

@RequiresUser
package com.myapp.web;
import io.fluxzero.sdk.tracking.handling.authentication.RequiresUser;

Then allow anonymous requests on the healthcheck method. In a Kotlin-only project, a class-level default provides the same method-override pattern:

package com.myapp.web;
import io.fluxzero.sdk.tracking.handling.authentication.NoUserRequired;
import io.fluxzero.sdk.web.HandleGet;
public class HealthEndpoint {
@HandleGet("/health")
@NoUserRequired
public String health() {
return "OK";
}
}

This exception applies to the healthcheck handler. It does not grant anonymous access to protected commands or queries sent by that handler.

Type policy includes inherited type and interface annotations. Different authorization annotations inherited this way can coexist; the table describes fallback between levels, not replacement of individual inherited annotations.

When multiple JARs define package-info for the same package, the first definition selected by the classloader supplies that package’s policy. Other definitions of that same package are not combined. This policy applies to classes from every JAR in that package; the application controls precedence through its packaging and classloader configuration. Ancestor authorization packages use the defining loader of the payload or handler class, including applications loaded below the SDK’s loader. Changing the thread context loader does not change that policy.


You can define custom annotations using enums for structured roles.

public enum Role {
ADMIN, EDITOR, USER
}
@RequiresAnyRole
@Target({ElementType.TYPE, ElementType.METHOD})
public @interface RequiresRole {
Role[] value();
}
@HandleCommand
@RequiresRole(Role.ADMIN)
void handle(DeleteAccount command) { ... }

  • Put shared requirements on command and query types so their handlers respect the operation’s access policy.
  • Apply handler annotations for additional requirements or explicit exceptions to handler package defaults.
  • Set default access rules at the package level, and override them as needed.
  • Create custom annotations to avoid repeating role strings throughout your codebase.


User roles are resolved by the configured UserProvider, which extracts user info from message metadata (e.g. headers or tokens). Fluxzero uses a pluggable SPI to register this provider.

Use User.id() for ownership checks, audit references, and other behavior that needs a stable user identity. The SDK requires every User implementation to supply String id() explicitly; it never derives identity from a principal name. User remains a Principal, with an optional getName() that defaults to id(). Override getName() only for a distinct principal/display name, not for identity comparisons.

With fluxzero.defaults.version >= 2026.08.04 or fluxzero.auth.useUserIdMetadata=true, AbstractUserProvider stores this ID in metadata and resolves it through getUserById(...). Compatibility defaults retain complete-user JSON; requiring id() does not change that wire format or add an automatic JSON property. New readers continue to accept complete-user metadata. If an application’s stable ID changes, its provider must explicitly resolve earlier identity values while old messages remain in flight; the SDK does not infer identities from display names.


You can implement a custom UserProvider to extract users from headers, JWT tokens, cookies, etc.

public class MyUserProvider extends AbstractUserProvider {
public MyUserProvider() {
super("Authorization", MyUser.class);
}
@Override
public User fromMessage(HasMessage message) {
if (message.toMessage() instanceof WebRequest request) {
return decodeToken(request.getHeader("Authorization"));
}
return super.fromMessage(message);
}
private User decodeToken(String header) {
// Parse and validate JWT token here
return ...;
}
}

Your UserProvider can also implement:

  • getSystemUser() — returns the default system-level user (used in tests and scheduled handlers)
  • getUserById(...) — used by test utilities like fixture.whenCommandByUser(...)

This ensures consistent behavior across environments.


To register your custom UserProvider, use Java’s SPI mechanism:

src/main/resources/META-INF/services/io.fluxzero.sdk.tracking.handling.authentication.UserProvider

Add each provider class (one per line):

com

© 2026 Fluxzero