Skip to content

Authorization

Authorization in Pramnos is three layers that answer different questions, and most applications use all three.

Answers Lives in Changes by
Pramnos\User\UserTypes what may this kind of account reach configuration a deploy
Pramnos\Auth\Gate what does this rule mean code a deploy
Pramnos\Auth\Permissions what has this installation granted a table an admin, at runtime

A rule like "the author, or a moderator" is not a row — written as rows it becomes one row per article per user. A grant like "this customer's support team may export reports" is not a rule — written in code it is the same for every installation. And "administrators can open the administration area" is neither: it is what the account is, decided before any record is in sight. Nothing here replaces anything else, and the bridge between the last two is one line.

Four places ask:

Layer Class Decides
Controller actions Pramnos\Application\Controllercan(), auth() whether an action runs
API endpoints Pramnos\Application\ApiCrudController::authorize() whether a CRUD verb is allowed
Routes Pramnos\Routing\Router::hasPermissions() whether a route is refused
Navigation Pramnos\Application\NavRegistry whether a menu item is shown

Refusals throw Pramnos\Auth\AuthorizationException — code 403, and it names what was refused.


Gates: rules in code

Defining

use Pramnos\Auth\Gate;

Gate::define('update-post', function ($user, $post) {
    return $user->userid === $post->userid;
});

// "an administrator may do anything", once, instead of at the top of every rule
Gate::before(fn ($user) => $user->isAdmin() ? true : null);

A rule returns true to allow, false to deny, and null for no opinion — which falls through to the next step rather than refusing. That is what lets several rules cover one ability without fighting.

Policies

A policy is an ordinary class whose methods are ability names:

namespace App\Policies;

class PostPolicy
{
    public function update($user, $post)
    {
        return $user->userid === $post->userid;
    }

    /** Runs before this policy's own methods — narrower than the global hook. */
    public function before($user, $ability, ...$args)
    {
        return $user->isModerator() ? true : null;
    }
}
Gate::policy(\App\Models\Post::class, \App\Policies\PostPolicy::class);

Gate::allows('update', $post);        // → PostPolicy::update($user, $post)
Gate::allows('update-post', $post);   // → PostPolicy::updatePost(), if it has one

Hyphens and underscores fold to camelCase, so an ability can read naturally in a route file and still be a method name. A policy can be found from a class name as well as an instance, which is what create needs — there is no object yet.

Asking

Gate::allows('update-post', $post);                    // bool, current user
Gate::denies('update-post', $post);                    // the same, inverted
Gate::authorize('update-post', $post);                 // or throws

Gate::forUser($other)->check('update-post', $post);    // somebody else
Gate::current()->any(['edit', 'publish'], $post);      // any of these
Gate::current()->all(['edit', 'publish'], $post);      // all of these

Inside a controller, can() and cannot() are the short form:

if ($this->cannot('update-post', $post)) {
    return $this->redirect('/posts');
}

The order a decision is made in

  1. before callbacks, in registration order. A non-null return decides immediately.
  2. A named ability, if one was defined for this name.
  3. A policy, if the first argument has one carrying a method of this name.
  4. The permission store, if the bridge is on.
  5. Otherwise deny — an ability nobody defined is not an ability.
  6. after callbacks, which may override the result.

Knowing this order is how every "why was this allowed" question gets answered.

Who the user is

By default the gate asks Pramnos\Http\RequestIdentity — where the framework's own authentication puts the answer. An application that identifies users some other way says so once:

Gate::resolveUserUsing(fn () => MyApp::currentUser());

Rules receive null when nobody is signed in, rather than the check throwing — so a public-facing rule can simply say what an anonymous visitor may do.

Seeing which step decided

The order above is the contract, and until the toolbar carried it there was no way to observe it. A rule is a closure in a bootstrap file, so it appears in no stack trace. A before hook that returns true skips everything after it and leaves no mark. The SQL panel cannot help, because a decision may touch no database at all. And a 403 says something refused, not which of six steps did.

