feat(access): serve-time file access control middleware

Add an opt-in FileAccessControl middleware that resolves the warehouse file
and enforces File::canBeAccessedBy() (default public, so existing consumers
are unaffected) when files.access_control.enabled is set. Resolution is
delegated to a configurable files.warehouse.resolver (ResolvesWarehouseFiles)
and the resolved file is stashed on the request so the controller reuses it.
Exposed as the `files.access` route-middleware alias and auto-attached to the
package warehouse route. Adds the FileAccessControl unit suite.
This commit is contained in:
Fabian @ Blax Software 2026-06-23 11:08:28 +02:00
parent 2ee680cba1
commit 20b1c91e6e
8 changed files with 306 additions and 30 deletions

View File

@ -83,6 +83,17 @@ return [
'enabled' => true, 'enabled' => true,
'prefix' => 'warehouse', 'prefix' => 'warehouse',
'middleware' => ['web'], 'middleware' => ['web'],
/*
| Resolver used by the access-control middleware to find the file for a
| warehouse request. A class implementing
| Blax\Files\Contracts\ResolvesWarehouseFiles (or any invokable). When
| null, the package WarehouseService is used. Host apps with a custom
| lookup flow (encrypted ids, client/server assets, ) point this at
| their own resolver so the middleware reuses it. Keep this a class
| string closures here would break `config:cache`.
*/
'resolver' => env('FILES_WAREHOUSE_RESOLVER'),
], ],
/* /*
@ -123,13 +134,16 @@ return [
| Access Control | Access Control
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| |
| When laravel-roles is installed, files can be protected via access | When enabled, the FileAccessControl middleware enforces the File model's
| checks. Set 'enabled' to false to serve all files publicly. | canBeAccessedBy() decision on every warehouse request and 403s anyone who
| is not allowed. Default false = serve all files publicly (backwards
| compatible). Override canBeAccessedBy() on your File model to define the
| per-file policy.
| |
*/ */
'access_control' => [ 'access_control' => [
'enabled' => false, 'enabled' => env('FILES_ACCESS_CONTROL', false),
], ],
]; ];

View File

