bundle / identity-laravel
Laravel scope authorization SDK for Bundle Identity
Requires
- php: ^8.2
- ext-grpc: *
- google/protobuf: ^4.27
- grpc/grpc: ^1.57
- illuminate/contracts: ^12.0
- illuminate/http: ^12.0
- illuminate/routing: ^12.0
- illuminate/support: ^12.0
Requires (Dev)
- orchestra/testbench: ^10.0
- phpunit/phpunit: ^11.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
bundle/identity-laravel adds fail-closed, server-backed scope and role authorization to Laravel applications. It uses Bundle Identity's existing bundle.identity.authorization.v1.AuthorizationService contract over mutually authenticated TLS. The package does not parse JWTs, reproduce RBAC rules, or require Protobuf generation in consuming applications.
Quick start
- Install the PHP gRPC extension and this package.
- Publish the package configuration.
- Set the Bundle Identity endpoint, region, and mTLS file paths in
.env. - Register a trusted subject resolver in
App\Providers\AppServiceProvider. - Put Laravel authentication before
bundle.scopeorbundle.roleon protected routes. - Mark routes intentionally excluded from Bundle Identity authorization with
bundle.publicwhen default-deny is enabled.
Installation
The verified production baseline is PHP 8.2+ and Laravel 12. Laravel 10/11 are intentionally not declared because the currently resolvable framework/Testbench releases are blocked by Composer security advisories. The consuming runtime needs the PHP grpc extension. The Protobuf runtime and generated messages are installed through Composer.
Install and enable the PHP gRPC extension for both CLI and the PHP runtime serving Laravel. The exact package command depends on the deployment image; a typical PECL installation is:
pecl install grpc php --ri grpc
Install from a private Composer registry:
composer config repositories.bundle-identity composer https://composer.example.internal composer require bundle/identity-laravel:^1.0
Or install directly from a private Git repository:
composer config repositories.bundle-identity vcs git@github.com:your-org/bundle-identity-laravel.git composer require bundle/identity-laravel:^1.0
Laravel discovers BundleIdentityServiceProvider through Composer. Publish the optional configuration file:
php artisan vendor:publish \
--provider="Bundle\Identity\BundleIdentityServiceProvider" \
--tag=bundle-identity-config
Verify the runtime before deployment:
php --ri grpc composer show bundle/identity-laravel php artisan config:cache
Configuration
Set the endpoint, region routing metadata, and certificate paths in the consuming application's environment:
BUNDLE_IDENTITY_SCOPE_ENDPOINT=identity.internal:50051 BUNDLE_IDENTITY_REGION_ID=00000000-0000-0000-0000-000000000001 BUNDLE_IDENTITY_ROOT_CA=/run/secrets/bundle/root-ca.pem BUNDLE_IDENTITY_CLIENT_KEY=/run/secrets/bundle/client.key BUNDLE_IDENTITY_CLIENT_CRT=/run/secrets/bundle/client-chain.pem BUNDLE_IDENTITY_SCOPE_DEADLINE_MS=1000 BUNDLE_IDENTITY_SCOPE_POLICY=all BUNDLE_IDENTITY_DEFAULT_DENY=true
The endpoint must be host:port, without http:// or https://. gRPC receives root trust, the client private key, and the client certificate chain through ChannelCredentials::createSsl; there is no insecure transport fallback and no hostname override. Consequently, the endpoint hostname must match the server certificate. Keep the private key outside the repository, readable only by the application process, and never put certificate contents in Laravel configuration.
The checked-out Bundle Identity server requires x-region-id metadata on every gRPC request. BUNDLE_IDENTITY_REGION_ID supplies that routing value. It is distinct from the authenticated user subject and does not replace mTLS application authentication.
All values in config/bundle-identity.php are scalar, so Laravel configuration caching is supported. Register the request-time callback in the consuming application's service provider rather than its config file:
<?php namespace App\Providers; use Bundle\Identity\BundleIdentityManager; use Illuminate\Http\Request; use Illuminate\Support\ServiceProvider; final class AppServiceProvider extends ServiceProvider { public function boot(BundleIdentityManager $identity): void { $identity->userSub( fn (Request $request) => $request->user()?->jwtSubject(), ); } }
If the application's JWT middleware stores verified claims on the request, resolve only that verified claim:
$identity->userSub( fn (Request $request) => $request->attributes->get('verified_jwt')?->sub, );
Do not place the callback inside config/bundle-identity.php; closures cannot be serialized by php artisan config:cache.
Run authentication middleware before Bundle Identity authorization. The service provider places scope enforcement after Laravel's standard Authenticate middleware in the middleware priority list. Custom authentication middleware must likewise be ordered before ScopeMiddleware. The callback must use identity already verified by the application. Never return an unverified JWT payload, query parameter, header-provided user ID, or request body field. If no callback is registered, the package requires an authenticated Laravel user and uses a verified sub request attribute when present, otherwise the user's authentication identifier.
Route authorization
Require one scope:
Route::get('/orders', [OrderController::class, 'index']) ->middleware(['auth', 'bundle.scope:orders.read']);
The configured default policy applies to multiple scopes:
Route::get('/orders', [OrderController::class, 'index']) ->middleware(['auth', 'bundle.scope:orders.read,orders.write']);
Override the policy explicitly by putting all or any first:
->middleware(['auth', 'bundle.scope:any,orders.read,orders.write']);
Each scope is sent independently to the existing unary server RPC. all stops at the first denial; any stops at the first allow. No RBAC decision is made locally.
A complete protected route group can be configured as follows:
use Illuminate\Support\Facades\Route; Route::middleware(['auth:sanctum'])->group(function (): void { Route::get('/orders', [OrderController::class, 'index']) ->middleware('bundle.scope:orders.read'); Route::post('/orders', [OrderController::class, 'store']) ->middleware('bundle.scope:orders.create'); Route::delete('/orders/{order}', [OrderController::class, 'destroy']) ->middleware('bundle.role:any,code:orders-admin,id:role-supervisor'); });
Controller and method attributes
use Bundle\Identity\Attributes\RequireScopes; #[RequireScopes('orders.read')] final class OrderController { public function index() {} #[RequireScopes(['orders.create', 'orders.admin'], policy: 'any')] public function store() {} }
Attribute discovery is automatically enforced for routes in Laravel's web and api groups and is cached in process. Precedence is deterministic:
- Explicit
bundle.scoperoute middleware overrides attributes. - A method attribute overrides its controller attribute.
- A controller attribute applies when the method has none.
Role authorization
Roles must be identified explicitly as code:<value> or id:<value>. This prevents a value from being interpreted as the wrong server field.
Route::get('/admin', AdminController::class) ->middleware(['auth', 'bundle.role:code:administrator']); Route::get('/review', ReviewController::class) ->middleware(['auth', 'bundle.role:any,code:reviewer,id:role-admin']);
Controller and method attributes are also supported:
use Bundle\Identity\Attributes\RequireRoles; #[RequireRoles('code:viewer')] final class ReportController { #[RequireRoles(['code:editor', 'id:role-admin'], policy: 'any')] public function update() {} }
For one role and for any, the SDK calls VerifyRole and short-circuits on the first assignment. Multiple all requirements use the server's VerifyRoles batch RPC, whose contract allows only when every selector is assigned. Route declarations override role attributes; method role attributes override controller role attributes. When both scope and role attributes are present at the effective level, both are enforced.
With default_deny=true, a grouped route without scope or role metadata returns 403. Mark an intentionally public route explicitly:
Route::get('/health', HealthController::class)->middleware('bundle.public');
If an application uses custom route groups, add both authorization middleware classes:
use Bundle\Identity\Middleware\RoleMiddleware; use Bundle\Identity\Middleware\ScopeMiddleware; use Illuminate\Foundation\Configuration\Middleware; // bootstrap/app.php, inside withMiddleware(...) ->withMiddleware(function (Middleware $middleware): void { $middleware->appendToGroup('internal', [ ScopeMiddleware::class, RoleMiddleware::class, ]); })
The package already adds these middleware classes to Laravel's standard web and api groups.
Configuration reference
| Environment variable | Required | Default | Purpose |
|---|---|---|---|
BUNDLE_IDENTITY_SCOPE_ENDPOINT |
Yes | — | TLS gRPC endpoint in host:port format. It serves scope and role verification. |
BUNDLE_IDENTITY_REGION_ID |
Yes | — | Region sent as x-region-id gRPC metadata. |
BUNDLE_IDENTITY_ROOT_CA |
Yes | — | Readable PEM root CA or CA chain path. |
BUNDLE_IDENTITY_CLIENT_KEY |
Yes | — | Readable PEM client private-key path. |
BUNDLE_IDENTITY_CLIENT_CRT |
Yes | — | Readable PEM client certificate/chain path. |
BUNDLE_IDENTITY_SCOPE_DEADLINE_MS |
No | 1000 |
Maximum duration of each gRPC authorization request. |
BUNDLE_IDENTITY_SCOPE_POLICY |
No | all |
Default matching policy for multi-scope and multi-role middleware. |
BUNDLE_IDENTITY_DEFAULT_DENY |
No | true |
Denies standard grouped routes that have no authorization declaration. |
After changing environment configuration in a deployed application, rebuild Laravel's cached configuration:
php artisan config:clear php artisan config:cache
Request and decision flow
- The consuming application authenticates and verifies its JWT or session.
- The configured callback resolves the verified user
subfor that request. - The SDK opens/reuses an mTLS gRPC channel. The client certificate authenticates the calling application; the subject identifies the authenticated user.
- Bundle Identity evaluates the requested scope or role assignment against centralized RBAC and returns
allowed. - The SDK returns 401 for a missing/invalid subject, 403 for denial or missing metadata, and 503 when a decision cannot be verified.
Timeouts, connection failures, TLS failures, malformed responses, and non-success gRPC statuses fail closed. PERMISSION_DENIED is treated as a normal authorization denial. Certificate private keys are never included in SDK log messages.
Testing
composer install
composer test
Unit and Testbench feature tests mock the scope client. Optional integration testing should use a dedicated Bundle Identity deployment with test CA/client material and a known region; do not use production private keys in CI. The package ships proto/authorization.proto and generated PHP messages/stub, so production installation does not run protoc.
Troubleshooting
- 401: authentication ran too late, or the subject callback returned an empty/non-scalar value.
- 403: Bundle Identity denied a requested scope/role, or default-deny found no authorization declaration.
- 503: inspect service reachability, deadline,
x-region-id, certificate chain, client-key pairing, and server hostname/SAN. The response intentionally does not expose internal TLS or gRPC details. Class Grpc\\ChannelCredentials not found: install and enableext-grpcfor the same PHP SAPI that serves Laravel.- TLS hostname failure: use an endpoint hostname present in the server certificate SAN; do not disable verification or add an override.
Release process
Tag releases with semantic versions and publish the complete repository, including src/Grpc/Generated and proto/authorization.proto. Regenerate and review the PHP artifacts whenever the owning Bundle Identity proto changes. No database migration or runtime code-generation step is required.