Skip to content

Commit 1089e85

Browse files
committed
feature #102 [AIBundle] Add #[IsGrantedTool] for tool access control (valtzu)
This PR was merged into the main branch. Discussion ---------- [AIBundle] Add `#[IsGrantedTool]` for tool access control | Q | A | ------------- | --- | Bug fix? | no | New feature? | yes | Docs? | yes | Issues | Fix php-llm/llm-chain#360 | License | MIT Add `#[IsGrantedTool]` attribute for tool access control with similar behavior as `#[IsGranted]` in `symfony/security-http`. Moved from php-llm/llm-chain#382 Commits ------- 40761da [AIBundle] Add `#[IsGrantedTool]` for tool access control
2 parents 9c0c1ee + 40761da commit 1089e85

File tree

9 files changed

+339
-1
lines changed

9 files changed

+339
-1
lines changed

src/ai-bundle/composer.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,9 @@
2525
},
2626
"require-dev": {
2727
"phpstan/phpstan": "^2.1",
28-
"phpunit/phpunit": "^11.5"
28+
"phpunit/phpunit": "^11.5",
29+
"symfony/expression-language": "^6.4 || ^7.0",
30+
"symfony/security-core": "^6.4 || ^7.0"
2931
},
3032
"config": {
3133
"sort-packages": true

src/ai-bundle/config/services.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
use Symfony\AI\Agent\Toolbox\ToolResultConverter;
2424
use Symfony\AI\AIBundle\Profiler\DataCollector;
2525
use Symfony\AI\AIBundle\Profiler\TraceableToolbox;
26+
use Symfony\AI\AIBundle\Security\EventListener\IsGrantedToolAttributeListener;
2627

2728
return static function (ContainerConfigurator $container): void {
2829
$container->services()
@@ -69,6 +70,8 @@
6970
'$toolbox' => service(ToolboxInterface::class),
7071
'$eventDispatcher' => service('event_dispatcher')->nullOnInvalid(),
7172
])
73+
->set('symfony_ai.security.is_granted_attribute_listener', IsGrantedToolAttributeListener::class)
74+
->tag('kernel.event_listener')
7275

7376
// profiler
7477
->set(DataCollector::class)

src/ai-bundle/doc/index.rst

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,27 @@ To inject only specific tools, list them in the configuration:
180180
tools:
181181
- 'Symfony\AI\Agent\Toolbox\Tool\SimilaritySearch'
182182
183+
To restrict the access to a tool, you can use the ``IsGrantedTool`` attribute, which
184+
works similar to ``IsGranted`` attribute in `symfony/security-http`. For this to work,
185+
make sure you have `symfony/security-core` installed in your project.
186+
187+
::
188+
189+
use Symfony\AI\Agent\Attribute\IsGrantedTool;
190+
191+
#[IsGrantedTool('ROLE_ADMIN')]
192+
#[AsTool('company_name', 'Provides the name of your company')]
193+
final class CompanyName
194+
{
195+
public function __invoke(): string
196+
{
197+
return 'ACME Corp.';
198+
}
199+
}
200+
The attribute ``IsGrantedTool`` can be added on class- or method-level - even multiple
201+
times. If multiple attributes apply to one tool call, a logical AND is used and all access
202+
decisions have to grant access.
203+
183204
Profiler
184205
--------
185206

