Permissions
RBAC model, permission naming, route middleware, command gating, and UI gating.
Cogeze ships role-based access control in the access-control plugin, which is
mandatory — it boots regardless of database state because users is a foreign-key target
for core tables.
Model#
User ──< UserGroup >── Role ──< Permission >── Module
A permission is a string. The convention is {module}.{action}:
articles.view articles.create articles.update articles.delete
plans.view orders.update users.delete
Modules group permissions in the admin UI. A plugin declares both at once:
public function permissions(): ?array
{
return [
'key' => 'articles',
'label' => 'Articles',
'permissions' => [
'articles.view' => 'View articles',
'articles.create' => 'Create articles',
'articles.update' => 'Edit articles',
'articles.delete' => 'Delete articles',
],
];
}
Seeded on install, idempotent. Re-installing does not duplicate rows or reset role assignments.
Protecting API routes#
// plugins/Article/routes/admin.php
use Illuminate\Support\Facades\Route;
use Plugins\Article\Controllers\ArticleController;
Route::middleware('permission:articles.view')->group(function () {
Route::get('articles', [ArticleController::class, 'index']);
});
Route::post('articles', [ArticleController::class, 'store'])
->middleware('permission:articles.create');
routes/admin.php is already behind auth:sanctum. The permission middleware adds the
check and returns a structured error:
| Situation | Status | Code |
|---|---|---|
| Not logged in | 401 |
ERR_UNAUTHENTICATED |
| Logged in, lacks permission | 403 |
ERR_FORBIDDEN |
Both are returned as JSON regardless of the Accept header. A guest request to an admin
endpoint must never produce a redirect or a 500.
Gating commands#
A Commander command can declare its own requirement:
$commander->register('article.stats', fn () => Article::count(), [
'auth' => 'articles.view',
]);
auth value |
Meaning |
|---|---|
| omitted | Public. |
true |
Any authenticated user. |
'articles.view' |
That permission. |
['a.view', 'b.view'] |
All of them. |
Gated commands fail closed: with no authorizer installed, they do not run. That matters because commands are the way plugins reach each other's data — a permissive default would turn one missing configuration into a data leak.
Trusted contexts skip the check:
app(Commander::class)->asSystem(fn () => $commander->run('article.stats'));
Use it for CLI, queue workers and seeding. Never expose it to plugin-supplied code.
Gating the admin UI#
Menu entries carry a permission; the sidebar hides what the user cannot reach:
public function nav(): array
{
return [[
'group' => 'Content', 'title' => 'Articles',
'url' => '/posts', 'icon' => 'Newspaper',
'permission' => 'articles.view',
]];
}
Inside a page, gate individual controls with CanRender:
const { CanRender } = window.AdminSDK.components;
<CanRender action="articles.create">
<Button onClick={onCreate}>New article</Button>
</CanRender>
<CanRender action={['articles.update', 'articles.delete']}>…</CanRender>
UI gating is presentation only. It hides controls the user cannot use; it is not a security
boundary. Every action must also be enforced server-side by the permission middleware —
a hidden button is still reachable with curl.
Checking in code#
$user->hasPermission('articles.update');
const can = window.AdminSDK.hooks.useCan();
if (can('articles.update')) { … }