Skip to content

Role-Based Access Control (RBAC) in Winter Boot

Role-based access control answers one question on every request: is this caller allowed to do this? Users get roles, roles carry permissions, and your code checks permissions — never raw user ids — at the boundary of each protected operation.

Winter Boot has no dedicated security module yet (see Security), so you build RBAC from framework primitives: a relational schema, #[Service] beans for the checks, interceptors or custom AOP attributes for enforcement, and declarative #[Cacheable] caching so permission lookups do not hit the database on every request. This guide wires all four pieces together.

Five tables, one naming convention:

  • users — who can log in.
  • roles — named groups such as admin, editor, viewer.
  • permissions — fine-grained grants named resource:action, e.g. orders:read, orders:refund, users:ban.
  • role_permissions — which role carries which permission (many-to-many).
  • user_roles — which user holds which role (many-to-many).
users ──< user_roles >── roles ──< role_permissions >── permissions

Create the tables once with a migration. With migrations: { enabled: true } on your datasource, put the files under /migrations/{dsName}/, e.g. /migrations/myapp/ — files run in alphabetical order and are tracked in the winter_migrations table. Switch between the two migration files:

The schema: users, roles, permissions, and the two join tables.

CREATE TABLE users (
id BIGINT NOT NULL PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
is_super_admin SMALLINT NOT NULL DEFAULT 0,
created_at BIGINT NOT NULL DEFAULT 0
);
CREATE TABLE roles (
id BIGINT NOT NULL PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(100) NOT NULL UNIQUE,
description VARCHAR(500) NOT NULL DEFAULT ''
);
CREATE TABLE permissions (
id BIGINT NOT NULL PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(150) NOT NULL UNIQUE,
description VARCHAR(500) NOT NULL DEFAULT ''
);
CREATE TABLE role_permissions (
role_id BIGINT NOT NULL,
permission_id BIGINT NOT NULL,
PRIMARY KEY (role_id, permission_id)
);
CREATE TABLE user_roles (
user_id BIGINT NOT NULL,
role_id BIGINT NOT NULL,
PRIMARY KEY (user_id, role_id)
);
CREATE INDEX idx_user_roles_user ON user_roles (user_id);
CREATE INDEX idx_role_permissions_role ON role_permissions (role_id);

Three beans carry the whole feature. Keep them separated so each one can be tested and cached independently:

RbacService

Answers hasPermission($userId, $permission) and getUserPermissions($userId). The only bean that reads the RBAC tables.

PasswordHasher

One #[Component] wrapping password_hash() / password_verify() so hashing policy lives in exactly one place.

AuthService

Verifies credentials, opens the session, and stamps the identity onto it. Controllers call it; they never touch the RBAC tables directly.

Define the contract as an interface and implement it in a class ending with Impl — that suffix is what makes beanByClass(RbacService::class) resolve, and lets you swap a fake in tests without touching callers. Switch between the four source files:

The contract. Every permission question in the app goes through this interface.

<?php
declare(strict_types=1);
namespace dev\example\service;
interface RbacService
{
/** All permission names granted to the user (via all their roles). */
public function getUserPermissions(int $userId): array;
/** True when the user holds the permission (or is super-admin). */
public function hasPermission(int $userId, string $permission): bool;
/** True when the user holds the role. */
public function hasRole(int $userId, string $role): bool;
public function assignRole(int $userId, string $role): void;
public function revokeRole(int $userId, string $role): void;
}

Why this shape:

  • One reader. Every permission question funnels through getUserPermissions(), so there is exactly one method to cache and one query to tune.
  • Writes evict. assignRole() / revokeRole() run inside a #[Transactional] write and evict the rbac-permissions cache after the transaction commits (the CacheEvict default), so a crash mid-write can never leave stale grants behind. allEntries: true is the safe default — one user’s new role can change what shared permission lists resolve to, so flushing the container beats reasoning about each affected key. Narrow it to a per-user key only once you have measured the flush cost.
  • Deny by default. Unknown users, missing rows, and empty role sets all resolve to “no permission” — the code never needs an explicit deny branch.

3. Enforcement: interceptor or AOP attribute

