Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 |

Expand Down
95 changes: 95 additions & 0 deletions templates/laravel-middleware/README.md
Original file line number Diff line number Diff line change
@@ -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.
44 changes: 44 additions & 0 deletions templates/laravel-middleware/composer.json
Original file line number Diff line number Diff line change
@@ -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
}
17 changes: 17 additions & 0 deletions templates/laravel-middleware/config/aport.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

return [
'api_key' => 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'),
],
];
5 changes: 5 additions & 0 deletions templates/laravel-middleware/env.example
Original file line number Diff line number Diff line change
@@ -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
28 changes: 28 additions & 0 deletions templates/laravel-middleware/examples/laravel-app/README.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

namespace App\Http\Controllers;

use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class RefundController
{
public function store(Request $request): JsonResponse
{
$validated = $request->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'),
],
]);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?php

use App\Http\Controllers\RefundController;
use Illuminate\Support\Facades\Route;

Route::post('/refunds', [RefundController::class, 'store'])
->middleware('aport.policy:finance.payment.refund.v1');
8 changes: 8 additions & 0 deletions templates/laravel-middleware/phpunit.xml.dist
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/autoload.php" colors="true">
<testsuites>
<testsuite name="APort Laravel Middleware">
<directory>tests</directory>
</testsuite>
</testsuites>
</phpunit>
82 changes: 82 additions & 0 deletions templates/laravel-middleware/src/AportClient.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
<?php

namespace Aport\Laravel;

use Aport\Laravel\Exceptions\AportVerificationException;
use Illuminate\Http\Client\Factory as HttpFactory;
use Illuminate\Http\Client\PendingRequest;

class AportClient
{
public function __construct(
private readonly ?string $apiKey,
private readonly string $baseUrl = 'https://api.aport.io',
private readonly ?HttpFactory $http = null,
private readonly int $timeout = 5,
) {
}

/**
* Verify an APort agent against a policy pack.
*
* @return array{
* verified: bool,
* policy: string,
* agent_id: string,
* passport: mixed,
* reasons: array,
* raw: array
* }
*/
public function verify(string $policy, string $agentId, array $context = []): array
{
if ($this->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);
}
}
40 changes: 40 additions & 0 deletions templates/laravel-middleware/src/AportServiceProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

namespace Aport\Laravel;

use Aport\Laravel\Console\MakeAportPolicyCommand;
use Aport\Laravel\Http\Middleware\RequireAportPolicy;
use Illuminate\Http\Client\Factory as HttpFactory;
use Illuminate\Support\ServiceProvider;

class AportServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->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,
]);
}
}
}
Loading