The core/Permissions extension is how an application tells the suite two different things: which of its node classes own rows in core_permission_index and can be edited from the group permission matrix, and — separately — how the application wants to override normal permission checks at runtime. Core calls the first half exactly once, when the AdminCP renders Members → Groups → (a group) → Permissions; it calls the second half from IPS\Content\Permissions, which sits in front of nearly every can*() method on nodes, items, comments and reviews.
The two halves have almost nothing to do with each other, and each one has a different set of ways to fail.
The contract
Every method on IPS\Extensions\PermissionsAbstract is optional — the abstract implements all four with harmless defaults. Nothing is abstract, so an empty class that extends it is legal (that is exactly what the Developer Center skeleton in applications/core/data/defaults/extensions/Permissions.txt generates).
namespace IPS\myapp\extensions\core\Permissions;
class Permissions extends \IPS\Extensions\PermissionsAbstract
{
/**
* Node classes this app owns, mapped to a matrix-row callback (or NULL).
* Callback signature: function( array $current, \IPS\Member\Group $group ) : array
* $current = every core_permission_index row for this app + perm_type,
* keyed by perm_type_id
* returns = rows keyed by node id:
* [ '_level' => int, 'label' => string,
* "{$class::$permissionLangPrefix}perm__{$key}" => bool|null ]
* (null renders a *disabled* checkbox)
*/
public function getNodeClasses() : array;
/**
* Runtime override for one permission check on one object.
* MUST return exactly one of:
* \IPS\Content\Permissions::PERM_DEFAULT (0) - do nothing, fall through
* \IPS\Content\Permissions::PERM_ALLOW (1) - force allow
* \IPS\Content\Permissions::PERM_DENY (2) - force deny
* $object may be a node, item, comment or review. $member may be NULL.
*/
public function checkPermission( string $permission, object $object, ?\IPS\Member $member = null ) : int;
/** Node IDs to force-allow, merged into the SQL permission clause. */
public function nodeIdsToAllow( string $permission, string $class, ?\IPS\Member $member = null ) : array;
/** Node IDs to force-deny, merged into the SQL permission clause. */
public function nodeIdsToDeny( string $permission, string $class, ?\IPS\Member $member = null ) : array;
}
Register it in applications/myapp/data/extensions.json:
{
"core": {
"Permissions": {
"Permissions": "IPS\\myapp\\extensions\\core\\Permissions\\Permissions"
}
}
}
The consumers, in full, are:
| Caller | Uses | Notes |
|---|---|---|
applications/core/modules/admin/members/groups.php::permissions() | getNodeClasses() | Called with allExtensions( 'core', 'Permissions', FALSE ) — every enabled app, no access check. |
IPS\Content\Permissions::can() | checkPermission() | First extension returning a non-zero value wins; the rest are never asked. |
IPS\Content\Permissions::loadMappings() | nodeIdsToAllow(), nodeIdsToDeny() | Results from all apps are array_merged into one include list and one exclude list. |
applications/core/modules/admin/developer/details.php | getNodeClasses() | Developer Center app scan; warns devscan__missing_permissions if a permission-bearing node class is not listed. |
applications/convert/sources/Library/Core.php | getNodeClasses() | Maps an imported perm_type string back to a node class. |
The include/exclude lists are combined asymmetrically in nodePermissionClause(). Includes are OR-ed into the group clause, so they grant access a member would not otherwise have. Excludes are appended as a separate AND ... NOT IN(...). If the same node id appears in both, the deny wins.
A minimal example
A third-party app myapp with a node class IPS\myapp\Category. Note that the node class itself needs $canBeExtended, $permApp, $permType, $permissionMap and $permissionLangPrefix — the extension is useless without them.
<?php
namespace IPS\myapp\extensions\core\Permissions;
use BadMethodCallException;
use IPS\Content\Permissions as PermissionCheck;
use IPS\Extensions\PermissionsAbstract;
use IPS\Member;
use IPS\myapp\Category;
use function defined;
if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
header( ( $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.0' ) . ' 403 Forbidden' );
exit;
}
class Permissions extends PermissionsAbstract
{
/**
* @brief Per-request memo. checkPermission() is NOT cached by core.
*/
protected static array $memo = [];
public function getNodeClasses(): array
{
return array(
'IPS\myapp\Category' => function( $current, $group )
{
$rows = array();
foreach( Category::roots( NULL ) as $root )
{
try
{
Category::populatePermissionMatrix( $rows, $root, $group, $current );
}
catch( BadMethodCallException $e ) {}
}
return $rows;
}
);
}
public function checkPermission( string $permission, object $object, ?Member $member = null ): int
{
/* Bail immediately on anything that is not ours. */
if ( !( $object instanceof Category ) or $permission !== 'view' )
{
return PermissionCheck::PERM_DEFAULT;
}
$member = $member ?: Member::loggedIn();
$key = $object->_id . '-' . (int) $member->member_id;
if ( isset( static::$memo[ $key ] ) )
{
return static::$memo[ $key ];
}
return static::$memo[ $key ] = $object->members_only && !$member->member_id
? PermissionCheck::PERM_DENY
: PermissionCheck::PERM_DEFAULT;
}
public function nodeIdsToDeny( string $permission, string $class, ?Member $member = null ): array
{
if ( $class !== Category::class or $permission !== 'view' )
{
return array();
}
$member = $member ?: Member::loggedIn();
if ( $member->member_id )
{
return array();
}
/* Cast: these ids go straight into SQL. */
return array_map( 'intval', array_values( Category::membersOnlyIds() ) );
}
}
And on the node class:
class Category extends \IPS\Node\Model implements \IPS\Node\Permissions
{
public static ?string $permApp = 'myapp';
public static ?string $permType = 'category';
public static string $permissionLangPrefix = 'perm_myapp_category_';
public static array $permissionMap = array(
'view' => 'view',
'read' => 2,
'add' => 3,
);
/* Without this, checkPermission() is never consulted for this class. */
public static bool $canBeExtended = true;
}
You also need language strings perm_myapp_category_perm__view, ..._perm__read, ..._perm__add, perm_myapp_category_perm__label and __app_myapp (the group-permission tab title is '__app_' . $class::$permApp).
The node disappears from the front end after I call setPermissions()
IPS\Node\Model::setPermissions( array $insert ) does not populate app, perm_type or perm_type_id. It deletes the existing row using the class statics, then inserts your array verbatim:
Db::i()->delete( 'core_permission_index', array( 'app=? AND perm_type=? AND perm_type_id=?', static::$permApp, static::$permType, $this->_id ) ); $permId = Db::i()->insert( 'core_permission_index', $insert );
Pass only perm_view/perm_2/… and you get a row with app = '', perm_type = '', perm_type_id = 0 (the schema defaults). The insert succeeds. Nothing errors. But every front-end query joins on core_permission_index.app = ? AND perm_type = ? AND perm_type_id = <node id>, so the node now matches nothing and silently vanishes from roots(), children(), loadIntoMemory() and every content listing. The failure surfaces on the front end, on a completely different request, with no error at all — not at the point where you wrote the bad row.
Do it a second time for a second node and you finally get an error, from the perm_type unique index on (app, perm_type, perm_type_id): an IPS\Db\Exception with MySQL code 1062, Duplicate entry … for key 'perm_type'. The reported node is the second one; the node that is actually broken is the first one.
Copy what IPS\Node\Controller::permissions() and IPS\Node\Api\NodeController::_createOrUpdate() both do — build the three identity keys yourself, and carry perm_id over if a row already existed so the primary key is stable:
$insert = array(
'app' => $node::$permApp,
'perm_type' => $node::$permType,
'perm_type_id' => $node->_id,
);
if ( isset( $current['perm_id'] ) )
{
$insert['perm_id'] = $current['perm_id'];
}
foreach( $node::$permissionMap as $key => $column )
{
$insert[ 'perm_' . $column ] = $groupsCsvOrStar; /* '*' or '1,2,4' */
}
$node->setPermissions( $insert );
Two more things about that array. Every column in $permissionMap must be present: if $permissionMap['read'] exists and $insert omits it, setPermissions() throws an Undefined array key warning and writes NULL into core_tags_perms.tag_perm_text. And once a bad row has been written, Model::permissions() will "repair" it on the next request by inserting a fresh row with perm_view => '' — so the node reappears, visible to nobody, and looks like an admin mistake.
checkPermission() fires for my topics but never for my categories
IPS\Content\Item::can(), Comment::canView() and friends call Permissions::can() unconditionally. IPS\Node\Model::can() does not:
if( !in_array( $permission, [ 'edit', 'delete' ] ) )
{
if( static::$canBeExtended and !( $member instanceof Group ) and $permCheck = PermissionsExtension::can( $permission, $this, $member ) )
{
return ( $permCheck === PermissionsExtension::PERM_ALLOW );
}
}
IPS\Node\Model::$canBeExtended defaults to false, and its docblock says only "Determines if this class can be extended via UI Extension" — it does not mention permissions at all. Every core node class that participates (IPS\forums\Forum, IPS\downloads\Category, IPS\gallery\Category, IPS\calendar\Calendar, …) redeclares it as true. If yours does not, your override runs for content inside the node and never for the node itself. Add public static bool $canBeExtended = true;.
Note the second half of that guard too: node can( 'edit' ) and can( 'delete' ) deliberately skip extensions. Model::canDelete(), however, does call PermissionsExtension::can( 'delete', $this ) — with no member argument, so always against Member::loggedIn(). So 'delete' reaches your extension through one path and not the other.
Returning PERM_DENY works, but returning anything else denies everything too
Every call site follows the same shape:
if( $permCheck = Permissions::can( 'view', $this, $member ) )
{
return ( $permCheck === Permissions::PERM_ALLOW );
}
That is a truthiness test followed by a strict identity test. PERM_DEFAULT is 0, so falling through works. But any non-zero return that is not exactly 1 short-circuits the entire check and returns false. Return 2 and you deny (intended). Return 3, or a node id, or a count, and you also deny — permanently, with no error.
The abstract's own note is the rule: "You MUST return PERM_DEFAULT for any object that you are not checking." A common way to break this is a missing early return, so a helper that returns a bool leaks out. Because PermissionsAbstract does not declare strict_types, and neither will your extension file unless you add it, return true; is weak-coerced to 1 — a silent PERM_ALLOW — and return false; becomes PERM_DEFAULT. Neither raises anything.
The override works on the forum but not in the AdminCP, the installer, or a CLI task
Both entry points in IPS\Content\Permissions are gated on the dispatcher:
if( !Dispatcher::hasInstance() or ( Dispatcher::i()->controllerLocation != 'front' and Dispatcher::i()->controllerLocation != 'api' ) )
{
return static::PERM_DEFAULT; /* can() */
/* getMapping() returns null, so no include/exclude clause is added at all */
}
Only IPS\Dispatcher\Front ('front'), Api ('api'), and the two dispatchers that borrow 'front' (External, Build) qualify. Admin is 'admin', Setup is 'setup', and anything that runs with no dispatcher at all — CLI scripts, cron entry points, some background-queue contexts — fails hasInstance().
This is the one to remember, because the consequence lands somewhere else entirely. Anything computed in a non-front context and then persisted is computed as if your extension did not exist: search index permissions, deletion-log permissions, digest and notification recipient lists, and moderator tooling in the AdminCP. Your extension is not a security boundary for those; it only filters what the front end renders.
My deny disappears for guests, or for one particular group
Content\Permissions calls Application::allExtensions( 'core', 'Permissions' ) with the default third argument, $checkAccess = TRUE. Inside allExtensions() that becomes:
if( $checkAccess !== FALSE )
{
if( !$application->canAccess( $checkAccess === TRUE ? NULL : $checkAccess ) )
{
continue;
}
}
canAccess( NULL ) checks Member::loggedIn() against the application's disabled_groups. So whether your extension is loaded at all depends on the logged-in member, while checkPermission() is handed a $member argument that may be somebody else. Two consequences:
- If an admin restricts your app to certain groups in AdminCP → System → Applications, every member outside those groups gets no override — including guests, if guests are excluded. Your deny becomes an allow for exactly the audience you were probably trying to block.
- When core checks permissions for a member other than the visitor (permission previews, API calls authenticated as one member checking another, notification eligibility), the app filter still uses the visitor.
By contrast groups.php passes FALSE explicitly, so getNodeClasses() is always collected from every enabled app. The two halves of the extension are loaded under different rules.
Another app's extension wins and mine is never asked
can() returns on the first non-default answer:
foreach( Application::allExtensions( 'core', 'Permissions' ) as $ext )
{
$result = $ext->checkPermission( $permission, $object, $member );
if( $result !== static::PERM_DEFAULT )
{
return $result;
}
}
There is no priority system and no way to signal "deny, and mean it". Iteration order comes from Application::applications(), which reads Store::i()->applications, built as Db::i()->select( '*', 'core_applications', NULL, 'app_position' ). app_position is the drag-to-reorder order in AdminCP → System → Applications. An administrator reordering applications for cosmetic reasons can flip which app's permission override wins, on a live site, with no code change and no log entry.
Nothing wraps that loop in try/catch either. An exception thrown from checkPermission() escapes into the middle of whatever was rendering — a topic list, a search result row — and takes the page with it.
SQL syntax error from Node::roots() after adding nodeIdsToAllow()
The two ID lists are not handled the same way. Excludes go through Db::i()->in(), which drops non-scalars and escapes strings. Includes are interpolated raw:
/* nodeIdsToAllow - no escaping, no binds */
$clause[] = 'core_permission_index.perm_type_id in (' . implode( ",", $includes ) . ')';
/* nodeIdsToDeny - escaped */
$return[] = [ Db::i()->in( 'core_permission_index.perm_type_id', $excludes, true ) ];
Return anything that is not a bare integer from nodeIdsToAllow() and you get a broken query. Return an associative array such as [ 4 => 'Announcements' ] and implode() uses the values, producing in (Announcements). Return arrays and you get Array to string conversion plus in (Array). Return user-supplied text and you have an injection point.
The exception is raised by IPS\Node\Model::nodesWithPermission() or Content\Item::getItemsWithPermission(), naming a table and a query your app does not own. Nothing in the error mentions your extension. Always finish with array_map( 'intval', array_values( $ids ) ).
Both lists are also merged across apps with array_merge, so an empty array is the correct "no opinion" answer — getMapping() checks count() and adds no clause when the merged list is empty.
The wrong member's allow/deny list is applied halfway through a request
loadMappings() memoises per request — but the member is not part of the cache key:
protected static function loadMappings( string $permission, string $nodeClass, Member $member ) : void
{
if( !isset( static::$nodeIncludesMap[ $nodeClass ][ $permission ] ) )
{
...
}
}
$member is passed to your methods but never consulted when deciding whether to rebuild. The first member checked in a request wins for every subsequent check of the same node class and permission key. This shows up wherever one request evaluates node visibility for more than one member: notification and follower fan-out, "who can see this" previews, API requests that resolve one member's content while authenticated as another, and any place that calls Node::roots( 'view', $someOtherMember ) after the page has already rendered a listing for the visitor.
You cannot fix this from the extension — the cache lives in core and is keyed above your code. What you can do is make nodeIdsToAllow()/nodeIdsToDeny() member-independent (rules that depend on the node, not the viewer) and put viewer-specific logic in checkPermission(), which is not cached.
checkPermission() runs hundreds of times on a single page
Node\Model::can() maintains $this->cachedPermissionChecks, and Content\Item has its own caching, but in both cases the extension call happens before the cache lookup — the comment in the source is literally "Extensions go first". A cached permission check still invokes every Permissions extension in the suite. Content\Permissions::can() adds no memoisation of its own.
On a forum index that is one call per forum per permission key per render pass; on a topic list it is one per topic, plus one per comment on the topic view. A single Db::i()->select() inside checkPermission() becomes hundreds of queries. Return PERM_DEFAULT from an instanceof check as the very first statement, and memoise anything expensive in a static property on the extension (extension objects are constructed fresh by constructExtensionClass(), so instance properties do not survive).
Search results and activity streams still show content my extension denies
Nothing under system/Content/Search/ references IPS\Content\Permissions or nodePermissionClause(). The MySQL search query filters on one column:
$where[] = array( "( index_permissions = '*' OR " . Db::i()->findInSet( 'index_permissions', $this->permissionArray() ) . ' )' );
index_permissions is written by Model::updateSearchIndexPermissions() from searchIndexPermissions(), which reads the raw core_permission_index columns and intersects them up the parent chain. Your checkPermission() and nodeIdsToDeny() are never involved. The same applies to DeletionLog::updateNodePermissions() and deleteLogPermissions().
If a node must be invisible in search as well as in listings, the denial has to be expressed as real group permissions in core_permission_index, not as an extension override.
My node type is missing from the group permissions screen
The group permission matrix is built only from getNodeClasses(). Implementing IPS\Node\Permissions and setting $permApp/$permType gets you a per-node permissions tab in the node's own ACP screen, but nothing on the group screen. This is not hypothetical: blog, cms and nexus all ship node classes implementing IPS\Node\Permissions and none of them registers a core/Permissions extension, so their categories are simply absent from Members → Groups → Permissions in a stock install.
Two further ways to be skipped even after registering:
groups.phpdoesif( !is_callable( $callback ) ) { continue; }. ANULLcallback registers the class for the Developer Center scan and the converter'sperm_typelookup, but adds no tab. Core uses this deliberately for'IPS\Application\Module' => NULL.- The extension list is cached in
Store::i()->extensionsand only rebuilt when that key is missing. Hand-editingextensions.jsonwithout going through the Developer Center leaves the stale list in place;applications/core/modules/admin/developer/extensions.phpis the only core code that callsunset( Store::i()->extensions )on extension changes.
The permissions tab for my app appears but has no rows
Your callback is only invoked if permission rows for your app already exist:
if ( isset( $current[ $class::$permApp ][ $class::$permType ] ) )
{
$matrix->rows = $callback( $current[ $class::$permApp ][ $class::$permType ], $group );
}
/* Add the matrix - runs unconditionally */
$form->addMatrix( $class, $matrix );
$current is built by selecting the entire core_permission_index table and indexing it [app][perm_type][perm_type_id]. On a fresh install where categories exist but no permission row has ever been written, that key is absent, the callback never runs, and the tab renders with headers and no rows. The tab is still added, because addTab() and addMatrix() are outside the isset().
Model::permissions() self-heals a missing row (it catches UnderflowException and inserts perm_view => ''), so merely loading the node once on the front end can make the tab populate later — which makes this look intermittent. Write real rows at install/upgrade time instead of relying on that.
Related: two node classes sharing a $permApp do not get two tabs. Form::addTab() keys on the language string, so '__app_' . $permApp collides and both matrices stack inside one tab.
A white screen on Members → Groups → Permissions after installing my app
This screen loops every enabled application's extension with no error handling around getNodeClasses(), and immediately dereferences the array keys as class names:
foreach ( Application::allExtensions( 'core', 'Permissions', FALSE ) as $ext )
{
foreach ( $ext->getNodeClasses() as $class => $callback )
{
...
$form->addTab( '__app_' . $class::$permApp );
$matrix = new Matrix( $class );
foreach ( $class::$permissionMap as $k => $v ) { ... }
}
}
A typo in a key, or a class that is not loadable because a file is missing after a partial upload, is a fatal Error: Class "IPS\myapp\Categry" not found raised inside applications/core/modules/admin/members/groups.php. The AdminCP error names a core file and a core controller; nothing in it identifies your application. The same is true of the Developer Center app scan and the converter, both of which call getNodeClasses() and use the keys as class names with no class_exists() guard.
Exceptions from the row callback are barely better. Only one type is handled:
try
{
$matrix->rows = $callback( $current[ $class::$permApp ][ $class::$permType ], $group );
}
catch( UnderflowException $e )
{
if( $e->getCode() != 199 )
{
throw $e;
}
Output::i()->error( 'generic_error', '4T382/1', 500, $e->getMessage() );
}
Code 199 is a specific signal — IPS\cms\Categories::disabledPermissions() throws new UnderflowException( 'invalid_guestgroup_admin', 199 ) when the configured guest group no longer exists — and it produces error 4T382/1 (the equivalent on the per-node screen is 4F383/1). An UnderflowException with any other code is rethrown; every other exception type is not caught at all.
Note in particular that Model::populatePermissionMatrix() opens with throw new BadMethodCallException if the node does not implement IPS\Node\Permissions, and it recurses into $node->children( NULL ). Of the four core extensions that use it, only downloads wraps the call:
try
{
Category::populatePermissionMatrix( $rows, $root, $group, $current );
}
catch( BadMethodCallException $e ) {}
forums, calendar and gallery do not. Copy the downloads version — if a subclass or child node in your tree ever stops implementing the interface, the unguarded version takes down a shared core screen for every application at once.
disabledPermissions() returns the right keys but the checkboxes are still enabled
The docblock on IPS\Node\Model::disabledPermissions() says:
@return array array( {group_id} => array( 'read', 'view', 'perm_7' );
That example is wrong in two ways. populatePermissionMatrix() compares against the values of $permissionMap, not its keys, and not a perm_-prefixed string:
foreach( $node->permissionTypes() AS $k => $v )
{
...
if ( array_key_exists( $group, $disabledPermissions ) and is_array( $disabledPermissions[ $group ] ) )
{
$disabled = in_array( $v, array_values( $disabledPermissions[ $group ] ) );
}
}
$v is the column suffix — 'view', 2, 3, … So 'read' never matches (the key is 'read', the value is 2), and 'perm_7' never matches (the value is 7). Only 'view' happens to work, because for that one entry key and value are the same string. The real core implementation, IPS\forums\Forum::disabledPermissions(), returns array( Settings::i()->guest_group => array( 2, 3, 4, 5 ) ) — raw column suffixes. Note also that in_array() here is loose, so the integer 2 and the string '2' both match, but 'read' does not.
There is no error when you get this wrong; the checkbox is simply left enabled, and an admin can grant a permission your node class expects to be impossible. The same array is also consulted by Node\Controller::permissions() for the per-node screen, so the two screens disagree in exactly the same way.
Unknown column 'perm_8'
core_permission_index has exactly seven permission columns: perm_view (TEXT NOT NULL DEFAULT '') and perm_2 through perm_7 (TEXT NULL). Every consumer builds its column name by string concatenation — 'perm_' . static::$permissionMap[ $permission ] — with no validation, in can(), canOnAny(), nodePermissionClause() and setPermissions().
An eighth entry in $permissionMap therefore produces Unknown column 'core_permission_index.perm_8' in 'where clause' from whichever listing query ran first. Seven permission keys per node class is a hard ceiling; if you need more, model the extra state as columns on your own node table and enforce it in checkPermission().
The reverse mistake is quieter: a $permissionMap key that no row ever populates leaves perm_N as NULL, and Model::can() treats a falsy permission string as "denied to everyone" without complaint.
Verified against
Read from Invision Community 5.0.19 source: system/Extensions/PermissionsAbstract.php, system/Content/Permissions.php, system/Node/Model.php, system/Node/Permissions.php, system/Node/Controller.php, system/Node/Api/NodeController.php, system/Application/Application.php, system/Db/Db.php, system/Content/Search/Mysql/Query.php, applications/core/modules/admin/members/groups.php, applications/core/modules/admin/developer/details.php, applications/convert/sources/Library/Core.php, applications/core/data/schema.json, and all five core implementations under applications/{core,forums,calendar,downloads,gallery}/extensions/core/Permissions/. Behaviour described here is what those files do, not what the docblocks say; where the two disagree, the disagreement is called out above.
Recommended Comments