Skip to content

Commit d5c2bbb

Browse files
committed
fix: ABAC root chain scoping and request context macro fallback
1 parent 44e8376 commit d5c2bbb

5 files changed

Lines changed: 167 additions & 14 deletions

File tree

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.5",
4+
"version": "1.0.6",
55
"type": "library",
66
"license": "MIT",
77
"keywords": [

src/Facades/Abac.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ public static function macros(): void
7171
* @return AccessResult|null
7272
*/
7373
Request::macro('abac', function (): ?AccessResult {
74-
return $this->input('abac');
74+
return $this->attributes->get('abac') ?? $this->input('abac');
7575
});
7676
}
7777
}

src/Services/Evaluators/AbacChainEvaluator.php

Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -25,25 +25,53 @@ public function __construct(
2525
* @throws Exception
2626
*/
2727
public function apply(Builder $query, AbacChain $chain, AccessContext $context): Builder
28+
{
29+
return $this->applyWithLinkMethod(
30+
$query,
31+
$chain,
32+
$context,
33+
'where'
34+
);
35+
}
36+
37+
/**
38+
* @template TModel of Model
39+
*
40+
* @param Builder<TModel> $query
41+
* @return Builder<TModel>
42+
*
43+
* @throws Exception
44+
*/
45+
private function applyWithLinkMethod(Builder $query, AbacChain $chain, AccessContext $context, string $linkMethod): Builder
2846
{
2947
$relatedChains = AbacChain::where('chain_id', $chain->id)->get();
3048
$relatedChecks = AbacCheck::where('chain_id', $chain->id)->get();
49+
$childLinkMethod = $this->resolveLinkMethod($chain->operator);
3150

32-
$method = match ($chain->operator) {
33-
LogicalOperators::OR->value => 'orWhere',
34-
default => 'where'
35-
};
51+
return $query->{$linkMethod}(function (Builder $subQuery) use ($relatedChains, $relatedChecks, $context, $childLinkMethod): void {
52+
foreach ($relatedChains as $nestedChain) {
53+
$this->applyWithLinkMethod($subQuery, $nestedChain, $context, $childLinkMethod);
54+
}
3655

37-
return $query->{$method}(
38-
function (Builder $subQuery) use ($relatedChains, $relatedChecks, $context): void {
39-
foreach ($relatedChains as $nestedChain) {
40-
$this->apply($subQuery, $nestedChain, $context);
41-
}
56+
foreach ($relatedChecks as $check) {
57+
if ($childLinkMethod === 'orWhere') {
58+
$subQuery->orWhere(function (Builder $checkQuery) use ($check, $context): void {
59+
$this->checkEvaluator->apply($checkQuery, $check, $context);
60+
});
4261

43-
foreach ($relatedChecks as $check) {
44-
$this->checkEvaluator->apply($subQuery, $check, $context);
62+
continue;
4563
}
64+
65+
$this->checkEvaluator->apply($subQuery, $check, $context);
4666
}
47-
);
67+
});
68+
}
69+
70+
private function resolveLinkMethod(string $operator): string
71+
{
72+
return match ($operator) {
73+
LogicalOperators::OR->value => 'orWhere',
74+
default => 'where',
75+
};
4876
}
4977
}

tests/Feature/AbacMiddlewareTest.php

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
use zennit\ABAC\Enums\Operators\ArithmeticOperators;
44
use zennit\ABAC\Enums\Operators\LogicalOperators;
55
use zennit\ABAC\Enums\PolicyMethod;
6+
use zennit\ABAC\Facades\Abac;
67
use zennit\ABAC\Models\AbacChain;
78
use zennit\ABAC\Models\AbacCheck;
89
use zennit\ABAC\Models\AbacPolicy;
@@ -54,6 +55,70 @@ function createTitlePolicy(string $value): void
5455
$this->actingAs($user)->getJson('/posts/post-one')->assertOk();
5556
});
5657

58+
it('allows access when any OR-linked grant matches', function () {
59+
config()->set('abac.middleware.resource_patterns', [
60+
'posts/([^/]+)' => Post::class,
61+
]);
62+
63+
Abac::addPermission('read', Post::class, ['resource.title' => 'Allowed Title']);
64+
Abac::addPermission('read', Post::class, ['resource.title' => 'Other Title']);
65+
66+
$user = User::query()->create([
67+
'_id' => 'u_or_grants',
68+
'slug' => 'or-grants-user',
69+
'name' => 'OR Grants User',
70+
'role' => 'member',
71+
]);
72+
73+
Post::query()->create([
74+
'_id' => 'p_or_grants',
75+
'slug' => 'or-grants-post',
76+
'title' => 'Allowed Title',
77+
'owner_id' => 'u_or_grants',
78+
]);
79+
80+
$this->actingAs($user)->getJson('/posts/or-grants-post')->assertOk();
81+
});
82+
83+
it('denies show access when requested resource does not match any OR-linked grant', function () {
84+
config()->set('abac.middleware.resource_patterns', [
85+
'posts/([^/]+)' => Post::class,
86+
]);
87+
88+
Abac::addPermission('read', Post::class, ['resource._id' => 'p_allowed_show']);
89+
Abac::addPermission('read', Post::class, ['resource._id' => 'p_other_allowed_show']);
90+
91+
$user = User::query()->create([
92+
'_id' => 'u_show_scope',
93+
'slug' => 'show-scope-user',
94+
'name' => 'Show Scope User',
95+
'role' => 'member',
96+
]);
97+
98+
Post::query()->create([
99+
'_id' => 'p_allowed_show',
100+
'slug' => 'allowed-show-post',
101+
'title' => 'Allowed Show Post',
102+
'owner_id' => 'u_show_scope',
103+
]);
104+
105+
Post::query()->create([
106+
'_id' => 'p_other_allowed_show',
107+
'slug' => 'other-allowed-show-post',
108+
'title' => 'Other Allowed Show Post',
109+
'owner_id' => 'u_show_scope',
110+
]);
111+
112+
Post::query()->create([
113+
'_id' => 'p_denied_show',
114+
'slug' => 'denied-show-post',
115+
'title' => 'Denied Show Post',
116+
'owner_id' => 'u_show_scope',
117+
]);
118+
119+
$this->actingAs($user)->getJson('/posts/denied-show-post')->assertUnauthorized();
120+
});
121+
57122
it('denies access when actor attributes do not satisfy policy checks', function () {
58123
config()->set('abac.middleware.resource_patterns', [
59124
'posts/([^/]+)' => Post::class,
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
<?php
2+
3+
use Illuminate\Http\Request;
4+
use zennit\ABAC\DTO\AccessContext;
5+
use zennit\ABAC\DTO\AccessResult;
6+
use zennit\ABAC\Enums\PolicyMethod;
7+
use zennit\ABAC\Facades\Abac;
8+
use zennit\ABAC\Tests\Fixtures\Models\Post;
9+
use zennit\ABAC\Tests\Fixtures\Models\User;
10+
11+
function makeAccessResult(string $userId, string $postId): AccessResult
12+
{
13+
$user = User::query()->create([
14+
'_id' => $userId,
15+
'slug' => $userId,
16+
'name' => 'Macro Test User',
17+
'role' => 'member',
18+
]);
19+
20+
Post::query()->create([
21+
'_id' => $postId,
22+
'slug' => $postId,
23+
'title' => 'Macro Test Post',
24+
'owner_id' => $userId,
25+
]);
26+
27+
$context = new AccessContext(
28+
method: PolicyMethod::READ,
29+
resource: Post::query(),
30+
actor: $user,
31+
);
32+
33+
return new AccessResult(
34+
query: Post::query(),
35+
reason: null,
36+
context: $context,
37+
can: true,
38+
);
39+
}
40+
41+
it('reads ABAC context from request attributes first', function () {
42+
Abac::macros();
43+
44+
$attributeResult = makeAccessResult('u_macro_attr', 'p_macro_attr');
45+
$inputResult = makeAccessResult('u_macro_input', 'p_macro_input');
46+
47+
$request = Request::create('/posts', 'GET', ['abac' => $inputResult]);
48+
$request->attributes->set('abac', $attributeResult);
49+
50+
expect($request->abac())->toBe($attributeResult);
51+
});
52+
53+
it('falls back to request input when attribute context is missing', function () {
54+
Abac::macros();
55+
56+
$inputResult = makeAccessResult('u_macro_fallback', 'p_macro_fallback');
57+
$request = Request::create('/posts', 'GET', ['abac' => $inputResult]);
58+
59+
expect($request->abac())->toBe($inputResult);
60+
});

0 commit comments

Comments
 (0)