src/ai-bundle/phpstan.dist.neon

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,12 @@ parameters:
22
level: 6
33
paths:
44
- src/
5+
ignoreErrors:
6+
-
7+
message: '#\\AuthorizationCheckerInterface::isGranted\(\) invoked with 3 parameters, 1-2 required#'
8+
path: src/*
9+
reportUnmatched: false # only needed for Symfony <= 7.4 versions
10+
-
11+
message: '#method_exists\(\) with Symfony\\Component\\Security\\Core\\Exception\\AccessDeniedException and ''setAccessDecision'' will always evaluate to true#'
12+
path: src/*
13+
reportUnmatched: false # only needed for Symfony < 7.3 versions
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\AI\AIBundle\Security\Attribute;
13+
14+
use Symfony\AI\Platform\Tool\Tool;
15+
use Symfony\Component\ExpressionLanguage\Expression;
16+
17+
/**
18+
* Checks if user has permission to access to some tool resource using security roles and voters.
19+
*
20+
* @see https://symfony.com/doc/current/security.html#roles
21+
*
22+
* @author Valtteri R <[email protected]>
23+
*/
24+
#[\Attribute(\Attribute::IS_REPEATABLE | \Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD)]
25+
final class IsGrantedTool
26+
{
27+
/**
28+
* @param string|Expression $attribute The attribute that will be checked against a given authentication token and optional subject
29+
* @param array<mixed>|string|Expression|\Closure(array<string,mixed>, Tool):mixed|null $subject An optional subject - e.g. the current object being voted on
30+
* @param string|null $message A custom message when access is not granted
31+
* @param int|null $exceptionCode If set, will add the exception code to thrown exception
32+
*/
33+
public function __construct(
34+
public string|Expression $attribute,
35+
public array|string|Expression|\Closure|null $subject = null,
36+
public ?string $message = null,
37+
public ?int $exceptionCode = null,
38+
) {
39+
}
40+
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\AI\AIBundle\Security\EventListener;
13+
14+
use Symfony\AI\Agent\Toolbox\Event\ToolCallArgumentsResolved;
15+
use Symfony\AI\AIBundle\Security\Attribute\IsGrantedTool;
16+
use Symfony\Component\ExpressionLanguage\Expression;
17+
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
18+
use Symfony\Component\Security\Core\Authorization\AccessDecision;
19+
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
20+
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
21+
use Symfony\Component\Security\Core\Exception\RuntimeException;
22+
23+
/**
24+
* Checks {@see IsGrantedTool} attributes on tools just before they are called.
25+
*
26+
* @author Valtteri R <[email protected]>
27+
*/
28+
class IsGrantedToolAttributeListener
29+
{
30+
public function __construct(
31+
private readonly AuthorizationCheckerInterface $authChecker,
32+
private ?ExpressionLanguage $expressionLanguage = null,
33+
) {
34+
}
35+
36+
public function __invoke(ToolCallArgumentsResolved $event): void
37+
{
38+
$tool = $event->tool;
39+
$class = new \ReflectionClass($tool);
40+
$method = $class->getMethod($event->metadata->reference->method);
41+
$classAttributes = $class->getAttributes(IsGrantedTool::class);
42+
$methodAttributes = $method->getAttributes(IsGrantedTool::class);
43+
44+
if (!$classAttributes && !$methodAttributes) {
45+
return;
46+
}
47+
48+
$arguments = $event->arguments;
49+
50+
foreach (array_merge($classAttributes, $methodAttributes) as $attr) {
51+
/** @var IsGrantedTool $attribute */
52+
$attribute = $attr->newInstance();
53+
$subject = null;
54+
55+
if ($subjectRef = $attribute->subject) {
56+
if (\is_array($subjectRef)) {
57+
foreach ($subjectRef as $refKey => $ref) {
58+
$subject[\is_string($refKey) ? $refKey : (string) $ref] = $this->getIsGrantedSubject($ref, $tool, $arguments);
59+
}
60+
} else {
61+
$subject = $this->getIsGrantedSubject($subjectRef, $tool, $arguments);
62+
}
63+
}
64+
65+
$accessDecision = null;
66+
// bc layer
67+
if (class_exists(AccessDecision::class)) {
68+
$accessDecision = new AccessDecision();
69+
$accessDecision->isGranted = false;
70+
$decision = &$accessDecision->isGranted;
71+
}
72+
73+
if (!$decision = $this->authChecker->isGranted($attribute->attribute, $subject, $accessDecision)) {
74+
$message = $attribute->message ?: (class_exists(AccessDecision::class, false) ? $accessDecision->getMessage() : 'Access Denied.');
75+
76+
$e = new AccessDeniedException($message, code: $attribute->exceptionCode ?? 403);
77+
$e->setAttributes([$attribute->attribute]);
78+
$e->setSubject($subject);
79+
if ($accessDecision && method_exists($e, 'setAccessDecision')) {
80+
$e->setAccessDecision($accessDecision);
81+
}
82+
83+
throw $e;
84+
}
85+
}
86+
}
87+
88+
/**
89+
* @param array<string, mixed> $arguments
90+
*/
91+
private function getIsGrantedSubject(string|Expression|\Closure $subjectRef, object $tool, array $arguments): mixed
92+
{
93+
if ($subjectRef instanceof \Closure) {
94+
return $subjectRef($arguments, $tool);
95+
}
96+
97+
if ($subjectRef instanceof Expression) {
98+
$this->expressionLanguage ??= new ExpressionLanguage();
99+
100+
return $this->expressionLanguage->evaluate($subjectRef, [
101+
'tool' => $tool,
102+
'args' => $arguments,
103+
]);
104+
}
105+
106+
if (!\array_key_exists($subjectRef, $arguments)) {
107+
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));
108+
}
109+
110+
return $arguments[$subjectRef];
111+
}
112+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\AI\AIBundle\Tests\Fixture\Tool;
13+
14+
use Symfony\AI\AIBundle\Security\Attribute\IsGrantedTool;
15+
use Symfony\Component\ExpressionLanguage\Expression;
16+
17+
#[IsGrantedTool('test:permission', new Expression('args["itemId"] ?? 0'), message: 'No access to ToolWithIsGrantedOnClass tool.')]
18+
final class ToolWithIsGrantedOnClass
19+
{
20+
public function __invoke(int $itemId): void
21+
{
22+
}
23+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\AI\AIBundle\Tests\Fixture\Tool;
13+
14+
use Symfony\AI\AIBundle\Security\Attribute\IsGrantedTool;
15+
use Symfony\Component\ExpressionLanguage\Expression;
16+
17+
final class ToolWithIsGrantedOnMethod
18+
{
19+
#[IsGrantedTool('ROLE_USER', message: 'No access to simple tool.')]
20+
public function simple(): bool
21+
{
22+
return true;
23+
}
24+
25+
#[IsGrantedTool('test:permission', 'itemId', message: 'No access to argumentAsSubject tool.')]
26+
public function argumentAsSubject(int $itemId): int
27+
{
28+
return $itemId;
29+
}
30+
31+
#[IsGrantedTool('test:permission', new Expression('args["itemId"]'), message: 'No access to expressionAsSubject tool.')]
32+
public function expressionAsSubject(int $itemId): int
33+
{
34+
return $itemId;
35+
}
36+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file is part of the Symfony package.
7+
*
8+
* (c) Fabien Potencier <[email protected]>
9+
*
10+
* For the full copyright and license information, please view the LICENSE
11+
* file that was distributed with this source code.
12+
*/
13+
14+
namespace Symfony\AI\AIBundle\Tests\Security;
15+
16+
use PHPUnit\Framework\Attributes\Before;
17+
use PHPUnit\Framework\Attributes\CoversClass;
18+
use PHPUnit\Framework\Attributes\Test;
19+
use PHPUnit\Framework\Attributes\TestWith;
20+
use PHPUnit\Framework\Attributes\UsesClass;
21+
use PHPUnit\Framework\MockObject\MockObject;
22+
use PHPUnit\Framework\TestCase;
23+
use Symfony\AI\Agent\Toolbox\Event\ToolCallArgumentsResolved;
24+
use Symfony\AI\AIBundle\Security\EventListener\IsGrantedToolAttributeListener;
25+
use Symfony\AI\AIBundle\Tests\Fixture\Tool\ToolWithIsGrantedOnClass;
26+
use Symfony\AI\AIBundle\Tests\Fixture\Tool\ToolWithIsGrantedOnMethod;
27+
use Symfony\AI\Platform\Tool\ExecutionReference;
28+
use Symfony\AI\Platform\Tool\Tool;
29+
use Symfony\Component\EventDispatcher\EventDispatcher;
30+
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
31+
use Symfony\Component\ExpressionLanguage\Expression;
32+
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
33+
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
34+
35+
#[CoversClass(IsGrantedToolAttributeListener::class)]
36+
#[UsesClass(EventDispatcher::class)]
37+
#[UsesClass(ToolCallArgumentsResolved::class)]
38+
#[UsesClass(Expression::class)]
39+
#[UsesClass(AccessDeniedException::class)]
40+
#[UsesClass(Tool::class)]
41+
#[UsesClass(ExecutionReference::class)]
42+
class IsGrantedToolAttributeListenerTest extends TestCase
43+
{
44+
private EventDispatcherInterface $dispatcher;
45+
private AuthorizationCheckerInterface&MockObject $authChecker;
46+
47+
#[Before]
48+
protected function setupTool(): void
49+
{
50+
$this->dispatcher = new EventDispatcher();
51+
$this->authChecker = $this->createMock(AuthorizationCheckerInterface::class);
52+
$this->dispatcher->addListener(ToolCallArgumentsResolved::class, new IsGrantedToolAttributeListener($this->authChecker));
53+
}
54+
55+
#[Test]
56+
#[TestWith([new ToolWithIsGrantedOnMethod(), new Tool(new ExecutionReference(ToolWithIsGrantedOnMethod::class, 'simple'), 'simple', '')])]
57+
#[TestWith([new ToolWithIsGrantedOnMethod(), new Tool(new ExecutionReference(ToolWithIsGrantedOnMethod::class, 'expressionAsSubject'), 'expressionAsSubject', '')])]
58+
#[TestWith([new ToolWithIsGrantedOnClass(), new Tool(new ExecutionReference(ToolWithIsGrantedOnClass::class, '__invoke'), 'ToolWithIsGrantedOnClass', '')])]
59+
public function itWillThrowWhenNotGranted(object $tool, Tool $metadata): void
60+
{
61+
$this->authChecker->expects(self::once())->method('isGranted')->willReturn(false);
62+
63+
self::expectException(AccessDeniedException::class);
64+
self::expectExceptionMessage(\sprintf('No access to %s tool.', $metadata->name));
65+
$this->dispatcher->dispatch(new ToolCallArgumentsResolved($tool, $metadata, []));
66+
}
67+
68+
#[Test]
69+
#[TestWith([new ToolWithIsGrantedOnMethod(), new Tool(new ExecutionReference(ToolWithIsGrantedOnMethod::class, 'simple'), '', '')], 'method')]
70+
public function itWillNotThrowWhenGranted(object $tool, Tool $metadata): void
71+
{
72+
$this->authChecker->expects(self::once())->method('isGranted')->with('ROLE_USER')->willReturn(true);
73+
$this->dispatcher->dispatch(new ToolCallArgumentsResolved($tool, $metadata, []));
74+
}
75+
76+
#[Test]
77+
#[TestWith([new ToolWithIsGrantedOnMethod(), new Tool(new ExecutionReference(ToolWithIsGrantedOnMethod::class, 'argumentAsSubject'), '', '')], 'method')]
78+
#[TestWith([new ToolWithIsGrantedOnClass(), new Tool(new ExecutionReference(ToolWithIsGrantedOnClass::class, '__invoke'), '', '')], 'class')]
79+
public function itWillProvideArgumentAsSubject(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+
85+
#[Test]
86+
#[TestWith([new ToolWithIsGrantedOnMethod(), new Tool(new ExecutionReference(ToolWithIsGrantedOnMethod::class, 'expressionAsSubject'), '', '')], 'method')]
87+
public function itWillEvaluateSubjectExpression(object $tool, Tool $metadata): void
88+
{
89+
$this->authChecker->expects(self::once())->method('isGranted')->with('test:permission', 44)->willReturn(true);
90+
$this->dispatcher->dispatch(new ToolCallArgumentsResolved($tool, $metadata, ['itemId' => 44]));
91+
}
92+
}

0 commit comments

Comments
 (0)