← Laracrate 13 / 19

Access control

Access control and authorization

Every collection declares who its files are for: public or private. It lives in the collection config (see the Configuration section) and is stored on each file as the access column, backed by the EduLazaro\Laracrate\Enums\FileAccess enum. How a private file reaches the browser is chosen where it is used:

Public file Private file Sensitive or encrypted file
url() Storage::disk()->url(), permanent, no checks a presigned GET straight to the disk (a signed local route on a local disk), expiring at expires or after laracrate.urls.signed_ttl its streamUrl()
streamUrl() / downloadUrl() served through PHP served through PHP: session, canView on every request, log the same

$file->url() delegates to StorageManager::urlFor(): public calls GeneratePublicUrlAction, a sensitive or encrypted file returns its stream route, and any other private file calls GenerateSignedUrlAction. The default signed link is cached server-side (config laracrate.urls.signed_cache_ttl, default 4 minutes) so a page rendering many files does not issue one presign per file; a link with its own expires is signed every time. If a disk is misconfigured or unreachable, the signed action logs a warning and returns null instead of throwing, so one broken file does not break the whole page.

A signed link is checked once, when it is made: whoever holds it can read the file until it expires. The stream route is checked on every request, so it never needs to expire and a revoked permission takes effect at once.

Declaring authorization rules

Authorization is declared in the PolicyRegistry (EduLazaro\Laracrate\Support\PolicyRegistry), keyed by the fileable morph alias (user, case, property, and so on). Register your closures from a service provider boot() method:

use EduLazaro\Laracrate\Support\PolicyRegistry;
use EduLazaro\Laracrate\Models\File;
use Illuminate\Database\Eloquent\Model;

public function boot(): void
{
    app(PolicyRegistry::class)
        ->viewable('case', fn (File $file, ?Model $user) => $user?->canAccessCase($file->fileable))
        ->editable('case', fn (File $file, ?Model $user) => $user?->isLawyer())
        ->deletable('case', fn (File $file, ?Model $user) => $user?->isAdmin());
}

Each registrar (viewable, editable, deletable) returns $this, so calls chain. The closure receives the File and the current user (which may be null for guests) and returns a boolean.

At decision time, three methods evaluate the rules: canView(), canEdit(), and canDelete(). They are exposed on the registry and mirrored on the model, so $file->canView($user), $file->canEdit($user), and $file->canDelete($user) all work in blade and controllers.

Default policy: deny with exceptions

If no closure is registered for a fileable type, Laracrate applies safe defaults driven by the registry logic:

  • The human creator can always view, edit, and delete their own file. A file counts as creator-owned when creator_type is user and creator_id matches the current user's key.
  • Public files are always viewable by anyone (canView returns true when access is public), regardless of registered closures.
  • For everything else, when no closure exists the answer is false. The system is closed by default: you opt files in, you never accidentally leak them.

So a registered viewable closure only runs for non-creators viewing a non-public file. Edit and delete have no public exception, only the creator shortcut and your closures.

The Gate bridge

By default the package binds EduLazaro\Laracrate\Policies\FilePolicy to Laravel's Gate, so you get native ergonomics on top of the registry. This is controlled by laracrate.policies.register_gate (default true):

@can('view', $file)
    <a href="{{ $file->url() }}">Open</a>
@endcan
// In a controller
$this->authorize('update', $file);

// As route middleware
Route::get('/files/{file}', ...)->middleware('can:view,file');

The bridge maps Laravel's canonical abilities to the registry methods: Gate view calls canView, update calls canEdit, and delete calls canDelete. If your app already registers its own File policy, or you do not want the bridge, set register_gate to false and call $file->canView() (and friends) directly.

Sensitive content and encryption

For files that must never be served by a direct storage URL, mark the collection sensitive (and encrypt to store them encrypted). Their url() is the stream route, so they are always proxied through EduLazaro\Laracrate\Http\Controllers\StreamFileController, which re-checks permissions on every request, logs the access, and (when the file is encrypted) decrypts the binary in memory before sending it. The storage backend URL is never exposed to the client. Any private file can be served the same way with streamUrl().

The per-request stream flow

StreamFileController has two actions, stream (inline, laracrate.files.stream) and download (attachment, laracrate.files.download). The routes sit behind laracrate.stream.middleware (['web', 'auth']) and carry no signature. Every request runs validateAccess() before a single byte is sent: $file->canView($request->user()), the same PolicyRegistry logic described in the Access control and authorization section. Failure aborts 403.

Because the policy is checked on every request, these links never expire and can be stored (in a document, an email to a colleague who also has access): a permission revoked later takes effect on the next request.

Files are served with range support (Accept-Ranges: bytes, 206 Partial Content), by the stream controller and by the local-disk route alike. Audio and video players depend on it: without ranges a browser cannot seek, cannot start a video whose index sits at the end until the whole file has arrived, and Safari does not play video at all. Only the requested bytes are read, with a ranged GET on S3/R2 or a seek on local disks. An encrypted file is decrypted whole and then sliced.

Audit and download tracking

sendFile() audits before streaming, once per playback: range requests that do not start at the first byte are not counted again. When the request is a download and laracrate.stream.increment_downloads is on (default true), the file's downloads_count is incremented and last_downloaded_at is stamped with a quiet save (no events fired); showing a file with stream is not a download. When laracrate.stream.log_access is on (default true), an info log records the file id, collection, user id, IP, and method. Streamed responses are sent with Cache-Control: private, no-store, no-cache, must-revalidate so sensitive bytes are never cached downstream.

Encryption at rest

Set encrypt => true on a collection to encrypt the binary before it is written to the backend. CreateFileAction reads the flag, runs EncryptFileAction on the binary, and persists is_encrypted = true on the file. Encryption uses Laravel's app key:

  • EncryptFileAction does Crypt::encryptString(base64_encode($binary)).
  • DecryptFileAction reads the cipher from the backend and returns base64_decode(Crypt::decryptString($cipher)).

Encryption requires the binary to be present server-side. If a collection has encrypt => true but the upload skipped your server (a direct presigned upload, for example), CreateFileAction throws, because there is nothing for PHP to encrypt. Encrypted collections must route the bytes through your app on the way in.

On the way out, the stream controller transparently decrypts: in sendFile(), when $file->is_encrypted is true it calls DecryptFileAction::create()->run(['file' => $file]), otherwise it reads the raw object. An encrypted file never gets a direct link: its url() is the stream route, since the binary has to pass through your app to be decrypted anyway.

Watermarks are baked in, not applied at stream time

Watermarking is not part of the stream flow. The watermark is rendered into the binary of the relevant variant when that variant is generated by the processing pipeline (see the Images, variants and watermarks section), not on each request. The stream controller only validates, decrypts, and serves: it never re-renders pixels. This keeps streaming cheap and makes the watermarked bytes the only bytes that exist for that variant.