@ -2,6 +2,7 @@
use Blax\Files\Http\Controllers\FileUploadController; use Blax\Files\Http\Controllers\FileUploadController;
use Blax\Files\Http\Controllers\WarehouseController; use Blax\Files\Http\Controllers\WarehouseController;
use Blax\Files\Http\Middleware\FileAccessControl;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
/* /*
@ -11,7 +12,15 @@ use Illuminate\Support\Facades\Route;
*/ */
if (config('files.warehouse.enabled', true)) { if (config('files.warehouse.enabled', true)) {
Route::middleware(config('files.warehouse.middleware', ['web'])) // The access-control middleware is always attached; it no-ops unless
// `files.access_control.enabled` is true, so this stays a public file
// server by default while letting consumers opt into per-file guards.
$warehouseMiddleware = array_merge(
(array) config('files.warehouse.middleware', ['web']),
[FileAccessControl::class],
);
Route::middleware($warehouseMiddleware)
->get(config('files.warehouse.prefix', 'warehouse') . '/{identifier?}', WarehouseController::class) ->get(config('files.warehouse.prefix', 'warehouse') . '/{identifier?}', WarehouseController::class)
->name('files.warehouse') ->name('files.warehouse')
->where('identifier', '[\/\w\.\-\=&@]*'); ->where('identifier', '[\/\w\.\-\=&@]*');

View File

@ -0,0 +1,19 @@
<?php
namespace Blax\Files\Contracts;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Request;
/**
* Resolves the warehouse file for an incoming request.
*
* A host application binds an implementation at `files.warehouse.resolver`
* so the access-control middleware can resolve files using the app's own
* lookup flow (encrypted ids, client/server assets, raw paths, ) without
* the package needing to know about it. Returning null means "not found".
*/
interface ResolvesWarehouseFiles
{
public function resolve(Request $request): ?Model;
}

View File

@ -17,10 +17,25 @@ class FilesServiceProvider extends \Illuminate\Support\ServiceProvider
$this->offerPublishing(); $this->offerPublishing();
$this->registerMigrations(); $this->registerMigrations();
$this->registerModelBindings(); $this->registerModelBindings();
$this->registerMiddleware();
$this->registerRoutes(); $this->registerRoutes();
$this->registerCommands(); $this->registerCommands();
} }
/**
* Expose the serve-time access-control middleware under the `files.access`
* alias so host apps that wire their own warehouse route can guard it with
* `->middleware('files.access')`. The package's own route attaches the
* middleware class directly (see routes/files.php).
*/
protected function registerMiddleware(): void
{
$this->app['router']->aliasMiddleware(
'files.access',
Http\Middleware\FileAccessControl::class,
);
}
/** /**
* Auto-load the package's migrations so fresh installs work without * Auto-load the package's migrations so fresh installs work without
* publishing. Disabled via `files.run_migrations = false` for projects * publishing. Disabled via `files.run_migrations = false` for projects

View File

@ -4,7 +4,7 @@ namespace Blax\Files\Http\Controllers;
use Blax\Files\Events\FileAccessed; use Blax\Files\Events\FileAccessed;
use Blax\Files\Events\FileNotFound; use Blax\Files\Events\FileNotFound;
use Blax\Files\Models\File; use Blax\Files\Http\Middleware\FileAccessControl;
use Blax\Files\Services\WarehouseService; use Blax\Files\Services\WarehouseService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Routing\Controller; use Illuminate\Routing\Controller;
@ -15,39 +15,20 @@ class WarehouseController extends Controller
{ {
$identifier ??= $request->get('id'); $identifier ??= $request->get('id');
$file = WarehouseService::searchFile($request, $identifier); // Reuse the file the access-control middleware already resolved (and
// authorized) when it ran, to avoid a second lookup. Falls back to a
// fresh search when access control is disabled (middleware no-ops).
$file = $request->attributes->has(FileAccessControl::ATTRIBUTE)
? $request->attributes->get(FileAccessControl::ATTRIBUTE)
: WarehouseService::searchFile($request, $identifier);
if (! $file) { if (! $file) {
FileNotFound::dispatch($identifier, $request); FileNotFound::dispatch($identifier, $request);
abort(404); abort(404);
} }
// Access control check (optional, via laravel-roles)
if (config('files.access_control.enabled') && $file->exists) {
$this->checkAccess($request, $file);
}
FileAccessed::dispatch($file, $request); FileAccessed::dispatch($file, $request);
return $file->respond($request); return $file->respond($request);
} }
protected function checkAccess(Request $request, File $file): void
{
// If laravel-roles is installed, check HasAccess
if (
trait_exists(\Blax\Roles\Traits\HasAccess::class)
&& method_exists($file, 'hasAccess')
) {
$user = $request->user();
if (! $user) {
abort(403, 'Authentication required.');
}
if (! $user->hasAccess($file)) {
abort(403, 'Access denied.');
}
}
}
} }

View File

@ -0,0 +1,74 @@
<?php
namespace Blax\Files\Http\Middleware;
use Blax\Files\Contracts\ResolvesWarehouseFiles;
use Blax\Files\Services\WarehouseService;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Serve-time access control for warehouse files.
*
* When `files.access_control.enabled` is true, this middleware resolves the
* file for the incoming warehouse request and aborts with 403 unless the
* current user may access it as decided by the File model's
* `canBeAccessedBy()` hook (which defaults to "public" on the base model, so
* existing consumers are unaffected until they override it).
*
* The resolved file is stashed on the request as `files.warehouse_file` so
* the downstream controller can reuse it instead of resolving a second time.
*
* File resolution is delegated to the resolver configured at
* `files.warehouse.resolver` (a class implementing {@see ResolvesWarehouseFiles}
* or any invokable), falling back to the package WarehouseService. This lets a
* host app layer in its own lookup without the package depending on it.
*/
class FileAccessControl
{
public const ATTRIBUTE = 'files.warehouse_file';
public function handle(Request $request, Closure $next): Response
{
if (! config('files.access_control.enabled')) {
return $next($request);
}
$file = $this->resolveFile($request);
// Always set the attribute when enabled so the controller can tell
// "middleware ran and resolved null (404)" from "middleware did not run".
$request->attributes->set(self::ATTRIBUTE, $file);
if ($file && $file->exists && method_exists($file, 'canBeAccessedBy')) {
if (! $file->canBeAccessedBy($request->user())) {
abort(403, 'You are not allowed to access this file.');
}
}
return $next($request);
}
protected function resolveFile(Request $request)
{
$resolver = config('files.warehouse.resolver');
if ($resolver) {
$instance = is_string($resolver) ? app($resolver) : $resolver;
if ($instance instanceof ResolvesWarehouseFiles) {
return $instance->resolve($request);
}
if (is_callable($instance)) {
return $instance($request);
}
}
return WarehouseService::searchFile(
$request,
$request->route('identifier') ?? $request->route('encrypted_id') ?? $request->get('id'),
);
}
}

View File