The debug toolbar's Gate tab lists every check the request made, with the step that answered:

Result Ability Decided by What Subject
allowed update-post policy PostPolicy::update App\Models\Post
allowed see-menu ×40 before a global before() hook decided
refused updatePost default nothing claimed this ability App\Models\Post

That last row is why the tab exists. With fallbackToPermissions() off — the default — an ability nobody defined is refused, so a typo in an ability name is indistinguishable from a deliberate deny: both produce false. default tells them apart, and the tab counts those separately so a badge says so before it is opened.

Identical checks collapse into a ×N, because rendering a permission-gated menu can ask the same question forty times and that should be one row.

Arguments are never in the payload. A policy check receives whole models, and the debug payload travels to the browser — so a subject appears as a class name and a user as an id, and nothing that came out of a database goes with it. It is also request-scoped by design: it shows what this request decided, not what a user may do in general, which is a question for the store.

Recording is opt-in — Gate::enableDecisionLog(), which the debug provider calls — so an application that never opens the toolbar pays one boolean check per decision. Same shape as Database::enableQueryLog().

Registration is process-wide

Abilities live in statics. That is right for a request and wrong for anything handling more than one, so Gate::reset() exists, and the test suite calls it between tests via Pramnos\Framework\Testing\GateIsolation — see the Testing Guide.



Permissions: grants in a table

Pramnos\Auth\Permissions reads and writes authserver.permissions, the framework's one permission store. A permission is a subject doing a privilege to a resource:

$permissions = \Pramnos\Auth\Permissions::getInstance();

// Grant: user 42 may edit the "articles" module
$permissions->allow(42, 'articles', 'edit');

// Deny explicitly — not the same as never granting
$permissions->deny(42, 'articles', 'delete');

// Ask
if ($permissions->isAllowed(42, 'articles', 'edit')) {
    // ...
}

Both allow() and deny() take the same shape, and so does isAllowed():

isAllowed(
    $subject,                      // usually a user id
    $resource,                     // 'articles', 'customers', a module name
    $privilege,                    // 'edit', 'delete', 'view'
    $resourceElement = '',         // a specific row, when the grant is per-record
    $resourceType    = 'module',
    $subjectType     = 'user',     // 'group' for a group grant
    $nonExistEqualsFalse = true
);

The parameter that decides your security model

$nonExistEqualsFalse is the one to understand before writing anything:

Value "No rule exists" means
true (default) denied — deny by default
false null — no opinion, and the caller decides

The null is not a nuisance to be cast away. It is what lets a caller tell "explicitly denied" from "nobody has said anything", and those two need different handling in any system that gained permissions after it had users. Casting null to false locks out every installation that never granted anything.

removePermission() deletes a rule, which returns that subject to "no opinion" — it is not the same as deny().

Resolving everything a user has

Pramnos\Auth\PermissionResolver answers the other direction — not "may they do X" but "what do they have":

$resolver    = new \Pramnos\Auth\PermissionResolver($database);
$permissions = $resolver->resolve($userId, $appId);   // string[]

Used by the router and by anything that needs the whole set at once rather than one check at a time. PermissionResolverInterface exists so an application can substitute its own.

hasPermission() on your user

Several framework call sites ask the user object directly:

