App permissions
An app declares the parts of core it intends to use, such as posts, mail or outbound HTTP. The site owner approves those permissions when activating the app. From then on, calls through $this->api() are checked against what was approved. This page explains the model, the catalogue and how refusals behave.
Read this first: it isn't a sandbox
An app is PHP running in the same process as core. Any app could open its own database connection, read .env, or call a core service directly. Nothing in Basehim prevents that, and on shared hosting nothing could without loading apps a completely different way.
What permissions do give you:
- the site owner can see what an app intends to touch, and approve it;
- an honest app can't exceed its declaration by accident;
- overreach shows up in the logs instead of passing silently.
Install apps you have reason to trust.
Enforcement is opt-in
An app that declares no permissions (no permissions key, or an empty list) runs unrestricted, as every app did before permissions existed. The Apps screen badges it as unrestricted.
Declaring a permissions array opts the app in, and from then on the declaration is also its ceiling:
{
"name": "Feed Importer",
"slug": "feed-importer",
"version": "1.0.0",
"namespace": "Acme\\FeedImporter",
"permissions": ["posts.write", "terms.write", "http.outbound", "schedule"]
}
Ask for the least you need. Every line is something a site owner has to read and accept.
The catalogue
| Permission | Risk | Allows |
|---|---|---|
posts.read, pages.read | low | Reading content, including drafts |
posts.write, pages.write | medium | Creating and editing content (also custom types, which count as posts) |
posts.delete, pages.delete | high | Trashing and permanently deleting |
media.read | low | Browsing the library |
media.write | medium | Uploading and editing metadata |
media.delete | high | Removing files |
users.read | medium | Accounts, roles and emails (never password hashes) |
users.write | high | Creating and editing accounts, including roles |
users.delete | high | Removing accounts |
comments.read | low | All comments, including spam |
comments.write | medium | Creating comments (skipping the spam guard) |
comments.moderate | medium | Approving, spamming, trashing |
terms.read, terms.write | low, medium | Categories, tags and other taxonomies |
menus.read, menus.write | low, medium | Navigation menus |
settings.read | medium | Site-wide settings |
settings.write | high | Changing site-wide settings |
mail.send | high | Sending email from the site's address |
http.outbound | high | Contacting other servers |
schedule | medium | Background tasks |
db.raw | high | Raw SQL through db() and schema(); bypasses everything above |
A write implies the matching read. An app with posts.write can read posts.
Always available, never declared: your app's own settings (getSetting/setSetting), its own cache namespace and its own log. Asking a site owner to approve an app's access to its own data would only train people to click through.
Approval
Activating an app that declares permissions opens a consent screen first. The site owner can untick individual permissions. The app then runs with less, and each refusal is logged, so the cause is easy to find.
- Grants survive deactivation, so switching an app off and on doesn't ask again.
- Grants are dropped on uninstall.
- An update that adds a permission doesn't get it automatically. The app keeps running with what was already granted. Calls that need the new permission are refused, and logged, until the site owner approves it on the app's consent screen.
- An update that adds the first permissions to an app that had none keeps running unrestricted, as before, and is flagged on the Apps screen for review. Enforcement starts once the site owner approves.
What a refusal looks like
API calls degrade instead of throwing. A refused call returns what it would return if there were no data (null, false, 0 or an empty array) and writes a line to your app's log naming the missing permission. A page render never turns into a white screen because of a policy decision.
db() and schema() are the exception: they throw a RuntimeException that names the missing db.raw permission, because every caller expects a real database object.
Check before you try, and degrade gracefully:
if ($this->allowed('mail.send')) {
$this->api()->mail()->sendTemplate($to, 'Report ready', 'Your report', $html);
} else {
$this->log('Mail not sent: mail.send was not granted', [], 'warning');
}
Logs
Each app writes to storage/logs/apps/{slug}-YYYY-MM-DD.log, readable under Apps → Logs. Files are kept for 7 days, and a file is rotated at 2 MB. Warnings and errors are also copied to the core log, so a site owner reading the main log sees an app in trouble.
The scanner
When an app is installed, and on demand, Basehim scans its PHP for things that reach around the permission broker:
new PDO,evaland shell execution;- direct
.envreads; - disabled TLS verification;
- a few other patterns.
It's a heuristic, not a verdict. Findings never block an install, but they are shown to the site owner. If your app does one of these things for a good reason, say why in your description.
Choosing permissions for common apps
| App | Permissions |
|---|---|
| Analytics that adds a script and stores hits in its own table | db.raw |
| Importer that creates draft posts from a feed | posts.write, terms.write, http.outbound, schedule |
| Weekly digest email | posts.read, users.read, mail.send, schedule |
| Comment moderation helper | comments.moderate |
| Settings-only app with an admin screen | Nothing to declare: its own settings need no permission. Without a permissions list the app is shown as unrestricted |
Leave a comment