makinacorpus/access-control

访问控制微框架。

1.3.0 2023-09-01 11:28 UTC

This package is auto-updated.

Last update: 2024-09-08 15:49:09 UTC


README

此访问控制微框架基于PHP属性:您可以在任何希望应用访问控制的类或方法上设置访问控制属性。

然后,当需要时,您的应用程序负责调用携带这些属性的对象的AuthorizationChecker::isGranted()方法。这意味着一旦您选择使用此API,您将必须实现何时何地调用访问检查,但不是如何实现。

此API提供了一个强大且易于使用的属性加载器和解析器,以及您可以在任何地方调用的授权API。它不提供已配置或实现的决定点,这是您项目决定在哪里以及何时执行这些访问控制检查的任务。

基于角色的访问控制(RBAC)

基于角色的访问控制是在主体具有特定角色时提供对资源的访问。此API对主体实现不可知,因此角色是一个离散的抽象。在API中,角色是一个简单的文本字符串。

namespace MyVendor\MyApp\SomeBoundingContext\Entity;

use MakinaCorpus\AccessControl\AccessRole;

#[AccessRole("ROLE_USER")]
class FooEntity
{
    public function canDoThat(UserInterface $subject)
    {
        if ($this->owner !== $subject->getUsername()) {
            return false;
        }
        return true;
    }
}

您需要实现MakinaCorpus\AccessControl\RoleChecker\RoleChecker接口并将其注册以完成此工作,例如:

namespace MyVendor\MyApp\AccessControl;

use MakinaCorpus\AccessControl\RoleChecker\RoleChecker;
use MyVendor\MyApp\Entity\SomeUserImplementation;

class MyRoleChecker implement RoleChecker
{
    public function subjectHasRole($subject, string $role): bool
    {
        return $subject instanceof SomeUserImplementation && $subject->hasRole($role);
    }
}

当使用Symfony包时,默认的RoleChecker实现会透明地使用Symfony当前用户角色。

基于权限的访问控制(PBAC)

基于角色的访问控制是在主体具有特定权限时提供对资源的访问。此API对主体实现不可知,因此权限是一个离散的抽象。在API中,权限是一个简单的文本字符串。

namespace MyVendor\MyApp\SomeBoundingContext\Entity;

use MakinaCorpus\AccessControl\AccessPermission;

#[AccessPermission("do_that_with_foo")]
class FooEntity
{
    public function canDoThat(UserInterface $subject)
    {
        if ($this->owner !== $subject->getUsername()) {
            return false;
        }
        return true;
    }
}

您需要实现MakinaCorpus\AccessControl\PermissionChecker\PermissionChecker接口并将其注册以完成此工作,例如。

namespace MyVendor\MyApp\AccessControl;

use MakinaCorpus\AccessControl\PermissionChecker\PermissionChecker;
use MyVendor\MyApp\Entity\SomeUserImplementation;

class MyPermissionChecker implement PermissionChecker
{
    public function subjectHasPermission($subject, string $$permission): bool
    {
        return $subject instanceof SomeUserImplementation && $subject->hasPermission($permission);
    }
}

没有默认的PermissionChecker实现。

域边界/驱动访问控制

描述

这种方法是最有趣的一种,您将能够将访问检查委托给任意对象方法、服务方法、资源方法或全局函数。

想法是在您的领域实现一个访问检查方法,使其与访问检查API解耦,这样您就可以实现与业务相关的、上下文相关的访问控制处理程序。

使用资源方法

让我们来看一个例子,假设您有一个公交命令

namespace MyVendor\MyApp\SomeBoundingContext\Entity;

use MakinaCorpus\AccessControl\AccessMethod;

#[AccessMethod("canDoThat(subject)")]
class FooEntity
{
    public function canDoThat(UserInterface $subject)
    {
        if ($this->owner !== $subject->getUsername()) {
            return false;
        }
        return true;
    }
}

就是这样,然后您需要在合适的时候调用Authorization::isGranted($yourEntity)

使用上下文参数方法

方法可以是任何上下文参数方法,例如,考虑以下实体

namespace MyVendor\MyApp\SomeBoundingContext\Entity;

class Product
{
    private int $quantityInStock;

    public function hasEnoughQuantity(int $needed): bool
    {
        return $this->quantityInStock > $needed;
    }
}

然后以下控制器函数(框架无关)

namespace MyVendor\MyApp\SomeBoundingContext\Entity;

use MakinaCorpus\AccessControl\AccessMethod;

#[AccessMethod("product.hasEnoughQuantity(quantityRequired)")]
public function addToCart(Product $product, int $quantityRequired)
{
    // Do something.
}

在这个例子中,productquantityRequired都是控制器参数,我们不是在处理资源。

使用服务方法

用例

让我们来看一个例子,假设您有一个公交命令

