Skip to content

Attribute-Based Access Control (ABAC) in Winter Boot

RBAC answers “is this user in the right role?” Attribute-based access control answers a richer question: “does this user, asking for this action, on this resource, in this context, satisfy a policy?” A support agent can refund a €50 paid order from their own region during business hours — but not a €5,000 order, not at midnight, and not outside their region. No role bundle expresses that; attributes do.

Winter Boot has no policy engine module, so you build ABAC the same way as RBAC: a policy schema, #[Service] beans for evaluation, a custom AOP attribute for enforcement, and declarative caching so neither policies nor attributes hit the database per request.

Every decision combines four attribute families against stored policies:

  • Subject — who is asking: user id plus profile facts (department, region, clearance_level).
  • Resource — what is targeted: the row’s own facts (owner_id, amount, region, status).
  • Action — what is attempted: refund, read, ban.
  • Environment — the context: current hour, caller IP.

A policy names a resource type and action, carries an allow / deny effect, a priority, and a set of conditions per family. Evaluation is first-applicable: policies run in priority order, the first one whose conditions all match decides, and anything unmatched is denied.

request (subject + resource + action + env)
→ policies for (resource type, action), priority ASC
→ first fully-matching policy decides allow / deny
→ no match → deny
RBAC ABAC
Grant is “user has role” Grant is “attributes satisfy a policy”
Changes when memberships change Changes when facts change (amount, hour, region)
Cache key: per user Policies cached globally; subject facts cached per user
Best for coarse access Best for fine-grained, contextual rules

Most apps run both: RBAC guards the route (admin tree), ABAC guards the operation (refund this order now).

ABAC needs three tables: subject facts, the policies themselves, and a resource table carrying the facts policies match on. (The example uses an orders table — substitute your own domain table and adjust the resolver in section 2.) Put the files next to the RBAC migrations under /migrations/{dsName}/:

Profiles, policies, and an orders table with matchable facts.

CREATE TABLE user_profiles (
user_id BIGINT NOT NULL PRIMARY KEY,
department VARCHAR(100) NOT NULL DEFAULT '',
region VARCHAR(50) NOT NULL DEFAULT '',
clearance_level INT NOT NULL DEFAULT 0
);
CREATE TABLE abac_policies (
id BIGINT NOT NULL PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(150) NOT NULL UNIQUE,
resource_type VARCHAR(100) NOT NULL,
action VARCHAR(100) NOT NULL,
effect VARCHAR(10) NOT NULL DEFAULT 'deny',
priority INT NOT NULL DEFAULT 100,
subject_conditions JSON,
resource_conditions JSON,
env_conditions JSON,
enabled SMALLINT NOT NULL DEFAULT 1
);
CREATE INDEX idx_abac_lookup ON abac_policies (resource_type, action, enabled, priority);
CREATE TABLE orders (
id BIGINT NOT NULL PRIMARY KEY AUTO_INCREMENT,
owner_id BIGINT NOT NULL,
amount DECIMAL(10, 2) NOT NULL DEFAULT 0,
region VARCHAR(50) NOT NULL DEFAULT '',
status VARCHAR(50) NOT NULL DEFAULT 'paid'
);

Four pieces, each with one job. Switch between the source files:

The contract. Callers pass ids and context; the implementation resolves every attribute itself, so enforcement points stay thin.

<?php
declare(strict_types=1);
namespace dev\example\service;
interface AbacService
{
/**
* True when some policy allows this user to perform the action
* on the resource right now. Anything else — no policy, missing
* attributes, unknown resource — is false.
*
* @param array{hour: int, ip: string} $env
*/
public function isAllowed(
int $userId,
string $resourceType,
string $action,
int $resourceId,
array $env
): bool;
}

Why this shape:

  • Three cacheable seams. Policies are global (one entry per type-and-action, shared by every user — tiny cardinality, huge hit rate). Subject facts are per-user with a short TTL. Resource facts are read live: they change with the domain row, and the row read is one indexed primary-key lookup, not a join.
  • First match wins. Priority is meaningful without a separate combining algorithm: guardrails go first (low numbers), overrides next, standard rules last. Anything unmatched is denied.
  • Nulls never match. The $value === null guard plus default => false on operators mean a missing fact or a typo’d operator denies instead of accidentally allowing.
  • One evaluator. Like RbacService, every ABAC question funnels through isAllowed() — one method to test, one place where fail-closed lives.

3. Enforcement with a #[RequireAbac] attribute

Section titled “3. Enforcement with a #[RequireAbac] attribute”

The guard reads the resource id from the endpoint’s arguments, builds the environment from the request, and asks AbacService — one attribute, no per-endpoint plumbing. It reuses the RBAC guide’s AuthService for identity, so users log in once and both systems agree on who is calling. Switch between the three files:

The guarded endpoint — start here. The {id} path segment binds to $id (same #[PathVariable] mechanism as any handler), and the attribute names the resource type and action to evaluate. The resource id reaches the interceptor as the method’s first argument.

<?php
declare(strict_types=1);
namespace dev\example\rest;
use dev\example\aop\RequireAbac;
use dev\winterframework\stereotype\RestController;
use dev\winterframework\stereotype\web\PathVariable;
use dev\winterframework\stereotype\web\PostMapping;
use dev\winterframework\stereotype\web\RequestMapping;
#[RestController]
#[RequestMapping(path: 'orders')]
class OrderRefundController
{
#[PostMapping(path: '{id}/refund')]
#[RequireAbac(resource: 'order', action: 'refund')]
public function refundOrder(#[PathVariable(name: 'id')] int $id): array
{
// Reached only when a policy allowed it.
return ['status' => 'refunded', 'orderId' => $id];
}
}

The #[Cacheable] attributes are already on the readers from section 2 — here is the backend wiring and why ABAC caches even better than RBAC. Extend the RBAC guide’s CacheConfig with two containers:

config/CacheConfig.php — additions
#[Bean('redisCacheManager')]
public function getRedisCacheManager(PhpRedisTemplate $redisTpl): CacheManager
{
$manager = new SimpleCacheManager();
$manager->addCache(new RedisCache(
$redisTpl,
'abac-policies',
CacheConfiguration::get(
maximumSize: 1000,
expireAfterWriteMs: 600_000, // 10 minutes: policies change rarely
)
));
$manager->addCache(new RedisCache(
$redisTpl,
'abac-subjects',
CacheConfiguration::get(
maximumSize: 20000,
expireAfterWriteMs: 300_000, // 5 minutes: profiles change more often
)
));
return $manager;
}

(use lines: dev\winterframework\cache\CacheConfiguration, dev\winterframework\cache\CacheManager, dev\winterframework\cache\impl\SimpleCacheManager, dev\winterframework\data\redis\cache\RedisCache, dev\winterframework\data\redis\phpredis\PhpRedisTemplate — same as the RBAC CacheConfig, plus the two containers.)

What this buys you, per guarded request:

  • Policies: one Redis read for the (type, action) entry, shared by all users — a hundred agents refunding orders share one cached policy list. Writes (savePolicy, setPolicyEnabled) evict after commit, so an admin UI built on PolicyService propagates cluster-wide with no extra code.
  • Subject facts: one Redis read per user per 5 minutes instead of a profile query per request.
  • Resource facts: read live every time — one indexed primary-key lookup. Rows change with the domain, so caching them would trade correctness for little: the join-heavy work (policies, profiles) is already cached.

No new infrastructure: the same application.yml as the RBAC guide (datasource with migrations: { enabled: true }, Redis module, #[EnableCaching] on the application class) covers ABAC. The only additions are the 003/004 migration files from section 1 — they run automatically in alphabetical order after the RBAC ones.

  • RBAC at the route, ABAC at the operation. Keep the RBAC interceptor on /admin/* and put #[RequireAbac] on the sensitive endpoints inside. When both deny, the first one in the chain answers — order them so the cheaper check (role) runs before the richer one (policy evaluation).
  • Give policies an admin UI, not SQL. PolicyService::savePolicy() and setPolicyEnabled() are the write API — a small admin controller on top turns policy rollouts into clicks instead of migrations, and every write evicts correctly because the eviction lives on the service, not the caller.
  • Audit decisions, not just denials. Log the matched policy name with each allow at debug level and each deny at warning. When a user asks “why was I blocked?”, the policy name is the answer — far more useful than “403”.
  • Test the policy matrix. Minimum: no matching policy → 403; guardrail amount → 403 even for seniors; senior within guardrail → 200; standard user in hours → 200, out of hours → 403; wrong department → 403; unknown resource id → 403; disable a policy → behaviour flips on the next request (proves eviction).
  • Watch the clock. hour comes from the server clock — run NTP, and if your users span time zones, store policy hours in UTC and convert explicitly rather than using server-local time.
  • Grow deliberately. When JSON conditions feel cramped, add (in this order): the $owner pattern for ownership ({"owner_id": {"$owner": true}} already works), a version/updated_at column plus a policy-change audit table, then multi-resource policies — each step reuses isAllowed() as the funnel.

Seed an order (id 1, owner 9, amount 50, region EU, status paid) and walk the matrix with the seeded users (passwords as created in the RBAC guide):

  1. Log in as alice (support, clearance 5), POST /orders/1/refund at 10:00 → 200.
  2. Same request at 22:00 → 403 (environment condition).
  3. Raise the order amount to 5,000, retry as alice → 403; as bob (clearance 9) → still 403 (guardrail beats override).
  4. Lower it to 500, retry as bob → 200 (senior override skips the standard limits).
  5. Log in as carol (sales) → 403 on every refund (no policy matches her department).
  6. Disable support-refund-standard via setPolicyEnabled, retry as alice → 403 on the next request (proves eviction).
  7. Drop the cookie → 401.

If step 6 stays 200, the policy cache is lying: confirm the attributes name the redisCacheManager bean and that setPolicyEnabled commits before the eviction fires.

  • RBAC — the companion guide: roles, sessions, and the AuthService reused above.
  • Caching — attribute options (key, condition, unless) and the in-memory / KV backends.
  • AOP — the full advice lifecycle and the runnable AOP guard example.
  • Sessions — identity fields and store backends behind currentUserId().