Now it's time to add custom authorization rules to our page. We will use these rules to control two things: visibility of the page itself, and visibility of the origin
column in the table. Each one will require a new permission.
UserFrosting doesn't have a UI to create new Permissions. This is done on purpose because, as you'll see, permission slugs are tightly coupled with the code and it doesn't make sense for an admin user to create new permissions through the UI when they'll need to modify the code to actually use them.
To create a new permission, we instead have to create a new row in the permissions
database table. To do this, we'll use a migration. We've already explained how to create migration earlier, but we will now create a new migration whose role will be solely to create the new entry into the database table, and drop them on rollback. We could also use that migration to assign our new permission to existing roles, even though this can be done in the admin interface.
Let's start by creating our migration class. That class will be located in the same place as our other migration and will be named PastriesPermissions
.
app/src/Database/Migrations/V100/PastriesPermissions.php:
<?php
namespace UserFrosting\App\Database\Migrations\V100;
use UserFrosting\Sprinkle\Core\Database\Migration;
class PastriesPermissions extends Migration
{
/**
* {@inheritdoc}
*/
public static $dependencies = [];
/**
* {@inheritdoc}
*/
public function up(): void
{
}
/**
* {@inheritdoc}
*/
public function down(): void
{
}
}
Unlike the PastriesTable
migration, this migration will have a dependency on the PermissionsTable
and RolesTable
migrations from the Account
Sprinkle.
One could reasonably assume at this point that the migrations for the Account
sprinkle might have already been run. While this is true in our case (since we already have a working UserFrosting installation), this might not be the case if someone is running all migrations at once, or if other dependencies are at play. So, it's always safer to declare the corresponding migrations as dependencies if you are going to interact with the table they correspond to.
With that cleared up, let's add the dependencies for the PermissionsTable
and RolesTable
migrations inside the migration $dependencies
property. Don't forget to import the classes.
use UserFrosting\Sprinkle\Account\Database\Migrations\v400\PermissionsTable;
use UserFrosting\Sprinkle\Account\Database\Migrations\v400\RolesTable;
// ...
public static $dependencies = [
RolesTable::class,
PermissionsTable::class,
];
The clever reader may ask about the PermissionRolesTable
migration. This migration is tied to the permission_roles
table which we will indirectly use to associate our new permission with roles. Since the PermissionsTable
migration already defines the PermissionRolesTable
as a dependency, we are not required to define it again on our side. By this logic, we could also skip the RolesTable
from our dependencies list, but let's keep it anyway for sanity reasons.
Next, let's define our new permissions, in a new method at the bottom of the class:
protected function pastryPermissions(): array
{
return [
[
'slug' => 'see_pastries',
'name' => 'See the pastries page',
'conditions' => 'always()',
'description' => 'Enables the user to see the pastries page',
],
[
'slug' => 'see_pastry_origin',
'name' => 'See pastry origin',
'conditions' => 'always()',
'description' => 'Allows the user to see the origin of a pastry',
],
];
}
Now, we need to use
the Permission
model. This will be used to create the new permission object for saving to the database. Add the namespace alias in the header of the migration class:
use UserFrosting\Sprinkle\Account\Database\Models\Permission;
We can now add the code to save the permissions in the up()
method :
foreach ($this->pastryPermissions() as $permissionInfo) {
$permission = new Permission($permissionInfo);
$permission->save();
}
The foreach
will simply loop through the permissions defined in the pastryPermissions
method's returned array and create a new record in the database using the Permission
model.
Finally we do the same thing for the down()
method, but deleting each entries instead of saving them:
foreach ($this->pastryPermissions() as $permissionInfo) {
/** @var Permission */
$permission = Permission::where($permissionInfo)->first();
$permission->delete();
}
Our finalized seed should now look like this:
<?php
namespace UserFrosting\App\Database\Migrations\V100;
use UserFrosting\Sprinkle\Account\Database\Migrations\v400\PermissionsTable;
use UserFrosting\Sprinkle\Account\Database\Migrations\v400\RolesTable;
use UserFrosting\Sprinkle\Account\Database\Models\Permission;
use UserFrosting\Sprinkle\Core\Database\Migration;
class PastriesPermissions extends Migration
{
/**
* {@inheritdoc}
*/
public static $dependencies = [
RolesTable::class,
PermissionsTable::class,
];
/**
* {@inheritdoc}
*/
public function up(): void
{
foreach ($this->pastryPermissions() as $permissionInfo) {
$permission = new Permission($permissionInfo);
$permission->save();
}
}
/**
* {@inheritdoc}
*/
public function down(): void
{
foreach ($this->pastryPermissions() as $permissionInfo) {
/** @var Permission */
$permission = Permission::where($permissionInfo)->first();
$permission->delete();
}
}
/**
* @return string[][]
*/
protected function pastryPermissions(): array
{
return [
[
'slug' => 'see_pastries',
'name' => 'See the pastries page',
'conditions' => 'always()',
'description' => 'Enables the user to see the pastries page',
],
[
'slug' => 'see_pastry_origin',
'name' => 'See pastry origin',
'conditions' => 'always()',
'description' => 'Allows the user to see the origin of a pastry',
],
];
}
}
You can now run the migration using :
$ php bakery migrate
You can make sure the migration was successful by logging in as the root user and going to the permissions page:
Before we continue, you'll have to login as a non-root user to test permissions. If the top navigation bar is red and says you are signed in as the root user, the authorization system will be completely bypassed. It's important that you use a different user to make sure that the new permissions are actually working properly.
see_pastries
permissionFirst we'll add a permission to the main PastriesPageAction
method. Users without our see_pastries
permission will not be able to see the page. In that case, a ForbiddenException
will be thrown. The namespace alias for this exception should already be added: use UserFrosting\Sprinkle\Account\Exceptions\ForbiddenException;
.
Next, we need to inject the Authenticator in the method, as a property. Then, we add the check:
// Access-controlled page
if (!$authenticator->checkAccess('see_pastries')) {
throw new ForbiddenException();
}
The full method should now be:
app/src/Controller/PastriesPageAction.php:
public function __invoke(
Response $response,
Authenticator $authenticator,
Twig $view,
): Response {
// Access-controlled page
if (!$authenticator->checkAccess('see_pastries')) {
throw new ForbiddenException();
}
// Get pastries from the database
$pastries = Pastries::all();
return $view->render($response, 'pages/pastries.html.twig', [
'pastries' => $pastries,
]);
}
At this point, we haven't yet added the see_pastries
permission to any role. This means if you navigate to the page (with a non-root user), you'll get an error.
Now that non-root users don't have access to the page, it would be nice to hide the link to the page in the sidebar menu. Let's dive back into our implementation of that link and add the permission verification using the custom checkAccess
Twig function provided by UserFrosting:
app/templates/navigation/sidebar-menu.html.twig:
{% extends "@admin-sprinkle/navigation/sidebar-menu.html.twig" %}
{% block navigation %}
{{ parent() }}
{% if checkAccess('see_pastries') %}
<li>
<a href="{{ urlFor('pastries') }}"><i class="fas fa-utensils fa-fw"></i> <span>{{translate('PASTRIES.LIST')}}</span></a>
</li>
{% endif %}
{% endblock %}
The link should now be hidden from the menu when you refresh the page.
Now to make sure everything works correctly, let's add that see_pastries
permission to the User role. Once this is done, a normal user will regain access to the page. Using a root account, navigate to the Roles page and select Manage permissions from the Actions dropdown menu of the User role.
Select the see_pastries
permission from the bottom dropdown (use the search field to easily find it) and then click update permissions
.
Your non-root user should now have access to the pastry page again (assuming they have the User role).
see_pastry_origin
permissionFor this permission, we won't need to add anything to the controller. We will simply hide the origin
column in the Twig template if the user doesn't have the permission (Note: the resulting data won't be visible in any api request). The checkAccess
function needs to be used twice so it can control the table header as well as the rows in the loop:
app/templates/pages/pastries.html.twig:
{% extends 'pages/abstract/dashboard.html.twig' %}
{# Overrides blocks in head of base template #}
{% block page_title %}Pastries{% endblock %}
{% block page_description %}This page provides a yummy list of pastries{% endblock %}
{% block body_matter %}
<div class="row">
<div class="col-md-12">
<div class="box box-primary">
<div class="box-header">
<h3 class="box-title"><i class="fa fa-cutlery fa-fw"></i> List of Pastries</h3>
</div>
<div class="box-body">
<tr>
<th>Name</th>
{% if checkAccess('see_pastry_origin') %}<th>Origin</th>{% endif %}
<th>Description</th>
</tr>
{% for pastry in pastries %}
<tr>
<td>{{pastry.name}}</td>
{% if checkAccess('see_pastry_origin') %}<td>{{pastry.origin}}</td>{% endif %}
<td>{{pastry.description}}</td>
</tr>
{% endfor %}
</div>
</div>
</div>
</div>
{% endblock %}
The non-root user should no longer be able to see the origin column. You can now add that permission to the Site Administrator role if you want users with the "Site Administrator" role to be able to see the origin column.
In this example we used always()
as the callback for our permission. If you want to learn more about the Authorization System, you could try adding conditions to a new see_pastry_details
permission to control which columns can be viewed using the same slug, and passing the column name to the callback.