Skip to content

Commit 44e8376

Browse files
committed
feat: harden ABAC defaults and improve empty-read/cache-write behavior
Fix collection READ authorization to allow empty result sets for list endpoints while keeping single-resource checks strict, and add a configurable cache write-flush toggle to prevent log/flush storms during bulk operations. Default missing-policy fallback to deny, validate configured actor attribute model classes, and add warnings/tests/docs for safer policy/attribute observability.
1 parent 75c5276 commit 44e8376

14 files changed

Lines changed: 265 additions & 4 deletions

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "zennit/abac",
33
"description": "Attribute-Based Access Control (ABAC) for Laravel",
4-
"version": "1.0.4",
4+
"version": "1.0.5",
55
"type": "library",
66
"license": "MIT",
77
"keywords": [

config/abac.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
'ttl' => env('ABAC_CACHE_TTL', AbacDefaults::CACHE_TTL),
2222
'prefix' => env('ABAC_CACHE_PREFIX', AbacDefaults::CACHE_PREFIX),
2323
'include_context' => env('ABAC_CACHE_INCLUDE_CONTEXT', AbacDefaults::CACHE_INCLUDE_CONTEXT),
24+
'flush_on_write' => env('ABAC_CACHE_FLUSH_ON_WRITE', AbacDefaults::CACHE_FLUSH_ON_WRITE),
2425
],
2526