Section titled “3. Enforcement: interceptor or AOP attribute”

Pick the enforcement point that matches the granularity you need. Most apps end up using both: the interceptor for coarse route gates, the attribute for per-operation checks.

Approach Scope Best for
HandlerInterceptor URI patterns (^\/admin\/.*) Coarse gates: whole admin tree needs admin role
Custom AOP #[RequirePermission] Single endpoint or service method Fine-grained: this method needs orders:refund

Option A — coarse gate with a HandlerInterceptor

Section titled “Option A — coarse gate with a HandlerInterceptor”

The interceptor resolves the caller from the session, asks RbacService, and short-circuits with 401 (not logged in) or 403 (logged in, not allowed). Switch between the interceptor and its registration:

The gate. Takes its collaborators as plain constructor arguments — it is created with new in the configurer, so #[Autowired] would not be injected here.

<?php
declare(strict_types=1);
namespace dev\example\interceptor;
use dev\example\service\AuthService;
use dev\example\service\RbacService;
use dev\winterframework\core\web\HandlerInterceptor;
use dev\winterframework\util\log\Wlf4p;
use dev\winterframework\web\http\HttpRequest;
use dev\winterframework\web\http\HttpStatus;
use dev\winterframework\web\http\ResponseEntity;
use Throwable;
class RbacInterceptor implements HandlerInterceptor
{
use Wlf4p;
public function __construct(
private AuthService $auth,
private RbacService $rbac,
private string $requiredRole = 'admin',
) {
}
public function preHandle(HttpRequest $request, ResponseEntity $response): bool
{
$userId = $this->auth->currentUserId($request);
if ($userId === null) {
$response->withStatus(HttpStatus::$UNAUTHORIZED)
->withJson(['error' => 'Authentication required']);
return false;
}
if (!$this->rbac->hasRole($userId, $this->requiredRole)) {
self::logWarning('RBAC deny: user ' . $userId . ' hit ' . $request->getUri());
$response->withStatus(HttpStatus::$FORBIDDEN)
->withJson(['error' => 'Forbidden']);
return false;
}
return true;
}
public function postHandle(HttpRequest $request, ResponseEntity $response): void {}
public function afterCompletion(
HttpRequest $request,
ResponseEntity $response,
?Throwable $ex = null
): void {}
}

Option B — fine-grained guard with a custom AOP attribute

Section titled “Option B — fine-grained guard with a custom AOP attribute”

For per-operation checks (orders:refund on one endpoint, users:ban on another), a custom AOP attribute reads better than a pile of URI regexes. It follows the standard custom AOP shape: an attribute class plus a WinterAspect interceptor that calls stopExecution() with a 403 instead of letting the method run. Switch between the three files:

The guarded endpoint — start here to see what the guard protects. AOP attributes belong on endpoint methods, not on controller helpers — helpers run as ordinary calls and never trigger advice.

<?php
declare(strict_types=1);
namespace dev\example\rest;
use dev\example\aop\RequirePermission;
use dev\winterframework\stereotype\RestController;
use dev\winterframework\stereotype\web\GetMapping;
use dev\winterframework\stereotype\web\RequestMapping;
#[RestController]
#[RequestMapping(path: 'orders')]
class OrderController
{
#[GetMapping(path: 'pending')]
#[RequirePermission('orders:read')]
public function pendingOrders(): array
{
return ['orders' => []];
}
}

Every guarded request calls hasPermission(), which calls getUserPermissions() — a three-table join. Uncached, that join runs on every single request. The #[Cacheable] on getUserPermissions() (added in section 2) already declares the caching; here is the backend that makes it cheap and shared across nodes. Switch between the two files:

Enable caching on the application class. #[EnableCaching] must sit on the same class as #[WinterBootApplication] — anywhere else throws a TypeError at startup.

use dev\winterframework\stereotype\cache\EnableCaching;
use dev\winterframework\stereotype\WinterBootApplication;
#[WinterBootApplication(configDirectory: ['config'])]
#[EnableCaching]
class RbacApplication {
}

The third step needs no file: RbacServiceImpl already names the manager with cacheManager: 'redisCacheManager' on both #[Cacheable] and #[CacheEvict]. When several CacheManager beans exist, the attribute must name the right one — otherwise the default manager answers and your Redis cache sits empty.

