Files
InvoiceShelf/app/Services/Ai/Tools/ListRecentPaymentsTool.php
Darko Gjorgjijoski ac2a8ca939 fix(security): gate AI tools by user ability and block admin-URL SSRF
The AI chat assistant scoped tool queries by company but ignored the
per-user Bouncer abilities the rest of the app enforces, so any `use ai`
holder could read customers, invoices, payments, and company financials
their role couldn't otherwise see. Each AiTool now declares a required
ability (entity-aligned); the registry hides unauthorized tools from the
model and refuses to execute them as a backstop.

Separately, admin/owner-supplied URLs were fetched server-side with no
guard against private/reserved targets (SSRF): the AI base URL, the
CurrencyConverter "DEDICATED" exchange-rate URL, and S3/Spaces file-disk
endpoints. A shared PrivateNetworkGuard now backs a PublicHttpUrl
validation rule (save-time) and runtime guards in each driver.

- AiTool::requiredAbility() + mapping across all 12 tools
- AiToolRegistry filters schemas() by ability and re-checks in execute()
- PrivateNetworkGuard / BlockedUrlException / PublicHttpUrl rule (new)
- Rule wired into AI config (service + 3 controllers), exchange-rate,
  and file-disk endpoints; runtime guards in OpenRouterDriver,
  CurrencyConverterDriver, and FileDiskService
- Tests for ability filtering, the guard, the rule, and 422 rejections
2026-06-05 00:01:09 +02:00

85 lines
2.6 KiB
PHP

<?php
namespace App\Services\Ai\Tools;
use App\Models\Payment;
use Carbon\Carbon;
class ListRecentPaymentsTool extends AiTool
{
private const DEFAULT_DAYS = 30;
private const MAX_DAYS = 365;
private const DEFAULT_LIMIT = 20;
private const MAX_LIMIT = 100;
public function name(): string
{
return 'list_recent_payments';
}
public function description(): string
{
return 'List payments received in the last N days for the current company, sorted most recent first. Returns payment number, customer, amount, payment date, and payment method.';
}
public function parameterSchema(): array
{
return [
'type' => 'object',
'properties' => [
'days' => [
'type' => 'integer',
'minimum' => 1,
'maximum' => self::MAX_DAYS,
'description' => 'How many days back to look (default 30, max 365).',
],
'limit' => [
'type' => 'integer',
'minimum' => 1,
'maximum' => self::MAX_LIMIT,
'description' => 'Max rows to return (default 20, max 100).',
],
],
'required' => [],
];
}
public function requiredAbility(): ?array
{
return ['view-payment', Payment::class];
}
public function execute(array $arguments, int $companyId, int $userId): mixed
{
$days = min((int) ($arguments['days'] ?? self::DEFAULT_DAYS), self::MAX_DAYS);
$limit = min((int) ($arguments['limit'] ?? self::DEFAULT_LIMIT), self::MAX_LIMIT);
$since = Carbon::now()->subDays($days)->startOfDay();
$payments = Payment::query()
->where('company_id', $companyId)
->where('payment_date', '>=', $since)
->with(['customer:id,name', 'paymentMethod:id,name'])
->latest('payment_date')
->limit($limit)
->get();
return [
'since' => $since->toDateString(),
'payments' => $payments->map(fn (Payment $p): array => [
'id' => $p->id,
'payment_number' => $p->payment_number,
'payment_date' => $this->asDate($p->payment_date),
'amount' => $p->amount,
'customer_id' => $p->customer_id,
'customer_name' => $p->customer?->name,
'invoice_id' => $p->invoice_id,
'payment_method' => $p->paymentMethod?->name,
])->all(),
];
}
}