namespace MyVendor\MyApp\SomeBoundingContext\Command;

use MakinaCorpus\AccessControl\AccessService;

#[AccessService("ThatService.canDoThat(subject, resource)")]
class DoThisOrThatCommand
{
}

并实现该服务

namespace MyVendor\MyApp\SomeBoundingContext\AccessControl;

class ThatService
{
    public function canDoThat(UserInterface $subject, $resource)
    {
        if ($resource->issuer !== $subject->getUsername()) {
            return false;
        }
        return true;
    }
}

然后将它注册到访问控制组件配置中(假设在这个示例中您正在使用Symfony容器)

services:
    MyVendor\MyApp\SomeBoundingContext\AccessControl\ThatService:
        tags: ['access_control.method']

我们假设您为您的总线编写了这样的装饰器,允许您的代码透明地连接到访问控制API(以下代码属于您的基础设施层,与领域无关)

namespace MyVendor\MyApp\Bus;

use MakinaCorpus\AccessControl\Authorization;

class MyBusAccessDecorator implements MyBus
{
    private Authorization $authorization;
    private MyBus $decorated;

    public function dispatch(object $command): void
    {
        if (!$this->authorization->isGranted($command)) {
            throw new \Exception("YOU SHALL NOT PASS");
        }
        $this->decorated->dispatch($command);
    }
}

参数显式命名

如果您的方法参数与上下文值不同,您可以显式编写命名参数,如下所示

namespace MyVendor\MyApp\SomeBoundingContext\AccessControl;

class ThatService
{
    public function canDoThat(UserInterface $myBusinessUser, $myDomainEntity)
    {
        if ($myDomainEntity->issuer !== $myBusinessUser->getUsername()) {
            return false;
        }
        return true;
    }
}

然后

use MakinaCorpus\AccessControl\AccessService;

#[AccessService("ThatService.canDoThat(myBusinessUser: subject, myDomainEntity: resource)")]
class DoThisOrThatCommand
{
}

资源属性作为参数

现在假设您想获取一个命令属性

namespace MyVendor\MyApp\SomeBoundingContext\AccessControl;

class ThatService
{
    public function canDoThat(UserInterface $myBusinessUser, $resource)
    {
        if ($someId !== $resource) {
            return false;
        }
        return true;
    }
}

然后

use MakinaCorpus\AccessControl\AccessService;

#[AccessService("ThatService.canDoThat(myBusinessUser: subject, myDomainEntity: resource.entityId)")]
class DoThisOrThatCommand
{
    public $entityId;
}

注意,也可以获取资源之外任何对象的属性,考虑以下访问方法签名

namespace MyVendor\MyApp\SomeBoundingContext\AccessControl;

class ThatService
{
    public function canDoThat(string $userId, $resource)
    {
        if ($userId !== $resource->userId) {
            return false;
        }
        return true;
    }
}

然后您可以结合显式参数命名并编写

use MakinaCorpus\AccessControl\AccessService;

#[AccessService("ThatService.canDoThat(userId: subject.id, resource)")]
class DoThisOrThatCommand
{
}

属性名(跟在点之后)可以是以下之一

  • 一个公开、受保护或私有属性名,
  • 一个公开、受保护、私有方法名,
  • 如果是一个方法,它必须没有参数,或者只有可选参数。

如果属性或方法不存在,将静默地返回 null

如果方法无法调用,将引发异常。

在所有情况下

Authorization

  • 查找 AccessServiceAccessMethod 属性,并解析它。
  • 使用 AccessService,它将搜索已注册的 ThatService 服务,该服务应该是使用依赖注入容器注册的实例(关于它如何到达这里的具体细节并不重要)。
  • 使用 AccessMethod,它将在资源本身找到的匹配方法上应用其余算法。
  • 收集传递给该方法的参数,这些参数来自它们各自的名字:subject 表示登录用户,resource 表示提供给 isGranted() 方法的任意对象。
  • 如果提供了未识别的参数,它将失败、记录并拒绝。
  • 使用与上下文相关的 SubjectLocator 来查找当前运行时上下文相关的主体。
  • 检查 ThatService 或资源上是否存在 canDoThat() 方法,并且它接受与 subjectresource 对应的给定类型参数。
  • 确保即将传递给 canDoThat() 的参数是类型兼容的。
  • 使用找到的 $subject 和给定的 $resource 调用 $thatServiceInstance.canDoThat()
  • 在我们的案例中,$resource 是命令,那么命令将被传递给服务方法。

这种实现背后的思想是允许您的领域代码在访问控制框架方面保持无依赖性,当然,属性声明在您的命令上是例外。然而,您的访问检查服务将保持无领域依赖性。

在发生任何错误的情况下,例如参数类型不匹配,将记录全面错误

  • 始终在 PSR-logger 实例中。
  • 如果处于调试模式,将引发异常。
  • 如果处于生产模式,将简单地拒绝访问。