What this buys you, in request terms:

  • First request after login: one DB join, result stored in Redis under a per-user key (the default key is method name + arguments, so user 7 and user 9 never share an entry).
  • Following requests for 5 minutes: zero DB hits — the AOP proxy returns the cached grant list without entering the method body.
  • Role grant/revoke: CacheEvict fires after the transaction commits, so the next check re-reads the DB. The 5-minute TTL is the backstop: even if an eviction were missed, a stale grant lives at most 5 minutes.

application.yml needs the server identity, the datasource with migrations enabled, and the Redis module:

config/application.yml
server:
port: 8080
address: 0.0.0.0
context-path: /
winter:
application:
name: RBAC Sample Application
id: rbac-sample-app
version: 1.0.0
propertySources:
-
name: env
provider: dev\winterframework\io\EnvPropertySource
datasource:
-
name: myapp
isPrimary: true
url: mysql:host=127.0.0.1;port=3306;dbname=myapp
username: app
password: "$env.DB_PASSWORD"
migrations:
enabled: true
useCli: false
modules:
-
module: dev\winterframework\data\redis\RedisModule
enabled: true
configFile: redis-config.yml

Out-of-the-box items teams usually discover one incident later — handled here up front:

  • Super-admin bypass, kept tiny. The is_super_admin flag exists for break-glass access only. It lives in hasPermission(), not scattered across controllers, so auditing who bypassed what means reading one method. Never expose it as a role users can grant each other.
  • Audit every denial. Both guards already log denials with logWarning. Ship those lines to your log aggregator and alert on spikes — a burst of 403s from one user is either an attacker probing or a broken role rollout. Log user id, permission, and URI only — never passwords, tokens, or bodies.
  • Separate authentication from authorisation failures. 401 (unknown or expired session) tells the client to log in again; 403 (valid session, missing grant) tells it to stop asking. Merging them breaks client retry logic and confuses incident triage.
  • Test the matrix, not just the happy path. At minimum: anonymous → 401; logged-in without the grant → 403; with the grant → 200; revoke-then-retry → 403 again (proves the eviction works); super-admin → 200 everywhere. The revoke test is the one that catches stale-cache bugs.
  • Do not cache the session’s roles. It is tempting to stamp permissions into the session at login and skip the lookup entirely — but then a revoked grant stays valid until the session expires. Sessions carry identity; RbacService (cached, short TTL) carries grants.
  • Pick the session store for your topology. Single node: the default file store is fine. Multiple nodes or restarts that keep logins: declare one \SessionHandlerInterface bean backed by the database or Redis (see Sessions).
  • Rate-limit the login endpoint. RBAC means nothing if passwords can be brute-forced. Throttle /login per IP (a HandlerInterceptor counting in Redis is one option) and return the same response for unknown users and wrong passwords so callers cannot enumerate accounts.
  • Grow deliberately. When flat roles stop fitting, add (in this order): permission groups in the admin UI (display-only, no schema change), then ownership checks (order.owner_id = caller), and only then hierarchical roles or attribute-based rules — each step reuses RbacService as the funnel.
  1. Run migrations, start the app, create a user and grant them viewer.
  2. Log in (POST /login) and keep the session cookie.
  3. GET /orders/pending → 200.
  4. Call an endpoint guarded by orders:refund → 403.
  5. assignRole(user, 'editor'), retry → 200 (proves grant + eviction).
  6. revokeRole(user, 'editor'), retry → 403 (proves eviction again).
  7. Drop the cookie and retry → 401.

If step 5 or 6 misbehaves, the cache is lying: confirm the attribute names the redisCacheManager bean and that assignRole/revokeRole actually commit before the eviction fires.

  • Caching — attribute options (key, condition, unless) and the in-memory / KV backends.
  • Interceptors — ordering, URI patterns, and afterCompletion cleanup.
  • AOP — the full advice lifecycle and the runnable AOP guard example.
  • Sessions — stores, cookie options, and the identity fields that travel with each session.
  • Transactions — propagation and rollback rules behind the #[Transactional] writes above.