2627
/*
@@ -63,6 +64,7 @@
6364
],
6465

6566
'policy' => [
67+
// Valid values: allow|deny
6668
'default_policy_behavior' => env('ABAC_DEFAULT_POLICY_BEHAVIOR', AbacDefaults::DEFAULT_POLICY_BEHAVIOR),
6769
],
6870

docs/CONSUMER_SETUP.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,18 @@ If your models use UUID/custom PKs:
4343
```dotenv
4444
ABAC_PRIMARY_KEY=id
4545
ABAC_FALLBACK_PRIMARY_KEY=_id
46+
ABAC_DEFAULT_POLICY_BEHAVIOR=deny
47+
ABAC_CACHE_FLUSH_ON_WRITE=true
4648
```
4749

4850
Set the model PK normally (`$primaryKey`, `$incrementing`, `$keyType`).
4951

52+
`ABAC_CACHE_FLUSH_ON_WRITE` controls automatic ABAC cache invalidation on policy/check/chain writes.
53+
Keep it `true` for standard behavior, or set it to `false` during large bulk imports and flush cache manually after the batch.
54+
55+
`ABAC_DEFAULT_POLICY_BEHAVIOR` controls fallback when a route is mapped to a resource but no policy matches.
56+
Use `deny` for fail-safe behavior (recommended), and only use `allow` when you explicitly accept permissive fallback.
57+
5058
## 4) Add middleware to protected routes
5159

5260
```php

docs/OPERATIONS.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,14 @@ For strict environments, use:
3333
- `ABAC_CACHE_INCLUDE_CONTEXT=true`
3434
- `ABAC_CACHE_STORE=redis` (or `database` when Redis is unavailable)
3535

36+
## Bulk writes and cache invalidation
37+
38+
During large seed/import operations, frequent ABAC model writes can trigger repeated cache flushes.
39+
40+
- Default behavior: `ABAC_CACHE_FLUSH_ON_WRITE=true` (flush after create/update/delete on ABAC models).
41+
- Bulk mode: set `ABAC_CACHE_FLUSH_ON_WRITE=false` for the batch window to avoid flush storms.
42+
- After bulk updates: re-enable write flushes and perform a single cache flush through your app service container.
43+
3644
## Optional metrics hooks
3745

3846
For custom telemetry (evaluation count/latency/cache hit ratio), bind your own

docs/SECURITY_MODEL.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,12 @@ This package evaluates authorization decisions (ABAC). It does not replace authe
2121

2222
Use the hardened defaults from [Operations Guide](OPERATIONS.md).
2323

24+
- Set `ABAC_DEFAULT_POLICY_BEHAVIOR=deny` to avoid permissive fallback when no policy matches.
25+
- Treat `allow` fallback as an explicit migration/rollout choice, not a long-term default.
26+
2427
## Operational controls
2528

2629
- Monitor logs for `abac.policy_miss` and denied chain outcomes.
30+
- Monitor logs for `abac.actor_attributes_empty` to catch missing actor attribute seed/data issues.
2731
- Review route-to-model mappings whenever new endpoints are added.
2832
- Keep middleware attached only to routes with explicit auth requirements.

docs/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ A flexible Attribute-Based Access Control (ABAC) implementation for Laravel 12+.
1919
- **Widening Behavior** — Each grant adds an OR branch for additive access
2020
- **Idempotent Operations** — Duplicate constraints are automatically deduplicated
2121
- **Middleware Protection** — Route protection via `abac` middleware
22-
- **Cache Invalidation** — Automatic cache clearing on policy updates
22+
- **Cache Invalidation** — Automatic cache clearing on policy updates with optional write-flush toggle for bulk operations
2323

2424
## Installation
2525

src/Models/Concerns/FlushesAbacCache.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ protected static function registerAbacCacheFlushHooks(): void
1111
{
1212
$flushCache = static function (): void {
1313
try {
14+
if (! config('abac.cache.flush_on_write', true)) {
15+
return;
16+
}
17+
1418
if (app()->bound(AbacCacheManager::class)) {
1519
app(AbacCacheManager::class)->flush();
1620
}

src/Services/AbacAttributeLoader.php

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use Exception;
66
use Illuminate\Database\Eloquent\Model;
7+
use Illuminate\Support\Facades\Log;
78
use zennit\ABAC\Models\AbacActorAdditionalAttribute;
89
use zennit\ABAC\Traits\AccessesAbacConfiguration;
910

@@ -24,6 +25,8 @@
2425
*/
2526
public function loadAllActorAttributes(Model $actor): Model
2627
{
28+
$this->validateConfiguredActorModelClass();
29+
2730
$actorId = $actor->getKey();
2831

2932
if (is_null($actorId)) {
@@ -45,11 +48,40 @@ private function loadAdditionalActorAttributes(string|int $id): array
4548
{
4649
$attributes = AbacActorAdditionalAttribute::where('_id', $id)->get();
4750

51+
if ($attributes->isEmpty() && ! AbacActorAdditionalAttribute::query()->exists()) {
52+
Log::warning('ABAC actor additional attributes table is empty.', [
53+
'event' => 'abac.actor_attributes_empty',
54+
]);
55+
}
56+
4857
$resolved = [];
4958
foreach ($attributes as $attribute) {
5059
$resolved[$attribute->key] = $attribute->value;
5160
}
5261

5362
return $resolved;
5463
}
64+
65+
/**
66+
* @throws Exception
67+
*/
68+
private function validateConfiguredActorModelClass(): void
69+
{
70+
$actorModelClass = $this->getActorAdditionalAttributes();
71+
72+
if (! class_exists($actorModelClass)) {
73+
throw new Exception(sprintf(
74+
'Configured ABAC actor model class "%s" does not exist.',
75+
$actorModelClass
76+
));
77+
}
78+
79+
if (! is_subclass_of($actorModelClass, Model::class)) {
80+
throw new Exception(sprintf(
81+
'Configured ABAC actor model class "%s" must extend %s.',
82+
$actorModelClass,
83+
Model::class
84+
));
85+
}
86+
}
5587
}

src/Services/AbacService.php

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
use zennit\ABAC\DTO\AccessContext;
1414
use zennit\ABAC\DTO\AccessResult;
1515
use zennit\ABAC\DTO\PermissionGrant;
16+
use zennit\ABAC\Enums\PolicyMethod;
1617
use zennit\ABAC\Logging\AbacAuditLogger;
1718
use zennit\ABAC\Models\AbacChain;
1819
use zennit\ABAC\Services\Evaluators\AbacChainEvaluator;
@@ -233,6 +234,10 @@ private function _evaluate(AccessContext $context): AccessResult
233234
$resourceQuery = $this->evaluator->apply($context->resource, $chain, $context);
234235
$allowed = $resourceQuery->exists();
235236

237+
if (! $allowed && $this->isCollectionReadRequest($context)) {
238+
$allowed = true;
239+
}
240+
236241
$this->logger->logChainOutcome($context, $allowed, $policy->id, $chain->id);
237242

238243
return new AccessResult(
@@ -243,6 +248,60 @@ private function _evaluate(AccessContext $context): AccessResult
243248
);
244249
}
245250