如果您不传递 "Service.method()" 字符串,而是单个方法名,如 "canDoThat()",则该方法必须已注册和标识(可以是 PHP 支持的任何可调用的函数)或作为函数存在。

您还可以使用函数 FQDN,如 MyVendor\SomeNamespace\foo()

资源定位器

假设您在一个具有命令总线且希望对不是命令本身的专用资源执行访问检查的应用程序中工作。

在您的依赖注入容器中拥有以下实体类和专用存储库

namespace MyVendor\MyApp\SomeBoundingContext\Model;

class SomeEntity
{
    public int $id;
    public string $name;
}

interface SomeEntityRepository
{
    /* @throws \DomainException */
    public function find(int $id): SomeEntity;
}

并且以下命令发送到总线

namespace MyVendor\MyApp\SomeBoundingContext\Command;

class UpdateSomeEntity
{
    public int $entityId;
    public string $newName;
}

您可能希望在总线级别检查访问,但希望提供实体作为资源,该资源上的访问策略将适用,而不是命令本身。

首先编写一个资源定位器,如下所示

namespace MyVendor\MyApp\SomeBoundingContext\ResourceLocator;

use MakinaCorpus\AccessControl\ResourceLocator\ResourceLocator;
use MyVendor\MyApp\SomeBoundingContext\Model\SomeEntity;
use MyVendor\MyApp\SomeBoundingContext\Model\SomeEntityRepository;

class SomeResourceLocator implements ResourceLocator
{
    private SomeEntityRepository $repository;

    public function __construct(SomeEntityRepository $repository)
    {
        $this->repository = $repository;
    }

    /**
     * {@inheritdoc}
     */
    public function loadResource(string $resourceType, $resourceId)
    {
        try {
            if (SomeEntity::class === $resourceType && \is_int($resourceId) {
                return $this->repository->find($resourceId);
            }
        } catch (\DomainException $e) {
            // Or let the exception pass, but it violates the contract.
        }
        return null;
    }
}

然后将其注册到访问控制组件配置中(考虑到在这个示例中您正在使用 Symfony 容器)

services:
    MyVendor\MyApp\SomeBoundingContext\ResourceLocator\SomeResourceLocator:
        tags: ['access_control.resource_locator']

为了使授权检查器能够找到正确的资源进行访问检查,您只需要在您的命令上添加 AccessResource 属性。

namespace MyVendor\MyApp\SomeBoundingContext\Command;

use MakinaCorpus\AccessControl\AccessResource;
use MakinaCorpus\AccessControl\AccessService;
use MyVendor\MyApp\SomeBoundingContext\Model\SomeEntity;

#[AccessResource(SomeEntity::class, "entityId")]
#[AccessService(ThatService.canDoThat(resource))]
class UpdateSomeEntity
{
    public int $entityId;
    // ... other properties.
}

这实际上意味着:“从我的$entityId属性中找到标识符,获取SomeClass实体,然后使用ThatService.canDoThat()方法,将加载的实体作为第一个参数传递”。

定义多个属性

当你定义多个属性时,将按顺序进行检查。检查将作为OR条件执行,单个允许访问的检查将允许一切。

例如

namespace MyVendor\MyApp\SomeBoundingContext\Command;

use MakinaCorpus\AccessControl\AccessMethod;
use MakinaCorpus\AccessControl\AccessRole;

#[AccessRole("ROLE_ADMIN")]
#[AccessMethod("ThatService.canDoThat(subject, resource)")]
class DoThisOrThatCommand
{
}

如果任一访问控制属性表示“是”,则它将正常工作。

如果你需要执行AND条件,则需要使用AccessAllOrNothing()属性显式地这样做,例如

namespace MyVendor\MyApp\SomeBoundingContext\Command;

use MakinaCorpus\AccessControl\AccessAllOrNothing;
use MakinaCorpus\AccessControl\AccessMethod;
use MakinaCorpus\AccessControl\AccessRole;

#[AccessAllOrNothing]
#[AccessRole("ROLE_ADMIN")]
#[AccessMethod("ThatService.canDoThat(subject, resource)")]
class DoThisOrThatCommand
{
}

在这种情况下,所有属性都需要表示“是”,才能通过。

访问委托

访问委托是一种特定的访问策略,它将访问检查委托给同一项目中另一个现有的PHP类。

假设你有以下总线命令

namespace MyVendor\MyApp\SomeBoundingContext\Command;

use MakinaCorpus\AccessControl\AccessRole;

#[AccessRole("ROLE_ADMIN")]
class DoThisOrThatCommand
{
}

并且你想要在一个控制器方法上应用相同的策略

namespace MyVendor\MyApp\SomeBoundingContext\Controller;

use MakinaCorpus\AccessControl\AccessDelegate;
use MyVendor\MyApp\SomeBoundingContext\Command\DoThisOrThatCommand

class SomeController
{
    #[AccessDelegate(DoThisOrThatCommand::class)]
    public function doThisOrDoThatAction(Request $request, /* ... */): Response
    {
    }
}

你可以这样做。

当使用此功能时,委托对象将被用作资源 而不是你委托到的类。为了评估这个问题,请在委托类上使用显式的AccessResource属性来触发ResourceLocator资源加载器。

这个API不是什么

其他未实现的方法

一些众所周知的访问控制方法尚未实现,并且可能永远不会由这个API实现

