RbacService
Answers hasPermission($userId, $permission) and getUserPermissions($userId).
The only bean that reads the RBAC tables.
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 >── permissionsCreate 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);Baseline roles and grants, so every environment starts identical.
INSERT INTO roles (name, description) VALUES ('admin', 'Full access'), ('editor', 'Can manage orders'), ('viewer', 'Read-only access');
INSERT INTO permissions (name, description) VALUES ('orders:read', 'View orders'), ('orders:refund', 'Refund orders'), ('users:ban', 'Ban users');
INSERT INTO role_permissions (role_id, permission_id)SELECT r.id, p.id FROM roles r CROSS JOIN permissions pWHERE r.name = 'admin';
INSERT INTO role_permissions (role_id, permission_id)SELECT r.id, p.id FROM roles r JOIN permissions p ON p.name IN ('orders:read', 'orders:refund')WHERE r.name = 'editor';
INSERT INTO role_permissions (role_id, permission_id)SELECT r.id, p.id FROM roles r JOIN permissions p ON p.name = 'orders:read'WHERE r.name = 'viewer';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.
<?phpdeclare(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;}The implementation. getUserPermissions() is the single cached reader;
the writes evict it transactionally.
<?phpdeclare(strict_types=1);
namespace dev\example\service;
use dev\winterframework\cache\stereotype\Cacheable;use dev\winterframework\cache\stereotype\CacheEvict;use dev\winterframework\pdbc\PdbcTemplate;use dev\winterframework\stereotype\Autowired;use dev\winterframework\stereotype\Service;use dev\winterframework\txn\stereotype\Transactional;
#[Service]class RbacServiceImpl implements RbacService{ #[Autowired] protected PdbcTemplate $pdbc;
#[Cacheable(cacheNames: 'rbac-permissions', cacheManager: 'redisCacheManager')] public function getUserPermissions(int $userId): array { // Cache key defaults to method name + arguments, so each user // gets their own entry. The body runs only on a cache miss. $rows = $this->pdbc->queryForList( 'SELECT p.name FROM permissions p' . ' JOIN role_permissions rp ON rp.permission_id = p.id' . ' JOIN user_roles ur ON ur.role_id = rp.role_id' . ' WHERE ur.user_id = ?', [$userId] ); return array_column($rows, 'name'); }
public function hasPermission(int $userId, string $permission): bool { if ($this->isSuperAdmin($userId)) { return true; } return in_array($permission, $this->getUserPermissions($userId), true); }
public function hasRole(int $userId, string $role): bool { $count = $this->pdbc->queryForScalar( 'SELECT COUNT(*) FROM user_roles ur' . ' JOIN roles r ON r.id = ur.role_id' . ' WHERE ur.user_id = ? AND r.name = ?', [$userId, $role] ); return ((int)$count) > 0; }
#[Transactional] #[CacheEvict(cacheNames: 'rbac-permissions', cacheManager: 'redisCacheManager', allEntries: true)] public function assignRole(int $userId, string $role): void { $this->pdbc->update( 'INSERT INTO user_roles (user_id, role_id)' . ' SELECT ?, r.id FROM roles r WHERE r.name = ?', [$userId, $role] ); }
#[Transactional] #[CacheEvict(cacheNames: 'rbac-permissions', cacheManager: 'redisCacheManager', allEntries: true)] public function revokeRole(int $userId, string $role): void { $this->pdbc->update( 'DELETE FROM user_roles WHERE user_id = ?' . ' AND role_id = (SELECT id FROM roles WHERE name = ?)', [$userId, $role] ); }
private function isSuperAdmin(int $userId): bool { return (bool)$this->pdbc->queryForScalar( 'SELECT is_super_admin FROM users WHERE id = ?', [$userId] ); }}Hashing policy in exactly one place, so upgrades touch one file.
<?phpdeclare(strict_types=1);
namespace dev\example\service;
use dev\winterframework\stereotype\Component;
#[Component]class PasswordHasher{ public function hash(string $plainPassword): string { return password_hash($plainPassword, PASSWORD_BCRYPT); }
public function verify(string $plainPassword, string $hash): bool { return password_verify($plainPassword, $hash); }}Verifies credentials and owns the session. Note queryForMap throws
EmptyResultDataAccessException when no row matches — that becomes the
same null as a wrong password, so callers cannot enumerate accounts.
<?phpdeclare(strict_types=1);
namespace dev\example\service;
use dev\winterframework\pdbc\PdbcTemplate;use dev\winterframework\pdbc\ex\EmptyResultDataAccessException;use dev\winterframework\stereotype\Autowired;use dev\winterframework\stereotype\Service;use dev\winterframework\web\http\HttpRequest;use dev\winterframework\web\http\ResponseEntity;use dev\winterframework\web\session\SessionManager;use dev\winterframework\web\session\SessionOptions;
#[Service]class AuthService{ #[Autowired] protected PdbcTemplate $pdbc;
#[Autowired] protected PasswordHasher $passwords;
#[Autowired] protected SessionManager $sessions;
#[Autowired] protected \SessionHandlerInterface $store;
#[Autowired] protected RbacService $rbac;
private SessionOptions $options;
public function __construct() { $this->options = new SessionOptions(name: 'SID', expirySecs: 86400); }
/** Returns the user id on success, null on bad credentials. */ public function login(string $username, string $password): ?int { try { $row = $this->pdbc->queryForMap( 'SELECT id, password_hash FROM users WHERE username = ?', [$username] ); } catch (EmptyResultDataAccessException $e) { return null; } if (!$this->passwords->verify($password, $row['password_hash'])) { return null; } return (int)$row['id']; }
public function loginAndCommit(HttpRequest $req, int $userId, string $username): ResponseEntity { $session = $this->sessions->open($req, $this->store, $this->options); $session->setUsername($username); $session->set('uid', $userId);
$res = ResponseEntity::ok(['status' => 'logged-in']); $this->sessions->commit($session, $res, $this->store, $this->options); return $res; }
public function currentUserId(HttpRequest $req): ?int { $session = $this->sessions->open($req, $this->store, $this->options); if ($session->isNew()) { return null; } $uid = $session->get('uid'); return is_numeric($uid) ? (int)$uid : null; }}Why this shape:
getUserPermissions(), so there is exactly one method to cache and one
query to tune.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.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 |
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.
<?phpdeclare(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 {}}The registration. The configurer is itself a bean, so its dependencies are injected and handed to the interceptor’s constructor.
<?phpdeclare(strict_types=1);
namespace dev\example\config;
use dev\example\interceptor\RbacInterceptor;use dev\example\service\AuthService;use dev\example\service\RbacService;use dev\winterframework\core\web\config\InterceptorRegistry;use dev\winterframework\core\web\config\WebMvcConfigurer;use dev\winterframework\stereotype\Autowired;use dev\winterframework\stereotype\Configuration;
#[Configuration(name: 'webMvcConfigurer')]class WebConfig implements WebMvcConfigurer{ #[Autowired] protected AuthService $auth;
#[Autowired] protected RbacService $rbac;
public function addInterceptors(InterceptorRegistry $registry): void { $registry->addInterceptor( new RbacInterceptor($this->auth, $this->rbac, 'admin'), '^\/admin\/.*' ); }}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.
<?phpdeclare(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' => []]; }}The attribute. Declares the required permission, hands out one shared interceptor, and validates at boot that it sits on a suitable method.
<?phpdeclare(strict_types=1);
namespace dev\example\aop;
use Attribute;use dev\winterframework\reflection\ref\RefMethod;use dev\winterframework\reflection\support\StereoTypeValidations;use dev\winterframework\stereotype\StereoTyped;use dev\winterframework\stereotype\aop\AopStereoType;use dev\winterframework\stereotype\aop\WinterAspect;use dev\winterframework\type\TypeAssert;
#[Attribute(Attribute::TARGET_METHOD)]#[StereoTyped]class RequirePermission implements AopStereoType{ use StereoTypeValidations;
private ?RequirePermissionInterceptor $interceptor = null;
public function __construct( public string $value = '', ) { }
public function isPerInstance(): bool { return false; // one shared interceptor for all guarded methods }
public function getAspect(): WinterAspect { if (!isset($this->interceptor)) { $this->interceptor = new RequirePermissionInterceptor(); } return $this->interceptor; }
public function init(object $ref): void { /** @var RefMethod $ref */ TypeAssert::typeOf($ref, RefMethod::class); $this->validateAopMethod($ref, 'RequirePermission'); }}The advice. begin() runs before the endpoint body: unknown sessions get
401, missing grants get 403 via stopExecution(), so the body never
runs. Services come from the application context because the interceptor
is shared, not a bean.
<?phpdeclare(strict_types=1);
namespace dev\example\aop;
use dev\example\service\AuthService;use dev\example\service\RbacService;use dev\winterframework\core\aop\AopExecutionContext;use dev\winterframework\stereotype\aop\AopContext;use dev\winterframework\stereotype\aop\WinterAspect;use dev\winterframework\util\log\Wlf4p;use dev\winterframework\web\http\HttpStatus;use dev\winterframework\web\http\ResponseEntity;use Throwable;
class RequirePermissionInterceptor implements WinterAspect{ use Wlf4p;
public function begin(AopContext $ctx, AopExecutionContext $exCtx): void { /** @var RequirePermission $stereo */ $stereo = $ctx->getStereoType(); $appCtx = $ctx->getApplicationContext();
$auth = $appCtx->beanByClass(AuthService::class); $rbac = $appCtx->beanByClass(RbacService::class);
$request = $appCtx->getCurrentHttpRequest(); $userId = ($request !== null) ? $auth->currentUserId($request) : null;
if ($userId === null) { $exCtx->stopExecution( ResponseEntity::status(HttpStatus::$UNAUTHORIZED)->withJson([ 'error' => 'Authentication required', ]) ); return; }
if (!$rbac->hasPermission($userId, $stereo->value)) { self::logWarning( 'RBAC deny: user ' . $userId . ' lacks ' . $stereo->value . ' on ' . $ctx->getMethod()->getName() ); $exCtx->stopExecution( ResponseEntity::status(HttpStatus::$FORBIDDEN)->withJson([ 'error' => 'Forbidden', ]) ); } }
public function beginFailed(AopContext $ctx, AopExecutionContext $exCtx, Throwable $ex): void { self::logError('RequirePermission begin failed: ' . $ex->getMessage()); }
public function commit(AopContext $ctx, AopExecutionContext $exCtx, mixed $result): void {}
public function commitFailed( AopContext $ctx, AopExecutionContext $exCtx, mixed $result, Throwable $ex ): void { self::logError('RequirePermission commit failed: ' . $ex->getMessage()); }
public function failed(AopContext $ctx, AopExecutionContext $exCtx, Throwable $ex): void { self::logError($ctx->getMethod()->getName() . ' failed: ' . $ex->getMessage()); }}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 {}Register a Redis-backed CacheManager. The default in-memory cache works
on one node; every other node would keep its own copy of each user’s
grants. RedisCache (from the Redis module)
gives all nodes one shared copy, so the CacheEvict on assignRole() /
revokeRole() invalidates everyone at once.
<?phpdeclare(strict_types=1);
namespace dev\example\config;
use dev\winterframework\cache\CacheConfiguration;use dev\winterframework\cache\CacheManager;use dev\winterframework\cache\impl\SimpleCacheManager;use dev\winterframework\data\redis\cache\RedisCache;use dev\winterframework\data\redis\phpredis\PhpRedisTemplate;use dev\winterframework\stereotype\Bean;use dev\winterframework\stereotype\Configuration;
#[Configuration]class CacheConfig{ #[Bean('redisCacheManager')] public function getRedisCacheManager(PhpRedisTemplate $redisTpl): CacheManager { $rbacCache = new RedisCache( $redisTpl, 'rbac-permissions', CacheConfiguration::get( maximumSize: 20000, expireAfterWriteMs: 300_000, // 5 minutes: bounds staleness ) );
$manager = new SimpleCacheManager(); $manager->addCache($rbacCache);
return $manager; }}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:
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:
server: port: 8080 address: 0.0.0.0 context-path: /winter: application: name: RBAC Sample Application id: rbac-sample-app version: 1.0.0propertySources: - name: env provider: dev\winterframework\io\EnvPropertySourcedatasource: - 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: falsemodules: - module: dev\winterframework\data\redis\RedisModule enabled: true configFile: redis-config.ymlOut-of-the-box items teams usually discover one incident later — handled here up front:
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.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.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.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.RbacService (cached, short TTL) carries grants.\SessionHandlerInterface bean backed by the database or Redis (see
Sessions)./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.order.owner_id = caller), and only then hierarchical
roles or attribute-based rules — each step reuses RbacService as the funnel.viewer.POST /login) and keep the session cookie.GET /orders/pending → 200.orders:refund → 403.assignRole(user, 'editor'), retry → 200 (proves grant + eviction).revokeRole(user, 'editor'), retry → 403 (proves eviction again).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.
key,
condition, unless) and the in-memory / KV backends.afterCompletion cleanup.#[Transactional] writes above.