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\Controller — can(), 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;
});
// "a superuser may do anything", once, instead of at the top of every rule.
// $user is null for an anonymous visitor, hence the null-safe read.
Gate::before(fn (?object $user) => (int) ($user?->usertype ?? 0) >= 98 ? 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 (int) ($user?->usertype ?? 0) >= 90 ? 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:
The order a decision is made in¶
beforecallbacks, in registration order. A non-nullreturn decides immediately.- A named ability, if one was defined for this name.
- A policy, if the first argument has one carrying a method of this name.
- The permission store, if the bridge is on.
- Otherwise deny — an ability nobody defined is not an ability.
aftercallbacks, 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:
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 without the toolbar it cannot be observed. 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');
// Several privileges at once: each gets the same element, resource type and subject type
$permissions->allow('7', 'articles', ['read', 'edit'], '', 'module', 'group');
// Many resources at once, for one subject: one transaction, one cache flush, one webhook
$permissions->allowMany($roleId, [
'articles' => ['read', 'create', 'edit'],
'comments' => ['read'],
], 'module', 'group');
// Ask
if ($permissions->isAllowed(42, 'articles', 'edit')) {
// ...
}
allowMany() grants on the whole of each resource. It writes either every grant or none,
because a role set up halfway works on some screens and not on others. It flushes the
permission cache once and queues one permissions_changed for the subject. In the
authserver store it is two statements whatever it grants: one DELETE for the rows it
replaces and one multi-row INSERT. Setting up a new
organisation's roles is the case it exists for: per-privilege allow() did all of that
for every privilege. The list form of allow() without an element goes through it.
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.
How an allow and a deny are decided¶
Rows for the same object and action — from the user and from every active role they hold —
are compared by priority: the higher priority decides, and a deny wins a tie. Two rows at
the default 100 are a deny; an allow at 500 overrides a deny at 100, which is how one exception
is carved out of a broad refusal. Nothing but an allow is an implicit deny.
The priority is compared as it is stored, and stored as it was written — the same on MySQL and
PostgreSQL. PermissionResolver, the authserver.effective_permissions view and the PL/pgSQL
helpers that read it all apply this one rule.
The Permissions screen¶
Admin → Access → Permissions edits authserver.permissions rows directly:
| Field | Meaning |
|---|---|
| Subject | a user or a role, by id — the two the resolver reads; anything else is refused |
| Object type / Object ID | what is protected; a blank id means every object of the type |
| Action | read, update, *… |
| Grant | allow or deny |
| Priority | see above; 100 unless set |
| Application ID | the one application the grant applies within; blank for every application |
| Expires | when the grant stops counting; blank for permanent |
| Conditions | a JSON object passed with the grant to the application, which evaluates it; blank for unconditional |
An application's grant uses that application's vocabulary. Once an application has declared
its capabilities (AuthServer Integration Guide §5),
entering its id in Application ID turns Object type into a list of its active resources,
Action into the chosen resource's active scopes plus *, and lists under Conditions the
condition keys it evaluates, each with its value type. Saving an object type or action it does
not declare is refused with the reason — a typo such as invoice for invoices would otherwise be
stored and never match anything. A grant with no application, or for an application that has
declared nothing, stays free text.
The permissions list and a role's page mark a grant whose resource or scope its application no longer declares (or never did), with the reason under the object type. The grant is still stored and still sent; the mark is why it has stopped having any effect in the application.
CapabilitiesSyncService::catalog() is the data behind both, and problemWith($catalog, $appId,
$objectType, $action) the check — an application's own writer of grants can call it too.
Telling applications that something changed¶
Every write that changes what somebody may do queues a permissions_changed webhook: a row on
this screen, a grant or revocation on a user's page, a decision in the Administration screens
panel, Permissions::allow() / deny() / removePermission(), a role saved, deactivated or
deleted, a role given or taken, and an organisation membership added or removed. All of them go
through one call, which an application's own writer should use too:
\Pramnos\Auth\WebhookService::permissionsChanged('user', $userId, ['operation' => 'update']);
\Pramnos\Auth\WebhookService::permissionsChanged('role', $roleId, ['operation' => 'deactivate']);
The body sent is {"subject_type": …, "subject_id": …} plus the context. A role's event names
no user: the subscriber drops the cache of everyone holding it. The call never throws — the
change it reports has already been written.
It goes to the applications the change concerns, not to every subscriber: with an app_id in
the context, that application alone; otherwise the applications the user — or each holder of the
role — has an unexpired token for or has consented to. WebhookService::queueEventForApplications()
does the same for any event.
hasPermission() on your user¶
Several framework call sites ask the user object directly:
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 |
A user's page lists the roles they hold — each with its organisation or system-wide,
when it was granted and until when — and marks one that does not count (the role was
deactivated, or the assignment expired), since that is the usual answer to a missing
permission. In code, Role::heldBy($userId).
Deciding what a role is is the same order of privilege as deciding what it may do, so these screens open at the same floor as Permissions.
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.
An organisation's role is only enforced per organisation if the application says which organisation a request is about. Without an organisation resolver, a user's roles from every organisation are read together, so a Client in one organisation and an Editor in another can edit in both.
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: a revoked
assignment grants nothing, and a deactivated role grants nothing to anybody assigned it — in
PermissionResolver::resolve(), so in Permissions::isAllowed() and in administration screens
granted by permission too. An assignment to a role id that has no row in authserver.roles is
not dropped by this; only a role marked inactive is.
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. Their organisation's role can still be taken from them after they have left: on PostgreSQL the membership trigger guards a grant, and deactivating an assignment grants nothing.
User groups: an optional feature¶
A group is a named set of accounts. It is the usergroups feature, and it is off unless a
project turns it on.
Turning it on¶
- A new project:
initasks Enable User groups [usergroups]? [y/N], only whenauthis enabled, because a membership row references a user. You can also pass it on the command line:--features=auth,…,usergroups. -
An existing project:
The migration creates usergroups (groupid, name, description, order) and
userstogroups (userid, groupid). A membership is deleted with its user and with its
group. An installation that already has either table keeps it as it is. In that case
userstogroups gets no foreign key to a groupid whose type the migration cannot know, and
the screen deletes a group's memberships itself.
The screen¶
Administration → People → Groups (/admin/Groups) appears only while the feature is on.
There you:
- find a group in a datatable of name, description and members (
Groups/data), the most members first, searched and sorted; - create, edit and delete groups;
- open a group to see its members;
- add a member by username, email or user id;
- remove a member.
Nothing is scaffolded into the project. The controller is found through the framework's
fallback, so a project that enables the feature later has the screen at once. An
application class named Groups replaces it, and project:publish-views copies its views
(groups/).
Roles held by a group¶
A group can hold roles. Every member then holds them, by the same rules as a role given directly:
- the assignment must be active and not expired, and the role itself active;
- in a check scoped to an organisation (
resolveForOrganization()), a system-wide role always counts, and an organisation's own role counts only while the member belongs to that organisation.
Give and withdraw them on the group's page, in the Roles section. A withdrawn role is
switched off, not deleted, so the record stays, exactly as Role::revokeFrom() does for a
user. Every change that alters somebody's permissions tells the applications they use, the
same permissions_changed event a direct role assignment sends:
- a role given to or withdrawn from a group;
- a member joining or leaving a group that holds roles;
- deleting such a group.
The assignments are in authserver.group_roles (groupid, roleid, granted_by,
granted_at, expires_at, is_active), created by the same feature. Deleting a role
removes them, along with its user assignments and its own grants in
authserver.permissions (subject_type = 'role'). There is no need to delete the grants
first. A role given to a group is found by WebhookService among the role's holders.
A permission check about a group is answered about the group:
$permissions->isAllowed($groupId, 'articles', 'edit', '', 'module', 'group') is true when a
role the group holds allows it.
What else reads membership¶
- Mail audiences. The mass-message screen offers each group as an audience.
- The legacy permission store. On an installation still using
<prefix>permissions,isAllowed($userId, …)falls back to the user's groups when the user has no rule of their own, and a group's deny wins over another group's allow. - Your own code.
$user->getGroups()returns the groups an account is in, keyed by group id. A membership change is seen by the next call: the screen flushes theusergroupsSQL-cache category thatgetGroups()reads under.
Erasing an account under GDPR removes its memberships. userstogroups is also listed as
personal data, so the MCP database-inspection tool will not read it.
An organisation's own administrator¶
Somebody can be given one organisation to run — its members, its invitations, who holds its
roles — without any of the administration area. They are whoever is allowed manage on the
object organization with that organisation's id:
Permissions::getInstance()->allow($userId, 'organization', 'manage', (string) $orgId);
// or to a role of the organisation, which its members then hold
A permission rather than a flag, so a deny, an expiry and the audit trail work as for any other
grant, and it can come through a role. A superuser manages every organisation.
OrganizationAdmin::manages($user, $orgId) is the check; managedBy($user) lists them.
What they can do, in that organisation only — at /organization, with a link in the account
sidebar for anybody who manages one:
| Add by address | an account becomes a member at once; an address with no account gets an invitation into the organisation — either with one of its roles |
| Remove a member | the membership ends; their assignments stay and stop counting |
| Give / take a role | only the organisation's own roles, and only one that allows nothing the manager is not allowed themselves |
| Invitations | see them, and withdraw one still waiting |
Roles are created and edited by an administrator, not here — a manager assigns what exists. Every
change is written to the affected account's activity log and sent as permissions_changed.
The same addresses answer JSON for a script (Accept: application/json or X-Requested-With):
GET organization |
{organizations: [{organization_id, name}]} |
GET organization/view/{id} |
{organization_id, name, members, roles, invitations} |
POST organization/add/{id} |
email, optional roleid |
POST organization/remove/{id} |
userid |
POST organization/giverole/{id}, takerole/{id} |
userid, roleid |
POST organization/withdraw/{id} |
invitation |
A write answers {ok, message}, a refusal {ok: false, error} with 401, 403 or 422. Writes
are POST with the session's token — the form field or X-CSRF-Token — so they are for a
signed-in browser, including a single-page application on the same site. OrganizationAdmin is
the service underneath, for an application's own API.
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¶
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¶
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.
Telling the framework which organisation a request is about¶
The framework cannot guess the organisation: one application takes it from a subdomain, another from a header, another from the signed-in member's current workspace. So it asks the application, once:
// where the application boots, e.g. Application::init() or a service provider
\Pramnos\Auth\OrganizationScope::resolveWith(fn () => Tenant::currentOrganizationId());
From then on every permission check the framework makes in a request uses
resolveForOrganization() for the organisation the resolver returns:
ApiCrudController::authorize(), permissionFor() and permissionForObject(),
Permissions::isAllowed(), and Gate's permission fallback. A resolver returning null
means "this request is about no organisation" and is checked as before.
This changes what an unanswered action means for the generated CRUD endpoints. Read
Checking permissions in an organisation before
you turn it on. /api/internal/permissions calls resolveForOrganization() when the
resource server passes organization_id; see the
AuthServer Integration guide.
For reads, isolating tenants is usually not an authorization question at all but a query one, and a global scope on the models is the answer. It is one line and covers every list path, including the REST endpoints:
Invoice::addGlobalScope('tenant', fn(string $f): string =>
($f === '' ? '' : "({$f}) AND ") . 'organization_id = ' . $currentOrgId
);
See the ORM guide. The two work together: the scope decides which rows a member sees, and the organisation's roles decide what they may do to them.
And a warning about the default¶
ApiCrudController::authorize() treats "no rule at all" as allowed when no organisation
is in scope, deliberately, so that a project which has granted nothing keeps working. A
scaffolded multi-tenant API without an organisation resolver is therefore 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:
- set
OrganizationScope::resolveWith(), so an action no role grants is refused; - register the models' tenant scope, so a member only sees their organisation's rows;
- do both before you generate the endpoints, not after.
Checking permissions in an organisation¶
This section is for an application whose users belong to several organisations and hold a
different role in each: a project agency whose clients log in, a SaaS with workspaces,
a municipality's departments. Read it before setting OrganizationScope::resolveWith(),
because it changes how the generated API answers.
The problem, in one example¶
Maria is a Client in organisation A and an Editor in organisation B. The roles grant only what each may do. Nothing is written as "not allowed":
| Role | Organisation | Grants on posts |
|---|---|---|
| Client | A | read |
| Editor | B | read, update |
Maria opens a post of organisation A and presses Save (PUT /posts/42).
| What the check reads | Result | |
|---|---|---|
| No organisation resolver | every role Maria holds anywhere: Client and Editor | update is granted by Editor: saved. Wrong. |
| Resolver, organisation A | system-wide roles, and A's roles because she is a member: Client | no role grants update: refused. Right. |
| Resolver, organisation B | system-wide roles, and B's: Editor | update granted: saved. Right. |
The first row is two problems, not one. Read without an organisation, Maria's roles are
merged across organisations. And even with the merge removed, the Client role says nothing
about update, and outside an organisation "nothing" means "allowed" for the generated
endpoints (see API endpoints). The resolver fixes both: the roles are read
for one organisation, and an action no role grants is refused.
What the resolver changes, and where¶
| Check | Without a resolver | With one, and an organisation in scope |
|---|---|---|
| Which roles count | every role the user holds | system-wide roles, plus that organisation's if the user is an active member |
ApiCrudController::authorize() / permissionFor() with no matching grant |
allowed | refused |
ApiCrudController::permissionForObject() with no matching grant |
no opinion (null) |
refused (false) |
Permissions::isAllowed() with no matching grant |
false (or null with the last argument false) |
the same |
Gate with fallbackToPermissions() |
refuses an ability nobody grants | the same, in that organisation |
| The permission store can't be read | no opinion | refused |
Permissions::isAllowed() and Gate already refused what nobody granted. For them the
resolver only removes the merge across organisations. The generated CRUD endpoints are the
ones whose answer to "nothing granted" changes.
A request the resolver says is about no organisation (it returns null) is checked
exactly as before.
Setting it up¶
use Pramnos\Auth\OrganizationScope;
// Once, where the application boots.
OrganizationScope::resolveWith(function (): ?int {
return Tenant::currentOrganizationId(); // int, or null for "no organisation"
});
The resolver may return an int, a numeric string, or null. It is called per check, so
keep it cheap: read a value the request already established, don't query for it.
One controller whose organisation comes from somewhere else, for example its URL:
class ProjectsController extends \Pramnos\Application\ApiCrudController
{
protected function permissionOrganizationId(): ?int
{
return (int) $this->request->get('organization') ?: null;
}
}
A check outside the request, such as a worker acting for one tenant, or a screen listing what a member may do in each of their organisations:
$permissions = \Pramnos\Auth\Permissions::getInstance();
$permissions->isAllowedInOrganization($userId, $organizationId, 'posts', 'update');
It takes the same arguments as isAllowed(), with the organisation second, and does not
change the request's organisation for anything else. Answers are cached per organisation, so
asking about A and then B on one instance gives each its own answer.
Before you turn it on¶
Once an organisation is in scope, an endpoint action that no role grants is refused for everyone, the organisation's owner included. That is the point: a role model is closed until a grant opens it. It also means:
- Every role needs every action it should have. An Owner role that was relying on
"nothing said, so allowed" for
deletelosesdelete. List each role's actions per resource before switching. - A new endpoint is closed until granted. Add the grant to the roles in the same change that adds the endpoint, or nobody can use it.
- System-wide roles keep working everywhere. A support or administrator role with
organization_idNULL counts in every organisation, which is usually what staff need. - Direct grants to a user count in every organisation. They belong to the person, not to a role, so there is no organisation to scope them by.
- Your own
User::hasPermission()scheme is asked first, unchanged. If the application declares its own permission names, the generated endpoints use those answers before the store's, with no organisation. See API endpoints.
When something goes wrong, the answer is no¶
Every failure refuses. None falls back to the unscoped check, because the unscoped check merges every organisation's roles, which is the thing this exists to prevent.
| What happened | Result | Where it is recorded |
|---|---|---|
| The resolver throws | refused | error log: Refused <resource>.<action>: the organisation could not be resolved: … |
The resolver returns something that is not an id ('abc', 0, an array) |
refused | the same |
The application supplies its own resolver (permissionResolver()) that doesn't implement OrganizationPermissionResolverInterface |
refused in an organisation; unchanged outside one | error log names the class |
| The permission tables can't be read | refused in an organisation; no opinion outside one | — |
Diagnosing a refusal nobody expected:
1. Check the error log for Refused. It names the resource and action.
2. Check what the resolver returned for the request (it returns null → the old rules apply).
3. Check the member's roles in that organisation with
PermissionResolver::resolveForOrganization($userId, null, $orgId): is there a grant
for that object_type and action?
4. Check the membership: an inactive or expired row in authserver.user_organizations
means the organisation's roles don't count.
A permission resolver of your own¶
ApiCrudController::permissionResolver() can return a resolver of the application's own, as
PermissionResolverInterface. To answer in an organisation it must also implement
OrganizationPermissionResolverInterface::resolveForOrganization(). Without it, every check
in an organisation is refused and the error log says which class is missing it. It is a
separate interface because adding a method to PermissionResolverInterface would stop every
existing implementation from loading.
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 — the screen opens at 98
\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:
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. With an organisation resolver
set, the store answers for the request's organisation only.
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 | No organisation in scope | An organisation in scope |
|---|---|---|
| explicit allow | allowed | allowed |
| explicit deny | refused | refused |
| no rule at all | allowed | refused |
| can't be read | no opinion → allowed | refused |
The left column's last two rows are 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. The right column applies once the application has said which organisation a request is about, because an application with roles per organisation means "not granted" when it grants nothing; see Checking permissions in an organisation.
To tighten it, override in the generated controller:
protected function authorize(string $action): bool
{
return parent::authorize($action)
&& (int) ($this->requestUser()?->usertype ?? 0) >= 90;
}
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.
In an organisation, permissionForObject() returns false where it would return null, so
the same === false comparison refuses a record no role grants. Nothing in the controller
changes.
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:
Routes with no permissions declared are never refused here.
Administration screens: by usertype or by permission¶
Every screen in the administration area has an ability named after its menu item —
admin.users, admin.roles, admin.logs — declared on its controller:
class Reports extends \Pramnos\Application\Controller
{
protected string $adminAbility = 'admin.reports'; // the NavItem's id
protected int $requiredUserType = 98; // the floor, for usertype mode
}
The framework's own screens are at AdminAccess::defaultUsertype() — 98, or the
admin_default_usertype setting. An application's screen states its own floor, as above.
Controller::exec() checks it before any action runs, so an action that forgets its own
check is still behind it — inside the administration area, where the application has one. A
controller that extends a screen to serve a public page outside the area (a status page built on
Health) is not an administration screen there; an action that calls requireMinUserType()
itself is checked wherever it is reached. The menu item and the screen ask the same question by the same name,
so a link cannot show a screen that refuses, or hide one that opens.
Which question that is depends on one setting, admin_access:
admin_access |
A screen opens for | Two administrators at 90 |
|---|---|---|
usertype (default) |
anybody at or above the screen's floor — 98 for every framework screen, admin_default_usertype to move it |
see the same area |
permissions |
whoever holds the screen's ability — granted to them or to a role they hold | see what each was given |
mixed |
the floor, with decisions on top: an allow opens a screen below its floor, a deny closes it above | see the same area, minus what was denied, plus what was allowed |
mixed with nothing decided is exactly usertype, which makes it the way to start using grants on
a running installation: switch, then allow or deny screen by screen.
Under permissions and mixed:
- Under
permissions, nothing granted means closed. Unlike an ordinary menu permission, silence is a no there: the grant is the access. Undermixed, silence is the floor. - A deny wins — from the user or from any role they hold — over any allow.
- The superuser opens everything — usertype at or above
admin_superuser_usertype, 98 unless set — so switching modes before granting anything cannot lock the last administrator out. - A refused screen sends you to the first one you may open, with a message, rather than out of the area.
- Somebody below the area's floor,
admin.min_usertype, reaches the area when a grant opens at least one screen to them; otherwise the floor refuses them as it does underusertype.
This is the intended way to give an account part of the area: framework screens open at 98, a superuser's, and everybody else holds a role that allows what they need —
// app settings: admin_access = mixed
AdminAccess::setDecisions('role', $supportRoleId, [
'admin.users' => 'allow', 'admin.logs' => 'allow',
], $whatTheEditorMayGrant);
— rather than a usertype in between, which opens every screen at once.
Granting screens¶
The Administration screens panel on a user's page decides for that person; the same
panel on a role's page decides for everyone holding the role. Each screen is Default,
Allow or Deny — Default meaning closed under permissions and the floor under mixed. On a
user's page a second column shows what they can open in the end — through their roles and
usertype as well.
The panel can be changed by the superuser or by somebody holding admin.permissions, and only
for the screens that editor can open themselves: a grant is the access, so handing out one you do
not hold would go round every other check.
In code, the same rows:
use Pramnos\Auth\AdminAccess;
AdminAccess::setDecisions('role', $supportRoleId, [
'admin.users' => 'allow',
'admin.logs' => 'allow',
'admin.roles' => 'deny',
'admin.queue' => 'default', // neither: back to the mode's default
], $whatTheEditorMayGrant);
AdminAccess::decisionsFor('user', 42); // ['admin.users' => 'allow']
AdminAccess::allows($user, 'admin.roles', 90); // what the screen will answer
AdminAccess::mode(); // usertype | permissions | mixed
A decision is an allow or deny row in authserver.permissions — object admin.users, action
view, no record id; a deny is stored above an allow in priority. setDecisions() touches only
those rows. setGrants() / grantsFor() are the allow-only form of the same.
An application's own screens¶
Register the menu item in the Admin section and declare the same id on the controller. It then
appears in the panel, is granted like the framework's own, and is guarded in exec(). A screen
with public actions on the same controller — a monitor endpoint — lists them in
$adminPublicActions. AdminScreensDeclareAnAbilityTest fails when a framework screen and its
menu item disagree.
Navigation¶
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 |
An Admin item under admin_access = permissions is the exception: it is shown only when its
ability is granted — see Administration screens.
A menu that vanished because nobody had granted anything yet would look like a broken install.
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:
- Ask the store directly with
$nonExistEqualsFalse = false. If it returnsnull, there is no rule and you are looking at a default, not a denial: - Check the subject type. A grant made to a group is not found by a check that asks
about a user, and vice versa —
$subjectTypehas to match how it was written. - Check
$resourceElement. A grant for a specific record does not answer a check for the resource as a whole. - For a route, remember it is matching, not refusing: if the URL 404s, the permission is a candidate before the route file is.
- For a controller action, check
user_permissionsis actually populated. If it is empty,action_permissionswas 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.
Why the API has this shape¶
| Choice | Why |
|---|---|
$this->can() / $this->cannot() on a controller, or Gate::authorize() — no $this->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 |
Gate:: statics, no global auth() helper |
the framework already has unrelated auth() methods; another spelling would be the most confusing of them |
| policies and a permission-store bridge | the store is already there and used; a gate that ignored it would split authorization in two |
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, carryinggetAbility()Pramnos\Auth\Permissions— the store:allow(),deny(),removePermission(),isAllowed(),setDefaultPermission()Pramnos\Auth\PermissionResolver— every permission a user has, as a listPramnos\Auth\PermissionResolverInterface— substitute your ownPramnos\Application\Controller—can(),cannot(),auth(),$actions_auth,$action_permissionsPramnos\Application\ApiCrudController—authorize(),permissionFor(),permissionForObject(),permissionOrganizationId()Pramnos\Auth\OrganizationScope—resolveWith(),current(),isConfigured(),reset()Pramnos\Auth\OrganizationPermissionResolverInterface— a resolver that can answer in one organisationPermissions::isAllowedInOrganization()—isAllowed()about a named organisationPramnos\Routing\Router—hasPermissions(),addRoute();Pramnos\Routing\Route—requirePermissions(),addPermissions()Pramnos\Application\NavRegistry— permission-gated menu items
Related guides:
- Authentication — establishing who the user is
- Legacy permissions migration — moving a
hand-built
<prefix>permissionstable intoauthserver.permissions - AuthServer integration — where permissions live