  • 基于身份的访问控制(IBAC):这个框架旨在保持小巧和快速,并且不知道关于主体类型或身份的任何信息,因为我们不知道关于主体的任何信息,所以我们不能识别它。

  • 基于格的访问控制(LBAC):它从未成为目标,因为这些访问控制在我们通常工作的应用中并不常见。

  • 基于属性的访问控制(ABAC):虽然它是我们的目标之一,但它在实体/资源通用性方面有非常深远的影响,需要提供一个高效的模型实现,因此,以及因为每个项目都不同,我们选择暂时不实现它。注意,如果确实需要,它可以使用域绑定/驱动访问控制有效地替换或实现。

访问控制列表

访问控制列表(ACL)是一个广泛的话题,这个API没有涵盖,尽管这个API可以用作API系统的前端。

你需要实现什么来使其工作

上下文相关的主体定位器

如果你使用Symfony,Security组件将透明地使用,并在找到的情况下提供当前的UserInterface。如果没有主体,需要它的访问检查将失败并拒绝。

主体权限检查器(可选)

Symfony没有通用的基于权限的访问检查,因此你需要实现自己的。

实现基于权限的访问检查是可选的。

资源定位器(可选)

如果你希望使用资源加载器和访问资源属性,你需要实现自己的资源定位器。

使用与资源定位器相关的属性是可选的。

主体角色检查器(可选)

如果你使用Symfony,角色将通过Security组件透明地处理。

实现基于角色的访问检查是可选的。

服务方法(可选)

为了使用服务方法,你需要将你的服务注册到Symfony容器中,并使用access_control.service标签标记它们。

将委托给ContainerServiceLocator实现来查找它们。

Symfony集成

这个包提供了对Symfony 5.x和6.x的集成。

设置

通过将以下内容添加到config/bundles.php来启用它

return [
    MakinaCorpus\AccessControl\Bridge\Symfony\AccessControlBundle::class => ['all' => true],
];

没有需要完成的配置。

与控制器集成

所有控制器参数都将作为上下文参数用于访问控制策略,这对于AccessMethodAccessService策略特别有用。以下是一些示例。

使用对象参数方法

您可以使用任何控制器参数对象的任何方法作为访问控制方法

namespace App\Controller;

use App\Entity\BlogPost;
use MakinaCorpus\AccessControl\AccessMethod;

class BlogPostController
{
    /**
     * Let's consider that you have an ArgumentValueResolver for the
     * BlogPost class here.
     */
    #[AccessMethod(post.isUserOwner(subject)]
    public function edit(BlogPost $post)
    {
    }
}

在本例中,方法访问检查将调用控制器参数实例$post上的BlogPost::isUserOwner()方法,并传递默认的subject,即当前登录的UserInterface(如果有)。

将参数作为方法参数使用

namespace App\Controller;

use App\Entity\BlogPost;
use MakinaCorpus\AccessControl\AccessMethod;

class BlogPostController
{
    /**
     * Let's consider that you have an ArgumentValueResolver for the
     * BlogPost class here.
     */
    #[AccessMethod(post.isTokenValid(token: accessToken)]
    public function view(BlogPost $post, string $accessToken)
    {
    }
}

在本例中,方法访问检查将调用控制器参数实例$post上的BlogPost::isTokenValid()方法,并传递控制器参数accessToken的值,作为isTokenValid()方法的$token命名参数。

使用请求GET和POST参数

方法表达式目前还不支持在上下文参数上调用带参数的方法,尽管如此,使用传入的请求查询参数是一个常见的用例。

为了解决这个问题,当在控制器方法上使用访问策略时,默认提供了元参数

  • _get.PARAM_NAME:将返回PARAM_NAME GET参数的值
  • _post.PARAM_NAME:将返回PARAM_NAME POST参数的值

警告:这些上下文参数名称可能会被您的控制器参数名称覆盖。

使用查询POST参数

@todo

Symfony用户是默认主体

如果您在AccessMethodAccessService参数中未指定subject参数,默认将始终是登录的Symfony的UserInterface实例(如果有)。

关于PHP 8属性说明

所有属性类也可以透明地用作Doctrine注解。

当通过Symfony包使用它时,如果已在Symfony容器中注册,则注解读取器将被适当配置。