Skip to content
This repository was archived by the owner on Jul 16, 2025. It is now read-only.

Commit 4504d93

Browse files
committed
feat: Add #[IsGranted] for tool access control
Resolves #360
1 parent cb6fc83 commit 4504d93

File tree

6 files changed

+265
-0
lines changed

6 files changed

+265
-0
lines changed

composer.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,11 @@
5858
"symfony/dom-crawler": "^6.4 || ^7.1",
5959
"symfony/dotenv": "^6.4 || ^7.1",
6060
"symfony/event-dispatcher": "^6.4 || ^7.1",
61+
"symfony/expression-language": "^6.4 || ^7.1",
6162
"symfony/finder": "^6.4 || ^7.1",
6263
"symfony/http-foundation": "^6.4 || ^7.1",
6364
"symfony/process": "^6.4 || ^7.1",
65+
"symfony/security-core": "^6.4 || ^7.1",
6466
"symfony/var-dumper": "^6.4 || ^7.1"
6567
},
6668
"suggest": {
@@ -73,7 +75,9 @@
7375
"mrmysql/youtube-transcript": "For using the YouTube transcription tool.",
7476
"probots-io/pinecone-php": "For using the Pinecone as retrieval vector store.",
7577
"symfony/dom-crawler": "For using the Crawler tool.",
78+
"symfony/expression-language": "For using Expressions with #[IsGranted] attribute",
7679
"symfony/http-foundation": "For using the SessionStore as message store.",
80+
"symfony/security-core": "For using #[IsGranted] attribute for tool access control",
7781
"psr/cache": "For using the CacheStore as message store."
7882
},
7983
"config": {
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
namespace PhpLlm\LlmChain\Chain\Toolbox\Security\Attribute;
4+
5+
use PhpLlm\LlmChain\Platform\Tool\Tool;
6+
use Symfony\Component\ExpressionLanguage\Expression;
7+
8+
/**
9+
* Checks if user has permission to access to some tool resource using security roles and voters.
10+
*
11+
* @see https://symfony.com/doc/current/security.html#roles
12+
*
13+
* @author Valtteri R <[email protected]>
14+
*/
15+
#[\Attribute(\Attribute::IS_REPEATABLE | \Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD)]
16+
final class IsGranted
17+
{
18+
/**
19+
* @param string|Expression $attribute The attribute that will be checked against a given authentication token and optional subject
20+
* @param array<mixed>|string|Expression|\Closure(array<string,mixed>, Tool):mixed|null $subject An optional subject - e.g. the current object being voted on
21+
* @param string|null $message A custom message when access is not granted
22+
* @param int|null $exceptionCode If set, will add the exception code to thrown exception
23+
*/
24+
public function __construct(
25+
public string|Expression $attribute,
26+
public array|string|Expression|\Closure|null $subject = null,
27+
public ?string $message = null,
28+
public ?int $exceptionCode = null,
29+
) {
30+
}
31+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
<?php
2+
3+
namespace PhpLlm\LlmChain\Chain\Toolbox\Security\EventListener;
4+
5+
use PhpLlm\LlmChain\Chain\Toolbox\Event\ToolCallArgumentsResolved;
6+
use PhpLlm\LlmChain\Chain\Toolbox\Security\Attribute\IsGranted;
7+
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
8+
use Symfony\Component\ExpressionLanguage\Expression;
9+
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
10+
use Symfony\Component\Security\Core\Authorization\AccessDecision;
11+
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
12+
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
13+
use Symfony\Component\Security\Core\Exception\RuntimeException;
14+
15+
/**
16+
* Checks {@see IsGranted} attributes on tools just before they are called.
17+
*
18+
* @author Valtteri R <[email protected]>
19+
*/
20+
#[AsEventListener]
21+
class IsGrantedAttributeListener
22+
{
23+
public function __construct(
24+
private readonly AuthorizationCheckerInterface $authChecker,
25+
private ?ExpressionLanguage $expressionLanguage = null,
26+
) {
27+
}
28+
29+
public function __invoke(ToolCallArgumentsResolved $event): void
30+
{
31+
$tool = $event->tool;
32+
$class = new \ReflectionClass($tool);
33+
$method = $class->getMethod($event->metadata->reference->method);
34+
$classAttributes = $class->getAttributes(IsGranted::class);
35+
$methodAttributes = $method->getAttributes(IsGranted::class);
36+
37+
if (!$classAttributes && !$methodAttributes) {
38+
return;
39+
}
40+
41+
$arguments = $event->arguments;
42+
43+
foreach (array_merge($classAttributes, $methodAttributes) as $attr) {
44+
/** @var IsGranted $attribute */
45+
$attribute = $attr->newInstance();
46+
$subject = null;
47+
48+
if ($subjectRef = $attribute->subject) {
49+
if (\is_array($subjectRef)) {
50+
foreach ($subjectRef as $refKey => $ref) {
51+
$subject[\is_string($refKey) ? $refKey : (string) $ref] = $this->getIsGrantedSubject($ref, $tool, $arguments);
52+
}
53+
} else {
54+
$subject = $this->getIsGrantedSubject($subjectRef, $tool, $arguments);
55+
}
56+
}
57+
58+
$accessDecision = null;
59+
// bc layer
60+
if (class_exists(AccessDecision::class)) {
61+
$accessDecision = new AccessDecision();
62+
$accessDecision->isGranted = false;
63+
$decision = &$accessDecision->isGranted;
64+
}
65+
66+
if (!$decision = $this->authChecker->isGranted($attribute->attribute, $subject, $accessDecision)) {
67+
$message = $attribute->message ?: $accessDecision->getMessage();
68+
69+
$e = new AccessDeniedException($message, code: $attribute->exceptionCode ?? 403);
70+
$e->setAttributes([$attribute->attribute]);
71+
$e->setSubject($subject);
72+
if ($accessDecision) {
73+
$e->setAccessDecision($accessDecision);
74+
}
75+
76+
throw $e;
77+
}
78+
}
79+
}
80+
81+
/**
82+
* @param array<string, mixed> $arguments
83+
*/
84+
private function getIsGrantedSubject(string|Expression|\Closure $subjectRef, object $tool, array $arguments): mixed
85+
{
86+
if ($subjectRef instanceof \Closure) {
87+
return $subjectRef($arguments, $tool);
88+
}
89+
90+
if ($subjectRef instanceof Expression) {
91+
$this->expressionLanguage ??= new ExpressionLanguage();
92+
93+
return $this->expressionLanguage->evaluate($subjectRef, [
94+
'tool' => $tool,
95+
'args' => $arguments,
96+
]);
97+
}
98+
99+
if (!\array_key_exists($subjectRef, $arguments)) {
100+
throw new RuntimeException(\sprintf('Could not find the subject "%s" for the #[IsGranted] attribute. Try adding a "$%s" argument to your tool method.', $subjectRef, $subjectRef));
101+
}
102+
103+
return $arguments[$subjectRef];
104+
}
105+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpLlm\LlmChain\Tests\Chain\Toolbox\Security;
6+
7+
use PhpLlm\LlmChain\Chain\Toolbox\Event\ToolCallArgumentsResolved;
8+
use PhpLlm\LlmChain\Chain\Toolbox\Security\EventListener\IsGrantedAttributeListener;
9+
use PhpLlm\LlmChain\Platform\Tool\ExecutionReference;
10+
use PhpLlm\LlmChain\Platform\Tool\Tool;
11+
use PhpLlm\LlmChain\Tests\Fixture\Tool\ToolWithIsGrantedOnClass;
12+
use PhpLlm\LlmChain\Tests\Fixture\Tool\ToolWithIsGrantedOnMethod;
13+
use PHPUnit\Framework\Attributes\Before;
14+
use PHPUnit\Framework\Attributes\CoversClass;
15+
use PHPUnit\Framework\Attributes\Test;
16+
use PHPUnit\Framework\Attributes\TestWith;
17+
use PHPUnit\Framework\Attributes\UsesClass;
18+
use PHPUnit\Framework\MockObject\MockObject;
19+
use PHPUnit\Framework\TestCase;
20+
use Symfony\Component\EventDispatcher\EventDispatcher;
21+
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
22+
use Symfony\Component\ExpressionLanguage\Expression;
23+
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
24+
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
25+
26+
#[CoversClass(IsGrantedAttributeListener::class)]
27+
#[UsesClass(EventDispatcher::class)]
28+
#[UsesClass(ToolCallArgumentsResolved::class)]
29+
#[UsesClass(Expression::class)]
30+
#[UsesClass(AccessDeniedException::class)]
31+
#[UsesClass(Tool::class)]
32+
#[UsesClass(ExecutionReference::class)]
33+
class IsGrantedAttributeListenerTest extends TestCase
34+
{
35+
private EventDispatcherInterface $dispatcher;
36+
private AuthorizationCheckerInterface&MockObject $authChecker;
37+
38+
#[Before]
39+
protected function setupTool(): void
40+
{
41+
$this->dispatcher = new EventDispatcher();
42+
$this->authChecker = $this->createMock(AuthorizationCheckerInterface::class);
43+
$this->dispatcher->addListener(ToolCallArgumentsResolved::class, new IsGrantedAttributeListener($this->authChecker));
44+
}
45+
46+
#[Test]
47+
#[TestWith([new ToolWithIsGrantedOnMethod(), new Tool(new ExecutionReference(ToolWithIsGrantedOnMethod::class, 'simple'), 'simple', '')])]
48+
#[TestWith([new ToolWithIsGrantedOnMethod(), new Tool(new ExecutionReference(ToolWithIsGrantedOnMethod::class, 'argumentAsSubject'), 'argumentAsSubject', '')])]
49+
#[TestWith([new ToolWithIsGrantedOnMethod(), new Tool(new ExecutionReference(ToolWithIsGrantedOnMethod::class, 'expressionAsSubject'), 'expressionAsSubject', '')])]
50+
#[TestWith([new ToolWithIsGrantedOnClass(), new Tool(new ExecutionReference(ToolWithIsGrantedOnClass::class, '__invoke'), 'ToolWithIsGrantedOnClass', '')])]
51+
public function itWillThrowWhenNotGranted(object $tool, Tool $metadata): void
52+
{
53+
$this->authChecker->expects(self::once())->method('isGranted')->willReturn(false);
54+
55+
self::expectException(AccessDeniedException::class);
56+
self::expectExceptionMessage(\sprintf('No access to %s tool.', $metadata->name));
57+
$this->dispatcher->dispatch(new ToolCallArgumentsResolved($tool, $metadata, []));
58+
}
59+
60+
#[Test]
61+
#[TestWith([new ToolWithIsGrantedOnMethod(), new Tool(new ExecutionReference(ToolWithIsGrantedOnMethod::class, 'simple'), '', '')], 'method')]
62+
public function itWillNotThrowWhenGranted(object $tool, Tool $metadata): void
63+
{
64+
$this->authChecker->expects(self::once())->method('isGranted')->with('ROLE_USER')->willReturn(true);
65+
$this->dispatcher->dispatch(new ToolCallArgumentsResolved($tool, $metadata, []));
66+
}
67+
68+
#[Test]
69+
#[TestWith([new ToolWithIsGrantedOnMethod(), new Tool(new ExecutionReference(ToolWithIsGrantedOnMethod::class, 'argumentAsSubject'), '', '')], 'method')]
70+
#[TestWith([new ToolWithIsGrantedOnClass(), new Tool(new ExecutionReference(ToolWithIsGrantedOnClass::class, '__invoke'), '', '')], 'class')]
71+
public function itWillProvideArgumentAsSubject(object $tool, Tool $metadata): void
72+
{
73+
$this->authChecker->expects(self::once())->method('isGranted')->with('test:permission', 44)->willReturn(true);
74+
$this->dispatcher->dispatch(new ToolCallArgumentsResolved($tool, $metadata, ['itemId' => 44]));
75+
}
76+
77+
#[Test]
78+
#[TestWith([new ToolWithIsGrantedOnMethod(), new Tool(new ExecutionReference(ToolWithIsGrantedOnMethod::class, 'expressionAsSubject'), '', '')], 'method')]
79+
public function itWillEvaluateSubjectExpression(object $tool, Tool $metadata): void
80+
{
81+
$this->authChecker->expects(self::once())->method('isGranted')->with('test:permission', 44)->willReturn(true);
82+
$this->dispatcher->dispatch(new ToolCallArgumentsResolved($tool, $metadata, ['itemId' => 44]));
83+
}
84+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?php
2+
3+
namespace PhpLlm\LlmChain\Tests\Fixture\Tool;
4+
5+
use PhpLlm\LlmChain\Chain\Toolbox\Security\Attribute\IsGranted;
6+
use Symfony\Component\ExpressionLanguage\Expression;
7+
8+
#[IsGranted('test:permission', new Expression('args["itemId"] ?? 0'), message: 'No access to ToolWithIsGrantedOnClass tool.')]
9+
final class ToolWithIsGrantedOnClass
10+
{
11+
public function __invoke(int $itemId): void
12+
{
13+
}
14+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
3+
namespace PhpLlm\LlmChain\Tests\Fixture\Tool;
4+
5+
use PhpLlm\LlmChain\Chain\Toolbox\Security\Attribute\IsGranted;
6+
use Symfony\Component\ExpressionLanguage\Expression;
7+
8+
final class ToolWithIsGrantedOnMethod
9+
{
10+
#[IsGranted('ROLE_USER', message: 'No access to simple tool.')]
11+
public function simple(): bool
12+
{
13+
return true;
14+
}
15+
16+
#[IsGranted('test:permission', 'itemId', message: 'No access to argumentAsSubject tool.')]
17+
public function argumentAsSubject(int $itemId): int
18+
{
19+
return $itemId;
20+
}
21+
22+
#[IsGranted('test:permission', new Expression('args["itemId"]'), message: 'No access to expressionAsSubject tool.')]
23+
public function expressionAsSubject(int $itemId): int
24+
{
25+
return $itemId;
26+
}
27+
}

0 commit comments

Comments
 (0)