if (method_exists($user, 'hasPermission') && $user->hasPermission('viewCustomer')) {

hasPermission() is not defined by the framework's User — the method_exists() guard is deliberate. An application that wants named permission checks implements it, usually over Permissions::isAllowed(). An application that does not is not broken; those call sites fall back to their own defaults.


Roles: naming a set of permissions

A permission row's subject can be a user or a role. Granting to a role and then giving people the role is the point of RBAC — you change what "operator" means once, rather than editing every operator's grants.

The screens

Admin → Access → Roles, or Roles from the console-scaffolded routes:

Screen What it does
roles every role, with its organisation and whether it is active
roles/view/{id} the role, the permissions granted to it, and who holds it
roles/edit/{id} name, description, owning organisation, active
roles/members/{id} add and remove holders

Requires usertype ≥ 90, the same as Permissions: deciding what a role is is the same order of privilege as deciding what it may do.

System-wide or an organisation's

The one field worth understanding on the form. A role with no organisation is system-wide and valid everywhere. A role that names one may only be held by a member of that organisation, and counts only within it — see Multi-tenancy below.

Assigning an organisation's role to somebody who is not a member is refused, with a message saying to add them to the organisation first. The check is in Role::assignTo(), not in the controller, so an API caller reaching the model directly gets the same answer.

A role's organisation cannot be changed once anybody holds it. The holders who are not members of the new organisation would silently stop having it — create a separate role for that organisation instead.

In code

use Pramnos\Auth\Role;

$role = new Role($controller);
$role->role_name       = 'operator';
$role->description     = 'Handles day-to-day invoicing';
$role->organization_id = 5;        // null for a system-wide role
$role->save();

if (!$role->assignTo($userId, grantedBy: $adminId)) {
    echo $role->getLastError();    // says why, in words for an operator
}

$role->revokeFrom($userId);
$role->holders();                  // [userid => granted_at]

assignTo() is idempotent — "add" pressed twice is not an error — and re-activates a previously revoked assignment rather than leaving it inactive.

What deactivating and revoking do

Neither deletes. Revoking a role sets the assignment's is_active = 0; deactivating a role sets the role's. Both are readable afterwards, and the resolver ignores both. Deleting the role does remove its assignments, because a row naming a role that no longer exists is not history anybody can read.

Removing somebody from an organisation touches none of this: their assignments stay, stop counting while they are out, and count again if they return.

Multi-tenancy: scoping to an organisation

The schema has organizations, user_organizations, and an organisation column on authserver.roles where NULL means "system-wide". Whether any of it is enforced depends on which method you call, and the difference matters more than it looks.

resolve() is not scoped, and never was

$resolver->resolve($userId, $appId);

Returns the permissions of every active role the user holds, whatever organisation that role belongs to. It scopes by application (permissions.app_id), not by organisation. In a multi-tenant application that is the wrong question: a role defined for organisation 5 will answer for organisation 3.

It stays that way because that is what it has always done and applications depend on it. Use it when there is one tenant, or when you are asking "what can this person do anywhere".

resolveForOrganization() is

$resolver->resolveForOrganization($userId, $appId, $organizationId);

A role counts only if it is system-wide (organization_id NULL), or belongs to that organisation and the user is an active, unexpired member of it.

Role's organisation User a member? Counts
NULL (system-wide) yes
the one being asked about yes yes
the one being asked about no, or membership inactive/expired no
a different one no

Membership is read from authserver.user_organizations (or whatever authserver_organization_table points at). is_active = 0 counts as not a member — that is how the admin screen removes somebody, keeping the audit trail — and so does a passed expires_at.

Leaving an organisation deletes nothing. The role assignment stays exactly where it is and stops counting; rejoining restores the same set. Revoking access and forgetting what somebody was are different operations.

Direct grants to the user are unaffected by any of this: they are on the person, not on a role, so they have no organisation to scope by.

It is one joined query — measurably cheaper than reading memberships separately, even when they are already in memory.

It is opt-in, and that is the part to get right

Nothing calls resolveForOrganization() for you. ApiCrudController, Gate and Permissions all use resolve(), because they are not given an organisation and have no way to guess one. Isolating tenants means your code deciding which organisation a request is about and asking that question.

For reads, that is usually not an authorization question at all but a query one, and a global scope on the models is the answer — one line, covering every list path including the REST endpoints:

Invoice::addGlobalScope('tenant', fn(string $f): string =>
    ($f === '' ? '' : "({$f}) AND ") . 'organization_id = ' . $currentOrgId
);

See the ORM guide.

And a warning about the default

ApiCrudController::authorize() treats "no rule at all" as allowed, deliberately, so that a project which has granted nothing keeps working. Combined with resolve() being the unscoped one, a scaffolded multi-tenant API is open by default: no grants written, no scope registered, every row served to everyone signed in.

That is the correct default for a single-tenant application and the wrong one for a multi-tenant one. If you are building the second, register the scope before you generate the endpoints, not after.

Usertypes: what a kind of account may reach

users.usertype is an integer read as a threshold, and a capability is what a threshold grants: admin.area, admin.users, devpanel. It is the layer that answers before there is any record to reason about.

\Pramnos\User\UserTypes::can(90, 'admin.settings');   // false — that is 98 and above
\Pramnos\User\UserTypes::capabilities(98);            // the resolved list
\Pramnos\User\UserTypes::label(95);                   // 'Administrator' — 95 is above the floor

The types, their capabilities, how an application declares its own, and why usertype_capabilities replaces the framework's map instead of merging with it are all in the Authentication guide, next to users.usertype itself. /admin/Users/types renders the running answer.

Which layer a question belongs to

The question The layer
May this kind of account reach this kind of screen? a usertype capability
May this account touch this record? a gate, or a permission row
Is the administration area browsable at all? admin.min_usertype

The third one is a separate thing again, and it is the one most often confused with the first: admin.min_usertype is a floor on the whole area, applied by Pramnos\Http\AdminArea before any screen's own check runs. It is not a substitute for a screen's check — do not remove a screen's own guard because the area has a floor. The area's floor stops browsing; the screen's guard is what decides whether that screen may act. A screen that is also reachable outside the area (a public monitor endpoint, say) has no floor at all.

Why capabilities are not permissions

A capability is about a class of account and a class of screen. It has no idea what a record is, and giving it one would mean a capability per record — which is a permissions table, badly. Conversely, a permission row cannot express "administrators can open the administration area" without a row per administrator, rewritten every time somebody is promoted.

The practical test: if the answer changes when a row in the database changes, it is a permission. If it changes when somebody's usertype changes, it is a capability. If it needs to look at the record — its author, its state, its owner — it is a gate.


Bridging the two

The gate answers rules; the store answers grants. One line connects them:

Gate::fallbackToPermissions();   // off by default

With it on, an ability written as resource.privilege that no gate or policy claims is answered by Permissions::isAllowed($userId, $resource, $privilege, …). So:

  • rules that need reasoning are define()d or written as policies;
  • everything else is data an administrator can edit, without a deploy;
  • and one Gate::allows('reports.export') asks whichever layer owns the answer.

The store is asked with $nonExistEqualsFalse = false, so "no rule" arrives as no opinion rather than as a denial — the gate decides what that means, not the absence of a row.

It is off by default and deliberately explicit: a gate that silently consulted a database for names nobody registered would be a gate whose answers cannot be read off the code. Turning it on is a decision, and it should look like one.

Abilities with no . are never sent to the store — there would be nothing to tell it, and guessing a resource is worse than declining.


Controller actions

Controller::auth($action) runs before an action and can refuse it. It reads three properties:

class Articles extends \Pramnos\Application\Controller
{
    /** Actions that require a logged-in user. */
    public $actions_auth = ['edit', 'delete'];

    /** Permissions each action requires. */
    protected $action_permissions = [
        'edit'   => ['articles.edit'],
        'delete' => ['articles.delete'],
    ];

    /** The current user's permissions, as resolved at boot. */
    protected $user_permissions = [];
}

actions_auth is the login check; action_permissions is the permission check, and it only applies when user_permissions has been populated. An empty user_permissions means the permission check is skipped — so a controller that never receives them is protected by actions_auth alone. That is worth knowing before assuming an action is guarded.


API endpoints

ApiCrudController::authorize(string $action) covers list|read|create|update|delete for generated CRUD endpoints. Its rule is three-valued, for the reason described above:

protected function authorize(string $action): bool
{
    return $this->permissionFor($action) !== false;
}
The store says Result
explicit allow allowed
explicit deny refused
no rule at all allowed

The last row is a compatibility decision, stated plainly so nobody discovers it in production: a project that has granted nothing keeps working exactly as it did before this class existed. A failure to read permissions is also treated as "no opinion" rather than as a decision, so a broken query cannot silently deny — or silently allow — on its own.

To tighten it, override in the generated controller:

protected function authorize(string $action): bool
{
    return parent::authorize($action) && $this->user()->isAdmin();
}

The endpoint question and the record question are different

authorize() asks whether a user may update invoices. It is not given an id, so it cannot ask whether they may update invoice 42 — and those are two questions with two answers.

A grant answers one of them. Which one depends on its object_id:

Grant authorize('read') invoice 42 invoice 43
object_id NULL or * allow allow allow
object_id = 42 no rule allow no rule
object_id = 42, deny no rule deny no rule

The middle rows are the important ones, in both directions: a grant on one record does not open the collection, and a deny on one record does not close it. A row-scoped grant read as a resource-wide one would hand back every invoice in the table to somebody who had been given exactly one.

To honour record-level grants, ask the second question where the id is — in the action itself, which is the only place the base class never sees:

// In a generated controller, where the action already has the id.
public function read($id): mixed
{
    if (($denied = $this->guard('read')) !== null) {
        return \Pramnos\Http\Response::json($denied, $denied['status']);
    }

    if ($this->permissionForObject('read', (string) $id) === false) {
        return \Pramnos\Http\Response::json(
            ['error' => 'forbidden'], 403
        );
    }

    // …
}

permissionForObject() is three-valued like everything else here, so compare against false rather than treating null as a refusal. Reading null as "no" would refuse every request in a project that has written no grants at all.


Routes

A route can carry required permissions:

$router->get('/admin/reports', 'Reports@index')
    ->requirePermissions(['reports.view']);

// or, adding to what a group already applied
$route->addPermissions('reports.export');

Both accept a string or an array. Router::addRoute() also takes them as its fourth argument, and a route group applies them to everything inside it.

Router::hasPermissions($route, $userPermissions) is what decides, and it runs after the route has matched: the route is found, then refused. The failure is an exception carrying 403, and when the refusal is about an OAuth scope the message names the missing scope:

Insufficient permissions to access this route. Missing scope: reports.export

Routes with no permissions declared are never refused here.


NavRegistry hides menu items the user may not use. Its rule is the same three-valued one, and its docblock is worth quoting because the edge case is the whole design:

Item Logged in Permission Result
no permission set kept
permission set no kept
permission set yes explicitly denied removed
permission set yes no rule for it kept — silence is not a deny

A menu that vanished because nobody had granted anything yet would look like a broken install, which is exactly what happened before the framework had a permission system.

A count beside a label

A NavItem may carry a badge — the notification count beside a menu entry:

NavRegistry::register(new NavItem(
    'user.messages', 'Messages', $base . 'messages',
    NavSection::User, 5, requireAuth: true, feature: 'messaging',
    icon: 'mail',
    badge: static fn (int $userId): int => MessagesController::unreadCount($userId),
));

A closure, not an int, and that is the whole of the design: navigation is registered once at boot, so a number resolved there is the count as it was when the process started — for an unread badge, always wrong and usually zero.

Read it with badgeCount($userId), and render it with badgeLabel($userId), which writes anything over ninety-nine as 99+. The difference between a hundred unread and four hundred is not one anybody acts on, and a four-digit badge is wider than the label it sits beside.

Four things it will not do, because a badge is decoration on a screen that is about something else and a navigation item that throws takes every page on the site with it:

  • It is not asked for a signed-out visitor. The navigation renders for everybody, and a count for user 0 is a query against an account that does not exist — on every page, for every crawler.
  • It is resolved once per account per request. A theme renders the navigation more than once — a header and a mobile menu are two renders of the same list.
  • A closure that throws counts zero. The database is unreachable, or the table has not been migrated.
  • A negative answer counts zero. «-1 unread» reads as a broken page rather than a broken count.

The closure must be cheap: an indexed COUNT, not a join. It runs on every page a signed-in visitor loads.


Diagnosing a decision

When a check returns something surprising, in this order:

  1. Ask the store directly with $nonExistEqualsFalse = false. If it returns null, there is no rule and you are looking at a default, not a denial:
    var_dump($permissions->isAllowed($uid, 'articles', 'edit', '', 'module', 'user', false));
    
  2. Check the subject type. A grant made to a group is not found by a check that asks about a user, and vice versa — $subjectType has to match how it was written.
  3. Check $resourceElement. A grant for a specific record does not answer a check for the resource as a whole.
  4. For a route, remember it is matching, not refusing: if the URL 404s, the permission is a candidate before the route file is.
  5. For a controller action, check user_permissions is actually populated. If it is empty, action_permissions was never consulted.

The debug toolbar's Auth tab shows who the server identified and by which credential, which settles the half of the question that is about identity rather than permission.


Pramnos\Policy\PolicyEngine is not this

There is a class called PolicyEngine and a table called framework_policies, and they have nothing to do with authorization. They execute data-retention policies — retention windows, aggregate refresh, compression, cache rebuilds. Same word, unrelated concept.

It is called out here because grepping for "policy" finds it, and finding it is enough to conclude you have found the authorization system. The one you want is Pramnos\Auth\Gate.


What this page used to say

Until 2026-08-14 this guide documented Gate::define(), policy classes with before/after hooks, auth()->can(), $this->authorize('update', $post) and \Pramnos\Auth\AuthorizationExceptionnone of which existed. The page came out of the v1.2 documentation reorganisation describing an API that was planned rather than shipped, and it was found by a consumer who tried to build on it.

It is recorded rather than quietly overwritten because of how it failed. Eight other guides had namespace slips — the class exists, the guide spells its path wrong — and a reader greps the class name, finds it one namespace over and moves on. This page was the only one where there was nothing to find under any namespace, and a reader who greps Gate and gets nothing back cannot tell "I searched wrong" from "this does not exist". That is the state in which somebody keeps looking for another hour, and it lands on whoever is doing authorization work — which is precisely where people reach for a framework instead of inventing something.

The gate now exists, because the design was sound and the gap was real: the permission store cannot express a rule. Three things differ from what that page described, and they are deliberate:

That page Today Why
$this->authorize('update', $post) on a controller $this->can() / $this->cannot(), or Gate::authorize() ApiCrudController::authorize(string $action): bool already exists with a different meaning; two authorize()s in one hierarchy is a trap even where PHP allows it
a global auth() helper Gate:: statics the framework has three unrelated auth() methods already; a fourth spelling would have been the worst of them
policies only policies and a permission-store bridge the store was already there and already used; a gate that ignored it would have split authorization in two

Reference## Reference

Classes:

  • Pramnos\Auth\Gate — rules: define(), policy(), before(), after(), allows(), denies(), authorize(), forUser(), current(), check(), any(), all(), enforce(), fallbackToPermissions(), resolveUserUsing(), reset()
  • Pramnos\Auth\AuthorizationException — a refusal, code 403, carrying getAbility()
  • Pramnos\Auth\Permissions — the store: allow(), deny(), removePermission(), isAllowed(), setDefaultPermission()
  • Pramnos\Auth\PermissionResolver — every permission a user has, as a list
  • Pramnos\Auth\PermissionResolverInterface — substitute your own
  • Pramnos\Application\Controllercan(), cannot(), auth(), $actions_auth, $action_permissions
  • Pramnos\Application\ApiCrudControllerauthorize(), permissionFor()
  • Pramnos\Routing\RouterhasPermissions(), addRoute(); Pramnos\Routing\RouterequirePermissions(), addPermissions()
  • Pramnos\Application\NavRegistry — permission-gated menu items

Related guides: