Skip to content

Commit aa8ba95

Browse files
committed
feat(auth): support multiple authenticatable models
1 parent 3126162 commit aa8ba95

12 files changed

+135
-28
lines changed

packages/auth/src/AuthConfig.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,11 @@
1515
final class AuthConfig
1616
{
1717
/**
18-
* @param null|class-string<CanAuthenticate> $authenticatable
18+
* @param array<class-string<CanAuthenticate>> $authenticatables
1919
* @param array<class-string,array<string,MethodReflector[]>> $policies
2020
*/
2121
public function __construct(
22-
public ?string $authenticatable = null,
22+
public array $authenticatables = [],
2323
public array $policies = [],
2424
) {}
2525

packages/auth/src/AuthenticatableDiscovery.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ public function discover(DiscoveryLocation $location, ClassReflector $class): vo
2626
public function apply(): void
2727
{
2828
foreach ($this->discoveryItems as $discoveryItem) {
29-
$this->authConfig->authenticatable = $discoveryItem;
29+
$this->authConfig->authenticatables[] = $discoveryItem;
3030
}
3131
}
3232
}

packages/auth/src/Authentication/AuthenticatableResolver.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@ interface AuthenticatableResolver
66
{
77
/**
88
* Resolves an authenticatable entity by the given ID.
9+
*
10+
* @param class-string<CanAuthenticate> $class
911
*/
10-
public function resolve(int|string $id): ?CanAuthenticate;
12+
public function resolve(int|string $id, string $class): ?CanAuthenticate;
1113

1214
/**
1315
* Resolves an identifier for the given authenticatable entity.

packages/auth/src/Authentication/AuthenticatableResolverInitializer.php

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
namespace Tempest\Auth\Authentication;
44

5-
use Tempest\Auth\AuthConfig;
65
use Tempest\Container\Container;
76
use Tempest\Container\Initializer;
87
use Tempest\Container\Singleton;
@@ -14,7 +13,6 @@ final class AuthenticatableResolverInitializer implements Initializer
1413
public function initialize(Container $container): AuthenticatableResolver
1514
{
1615
return new DatabaseAuthenticatableResolver(
17-
authConfig: $container->get(AuthConfig::class),
1816
database: $container->get(Database::class),
1917
);
2018
}

packages/auth/src/Authentication/CurrentAuthenticatableInitializer.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
{
1818
public function canInitialize(ClassReflector $class, null|string|UnitEnum $tag): bool
1919
{
20-
return $class->implements(CanAuthenticate::class);
20+
return $class->implements(CanAuthenticate::class) || $class->is(CanAuthenticate::class);
2121
}
2222

2323
public function initialize(ClassReflector $class, null|string|UnitEnum $tag, Container $container): object

packages/auth/src/Authentication/DatabaseAuthenticatableResolver.php

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
namespace Tempest\Auth\Authentication;
44

5-
use Tempest\Auth\AuthConfig;
65
use Tempest\Auth\Exceptions\AuthenticatableModelWasInvalid;
76
use Tempest\Auth\Exceptions\ModelIsNotAuthenticatable;
87
use Tempest\Database\Database;
@@ -13,19 +12,16 @@
1312
final readonly class DatabaseAuthenticatableResolver implements AuthenticatableResolver
1413
{
1514
public function __construct(
16-
private AuthConfig $authConfig,
1715
private Database $database,
1816
) {}
1917

20-
public function resolve(int|string $id): ?CanAuthenticate
18+
public function resolve(int|string $id, string $class): ?CanAuthenticate
2119
{
22-
$model = query($this->authConfig->authenticatable)->findById($id);
23-
24-
if (! ($model instanceof CanAuthenticate)) {
25-
throw new ModelIsNotAuthenticatable($this->authConfig->authenticatable);
20+
if (! is_a($class, CanAuthenticate::class, allow_string: true)) {
21+
throw new ModelIsNotAuthenticatable($class);
2622
}
2723

28-
return $model;
24+
return query($class)->findById($id);
2925
}
3026

3127
public function resolveId(CanAuthenticate $authenticatable): int|string

packages/auth/src/Authentication/SessionAuthenticator.php

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99

1010
final readonly class SessionAuthenticator implements Authenticator
1111
{
12-
public const string AUTHENTICATABLE_KEY = '#authenticatable';
12+
public const string AUTHENTICATABLE_KEY = '#authenticatable:id';
13+
public const string AUTHENTICATABLE_CLASS = '#authenticatable:class';
1314

1415
public function __construct(
1516
private AuthConfig $authConfig,
@@ -19,6 +20,11 @@ public function __construct(
1920

2021
public function authenticate(CanAuthenticate $authenticatable): void
2122
{
23+
$this->session->set(
24+
key: self::AUTHENTICATABLE_CLASS,
25+
value: $authenticatable::class,
26+
);
27+
2228
$this->session->set(
2329
key: self::AUTHENTICATABLE_KEY,
2430
value: $this->authenticatableResolver->resolveId($authenticatable),
@@ -34,11 +40,12 @@ public function deauthenticate(): void
3440
public function current(): ?CanAuthenticate
3541
{
3642
$id = $this->session->get(self::AUTHENTICATABLE_KEY);
43+
$class = $this->session->get(self::AUTHENTICATABLE_CLASS);
3744

38-
if (! $id) {
45+
if (! $id || ! $class) {
3946
return null;
4047
}
4148

42-
return $this->authenticatableResolver->resolve($id);
49+
return $this->authenticatableResolver->resolve($id, $class);
4350
}
4451
}

tests/Integration/Auth/AccessControl/PolicyBasedAccessControlTest.php

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,20 @@ public function policy_for_can_accept_multiple_actions(): void
242242
$this->assertFalse($downloadResultOther->granted);
243243
}
244244

245+
#[Test]
246+
public function policy_with_multiple_authenticatables(): void
247+
{
248+
$this->registerPoliciesFrom(MultiAuthenticatablePolicy::class);
249+
250+
$accessControl = $this->container->get(AccessControl::class);
251+
$document = new Document(title: 'Test Document', authorId: 1);
252+
$user = new User(userId: 1);
253+
$serviceAccount = new ServiceAccount(accountId: 1);
254+
255+
$this->assertTrue($accessControl->isGranted('view', $document, $user)->granted);
256+
$this->assertTrue($accessControl->isGranted('view', $document, $serviceAccount)->granted);
257+
}
258+
245259
#[Test]
246260
public function throws_exception_when_policy_resource_parameter_type_is_invalid(): void
247261
{
@@ -324,6 +338,17 @@ public function __construct(
324338
}
325339
}
326340

341+
final class ServiceAccount implements CanAuthenticate
342+
{
343+
public PrimaryKey $id;
344+
345+
public function __construct(
346+
int $accountId,
347+
) {
348+
$this->id = new PrimaryKey($accountId);
349+
}
350+
}
351+
327352
final class Comment
328353
{
329354
public function __construct(
@@ -432,6 +457,19 @@ public function readAndDownload(?Document $resource, ?User $subject): bool
432457
}
433458
}
434459

460+
final class MultiAuthenticatablePolicy
461+
{
462+
#[PolicyFor(Document::class)]
463+
public function view(?Document $_resource, null|User|ServiceAccount $subject): bool
464+
{
465+
if (! ($subject instanceof CanAuthenticate)) {
466+
return false;
467+
}
468+
469+
return true;
470+
}
471+
}
472+
435473
final class InvalidResourceTypePolicy
436474
{
437475
// expects a User as resource but will receive a Post

tests/Integration/Auth/Authentication/AuthenticatableDiscoveryTest.php

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,21 @@ public function discovers_authenticatable_class(): void
2121

2222
$discovery = new AuthenticatableDiscovery($config);
2323
$discovery->setItems(new DiscoveryItems([]));
24-
$discovery->discover(new DiscoveryLocation('', ''), new ClassReflector(Device::class));
24+
$discovery->discover(new DiscoveryLocation('', ''), new ClassReflector(AuthenticatableDevice::class));
25+
$discovery->discover(new DiscoveryLocation('', ''), new ClassReflector(AuthenticatableUser::class));
2526
$discovery->apply();
2627

27-
$this->assertSame(Device::class, $config->authenticatable);
28+
$this->assertContains(AuthenticatableDevice::class, $config->authenticatables);
29+
$this->assertContains(AuthenticatableUser::class, $config->authenticatables);
2830
}
2931
}
3032

31-
final class Device implements CanAuthenticate
33+
final class AuthenticatableDevice implements CanAuthenticate
34+
{
35+
public PrimaryKey $id;
36+
}
37+
38+
final class AuthenticatableUser implements CanAuthenticate
3239
{
3340
public PrimaryKey $id;
3441
}

tests/Integration/Auth/Authentication/CurrentAuthenticatableTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ protected function configure(): void
2424
{
2525
$this->migrate(CreateMigrationsTable::class, CreateServiceAccountTableMigration::class);
2626

27-
$this->container->config(new AuthConfig(authenticatable: ServiceAccount::class));
27+
$this->container->config(new AuthConfig(authenticatables: [ServiceAccount::class]));
2828
}
2929

3030
#[Test]

0 commit comments

Comments
 (0)