Skip to content

Conversation

innocenzi
Copy link
Member

@innocenzi innocenzi commented Aug 15, 2025

Warning

This pull request depends on #1513, #1514 and #1515, so CI is expected to fail until I rebase it.


This pull request is a complete overhaul of the auth package.

Authentication

My main goal was removing the assumption that an authenticated model is a user (it could be an access token, an external application, or anything else that is not necessarily a "user").

Moreover, the previous implementation's SessionAuthenticator assumed that users were in a database. While true in most cases, this new implementation offers the ability to easily override this logic by providing an AuthenticatableResolver interface:

interface AuthenticatableResolver
{
    /**
     * Resolves an authenticatable entity by the given ID.
     */
    public function resolve(int|string $id): ?CanAuthenticate;

    /**
     * Resolves an identifier for the given authenticatable entity.
     */
    public function resolveId(CanAuthenticate $authenticatable): int|string;
}

The default implementation is a database implementation and requires the model to have a primary key (not necessarily named id). But it is possible to override it, for instance if the application interfaces with LDAP.

The concept of authenticator is preserved, with a default session-based implementation that depends on the AuthenticatableResolver interface to resolve a unique ID from the authenticatable model or resolve the authenticatable model instance given the authenticated ID.

Note to myself: I don't think it is possible, with this implementation, to support different authenticatable models at once (eg. a User and a ExternalApplication or something). I should explore that before merging.

Access control

My second goal was to not force a RBAC model on Tempest developers, as this is often very limiting in medium-sized+ applications. Instead, I implemented an ABAC (policy-based) model, which can use a RBAC model under the hood if desired. This is more flexible, and familiar with what Laravel and Symfony provide.

It is still possible to implement in userland the #[Allow] attribute that the previous implementation provided, but this is not included (for now). If we decide to provide an RBAC implementation, it should be done through an installer that would publish it to the user's application.

The ABAC implementation uses #[PolicyFor(Resource::class)] attributes and is quite flexible. It also interfaces with the authenticator to provide the currently-authenticated model if a subject is not specified.

Here are the usual ABAC terms:

  • An action is a specific operation that can be performed on a resource, such as view, edit, or delete.
  • A resource may be anything represented by a class.
  • A subject is the entity that is trying to perform the action, typically the authenticated user.

Here is what creating a policy looks like. They could be in any class (like controller actions or console commands). The #[PolicyFor] attribute requires the resource as its first argument and an optional action as its second one. If not provided, the action is the method name.

final class PostPolicy
{
    #[PolicyFor(Post::class)]
    public function view(Post $post): bool
    {
        if (! $post->published) {
            return false;
        }

        return true;
    }
}

The method associated to the attribute accepts the resource as its first argument and the subject as the second one. Both are optional: if the resource doesn't exist (eg. when creating it), it's not needed. And if the subject is not authenticated or provided, it's not needed either.

To determine if a subject has access to a resource, the AccessControl interface must be used. It has a denyAccessUnlessGranted method that throws if access is denied, and an isGranted method that returns a boolean.

final readonly class PostsController
{
    public function __construct(
        private AccessControl $accessControl,
    ) {}

    #[Get('/posts/{post}')]
    public function show(Post $post): View
    {
        $this->accessControl->denyAccessUnlessGranted('show', $post);

        // ...
    }

    #[Post('/posts')]
    public function store(CreatePostRequest $request): Redirect
    {
        $this->accessControl->denyAccessUnlessGranted('create', Post::class);

        // ...
    }
}

Note that in the PolicyBasedAccessControl implementation of AccessControl, the methods associated to #[PolicyFor] are checked and a nice exception is thrown if the parameters don't accept the required types:

The type of the resource parameter of the App\PostPolicy::view policy does not match the expected type App\Post.

Considerations

  • We can consider adding back an RBAC implementation as an installer, for convenience.
  • We should consider supporting multiple authenticatable models at once.
  • I was thinking about providing different authentication strategies by default.
    • For instance, frameworks usually provide a way to login with a username and a password. This implies too many assumptions that get in the way as soon as we use a slightly different authentication paradigm, so it's annoying. So currently, users have to use the PasswordChecker and fetch the user from the database manually (which is perfectly fine and not a lot of LoC).
    • If we find a way to do that, we could also provide other strategies, like passkeys, magic links, token-based auth, and maybe OAuth2 could fit this scope as well.
    • I explored the above, but the different ways to authenticate are too different to have a common interface, I think it's better to provide them though installers

@innocenzi innocenzi marked this pull request as draft August 15, 2025 21:07
@innocenzi innocenzi changed the title feat(auth)!: overhaul authentication and authentication feat(auth)!: overhaul authentication Aug 16, 2025
@innocenzi innocenzi changed the title feat(auth)!: overhaul authentication feat(auth)!: overhaul authentication and access control Aug 16, 2025
@innocenzi innocenzi force-pushed the feat/auth-improvements branch from e74b0c9 to aa8ba95 Compare August 24, 2025 14:47
@innocenzi innocenzi marked this pull request as ready for review August 24, 2025 15:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

1 participant