The security & trust model
How the three gates — registered, genuine, entitled — let Salieno Core run third-party panel code safely, and what that means when you write yours.
On this page
- Why a panel is trusted code, not trusted data
- The three gates
- Gate 1 — REGISTERED
- Gate 2 — GENUINE
- Gate 3 — ENTITLED
- Why a copied folder cannot run
- Where installed code lives
- What this means for you as an author
- Never store state in your panel's own folder
- Mask sensitive data with sanitizeErrorMessage
- Declare capabilities honestly
- Guard targeted and destructive operations
- In short
A panel extension is not data — it is executable PHP that runs inside Core with access to your customers' servers and credentials. That is a lot of trust to hand a third party, so Core does not extend it on faith. Every time a driver is about to run, one class decides whether it may: App\Services\Panels\PanelRegistry. It refuses unless three independent conditions hold, and it re-checks them on every resolve — not once at install. This article explains those gates, why a folder someone copies onto the box is inert, and the handful of author habits that follow directly from how the model works.
Why a panel is trusted code, not trusted data#
A theme is verified once and then treated as inert data. A panel is different: it is code that executes. So Core keeps the trust material around after install — the signed artifact itself, the signature headers, and a per-file hash manifest — and re-verifies against them every time the driver loads. The gate is not a one-time install check you can slip past afterward; it runs on the path that turns a database row into a live object, so there is no other way to get a driver instance.
If any gate fails, driverFor() returns null, and the caller falls back to EmptyServer — provisioning fails safely instead of executing an unverified or unlicensed panel. Core ships no built-in drivers at all, so a slug with no installed, enabled extension simply resolves to nothing.
The three gates#
Every resolve runs all three, in order. Each is independent: passing one tells you nothing about the others.
Gate 1 — REGISTERED#
There must be an enabled `panel_extensions` row for the slug:
$ext = PanelExtension::where('slug', $slug)->enabled()->first();
return $ext !== null ? $this->loadExtensionDriver($ext) : null;That row is written only by the installer, at the end of a signed marketplace install. A directory someone drops onto the server has no row, so there is no record to resolve and nothing to load. A disabled row is treated as absent — it will not resolve, and it will not even leak its connection or credential profile to the Server form.
Gate 2 — GENUINE#
The stored artifact must re-verify against the pinned Ed25519 marketplace key, and each on-disk class file's SHA-256 must match the hash recorded from that verified artifact at install:
$verified = app(MarketplaceSignature::class)->verify($ext->signatureHeaders(), $ext->artifactPath());
if (! ($verified['ok'] ?? false)) {
return $this->deny($ext, 'signature_'.($verified['error'] ?? 'invalid'));
}The key is pinned inside Core; you cannot forge a signature for it. That closes off hand-written drivers — an operator cannot author their own PHP and pass this check, because they cannot produce the signature the marketplace applies on approval.
The second half closes off tampering after install. When the installer unpacks a verified artifact, it records relative-path => sha256 for every file into the extension's manifest. The scoped autoloader consults that map before it requires anything:
// Bind loaded bytes to the verified artifact: refuse a class file edited after install.
$expected = $map['files'][$relative] ?? null;
if (is_string($expected) && $expected !== '' && ! hash_equals($expected, hash_file('sha256', $file))) {
Log::warning('Panel extension class file failed its hash check.', ['slug' => $slug, 'file' => $relative]);
return;
}
require $file;Edit a class file on disk and its hash no longer matches — the file will not load. The code that runs is exactly the code the marketplace signed, byte for byte.
Gate 3 — ENTITLED#
The activated licence must still own this panel, and the marketplace decides that — not anything on the box:
$owns = app(MarketplaceClient::class)->ownsPanel($ext->slug);
if ($owns === true) {
$ext->forceFill(['entitlement_checked_at' => now()])->saveQuietly();
return true;
}
if ($owns === false) {
return false; // marketplace answered: not owned
}
// $owns === null → unreachable. Trust a recent confirmation, then fail closed.
$graceHours = (int) config('licensing.entitlement_grace_hours', 72);
return $ext->entitlement_checked_at !== null
&& $ext->entitlement_checked_at->gt(now()->subHours($graceHours));A definite yes refreshes the confirmation timestamp; a definite no denies immediately. If the marketplace is unreachable, Core trusts the last confirmation for a grace window (72 hours by default) so a transient outage does not tear down live provisioning — and once that window lapses without a fresh confirmation, it fails closed. Install itself is proof of entitlement at that moment, because the signed grant the installer consumes is only ever issued to an owner; the load-time gate simply keeps re-confirming.
This is the gate that makes ownership real rather than cosmetic. The signature proves the code is genuine, but genuineness is not permission — the entitlement check is what ties a specific genuine artifact to a specific paying licence.
Why a copied folder cannot run#
Put the three gates together and the answer falls out. Take a perfectly genuine, signed cPanel folder off one server and drop it onto another install:
- No row. Nothing wrote a
panel_extensionsrecord, so Gate 1 finds nothing to resolve. - No entitlement. Even if you manufactured a row, the second install's licence never bought cPanel, so the marketplace answers not owned and Gate 3 denies it.
You cannot fake your way past Gate 2 either — you cannot forge the signature, and you cannot edit the extracted code without breaking its hash. There is no combination of file operations that turns a copied folder into a running driver.
Where installed code lives#
Installed extensions unpack to storage/app/panels/{slug}/ — a runtime artifact that is gitignored and not on Composer's normal autoload path. Dropping classes there loads nothing on its own: no standard autoloader is looking in that directory.
The only thing that loads a panel class is PanelRegistry's scoped autoloader, registered by the panel service provider. It refuses to load anything until the full gate has run and unlocked the slug, and then it loads only classes that belong to that unlocked extension's namespace and whose file hash matches the recorded value. A slug that has not passed the gate is not in the unlock list, so its namespace is never even considered. Loading is gated at both ends: nothing generic can trigger it, and the bytes it loads are pinned to the verified artifact.
What this means for you as an author#
The model is strict on purpose, and a few author habits follow directly from it.
Never store state in your panel's own folder#
Your extension directory is a stateless, hash-pinned artifact. Writing into it fights the system two ways. First, if you modify a file the manifest recorded, the hash check will refuse to load it. Second, an update replaces the entire directory with an atomic swap, so anything you wrote there is discarded the next time the operator updates. Keep all state in what Core passes you — the $server and $hosting models, which you may ->save() — and in the panel server itself. Your driver should read inputs, call an API, and return a shape; it should never treat its own folder as scratch space.
Mask sensitive data with sanitizeErrorMessage#
Panel APIs return raw error text full of IPs, absolute paths, and sometimes token fragments. Never return that verbatim — wrap it first. sanitizeErrorMessage() (inherited through AbstractPanel) strips IPs, paths, and auth tokens, caps the length, and — crucially — translates low-level cURL failures (SSL, timeout, DNS, refused) into guidance an operator can act on. The cPanel driver runs every API error through it:
$message = $this->sanitizeErrorMessage($status['message'] ?? 'Unknown error');Do the same everywhere you surface a message. For a background failure, secureAdminNotification() raises an admin notice with the same masking applied.
Declare capabilities honestly#
capabilities() is a security boundary as much as a UI one. Core shows a control only if you declare its capability, so declaring something you cannot actually honor produces a button that fails when a customer clicks it. Declare only what your driver truly implements. A partial panel that omits reseller or quick_actions is first-class — Core hides those surfaces cleanly — whereas a padded declaration is a broken one. See Capabilities and the UI for the full map.
Guard targeted and destructive operations#
Every method receives a specific target — one $hosting, one $server. Make sure an ambiguous or empty target can never widen into acting on everything. getPackage(), which may be handed a whole server group or a single server, shows the pattern: branch explicitly and reject anything else rather than falling through:
if ($source instanceof \App\Models\ServerGroup) {
$servers = $source->servers()->where('status', true)->get();
} elseif ($source instanceof \App\Models\Server) {
$servers = collect([$source]);
} else {
return ['success' => false, 'message' => 'Invalid source for package retrieval'];
}Apply the same discipline to suspend(), terminate(), and changePassword(): build the panel API call from the exact account identifier on $hosting, and if that identifier is missing or malformed, fail with a clear message instead of issuing a call that a panel might interpret as a wildcard. A destructive request should always name exactly one account.
In short#
Core will run your code because three things are simultaneously true: it is registered from a signed install, it is genuine and unmodified, and the licence still owns it. That safety is what lets a hosting operator install a third-party panel without auditing its source. Your part of the bargain is to stay a clean, stateless artifact — mask what you surface, declare only what you do, and target precisely — so the trust the model grants is trust you keep. When you are ready to ship, Publishing and updates walks through the signed install and update path that these gates enforce.