Cogeze
Core

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:

php
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#

php
// 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:

php
$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:

php
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:

php
public function nav(): array
{
    return [[
        'group' => 'Content', 'title' => 'Articles',
        'url' => '/posts', 'icon' => 'Newspaper',
        'permission' => 'articles.view',
    ]];
}

Inside a page, gate individual controls with CanRender:

tsx
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#

php
$user->hasPermission('articles.update');
tsx
const can = window.AdminSDK.hooks.useCan();
if (can('articles.update')) {}