251+
private function isCollectionReadRequest(AccessContext $context): bool
252+
{
253+
if ($context->method !== PolicyMethod::READ) {
254+
return false;
255+
}
256+
257+
$model = $context->resource->getModel();
258+
$primaryKeyCandidates = $this->getPrimaryKeyCandidates($model);
259+
$primaryKeyColumns = array_values(array_unique(array_merge(
260+
$primaryKeyCandidates,
261+
array_map(
262+
static fn (string $key): string => $model->qualifyColumn($key),
263+
$primaryKeyCandidates,
264+
),
265+
)));
266+
267+
return ! $this->queryHasPrimaryKeyConstraint($context->resource->getQuery()->wheres, $primaryKeyColumns);
268+
}
269+
270+
/**
271+
* @param array<int, mixed> $wheres
272+
* @param array<int, string> $primaryKeyColumns
273+
*/
274+
private function queryHasPrimaryKeyConstraint(array $wheres, array $primaryKeyColumns): bool
275+
{
276+
foreach ($wheres as $where) {
277+
if (! is_array($where)) {
278+
continue;
279+
}
280+
281+
$column = $where['column'] ?? null;
282+
283+
if (is_string($column) && in_array($column, $primaryKeyColumns, true)) {
284+
return true;
285+
}
286+
287+
if (($where['type'] ?? null) !== 'Nested') {
288+
continue;
289+
}
290+
291+
$nested = $where['query'] ?? null;
292+
293+
$nestedWheres = is_object($nested) && property_exists($nested, 'wheres')
294+
? ($nested->wheres ?? [])
295+
: [];
296+
297+
if ($this->queryHasPrimaryKeyConstraint($nestedWheres, $primaryKeyColumns)) {
298+
return true;
299+
}
300+
}
301+
302+
return false;
303+
}
304+
246305
private function makeCacheKey(AccessContext $context): string
247306
{
248307
return $this->cacheKeyStrategy->make($context, $this->getCacheIncludeContext());

src/Support/AbacDefaults.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ final class AbacDefaults
1414

1515
public const true CACHE_INCLUDE_CONTEXT = true;
1616

17+
public const true CACHE_FLUSH_ON_WRITE = true;
18+
1719
public const true LOGGING_ENABLED = true;
1820

1921
public const string LOG_CHANNEL = 'stack';
@@ -30,7 +32,7 @@ final class AbacDefaults
3032

3133
public const string FALLBACK_PRIMARY_KEY = '_id';
3234

33-
public const string DEFAULT_POLICY_BEHAVIOR = 'allow';
35+
public const string DEFAULT_POLICY_BEHAVIOR = 'deny';
3436

3537
public const false ALLOW_IF_UNMATCHED_ROUTE = false;
3638

@@ -47,6 +49,7 @@ public static function envVariables(): array
4749
'ABAC_CACHE_TTL' => self::CACHE_TTL,
4850
'ABAC_CACHE_PREFIX' => self::CACHE_PREFIX,
4951
'ABAC_CACHE_INCLUDE_CONTEXT' => self::CACHE_INCLUDE_CONTEXT,
52+
'ABAC_CACHE_FLUSH_ON_WRITE' => self::CACHE_FLUSH_ON_WRITE,
5053
'ABAC_LOGGING_ENABLED' => self::LOGGING_ENABLED,
5154
'ABAC_LOG_CHANNEL' => self::LOG_CHANNEL,
5255
'ABAC_DETAILED_LOGGING' => self::DETAILED_LOGGING,

0 commit comments

Comments
 (0)