diff --git a/README.md b/README.md index 77cbea9..8089866 100644 --- a/README.md +++ b/README.md @@ -184,7 +184,8 @@ aport-integrations/ │ └── postman-collection/ # Postman collection ├── templates/ # Integration scaffolding templates │ ├── javascript-middleware/ # Express.js template -│ └── python-middleware/ # FastAPI template +│ ├── python-middleware/ # FastAPI template +│ └── laravel-middleware/ # Laravel Composer package template ├── sdk/ # References to official SDKs │ └── README.md # Links to aport-sdks repository └── docs/ # Integration documentation @@ -247,7 +248,7 @@ Native support for popular web frameworks. | Express.js | Middleware package | ✅ Active | Community | | FastAPI | Middleware package | ✅ Active | Community | | Django | Middleware package | 🚧 In Progress | Community | -| Laravel | Composer package | 📋 Planned | Community | +| Laravel | [Composer package](templates/laravel-middleware/) | ✅ Active | Community | | Rails | Ruby gem | 📋 Planned | Community | | Go | Official SDK | 🚧 In Progress | Community | diff --git a/templates/laravel-middleware/README.md b/templates/laravel-middleware/README.md new file mode 100644 index 0000000..f05e7b1 --- /dev/null +++ b/templates/laravel-middleware/README.md @@ -0,0 +1,95 @@ +# APort Laravel Middleware Package + +Composer package template for protecting Laravel routes with APort policy verification. +It includes route middleware, a small APort HTTP client, configuration publishing, +an Artisan policy-stub command, tests, and a runnable example layout. + +## Features + +- `aport.policy` route middleware for Laravel 10, 11, and 12. +- Agent id extraction from `X-Agent-ID`, `X-APort-Agent-ID`, query strings, or JSON bodies. +- APort verification requests with Laravel's HTTP client. +- Fail-closed behavior by default, with an optional `APORT_FAIL_OPEN=true` mode. +- `php artisan aport:policy` command to create local policy context stubs. +- PHPUnit/Testbench coverage for allow, deny, and missing-agent paths. + +## Installation + +```bash +composer require aporthq/laravel-middleware +php artisan vendor:publish --tag=aport-config +``` + +Set the required environment variables: + +```env +APORT_API_KEY=aport_test_key +APORT_BASE_URL=https://api.aport.io +APORT_DEFAULT_POLICY=finance.payment.refund.v1 +APORT_TIMEOUT=5 +APORT_FAIL_OPEN=false +``` + +Laravel package auto-discovery registers `AportServiceProvider` and aliases the +middleware as `aport.policy`. + +## Route Usage + +```php +use App\Http\Controllers\RefundController; +use Illuminate\Support\Facades\Route; + +Route::post('/refunds', [RefundController::class, 'store']) + ->middleware('aport.policy:finance.payment.refund.v1'); +``` + +Requests must include an agent id: + +```bash +curl -X POST http://localhost/api/refunds \ + -H "Content-Type: application/json" \ + -H "X-Agent-ID: agt_refund_bot_123" \ + -d '{"amount": 49.99, "order_id": "ord_123"}' +``` + +After a successful check, the middleware adds attributes to the request: + +```php +$request->attributes->get('aport'); +$request->attributes->get('aport.agent_id'); +$request->attributes->get('aport.passport'); +$request->attributes->get('aport.policy'); +``` + +## Artisan Command + +Create a local policy context stub: + +```bash +php artisan aport:policy refund-approval --policy=finance.payment.refund.v1 +``` + +This writes `config/aport-policies/refund-approval.json` with a starter context +document that teams can review next to their Laravel configuration. + +## Example App + +See `examples/laravel-app` for a minimal route/controller layout that protects a +refund endpoint. Copy those files into a Laravel application, configure +`APORT_API_KEY`, and run the route through Laravel's normal HTTP kernel. + +## Testing + +```bash +composer install +composer test +``` + +The tests use Orchestra Testbench and Laravel HTTP fakes. No live APort API call +or real API key is required. + +## Notes + +The package uses Laravel's HTTP client as a lightweight transport wrapper. If an +official `aporthq/sdk-php` package is installed later, `AportClient` can be +adapted behind the same middleware contract without changing application routes. diff --git a/templates/laravel-middleware/composer.json b/templates/laravel-middleware/composer.json new file mode 100644 index 0000000..8accd58 --- /dev/null +++ b/templates/laravel-middleware/composer.json @@ -0,0 +1,44 @@ +{ + "name": "aporthq/laravel-middleware", + "description": "Laravel middleware and Artisan tooling for APort policy verification.", + "type": "library", + "license": "MIT", + "require": { + "php": "^8.1", + "illuminate/console": "^10.0|^11.0|^12.0", + "illuminate/contracts": "^10.0|^11.0|^12.0", + "illuminate/filesystem": "^10.0|^11.0|^12.0", + "illuminate/http": "^10.0|^11.0|^12.0", + "illuminate/routing": "^10.0|^11.0|^12.0", + "illuminate/support": "^10.0|^11.0|^12.0" + }, + "require-dev": { + "orchestra/testbench": "^8.0|^9.0|^10.0", + "phpunit/phpunit": "^10.5|^11.0" + }, + "suggest": { + "aporthq/sdk-php": "Use the official APort PHP SDK transport when it is available." + }, + "autoload": { + "psr-4": { + "Aport\\Laravel\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Aport\\Laravel\\Tests\\": "tests/" + } + }, + "extra": { + "laravel": { + "providers": [ + "Aport\\Laravel\\AportServiceProvider" + ] + } + }, + "scripts": { + "test": "vendor/bin/phpunit" + }, + "minimum-stability": "stable", + "prefer-stable": true +} diff --git a/templates/laravel-middleware/config/aport.php b/templates/laravel-middleware/config/aport.php new file mode 100644 index 0000000..ec5bce4 --- /dev/null +++ b/templates/laravel-middleware/config/aport.php @@ -0,0 +1,17 @@ + env('APORT_API_KEY'), + + 'base_url' => env('APORT_BASE_URL', 'https://api.aport.io'), + + 'default_policy' => env('APORT_DEFAULT_POLICY', 'finance.payment.refund.v1'), + + 'timeout' => (int) env('APORT_TIMEOUT', 5), + + 'fail_open' => (bool) env('APORT_FAIL_OPEN', false), + + 'context' => [ + 'service' => env('APP_NAME', 'laravel'), + ], +]; diff --git a/templates/laravel-middleware/env.example b/templates/laravel-middleware/env.example new file mode 100644 index 0000000..80e8492 --- /dev/null +++ b/templates/laravel-middleware/env.example @@ -0,0 +1,5 @@ +APORT_API_KEY=aport_test_key +APORT_BASE_URL=https://api.aport.io +APORT_DEFAULT_POLICY=finance.payment.refund.v1 +APORT_TIMEOUT=5 +APORT_FAIL_OPEN=false diff --git a/templates/laravel-middleware/examples/laravel-app/README.md b/templates/laravel-middleware/examples/laravel-app/README.md new file mode 100644 index 0000000..403123b --- /dev/null +++ b/templates/laravel-middleware/examples/laravel-app/README.md @@ -0,0 +1,28 @@ +# Minimal Laravel Usage Example + +Copy the `routes/api.php` and `RefundController.php` files into an existing +Laravel application after installing the package: + +```bash +composer require aporthq/laravel-middleware +php artisan vendor:publish --tag=aport-config +``` + +Configure `.env`: + +```env +APORT_API_KEY=aport_test_key +APORT_DEFAULT_POLICY=finance.payment.refund.v1 +``` + +Run the example request: + +```bash +curl -X POST http://localhost/api/refunds \ + -H "Content-Type: application/json" \ + -H "X-Agent-ID: agt_refund_bot_123" \ + -d '{"order_id":"ord_123","amount":49.99}' +``` + +The route only reaches `RefundController@store` when APort returns an allowed +verification result. diff --git a/templates/laravel-middleware/examples/laravel-app/app/Http/Controllers/RefundController.php b/templates/laravel-middleware/examples/laravel-app/app/Http/Controllers/RefundController.php new file mode 100644 index 0000000..c6cc917 --- /dev/null +++ b/templates/laravel-middleware/examples/laravel-app/app/Http/Controllers/RefundController.php @@ -0,0 +1,27 @@ +validate([ + 'order_id' => ['required', 'string'], + 'amount' => ['required', 'numeric', 'min:0.01'], + ]); + + return response()->json([ + 'status' => 'refund queued', + 'order_id' => $validated['order_id'], + 'amount' => $validated['amount'], + 'aport' => [ + 'agent_id' => $request->attributes->get('aport.agent_id'), + 'policy' => $request->attributes->get('aport.policy'), + ], + ]); + } +} diff --git a/templates/laravel-middleware/examples/laravel-app/routes/api.php b/templates/laravel-middleware/examples/laravel-app/routes/api.php new file mode 100644 index 0000000..353b3c6 --- /dev/null +++ b/templates/laravel-middleware/examples/laravel-app/routes/api.php @@ -0,0 +1,7 @@ +middleware('aport.policy:finance.payment.refund.v1'); diff --git a/templates/laravel-middleware/phpunit.xml.dist b/templates/laravel-middleware/phpunit.xml.dist new file mode 100644 index 0000000..e32338d --- /dev/null +++ b/templates/laravel-middleware/phpunit.xml.dist @@ -0,0 +1,8 @@ + + + + + tests + + + diff --git a/templates/laravel-middleware/src/AportClient.php b/templates/laravel-middleware/src/AportClient.php new file mode 100644 index 0000000..db7f84a --- /dev/null +++ b/templates/laravel-middleware/src/AportClient.php @@ -0,0 +1,82 @@ +apiKey === null || $this->apiKey === '') { + throw new AportVerificationException('APort API key is not configured.'); + } + + $response = $this->request()->post( + '/api/verify/policy/' . rawurlencode($policy), + [ + 'agent_id' => $agentId, + 'context' => $context === [] ? (object) [] : $context, + ], + ); + + if (! $response->successful()) { + throw AportVerificationException::fromResponse($response); + } + + $data = $response->json() ?? []; + $verified = (bool) ($data['verified'] ?? $data['allow'] ?? false); + + return [ + 'verified' => $verified, + 'policy' => (string) ($data['policy'] ?? $policy), + 'agent_id' => (string) ($data['agent_id'] ?? $agentId), + 'passport' => $data['passport'] ?? null, + 'reasons' => $this->normalizeReasons($data['reasons'] ?? []), + 'raw' => $data, + ]; + } + + private function normalizeReasons(mixed $reasons): array + { + if ($reasons === null || $reasons === '') { + return []; + } + + return is_array($reasons) ? $reasons : [$reasons]; + } + + private function request(): PendingRequest + { + $factory = $this->http ?? app(HttpFactory::class); + + return $factory + ->baseUrl(rtrim($this->baseUrl, '/')) + ->acceptJson() + ->asJson() + ->timeout($this->timeout) + ->withToken($this->apiKey); + } +} diff --git a/templates/laravel-middleware/src/AportServiceProvider.php b/templates/laravel-middleware/src/AportServiceProvider.php new file mode 100644 index 0000000..84b11b5 --- /dev/null +++ b/templates/laravel-middleware/src/AportServiceProvider.php @@ -0,0 +1,40 @@ +mergeConfigFrom(__DIR__ . '/../config/aport.php', 'aport'); + + $this->app->singleton(AportClient::class, function ($app): AportClient { + return new AportClient( + config('aport.api_key'), + config('aport.base_url', 'https://api.aport.io'), + $app->make(HttpFactory::class), + (int) config('aport.timeout', 5), + ); + }); + } + + public function boot(): void + { + $this->publishes([ + __DIR__ . '/../config/aport.php' => config_path('aport.php'), + ], 'aport-config'); + + $this->app['router']->aliasMiddleware('aport.policy', RequireAportPolicy::class); + + if ($this->app->runningInConsole()) { + $this->commands([ + MakeAportPolicyCommand::class, + ]); + } + } +} diff --git a/templates/laravel-middleware/src/Console/MakeAportPolicyCommand.php b/templates/laravel-middleware/src/Console/MakeAportPolicyCommand.php new file mode 100644 index 0000000..1bfa4d1 --- /dev/null +++ b/templates/laravel-middleware/src/Console/MakeAportPolicyCommand.php @@ -0,0 +1,42 @@ +argument('name'))->kebab()->toString(); + $policy = $this->option('policy') ?: config('aport.default_policy'); + $directory = config_path('aport-policies'); + $path = $directory . DIRECTORY_SEPARATOR . $name . '.json'; + + if ($files->exists($path) && ! $this->confirm("{$path} already exists. Overwrite it?")) { + $this->warn('Policy stub was not changed.'); + + return self::FAILURE; + } + + $files->ensureDirectoryExists($directory); + $files->put($path, json_encode([ + 'policy' => $policy, + 'description' => 'Describe the protected action here.', + 'context' => [ + 'source' => 'laravel', + 'action' => $name, + ], + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL); + + $this->info("Created APort policy stub: {$path}"); + + return self::SUCCESS; + } +} diff --git a/templates/laravel-middleware/src/Exceptions/AportVerificationException.php b/templates/laravel-middleware/src/Exceptions/AportVerificationException.php new file mode 100644 index 0000000..f8fa992 --- /dev/null +++ b/templates/laravel-middleware/src/Exceptions/AportVerificationException.php @@ -0,0 +1,19 @@ +json() ?? []; + $message = $payload['message'] + ?? $payload['error'] + ?? 'APort verification request failed.'; + + return new self($message, $response->status()); + } +} diff --git a/templates/laravel-middleware/src/Http/Middleware/RequireAportPolicy.php b/templates/laravel-middleware/src/Http/Middleware/RequireAportPolicy.php new file mode 100644 index 0000000..1ccbc47 --- /dev/null +++ b/templates/laravel-middleware/src/Http/Middleware/RequireAportPolicy.php @@ -0,0 +1,121 @@ +extractAgentId($request); + + if ($agentId === null) { + return $this->jsonError( + 'APort agent id is required.', + 'Provide X-Agent-ID, X-APort-Agent-ID, agent_id, or agentId.', + 400, + ); + } + + try { + $result = $this->client->verify($policy, $agentId, $this->contextFor($request)); + } catch (AportVerificationException $exception) { + if ((bool) config('aport.fail_open', false)) { + $request->attributes->set('aport', [ + 'verified' => false, + 'agent_id' => $agentId, + 'policy' => $policy, + 'error' => $exception->getMessage(), + ]); + $request->attributes->set('aport.agent_id', $agentId); + $request->attributes->set('aport.policy', $policy); + + return $next($request); + } + + return $this->jsonError('APort verification failed.', $exception->getMessage(), 502); + } + + if (! $result['verified']) { + return $this->jsonError( + 'APort policy denied this request.', + 'The agent is not allowed to perform this action.', + 403, + ['reasons' => $result['reasons']], + ); + } + + $request->attributes->set('aport', $result); + $request->attributes->set('aport.agent_id', $agentId); + $request->attributes->set('aport.passport', $result['passport']); + $request->attributes->set('aport.policy', $policy); + + return $next($request); + } + + private function extractAgentId(Request $request): ?string + { + $candidates = [ + $request->header('X-Agent-ID'), + $request->header('X-APort-Agent-ID'), + $request->query('agent_id'), + $request->input('agent_id'), + $request->input('agentId'), + ]; + + foreach ($candidates as $candidate) { + if (is_string($candidate) && trim($candidate) !== '') { + return trim($candidate); + } + } + + return null; + } + + private function contextFor(Request $request): array + { + $configuredContext = config('aport.context', []); + $requestContext = $request->attributes->get('aport_context', []); + + if (! is_array($configuredContext)) { + $configuredContext = []; + } + + if (! is_array($requestContext)) { + $requestContext = []; + } + + return array_filter([ + ...$configuredContext, + 'method' => $request->method(), + 'path' => '/' . ltrim($request->path(), '/'), + 'route' => $request->route()?->getName(), + 'ip' => $request->ip(), + ...$requestContext, + ], static fn ($value): bool => $value !== null); + } + + private function jsonError( + string $message, + string $detail, + int $status, + array $extra = [], + ): JsonResponse { + return response()->json([ + 'message' => $message, + 'detail' => $detail, + ...$extra, + ], $status); + } +} diff --git a/templates/laravel-middleware/tests/Feature/RequireAportPolicyTest.php b/templates/laravel-middleware/tests/Feature/RequireAportPolicyTest.php new file mode 100644 index 0000000..25e34a9 --- /dev/null +++ b/templates/laravel-middleware/tests/Feature/RequireAportPolicyTest.php @@ -0,0 +1,118 @@ +set('aport.api_key', 'test-key'); + + Route::post('/refunds', function (Request $request) { + return response()->json([ + 'agent_id' => $request->attributes->get('aport.agent_id'), + 'policy' => $request->attributes->get('aport.policy'), + 'verified' => $request->attributes->get('aport')['verified'] ?? null, + ]); + })->middleware('aport.policy:finance.payment.refund.v1'); + } + + public function test_it_allows_verified_agents(): void + { + Http::fake([ + 'api.aport.io/*' => Http::response([ + 'verified' => true, + 'passport' => ['agent_id' => 'agt_123'], + ]), + ]); + + $response = $this + ->withHeader('X-Agent-ID', 'agt_123') + ->postJson('/refunds', ['amount' => 10]); + + $response + ->assertOk() + ->assertJson([ + 'agent_id' => 'agt_123', + 'policy' => 'finance.payment.refund.v1', + ]); + } + + public function test_it_rejects_missing_agent_ids(): void + { + $response = $this->postJson('/refunds', ['amount' => 10]); + + $response + ->assertStatus(400) + ->assertJson([ + 'message' => 'APort agent id is required.', + ]); + } + + public function test_it_rejects_denied_policy_results(): void + { + Http::fake([ + 'api.aport.io/*' => Http::response([ + 'verified' => false, + 'reasons' => ['refund limit exceeded'], + ]), + ]); + + $response = $this + ->withHeader('X-Agent-ID', 'agt_123') + ->postJson('/refunds', ['amount' => 1000]); + + $response + ->assertStatus(403) + ->assertJson([ + 'message' => 'APort policy denied this request.', + 'reasons' => ['refund limit exceeded'], + ]); + } + + public function test_it_fails_closed_when_aport_returns_an_error(): void + { + Http::fake([ + 'api.aport.io/*' => Http::response(['message' => 'APort unavailable'], 503), + ]); + + $response = $this + ->withHeader('X-Agent-ID', 'agt_123') + ->postJson('/refunds', ['amount' => 10]); + + $response + ->assertStatus(502) + ->assertJson([ + 'message' => 'APort verification failed.', + 'detail' => 'APort unavailable', + ]); + } + + public function test_it_can_fail_open_with_agent_and_policy_attributes(): void + { + config()->set('aport.fail_open', true); + + Http::fake([ + 'api.aport.io/*' => Http::response(['message' => 'APort unavailable'], 503), + ]); + + $response = $this + ->withHeader('X-Agent-ID', 'agt_123') + ->postJson('/refunds', ['amount' => 10]); + + $response + ->assertOk() + ->assertJson([ + 'agent_id' => 'agt_123', + 'policy' => 'finance.payment.refund.v1', + 'verified' => false, + ]); + } +} diff --git a/templates/laravel-middleware/tests/TestCase.php b/templates/laravel-middleware/tests/TestCase.php new file mode 100644 index 0000000..1a93c60 --- /dev/null +++ b/templates/laravel-middleware/tests/TestCase.php @@ -0,0 +1,16 @@ + Http::response([ + 'allow' => true, + 'passport' => ['agent_id' => 'agt_123'], + 'reasons' => ['within refund limit'], + ]), + ]); + + $client = new AportClient('test-key', 'https://api.aport.io', app(HttpFactory::class)); + $result = $client->verify('finance.payment.refund.v1', 'agt_123', ['amount' => 25]); + + $this->assertTrue($result['verified']); + $this->assertSame('agt_123', $result['agent_id']); + $this->assertSame(['within refund limit'], $result['reasons']); + + Http::assertSent(function (Request $request): bool { + return $request->url() === 'https://api.aport.io/api/verify/policy/finance.payment.refund.v1' + && $request['agent_id'] === 'agt_123' + && $request['context']['amount'] === 25; + }); + } + + public function test_it_normalizes_string_reasons(): void + { + Http::fake([ + 'api.aport.io/*' => Http::response([ + 'verified' => false, + 'reasons' => 'refund limit exceeded', + ]), + ]); + + $client = new AportClient('test-key', 'https://api.aport.io', app(HttpFactory::class)); + $result = $client->verify('finance.payment.refund.v1', 'agt_123'); + + $this->assertFalse($result['verified']); + $this->assertSame(['refund limit exceeded'], $result['reasons']); + } + + public function test_it_throws_when_api_key_is_missing(): void + { + $this->expectException(AportVerificationException::class); + + $client = new AportClient(null, 'https://api.aport.io', app(HttpFactory::class)); + $client->verify('finance.payment.refund.v1', 'agt_123'); + } + + public function test_it_throws_for_failed_api_responses(): void + { + Http::fake([ + 'api.aport.io/*' => Http::response(['message' => 'Policy not found'], 404), + ]); + + $this->expectException(AportVerificationException::class); + $this->expectExceptionMessage('Policy not found'); + + $client = new AportClient('test-key', 'https://api.aport.io', app(HttpFactory::class)); + $client->verify('missing.policy', 'agt_123'); + } +}