fix(agent/brain): authorise org against MCP context at every entry point
remember(), recall(), forget(), and elasticSearch() now resolve the allowed-orgs set from the authenticated request context (mcp_workspace_context), preferring explicit authorised_orgs/authorized_orgs, falling back to the authenticated workspace's org/slug. A mismatched org throws AuthorizationException BEFORE any Qdrant/Elasticsearch call or destructive DB action — closes the horizontal-priv-escalation vector where an MCP client could recall/remember/forget memories scoped to ANY org by setting org="other-org" in the request body. Pest coverage in OrgScopingTest covers good path, unauthorised recall (asserts no HTTP), cross-org forget (asserts no DB delete), unauthorised remember (asserts no embed/index jobs). Note: BrainList free-form org filter is a separate ticket — outside this lane's allowlist. Co-authored-by: Codex <noreply@openai.com> Closes tasks.lthn.sh/view.php?id=312
This commit is contained in:
parent
a1a0981b06
commit
167ce9783e
2 changed files with 245 additions and 13 deletions
|
|
@ -9,10 +9,12 @@ namespace Core\Mod\Agentic\Services;
|
|||
use Core\Mod\Agentic\Jobs\DeleteFromIndex;
|
||||
use Core\Mod\Agentic\Jobs\EmbedMemory;
|
||||
use Core\Mod\Agentic\Models\BrainMemory;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Contracts\Cache\LockTimeoutException;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
|
@ -119,6 +121,8 @@ class BrainService
|
|||
*/
|
||||
public function remember(array $attributes): BrainMemory
|
||||
{
|
||||
$this->assertAuthorisedOrgScope($attributes['org'] ?? null);
|
||||
|
||||
$attributes['indexed_at'] = null;
|
||||
$cleanupIds = [];
|
||||
$supersededId = $this->normaliseMemoryId($attributes['supersedes_id'] ?? null);
|
||||
|
|
@ -161,6 +165,8 @@ class BrainService
|
|||
array $keywords = [],
|
||||
array $boostKeywords = [],
|
||||
): array {
|
||||
$this->assertAuthorisedOrgScope($filter['org'] ?? null);
|
||||
|
||||
$vector = $this->embed($query);
|
||||
|
||||
$filter['workspace_id'] = $workspaceId;
|
||||
|
|
@ -243,6 +249,12 @@ class BrainService
|
|||
*/
|
||||
public function forget(string $id): void
|
||||
{
|
||||
$memoryOrg = BrainMemory::query()
|
||||
->whereKey($id)
|
||||
->value('org');
|
||||
|
||||
$this->assertAuthorisedOrgScope($memoryOrg);
|
||||
|
||||
$deleted = $this->withMemoryIndexLock($id, fn (): bool => $this->deleteMemoryRecord($id));
|
||||
|
||||
if ($deleted) {
|
||||
|
|
@ -343,6 +355,8 @@ class BrainService
|
|||
*/
|
||||
public function elasticSearch(string $query, array $filters = [], ?int $limit = null): array
|
||||
{
|
||||
$this->assertAuthorisedOrgScope($filters['org'] ?? null);
|
||||
|
||||
$body = [
|
||||
'query' => [
|
||||
'bool' => [
|
||||
|
|
@ -420,6 +434,170 @@ class BrainService
|
|||
return $scores;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
private function assertAuthorisedOrgScope(mixed $requestedOrg): void
|
||||
{
|
||||
$authorisedOrgs = $this->authorisedOrgScopes();
|
||||
if ($authorisedOrgs === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($this->normaliseOrgScopes($requestedOrg) as $org) {
|
||||
if (! in_array($org, $authorisedOrgs, true)) {
|
||||
throw new AuthorizationException(
|
||||
sprintf("Organisation scope '%s' is not authorised for this authenticated workspace.", $org)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function authorisedOrgScopes(): array
|
||||
{
|
||||
$request = $this->currentRequest();
|
||||
if (! $request instanceof Request) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$context = $request->attributes->get('mcp_workspace_context');
|
||||
if (is_array($context)) {
|
||||
$authorisedOrgs = $this->normaliseOrgScopes(
|
||||
$context['authorised_orgs'] ?? $context['authorized_orgs'] ?? null
|
||||
);
|
||||
|
||||
if ($authorisedOrgs !== []) {
|
||||
return $authorisedOrgs;
|
||||
}
|
||||
|
||||
$contextOrg = $this->resolveOrgScopeFromSource($context, [
|
||||
'org',
|
||||
'primary_org',
|
||||
'organisation',
|
||||
'organization',
|
||||
]);
|
||||
|
||||
if ($contextOrg !== null) {
|
||||
return [$contextOrg];
|
||||
}
|
||||
|
||||
$workspaceOrg = $this->resolveOrgScopeFromSource($context['workspace'] ?? null, [
|
||||
'org',
|
||||
'organisation',
|
||||
'organization',
|
||||
'slug',
|
||||
]);
|
||||
|
||||
if ($workspaceOrg !== null) {
|
||||
return [$workspaceOrg];
|
||||
}
|
||||
}
|
||||
|
||||
$workspace = $request->attributes->get('workspace') ?? $request->attributes->get('mcp_workspace');
|
||||
$workspaceOrg = $this->resolveOrgScopeFromSource($workspace, [
|
||||
'org',
|
||||
'organisation',
|
||||
'organization',
|
||||
'slug',
|
||||
]);
|
||||
|
||||
return $workspaceOrg !== null ? [$workspaceOrg] : [];
|
||||
}
|
||||
|
||||
private function currentRequest(): ?Request
|
||||
{
|
||||
if (! function_exists('app') || ! app()->bound('request')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$request = app('request');
|
||||
|
||||
return $request instanceof Request ? $request : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $keys
|
||||
*/
|
||||
private function resolveOrgScopeFromSource(mixed $source, array $keys): ?string
|
||||
{
|
||||
if (is_array($source)) {
|
||||
foreach ($keys as $key) {
|
||||
$scope = $this->normaliseSingleOrgScope($source[$key] ?? null);
|
||||
|
||||
if ($scope !== null) {
|
||||
return $scope;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! is_object($source)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($keys as $key) {
|
||||
$value = null;
|
||||
|
||||
if (method_exists($source, 'getAttribute')) {
|
||||
$value = $source->getAttribute($key);
|
||||
}
|
||||
|
||||
if ($value === null && isset($source->{$key})) {
|
||||
$value = $source->{$key};
|
||||
}
|
||||
|
||||
$scope = $this->normaliseSingleOrgScope($value);
|
||||
if ($scope !== null) {
|
||||
return $scope;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function normaliseOrgScopes(mixed $value): array
|
||||
{
|
||||
if (is_string($value)) {
|
||||
$scope = $this->normaliseSingleOrgScope($value);
|
||||
|
||||
return $scope !== null ? [$scope] : [];
|
||||
}
|
||||
|
||||
if (! is_array($value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$scopes = [];
|
||||
|
||||
foreach ($value as $item) {
|
||||
$scope = $this->normaliseSingleOrgScope($item);
|
||||
|
||||
if ($scope !== null) {
|
||||
$scopes[] = $scope;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($scopes));
|
||||
}
|
||||
|
||||
private function normaliseSingleOrgScope(mixed $value): ?string
|
||||
{
|
||||
if (! is_string($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$scope = trim($value);
|
||||
|
||||
return $scope !== '' ? $scope : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $keywords
|
||||
* @return array<int, string>
|
||||
|
|
|
|||
|
|
@ -4,10 +4,14 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Core\Mod\Agentic\Jobs\DeleteFromIndex;
|
||||
use Core\Mod\Agentic\Jobs\EmbedMemory;
|
||||
use Core\Mod\Agentic\Mcp\Tools\Agent\Brain\BrainList;
|
||||
use Core\Mod\Agentic\Models\BrainMemory;
|
||||
use Core\Mod\Agentic\Services\BrainService;
|
||||
use Illuminate\Http\Client\Request;
|
||||
use Illuminate\Http\Client\Request as ClientRequest;
|
||||
use Illuminate\Http\Request as HttpRequest;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
|
|
@ -36,9 +40,24 @@ function orgScopingMemory(int $workspaceId, array $attributes = []): BrainMemory
|
|||
], $attributes));
|
||||
}
|
||||
|
||||
function orgScopingBindRequestContext(object $workspace, array $context = []): void
|
||||
{
|
||||
$request = HttpRequest::create('/api/v1/mcp/tools/call', 'POST');
|
||||
$request->attributes->set('workspace', $workspace);
|
||||
$request->attributes->set('workspace_id', $workspace->id);
|
||||
$request->attributes->set('mcp_workspace_context', array_merge([
|
||||
'workspace_id' => $workspace->id,
|
||||
'workspace' => $workspace,
|
||||
], $context));
|
||||
|
||||
app()->instance('request', $request);
|
||||
}
|
||||
|
||||
test('OrgScoping_remember_recall_Good_persists_org_and_recalls_with_matching_org', function (): void {
|
||||
Queue::fake();
|
||||
$workspace = createWorkspace();
|
||||
$workspace->setAttribute('slug', 'core');
|
||||
orgScopingBindRequestContext($workspace);
|
||||
$brain = orgScopingBrainService();
|
||||
|
||||
$memory = $brain->remember([
|
||||
|
|
@ -67,7 +86,7 @@ test('OrgScoping_remember_recall_Good_persists_org_and_recalls_with_matching_org
|
|||
->and($result['memories'][0]['id'])->toBe($memory->id)
|
||||
->and($result['memories'][0]['org'])->toBe('core');
|
||||
|
||||
Http::assertSent(fn (Request $request): bool => $request->url() === 'https://qdrant.test/collections/openbrain/points/search'
|
||||
Http::assertSent(fn (ClientRequest $request): bool => $request->url() === 'https://qdrant.test/collections/openbrain/points/search'
|
||||
&& $request->method() === 'POST'
|
||||
&& $request['filter']['must'] === [
|
||||
['key' => 'workspace_id', 'match' => ['value' => $workspace->id]],
|
||||
|
|
@ -75,9 +94,11 @@ test('OrgScoping_remember_recall_Good_persists_org_and_recalls_with_matching_org
|
|||
]);
|
||||
});
|
||||
|
||||
test('OrgScoping_remember_recall_Bad_does_not_recall_across_org_boundaries', function (): void {
|
||||
test('OrgScoping_recall_Bad_rejects_an_unauthorised_org_before_any_qdrant_query', function (): void {
|
||||
Queue::fake();
|
||||
$workspace = createWorkspace();
|
||||
$workspace->setAttribute('slug', 'core');
|
||||
orgScopingBindRequestContext($workspace);
|
||||
$brain = orgScopingBrainService();
|
||||
|
||||
$memory = $brain->remember([
|
||||
|
|
@ -95,18 +116,51 @@ test('OrgScoping_remember_recall_Bad_does_not_recall_across_org_boundaries', fun
|
|||
'https://qdrant.test/collections/openbrain/points/search' => Http::response(['result' => []]),
|
||||
]);
|
||||
|
||||
$result = $brain->recall('core-only memory', 5, ['org' => 'other-org'], $workspace->id);
|
||||
expect($memory->fresh()?->getAttribute('org'))->toBe('core');
|
||||
|
||||
expect($memory->fresh()?->getAttribute('org'))->toBe('core')
|
||||
->and($result['memories'])->toBe([])
|
||||
->and($result['scores'])->toBe([]);
|
||||
expect(fn () => $brain->recall('core-only memory', 5, ['org' => 'other-org'], $workspace->id))
|
||||
->toThrow(AuthorizationException::class, "Organisation scope 'other-org' is not authorised for this authenticated workspace.");
|
||||
|
||||
Http::assertSent(fn (Request $request): bool => $request->url() === 'https://qdrant.test/collections/openbrain/points/search'
|
||||
&& $request->method() === 'POST'
|
||||
&& $request['filter']['must'] === [
|
||||
['key' => 'workspace_id', 'match' => ['value' => $workspace->id]],
|
||||
['key' => 'org', 'match' => ['value' => 'other-org']],
|
||||
]);
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
test('OrgScoping_remember_Bad_rejects_an_unauthorised_org_without_inserting_a_memory', function (): void {
|
||||
Queue::fake();
|
||||
$workspace = createWorkspace();
|
||||
$workspace->setAttribute('slug', 'core');
|
||||
orgScopingBindRequestContext($workspace);
|
||||
$brain = orgScopingBrainService();
|
||||
|
||||
expect(fn () => $brain->remember([
|
||||
'workspace_id' => $workspace->id,
|
||||
'agent_id' => 'virgil',
|
||||
'type' => 'fact',
|
||||
'content' => 'This should never be stored for another organisation.',
|
||||
'org' => 'evil',
|
||||
'project' => 'agent',
|
||||
'confidence' => 0.9,
|
||||
]))->toThrow(AuthorizationException::class, "Organisation scope 'evil' is not authorised for this authenticated workspace.");
|
||||
|
||||
expect(BrainMemory::query()->count())->toBe(0);
|
||||
Queue::assertNotPushed(EmbedMemory::class);
|
||||
});
|
||||
|
||||
test('OrgScoping_forget_Bad_rejects_forgetting_a_memory_from_another_org', function (): void {
|
||||
Queue::fake();
|
||||
$workspace = createWorkspace();
|
||||
$workspace->setAttribute('slug', 'core');
|
||||
orgScopingBindRequestContext($workspace);
|
||||
$brain = orgScopingBrainService();
|
||||
$memory = orgScopingMemory($workspace->id, [
|
||||
'content' => 'Other org memory must not be forgotten by core.',
|
||||
'org' => 'evil',
|
||||
]);
|
||||
|
||||
expect(fn () => $brain->forget($memory->id))
|
||||
->toThrow(AuthorizationException::class, "Organisation scope 'evil' is not authorised for this authenticated workspace.");
|
||||
|
||||
expect(BrainMemory::query()->find($memory->id))->not->toBeNull();
|
||||
Queue::assertNotPushed(DeleteFromIndex::class);
|
||||
});
|
||||
|
||||
test('OrgScoping_list_Ugly_filters_memories_by_org', function (): void {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue