0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

CakePHP × PHPStan:認証・認可プラグインの identity を User エンティティに統一する(undefined property 解消)

0
Posted at

Authentication / Authorization プラグインを使う中で発生した PHPStan のエラーを解消した記録です。

作業時の CakePHP 等のバージョン:CakePHP 5.x / Authentication 3.x / Authorization 3.x

サマリ

Authentication / Authorization プラグインは、チュートリアルにあるような形態で導入していました。Application::middleware() での認可ミドルウェアの読み込みは、次のような形で行っていました。

// src/Application.php(middleware() 内)
$middlewareQueue
    ->add(new AuthenticationMiddleware($this))
    ->add(new AuthorizationMiddleware($this));

この読み込み方では、identity がデコレータで wrap(包まれる)動きになっていました。
AuthorizationMiddleware は、identity が Authorization\IdentityInterface を実装していない場合に標準のデコレータで wrap する作りになっており、Authentication 側の wrapper と併せて、二重に wrap された状態になっていました。

この状態だと、認可ポリシーが受け取る identity のプロパティ($user->id など)が解決できず、PHPStan から「未定義プロパティ」として指摘されていました。

これを解消する方法として、次のことを行いました。

  1. User エンティティに Authentication / Authorization 両方の IdentityInterface を実装する
  2. AuthorizationMiddlewareidentityDecorator オプションで AuthorizationService を注入する(依存性注入 DI)
  3. テストでのユーザーの設定として、session に配列ではなく User エンティティを使用する

最終的に以下のような形にしています(公式ドキュメントの例を組み合わせて使用しています)。

// src/Model/Entity/User.php
namespace App\Model\Entity;

use Authentication\IdentityInterface as AuthenticationIdentity;
use Authorization\AuthorizationServiceInterface;
use Authorization\IdentityInterface as AuthorizationIdentity;
use Authorization\Policy\ResultInterface;
use Cake\ORM\Entity;

class User extends Entity implements AuthenticationIdentity, AuthorizationIdentity
{
    protected ?AuthorizationServiceInterface $authorization = null;

    // Authentication / Authorization 共通
    public function getIdentifier(): array|string|int|null
    {
        return $this->id;
    }

    public function getOriginalData(): \ArrayAccess|array
    {
        return $this;
    }

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

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

    public function applyScope(string $action, mixed $resource, mixed ...$optionalArgs): mixed
    {
        return $this->authorization->applyScope($this, $action, $resource, ...$optionalArgs);
    }

    public function setAuthorization(AuthorizationServiceInterface $service): static
    {
        $this->authorization = $service;

        return $this;
    }
}
// src/Application.php(middleware() 内)
$middlewareQueue->add(new AuthorizationMiddleware($this, [
    'identityDecorator' => function ($auth, $user) {
        return $user->setAuthorization($auth);
    },
]));

1. きっかけ

ポリシークラスで identity のプロパティを参照している箇所で、PHPStan のエラーが発生していました。

Access to an undefined property Authorization\IdentityInterface::$id.

identity の実体が Authorization\IdentityInterface 型として扱われていますが、そのインターフェースには $id のようなプロパティが定義されていないために発生していました。

PHPStan での一時的なエラー抑制

PHPStan を初めて導入するときや、大きなバージョンアップをした際には、エラーが大量に報告されることがあります。

全部を一気に解消するのは大変ですので、既存エラーを phpstan-baseline.neon に退避させて CI をいったん通す、新規のエラーはcommit時に解消し、以前からあるエラーは種類ごとに少しずつ解消していく、という運用を行っていました。

やり方は下記のリンクを参照ください。

本記事では、この運用で baseline に退避していたエラーのうち、identity 由来のものを解消した記録です。


2. identity はどのように wrap されているのか

Authentication と Authorization を併用している場合、ポリシーが受け取る identity は、おおむね次のような入れ子構造になります。

Authorization\IdentityDecorator(
    Authentication\Identity(
        App\Model\Entity\User
    )
)

次のような流れとなります。

  • User エンティティが Authentication\IdentityInterface を実装していない状態のため
    → AuthenticationMiddleware が Authentication\Identity で wrap される
  • その identity が Authorization\IdentityInterface を実装していない状態のため
    → AuthorizationMiddleware が Authorization\IdentityDecorator でさらに wrap される

この状態でも、デコレータがマジックメソッドや ArrayAccess で元データへ委譲してくれるため、実行時には $user->id のような参照が動き問題なく動作します。

一方で、静的解析では「Authorization\IdentityInterface$id は無い」という形になり、型として辿れず、先のエラーが発生します。


3. 公式チュートリアルのコード例

公式の CMS チュートリアルでは、Authentication / Authorization の導入手順が一通り紹介されており、コード例も提示されています。

ただし、そこでは identity は標準のデコレータに任せる構成までで、IdentityInterface を自前のエンティティに実装するところまでは書かれていないようです。

つまり、チュートリアルのとおりに作ると「動作はするが、デコレータ越しのため PHPStan で型が解決できない」状態となります。

本記事では、この「型が解決できない」状態を解消していきます。


4. undefined propertyの解決:User に両方の IdentityInterface を実装する

それぞれのプラグインの公式ドキュメントに、「自前の User クラスに IdentityInterface を実装する」方法が示されています。

Authentication プラグイン

Identity Objects の「User クラスへの IdentityInterface の実装」の例。

use Authentication\IdentityInterface;
use Cake\ORM\Entity;

class User extends Entity implements IdentityInterface
{
    public function getIdentifier(): array|string|int|null
    {
        return $this->id;
    }

    public function getOriginalData(): \ArrayAccess|array
    {
        return $this;
    }
}

Authorization プラグイン

認可ミドルウェア の「User クラスを identity として使う」では、can() などを AuthorizationService へ委譲(delegate)する例。

use Authorization\AuthorizationServiceInterface;
use Authorization\IdentityInterface;
use Authorization\Policy\ResultInterface;
use Cake\ORM\Entity;

class User extends Entity implements IdentityInterface
{
    public function can(string $action, mixed $resource): bool
    {
        return $this->authorization->can($this, $action, $resource);
    }

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

    public function applyScope(string $action, mixed $resource, mixed ...$optionalArgs): mixed
    {
        return $this->authorization->applyScope($this, $action, $resource, ...$optionalArgs);
    }

    public function getOriginalData(): \ArrayAccess|array
    {
        return $this;
    }

    public function setAuthorization(AuthorizationServiceInterface $service): static
    {
        $this->authorization = $service;

        return $this;
    }
}

両方を実装する

両方を併用する場合、公式ドキュメントの両インターフェースを実装する例。

use Authentication\IdentityInterface as AuthenticationIdentity;
use Authorization\IdentityInterface as AuthorizationIdentity;

class User extends Entity implements AuthorizationIdentity, AuthenticationIdentity
{
    public function getIdentifier(): int|string|null
    {
        return $this->id;
    }
}

両プラグインがそれぞれ IdentityInterface という同じ名前のインターフェースを別の名前空間で定義しています。
そのため use ... as ... で別名を付けて両方を実装することになります。やや冗長に見えますが、公式ドキュメントで示された書き方となります。

これらをまとめたものが、サマリに載せたコードとなります。


5. 依存性注入(DI):identityDecorator で AuthorizationService を注入する

UserAuthentication\IdentityInterface を実装すると、AuthenticationService は identity をそのまま返すようになりました。
プラグインの実装を見ると、次のような分岐が確認できます。

// vendor/cakephp/authentication/src/AuthenticationService.php(抜粋)
public function buildIdentity(ArrayAccess|array $identityData): IdentityInterface
{
    if ($identityData instanceof IdentityInterface) {
        return $identityData;
    }
    // ... ここで identityClass による wrap が行われる
}

つまり、エンティティが IdentityInterface を実装していれば、Authentication\Identity での wrap は行われず、エンティティ自身が identity になります。

一方で Authorization 側は、wrap を省く経路だと setAuthorization() が呼ばれず、エンティティに AuthorizationService が注入(DI)されないままになります。すると can() の中の $this->authorization が null のままとなり、認可判定が動かなくなってしまいます。

これを避けるため、公式ドキュメントでは identityDecorator オプションでエンティティに service を注入する例が示されていました。

$middlewareQueue->add(new AuthorizationMiddleware($this, [
    'identityDecorator' => function ($auth, $user) {
        return $user->setAuthorization($auth);
    },
]));

エンティティが interface を実装した場合、Authorization 側の service 注入(DI)が必要になります。


6. ポリシーの修正:引数型を User 型にする

identity がエンティティに統一できると、ポリシーの引数型も具体的な User 型にできます。CMS チュートリアルのポリシー例をもとにすると、次のような変化になります。

CMS チュートリアルの例:

// Before:interface 型のため、$user->id が「未定義プロパティ」と指摘されやすい
use Authorization\IdentityInterface;

public function canEdit(IdentityInterface $user, Article $article): bool
{
    return $user->id === $article->user_id;
}

エンティティ型に変更したコード:

// After:エンティティ型で受けると、$user->id が型として解決される
use App\Model\Entity\User;

public function canEdit(User $user, Article $article): bool
{
    return $user->id === $article->user_id;
}

これにより、@var などの注釈や baseline の該当エントリに頼らずに、アロー記法のまま型が分かるようになりました。

補足:引数が「同じ型」で2つ並ぶこともある

ポリシーメソッドの第1引数は認可の主体(identity)、第2引数は**操作対象のリソース(resource)**となります。
多くのポリシーでは、リソースが identity とは別の型になるため、型を見れば役割が分かります。

// actor(User)と resource(Article)の型が違うので分かりやすい
public function canEdit(User $user, Article $article): bool
{
    return $user->id === $article->user_id;
}

一方で、「ユーザー自身を操作対象にするポリシー」(たとえばユーザー情報の編集や管理者判定など)では、主体も対象も User になり、同じ型が2つ並びます

// actor も resource も User
public function canEdit(User $user, User $targetUser): bool
{
    return $user->id === $targetUser->id;
}

一見すると奇妙にも見えますが、identity を User 型に統一した結果です。
引数名や docblock で「主体(identity)」と「対象(resource)」を区別しておくと、分かりやすいと思います。


7. テストの修正:session にはエンティティを入れる

identity をエンティティに統一すると、テストの書き方も合わせる必要が出てきます。

session に「配列」を入れていると壊れる

テストでログイン状態を作るとき、session に配列を入れる書き方は、AuthComponent 時代のやり方でした。

公式の AuthComponent 時代のドキュメントに書かれていたコード例です。

// AuthComponent 時代の例
$this->session([
    'Auth' => [
        'User' => [
            'id' => 1,
            'username' => 'testing',
        ],
    ],
]);

identity が wrap されている形で使用している際には、このままのコードでも動作します。

ところが、identity をエンティティに統一したあとでこの書き方を使うと、配列は IdentityInterface を実装していないため、テスト内では Authentication\Identity(配列を wrap した wrapper)が identity になります。

コード(IdentityInterface を実装したエンティティ)と構造が食い違ってしまっています。

その結果、500 エラーになることがあります。
加えて、ポリシーの引数を User 型にしている場合、実体が wrapper のままだと型が一致せず TypeError になることもあります。

対処方法

Authentication プラグインの testing ドキュメントでは、1.x の頃から User エンティティを session に入れる例が示されています。

// Authentication プラグイン公式の testing 例
protected function login(int $userId = 1): void
{
    $user = $this->fetchTable('Users')->get($userId);
    $this->session(['Auth' => $user]);
}

AuthComponent 時代の配列を session に入れるやりかたのままであった場合、同時に直しておく必要があります。

対処のアレンジ

公式の例のように fetchTable('Users')->get() で取得したエンティティを入れるのがよいですが、フィクスチャのデータに依存せず、さまざまな値をセットしたユーザーでテストしたい場合は、エンティティを直接組み立ててしまった方が便利です。
共通化しておくと使いやすいため、ヘルパーを trait にする例です。

trait AuthSessionTrait
{
    protected function loginAs(array $data): void
    {
        // テスト用に guard を無効化し、id 等もそのまま設定する
        $this->session(['Auth' => new User($data, ['guard' => false])]);
    }
}
// 使い方
$this->loginAs(['id' => 1, 'username' => 'alice']);

8. まとめ

  • Authentication / Authorization を併用すると、identity が二重に wrap され、PHPStan からプロパティが辿れないことがあります
  • User エンティティに両方の IdentityInterface を実装し、identityDecoratorAuthorizationService を注入すると、identity をエンティティに統一できる
  • ポリシーの引数を User 型にすると、アロー記法のまま型が解決される
  • テストでは、session に配列ではなく entity を使用する必要があります

実行時の挙動自体は実装前後で大きく変わらないため、すぐに修正する必要はないかと思います。

一方で、CakePHP の方向性としてマジックメソッド廃止やネイティブ型化が進んでいます。

PHPStan を CI に組み込み、こうした型まわりの整理を早めに進めておく価値は、将来に向けてあるかもしれません。


参考リンク

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?