lsnepomuceno/laravel-a1-pdf-sign
A Laravel package for digitally signing PDF documents with A1 certificates. It integrates easily into your app to apply ICP-Brasil compliant signatures, add visual signature stamps, and protect files with a practical, fluent API.
Version 3.2.0 is an incremental release on top of the 3.1.0 agent tooling: it does not change the core signing API. If you have never used the package, the first productive path is still signing a PDF with an A1 certificate; the agent tools are a second step.
composer require lsnepomuceno/laravel-a1-pdf-sign
php artisan vendor:publish --tag=a1-pdf-sign-config
.pfx/.p12 certificate and its password, then sign a PDF in a test to confirm the certificate, password and PDF engine all work. Do this before touching anything agent-related — it isolates certificate problems from MCP problems.docs/decisions/0041-agents-are-authorised-per-document.md first. It is the design rationale for the whole 3.2.0 release and explains why the gate is consulted before the file is opened.UPGRADE.md#from-310-to-320. The only behaviour you get without editing anything is the new 50 MB agents.max_bytes cap.Mcp::oauthRoutes() for remote clients.A good first use case is a single-tenant app where every authenticated user may read and sign everything: define both abilities to return true, set agents.max_bytes to something sane for your documents, and move on. The second use case — a shared contracts disk where users must only see their own files — is what this release is really for, and is worth doing early because it forces you to think about path ownership before the tool is in production.
Define both abilities explicitly, in one place. The gate is the only thing standing between a model-chosen path and a customer's contract, and an undefined ability allows. Register Read and Sign together (a dedicated service provider or a AgentAbilities class) so a reader can see the whole policy at once:
use LSNepomuceno\LaravelA1PdfSign\Agents\Ability;
Gate::define(Ability::Read->value, function (User $user, string $disk, string $path) {
return $disk === 'contracts' && $user->ownsContractPath($path);
});
Gate::define(Ability::Sign->value, function (User $user, string $disk, string $path, string $destinationDisk, string $destinationPath) {
return $disk === 'contracts'
&& $user->ownsContractPath($path)
&& $destinationDisk === 'contracts'
&& str_starts_with($destinationPath, "signed/{$user->tenant_id}/");
});
The two signatures are deliberately different: Sign also receives the destination disk and path, because writing a signed artefact into another tenant's folder is as bad as reading their source document. Treat the destination check as mandatory, not decorative.
Normalize paths before comparing. The gate receives the strings the model produced. Reject .. segments and leading slashes, and resolve ownership by lookup rather than by string prefix alone — str_starts_with($path, "tenants/{$id}/") will not save you from tenants/1/../2/file.pdf.
Keep the gate cheap. It is consulted per call and receives strings, not models, so every check is a query unless you cache. Resolve the user's contract set or tenant prefix once per request (a memoised property on the user, or a request-scoped cache) rather than running a where for each ability evaluation.
Let the approval flow be the second gate, not the only one. Signing still prompts for human approval, and approval is requested even for a call the gate will refuse — the person is told it will be refused. That means the gate is your security boundary and approval is your intent boundary; do not use approval prompts as authorisation, and do not assume a refused call is silent.
Fail closed inside the gate. Return false for every unhandled case, and do not let an exception escape a gate closure expecting it to be interpreted as a denial. A true from a Gate::before callback bypasses per-document rules entirely, so do not add a global before for convenience.
Tune agents.max_bytes per deployment. 50 MB is generous for text contracts and tight for scanned annexes. Raise it if your documents legitimately exceed it; set it to null only if you understand that this restores 3.1.0's unbounded behaviour and lets a model name a multi-gigabyte file that the engine will hold in memory.
Throttle the MCP route and authenticate it. An unauthenticated MCP endpoint is an unauthenticated agent endpoint. Put your normal auth middleware plus a rate limit on it, and use Mcp::oauthRoutes() if remote clients (rather than your own app) are the consumers.
Leave ToolSearch alone unless you have grown the toolset. The package's own server exposes two tools and deliberately does not group them behind laravel/mcp's ToolSearch: at that size it saves nothing, and execute_tools would hide the read-only annotations the model uses to reason about what is safe to call. Adopt ToolSearch only if you have added many tools of your own to the same server.
Test the refusal path, not just the happy path. Gate::fake() or explicit definitions plus Storage::fake('contracts') gives you a fast feature test for the cross-tenant case. Assert that a refused path returns the refusal rather than a "file not found", since the design guarantees the gate is consulted before the document is looked at.
return true — so that the policy exists and the next developer extends it instead of discovering it.max_bytes key, and a missing key means the default. You cannot opt out by doing nothing; you opt out by setting the key to null. Watch for this if you have large scanned PDFs that used to go through.Storage::fake.Read and Sign are separate policies with separate signatures. A permissive Read closure does not constrain Sign in any way, and a Sign closure that ignores its destination arguments will happily let a signed file land in someone else's directory.Ability is not resolvable in your app, check your installed optional dependencies before debugging the gate.Gate::abilities() shows what is registered; a mistyped ability string silently falls back to allow. Using Ability::Read->value / Ability::Sign->value rather than literals removes that class of failure.How can I help you explore Laravel packages today?