user1507558
user1507558

Reputation: 454

CakePHP 4.1 User entity as authorization identity associated fields

I have just created a very minimal project in CakePHP 4.1, mostly mimicking the CMS tutorial, and want to implement a fairly straightforward piece of logic. Using the Authorization module I want to allow a user A to be able to view a user B if 1) they are actually the same user (A = B) OR 2) if A is an admin.

There are two DB tables - users and user_types. users has a foreign key user_type_id to user_types.

This relationship is reflected in code as:

##### in UsersTable.php #####

class UsersTable extends Table {
    public function initialize(array $config): void
    {
        parent::initialize($config);

        $this->setTable('users');
        $this->setDisplayField('name');
        $this->setPrimaryKey('id');
        $this->belongsTo('UserTypes');

        $this->addBehavior('Timestamp');
    }

    //...
}



##### in UserTypesTable.php #####

class UserTypesTable extends Table {
    public function initialize(array $config): void
    {
        parent::initialize($config);

        $this->setTable('user_types');
        $this->setDisplayField('name');
        $this->setPrimaryKey('id');
        $this->hasMany('Users');
    }

    //...
}

In UsersController.php I have:

    public function view($id = null)
    {
        $user = $this->Users->get($id, [
            'contain' => ['UserTypes'],
        ]);

        $this->Authorization->authorize($user);

        $this->set(compact('user'));
    }

And in UserPolicy.php:

use App\Model\Entity\User;

class UserPolicy
{
    public function canView(User $user, User $resource)
    {
        // TODO: allow view if $user and $resource are the same User or if $user is an admin
        //
        // My problem is that here $user->user_type is NULL
        // while $resource->user_type is populated correctly
    }
}

The code comment in the above excerpt shows where my problem is. I do not know how to get $user to have its user_type field populated in order to check whether they're an admin.

As a part of my efforts, I have set the User class to be the authorization identity, following this article: https://book.cakephp.org/authorization/2/en/middleware.html#using-your-user-class-as-the-identity. Code-wise this looks like:

##### relevant part of Application.php #####

$middlewareQueue
            ->add(new AuthenticationMiddleware($this))
            ->add(new AuthorizationMiddleware($this, [
                'identityDecorator' => function(\Authorization\AuthorizationServiceInterface $auth, \Authentication\IdentityInterface $user) {
                    return $user->getOriginalData()->setAuthorization($auth);
                }
            ]));





##### User.php #####

namespace App\Model\Entity;

use Authentication\PasswordHasher\DefaultPasswordHasher;
use Authorization\AuthorizationServiceInterface;
use Authorization\Policy\ResultInterface;
use Cake\ORM\Entity;

/**
 * User Entity
 *
 * @property int $id
 * @property string $email
 * @property string $password
 * @property string|null $name
 * @property \App\Model\Entity\UserType $user_type
 * @property \Cake\I18n\FrozenTime|null $created
 * @property \Cake\I18n\FrozenTime|null $modified
 * @property \Authorization\AuthorizationServiceInterface $authorization
 */
class User extends Entity implements \Authorization\IdentityInterface, \Authentication\IdentityInterface
{
    protected $_accessible = [
        'email' => true,
        'password' => true,
        'name' => true,
        'created' => true,
        'modified' => true,
    ];

    /**

    protected $_hidden = [
        'password',
    ];

    protected function _setPassword(string $password) : ?string
    {
        if (strlen($password) > 0) {
            return (new DefaultPasswordHasher())->hash($password);
        }
    }

    /**
     * @inheritDoc
     */
    public function can(string $action, $resource): bool
    {
        return $this->authorization->can($this, $action, $resource);
    }

    /**
     * @inheritDoc
     */
    public function canResult(string $action, $resource): ResultInterface
    {
        return $this->authorization->canResult($this, $action, $resource);
    }

    /**
     * @inheritDoc
     */
    public function applyScope(string $action, $resource)
    {
        return $this->authorization->applyScope($this, $action, $resource);
    }

    /**
     * @inheritDoc
     */
    public function getOriginalData()
    {
        return $this;
    }

    /**
     * Setter to be used by the middleware.
     * @param AuthorizationServiceInterface $service
     * @return User
     */
    public function setAuthorization(AuthorizationServiceInterface $service)
    {
        $this->authorization = $service;

        return $this;
    }

    /**
     * @inheritDoc
     */
    public function getIdentifier()
    {
        return $this->id;
    }
}

However, I have not been able to get the identity User in the UserPolicy.php file to have the user_type field populated. Some under-the-hood magic seems to happen when I call $this->Authorization->authorize() from the controller where I explicitly pass the resource together with its user type (since I have constructed it with 'contain' => ['UserTypes'] BUT the identity user is populated automatically by the Authorization module. Could someone please help me to find a way to bring associated tables data into the identity user of an authorization policy?

NOTE: I have fudged the code to make it work like this:

##### in UserPolicy.php #####


use App\Model\Entity\User;

class UserPolicy
{
    public function canView(User $user, User $resource)
    {
        $user = \Cake\Datasource\FactoryLocator::get('Table')->get('Users')->get($user->id, ['contain' => ['UserTypes']]);

        // Now both $user->user_type and $resource->user_type are correctly populated
    }
}

HOWEVER, this feels awfully "hacky" and not the way it's supposed to be, so my original question still stands.

Upvotes: 2

Views: 2739

Answers (1)

ndm
ndm

Reputation: 60463

The identity is being obtained by the resolver of the involved identifier. In case of the CMS tutorial that's the Password identifier which by default uses the ORM resolver.

The ORM resolver can be configured to use a custom finder in case you need to control the query for obtaining the user, that's where you should add the containment for your UserTypes association.

In your UsersTable add a finder like this:

public function findForAuthentication(\Cake\ORM\Query $query, array $options): \Cake\ORM\Query
{
    return $query->contain('UserTypes');
}

and configure the identifier's resolver to use that finder like this:

$service->loadIdentifier('Authentication.Password', [
    'resolver' => [
        'className' => 'Authentication.Orm',
        'finder' => 'forAuthentication',
    ],
    'fields' => [
        'username' => 'email',
        'password' => 'password',
    ]
]);

You need to specify the resolver class name too when overriding the resolver option, as by default it is just a string, not an array that would merge with the new config!

See also

Upvotes: 5

Related Questions