From 167ce9783e4821c03bfc35d6692debe4e61f3402 Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 25 Apr 2026 18:32:19 +0100 Subject: [PATCH] fix(agent/brain): authorise org against MCP context at every entry point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Closes tasks.lthn.sh/view.php?id=312 --- php/Services/BrainService.php | 178 +++++++++++++++++++++ php/tests/Feature/Brain/OrgScopingTest.php | 80 +++++++-- 2 files changed, 245 insertions(+), 13 deletions(-) diff --git a/php/Services/BrainService.php b/php/Services/BrainService.php index 46bea51..b718099 100644 --- a/php/Services/BrainService.php +++ b/php/Services/BrainService.php @@ -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 + */ + 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 $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 + */ + 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 $keywords * @return array diff --git a/php/tests/Feature/Brain/OrgScopingTest.php b/php/tests/Feature/Brain/OrgScopingTest.php index 81aef8e..63e4abe 100644 --- a/php/tests/Feature/Brain/OrgScopingTest.php +++ b/php/tests/Feature/Brain/OrgScopingTest.php @@ -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 {