@ -156,6 +156,24 @@ class File extends Model
return url("{$prefix}/{$this->id}"); return url("{$prefix}/{$this->id}");
} }
/*
|--------------------------------------------------------------------------
| Access Control
|--------------------------------------------------------------------------
*/
/**
* Decide whether the given user (null = guest) may fetch this file.
*
* The base model treats every file as public so existing consumers are
* unaffected. Apps that need serve-time privacy override this (and enable
* `files.access_control.enabled`) to gate avatars, private uploads, etc.
*/
public function canBeAccessedBy(?\Illuminate\Contracts\Auth\Authenticatable $user): bool
{
return true;
}
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| File Content Operations | File Content Operations

View File

@ -0,0 +1,146 @@
<?php
namespace Blax\Files\Tests\Unit;
use Blax\Files\Contracts\ResolvesWarehouseFiles;
use Blax\Files\Http\Middleware\FileAccessControl;
use Blax\Files\Models\File;
use Blax\Files\Tests\TestCase;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Request;
use Symfony\Component\HttpKernel\Exception\HttpException;
class FileAccessControlTest extends TestCase
{
/** A file whose access is gated: only user #1 (or any user, see flag) may read. */
protected function gatedFile(bool $ownerOnly = true): File
{
$file = new class extends File
{
public bool $ownerOnly = true;
public function canBeAccessedBy(?Authenticatable $user): bool
{
if (! $this->ownerOnly) {
return $user !== null; // authenticated-level
}
return $user !== null && (int) $user->getAuthIdentifier() === 1; // owner-level
}
};
$file->ownerOnly = $ownerOnly;
$file->exists = true; // simulate a persisted file without touching the DB
return $file;
}
protected function withResolver(File $file): void
{
config(['files.warehouse.resolver' => new class($file) implements ResolvesWarehouseFiles
{
public function __construct(private File $file) {}
public function resolve(Request $request): ?Model
{
return $this->file;
}
}]);
}
protected function pass(FileAccessControl $mw, Request $request): mixed
{
return $mw->handle($request, fn ($req) => response('served'));
}
public function test_disabled_serves_everything_without_checking(): void
{
config(['files.access_control.enabled' => false]);
// Resolver returns a file that would deny everyone; must be ignored.
$this->withResolver($this->gatedFile());
$response = $this->pass(new FileAccessControl, Request::create('/warehouse/x'));
$this->assertEquals('served', $response->getContent());
}
public function test_enabled_serves_public_base_file(): void
{
config(['files.access_control.enabled' => true]);
$public = new File(['name' => 'pub']);
$public->exists = true; // base File::canBeAccessedBy() returns true
$this->withResolver($public);
$response = $this->pass(new FileAccessControl, Request::create('/warehouse/x'));
$this->assertEquals('served', $response->getContent());
}
public function test_enabled_403s_guest_on_gated_file(): void
{
config(['files.access_control.enabled' => true]);
$this->withResolver($this->gatedFile());
try {
$this->pass(new FileAccessControl, Request::create('/warehouse/x'));
$this->fail('Expected a 403 HttpException for a guest on a gated file.');
} catch (HttpException $e) {
$this->assertEquals(403, $e->getStatusCode());
}
}
public function test_enabled_allows_owner_on_gated_file(): void
{
config(['files.access_control.enabled' => true]);
$this->withResolver($this->gatedFile());
$request = Request::create('/warehouse/x');
$request->setUserResolver(fn () => new class extends \Illuminate\Foundation\Auth\User
{
public function getAuthIdentifier()
{
return 1;
}
});
$response = $this->pass(new FileAccessControl, $request);
$this->assertEquals('served', $response->getContent());
}
public function test_enabled_403s_non_owner_on_owner_only_file(): void
{
config(['files.access_control.enabled' => true]);
$this->withResolver($this->gatedFile(ownerOnly: true));
$request = Request::create('/warehouse/x');
$request->setUserResolver(fn () => new class extends \Illuminate\Foundation\Auth\User
{
public function getAuthIdentifier()
{
return 999;
}
});
try {
$this->pass(new FileAccessControl, $request);
$this->fail('Expected a 403 for a non-owner user on an owner-only file.');
} catch (HttpException $e) {
$this->assertEquals(403, $e->getStatusCode());
}
}
public function test_resolved_file_is_stashed_on_request(): void
{
config(['files.access_control.enabled' => true]);
$public = new File(['name' => 'pub']);
$public->exists = true;
$this->withResolver($public);
$request = Request::create('/warehouse/x');
$this->pass(new FileAccessControl, $request);
$this->assertTrue($request->attributes->has(FileAccessControl::ATTRIBUTE));
$this->assertSame($public, $request->attributes->get(FileAccessControl::ATTRIBUTE));
}
}