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:
Snider 2026-04-25 18:32:19 +01:00
parent a1a0981b06
commit 167ce9783e
2 changed files with 245 additions and 13 deletions

View file

@ -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>

View file

@ -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 {