feat: add HasRequiredAccess trait for OR-combined entity dependencies

Introduces a generic "Required Access" mechanism: any model using
HasRequiredAccess can list other entities as required-access targets;
if the requesting entity has access to ANY of them — direct, role,
or permission — the holder is considered unlocked. Sits alongside
Required Roles / Permissions and is OR-combined with them.

The unlock check resolves in a single EXISTS query that joins
required_accesses with accesses, so cost stays O(1) regardless of
target count.

20 new unit tests cover relations, sync semantics, expiry handling,
isolation between holders, and the constant-cost query property.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Fabian @ Blax Software 2026-04-27 12:35:09 +02:00
parent 20d94caa33
commit 2ca17ba914
7 changed files with 647 additions and 2 deletions

View File

@ -26,6 +26,7 @@ return [
'permission_usage' => \Blax\Roles\Models\PermissionUsage::class,
'permission_member' => \Blax\Roles\Models\PermissionMember::class,
'access' => \Blax\Roles\Models\Access::class,
'required_access' => \Blax\Roles\Models\RequiredAccess::class,
],
'table_names' => [
@ -36,6 +37,7 @@ return [
'role_member' => 'role_members',
'role_permission' => 'role_permissions',
'accesses' => 'accesses',
'required_accesses' => 'required_accesses',
],
];

View File

@ -0,0 +1,48 @@
<?php
namespace Blax\Roles\Migrations;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Required-access pivot.
*
* Each row says: to unlock the holder (e.g. a Lection), the entity
* must already have access to the linked target (e.g. a Course). A holder
* may list any number of targets they are evaluated as an OR set, in the
* same spirit as Required Roles / Required Permissions.
*
* Idempotent so it's safe to auto-run on a project that already has the
* table from a previously published copy.
*/
return new class extends Migration
{
public function up(): void
{
$table = config('roles.table_names.required_accesses', 'required_accesses');
if (Schema::hasTable($table)) {
return;
}
Schema::create($table, function (Blueprint $blueprint) {
$blueprint->id();
$blueprint->morphs('holder'); // The gated entity (e.g. Lection)
$blueprint->morphs('required'); // The entity whose access unlocks the holder (e.g. Course)
$blueprint->timestamps();
$blueprint->unique(
['holder_type', 'holder_id', 'required_type', 'required_id'],
'required_access_unique',
);
$blueprint->index(['required_type', 'required_id'], 'required_access_reverse');
});
}
public function down(): void
{
Schema::dropIfExists(config('roles.table_names.required_accesses', 'required_accesses'));
}
};

View File

@ -0,0 +1,50 @@
<?php
namespace Blax\Roles\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
/**
* Pivot row for the "Required Access" feature.
*
* A holder model (anything using HasRequiredAccess) lists one or more
* required targets. At access-check time, if the requesting entity has
* an active Access entry to ANY of those targets, the holder is unlocked.
* This sits alongside Required Roles / Required Permissions and is
* evaluated with OR semantics.
*/
class RequiredAccess extends Model
{
use HasUuids;
protected $fillable = [
'holder_id',
'holder_type',
'required_id',
'required_type',
];
public function __construct(array $attributes = [])
{
parent::__construct($attributes);
$this->table = config('roles.table_names.required_accesses') ?: parent::getTable();
}
/**
* The gated entity that owns this requirement.
*/
public function holder()
{
return $this->morphTo();
}
/**
* The entity whose access unlocks the holder.
*/
public function required()
{
return $this->morphTo();
}
}

View File

@ -83,5 +83,6 @@ class RolesServiceProvider extends \Illuminate\Support\ServiceProvider
$this->app->bind(\Blax\Roles\Models\PermissionUsage::class, fn($app) => $app->make($app->config['roles.models.permission_usage']));
$this->app->bind(\Blax\Roles\Models\PermissionMember::class, fn($app) => $app->make($app->config['roles.models.permission_member']));
$this->app->bind(\Blax\Roles\Models\Access::class, fn($app) => $app->make($app->config['roles.models.access']));
$this->app->bind(\Blax\Roles\Models\RequiredAccess::class, fn($app) => $app->make($app->config['roles.models.required_access']));
}
}

View File

@ -322,8 +322,11 @@ trait HasAccess
/**
* Get role IDs for resolving access through roles.
* Returns null if this model doesn't use roles.
*
* Public so other access-resolution code (e.g. HasRequiredAccess) can
* reuse the same logic when building cross-table queries.
*/
protected function resolveAccessRoleIds(): ?Collection
public function resolveAccessRoleIds(): ?Collection
{
if (! method_exists($this, 'roles')) {
return null;
@ -344,8 +347,11 @@ trait HasAccess
/**
* Get permission IDs for resolving access through permissions.
* Returns null if this model doesn't use permissions.
*
* Public so other access-resolution code (e.g. HasRequiredAccess) can
* reuse the same logic when building cross-table queries.
*/
protected function resolveAccessPermissionIds(): ?Collection
public function resolveAccessPermissionIds(): ?Collection
{
if (! method_exists($this, 'permissions')) {
return null;

View File

@ -0,0 +1,198 @@
<?php
namespace Blax\Roles\Traits;
use Blax\Roles\Models\RequiredAccess;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
/**
* "Required Access" declarative dependency between gated entities.
*
* A holder lists one or more required targets. At check-time, if the
* requesting entity (typically a User) has any active Access entry to
* ANY of those targets direct, role-based, or permission-based the
* holder is considered unlocked.
*
* This sits alongside Required Roles and Required Permissions and is
* evaluated with OR semantics: holder is unlocked if any of {required
* roles, required permissions, required access} resolves true.
*
* Performance: hasUnlockedRequiredAccessFor() resolves the question in a
* single SQL statement that joins required_accesses with accesses, instead
* of N+1 lookups across linked targets.
*/
trait HasRequiredAccess
{
/**
* Pivot rows declaring this entity's required-access targets.
*/
public function requiredAccessLinks()
{
return $this->morphMany(
config('roles.models.required_access', RequiredAccess::class),
'holder',
);
}
/**
* Resolve the actual target Models (loaded polymorphically).
*
* Convenience for UIs that need to render the linked entities. For the
* lock check, prefer hasUnlockedRequiredAccessFor() it never loads
* targets and resolves in a single SQL statement.
*/
public function requiredAccessTargets(): EloquentCollection
{
$links = $this->requiredAccessLinks()->with('required')->get();
return $links->map(fn(Model $link) => $link->required)
->filter()
->values()
->pipe(fn($items) => new EloquentCollection($items->all()));
}
/**
* Add a required-access target. Idempotent on (holder, required).
*/
public function addRequiredAccess(Model $target): Model
{
$modelClass = config('roles.models.required_access', RequiredAccess::class);
return $modelClass::firstOrCreate([
'holder_type' => $this->getMorphClass(),
'holder_id' => $this->getKey(),
'required_type' => $target->getMorphClass(),
'required_id' => $target->getKey(),
]);
}
/**
* Remove a required-access target.
*
* @return int Number of pivot rows deleted (0 or 1).
*/
public function removeRequiredAccess(Model $target): int
{
return $this->requiredAccessLinks()
->where('required_type', $target->getMorphClass())
->where('required_id', $target->getKey())
->delete();
}
/**
* Replace this entity's required-access set with the given targets.
* Adds missing links, removes ones not in the new set.
*
* @param iterable<Model> $targets
*/
public function syncRequiredAccess(iterable $targets): void
{
$modelClass = config('roles.models.required_access', RequiredAccess::class);
$holderType = $this->getMorphClass();
$holderId = $this->getKey();
// Build the desired set keyed by "type|id" for cheap diffing.
$desired = [];
foreach ($targets as $target) {
$key = $target->getMorphClass() . '|' . $target->getKey();
$desired[$key] = [
'type' => $target->getMorphClass(),
'id' => $target->getKey(),
];
}
$existing = $this->requiredAccessLinks()->get();
$existingKeys = [];
foreach ($existing as $link) {
$key = $link->required_type . '|' . $link->required_id;
$existingKeys[$key] = $link;
}
// Delete the ones not in desired set.
foreach ($existingKeys as $key => $link) {
if (! array_key_exists($key, $desired)) {
$link->delete();
}
}
// Insert the ones not yet present.
foreach ($desired as $key => $entry) {
if (! array_key_exists($key, $existingKeys)) {
$modelClass::create([
'holder_type' => $holderType,
'holder_id' => $holderId,
'required_type' => $entry['type'],
'required_id' => $entry['id'],
]);
}
}
}
/**
* Does $entity have access to ANY of this holder's required-access
* targets? Direct, role-based, and permission-based access entries
* all count, mirroring HasAccess::hasAccess().
*
* Resolves the question in a single SQL statement (no per-target
* round-trips). Returns false if the entity is null, has no
* required-access links, or none of them are unlocked.
*/
public function hasUnlockedRequiredAccessFor(?Model $entity): bool
{
if (! $entity) {
return false;
}
$accessTable = (new (config('roles.models.access')))->getTable();
$requiredTable = config('roles.table_names.required_accesses', 'required_accesses');
$query = DB::table($requiredTable)
->where($requiredTable . '.holder_type', $this->getMorphClass())
->where($requiredTable . '.holder_id', $this->getKey())
->whereExists(function ($sub) use ($entity, $accessTable, $requiredTable) {
$sub->select(DB::raw(1))
->from($accessTable)
->whereColumn($accessTable . '.accessible_type', $requiredTable . '.required_type')
->whereColumn($accessTable . '.accessible_id', $requiredTable . '.required_id')
->where(function ($q) use ($accessTable) {
$q->whereNull($accessTable . '.expires_at')
->orWhere($accessTable . '.expires_at', '>', now());
})
->where(function ($q) use ($entity, $accessTable) {
// 1. Direct access from the entity itself.
$q->where(function ($s) use ($entity, $accessTable) {
$s->where($accessTable . '.entity_type', $entity->getMorphClass())
->where($accessTable . '.entity_id', $entity->getKey());
});
// 2. Access conferred by any of the entity's roles.
if (method_exists($entity, 'resolveAccessRoleIds')) {
$roleIds = $entity->resolveAccessRoleIds();
if ($roleIds !== null && $roleIds->isNotEmpty()) {
$roleMorphClass = (new (config('roles.models.role')))->getMorphClass();
$q->orWhere(function ($s) use ($accessTable, $roleMorphClass, $roleIds) {
$s->where($accessTable . '.entity_type', $roleMorphClass)
->whereIn($accessTable . '.entity_id', $roleIds);
});
}
}
// 3. Access conferred by any of the entity's permissions.
if (method_exists($entity, 'resolveAccessPermissionIds')) {
$permissionIds = $entity->resolveAccessPermissionIds();
if ($permissionIds !== null && $permissionIds->isNotEmpty()) {
$permMorphClass = (new (config('roles.models.permission')))->getMorphClass();
$q->orWhere(function ($s) use ($accessTable, $permMorphClass, $permissionIds) {
$s->where($accessTable . '.entity_type', $permMorphClass)
->whereIn($accessTable . '.entity_id', $permissionIds);
});
}
}
});
});
return $query->exists();
}
}

View File

@ -0,0 +1,340 @@
<?php
namespace Blax\Roles\Tests\Unit;
use Blax\Roles\Models\Permission;
use Blax\Roles\Models\RequiredAccess;
use Blax\Roles\Models\Role;
use Blax\Roles\RolesServiceProvider;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Orchestra\Testbench\TestCase;
use Workbench\App\Models\Article;
use Workbench\App\Models\User;
class HasRequiredAccessTest extends TestCase
{
use RefreshDatabase;
protected function getPackageProviders($app): array
{
return [RolesServiceProvider::class];
}
protected function defineEnvironment($app): void
{
$app['config']->set('database.default', 'testing');
$app['config']->set('database.connections.testing', [
'driver' => 'sqlite',
'database' => ':memory:',
'prefix' => '',
]);
$app['config']->set('roles.run_migrations', false);
}
protected function defineDatabaseMigrations(): void
{
$this->loadMigrationsFrom(__DIR__ . '/../../workbench/database/migrations');
}
// ─── relations / mutations ─────────────────────────────────────────
public function test_required_access_links_returns_empty_by_default(): void
{
$article = Article::create(['title' => 'Holder']);
$this->assertCount(0, $article->requiredAccessLinks);
}
public function test_add_required_access_creates_pivot_row(): void
{
$holder = Article::create(['title' => 'Holder']);
$target = Article::create(['title' => 'Target']);
$link = $holder->addRequiredAccess($target);
$this->assertInstanceOf(RequiredAccess::class, $link);
$this->assertSame($holder->getMorphClass(), $link->holder_type);
$this->assertEquals($holder->id, $link->holder_id);
$this->assertSame($target->getMorphClass(), $link->required_type);
$this->assertEquals($target->id, $link->required_id);
}
public function test_add_required_access_is_idempotent(): void
{
$holder = Article::create(['title' => 'Holder']);
$target = Article::create(['title' => 'Target']);
$first = $holder->addRequiredAccess($target);
$second = $holder->addRequiredAccess($target);
$this->assertSame($first->id, $second->id);
$this->assertCount(1, $holder->requiredAccessLinks()->get());
}
public function test_remove_required_access_deletes_pivot_row(): void
{
$holder = Article::create(['title' => 'Holder']);
$target = Article::create(['title' => 'Target']);
$holder->addRequiredAccess($target);
$deleted = $holder->removeRequiredAccess($target);
$this->assertSame(1, $deleted);
$this->assertCount(0, $holder->requiredAccessLinks()->get());
}
public function test_remove_required_access_returns_zero_when_not_linked(): void
{
$holder = Article::create(['title' => 'Holder']);
$other = Article::create(['title' => 'Other']);
$this->assertSame(0, $holder->removeRequiredAccess($other));
}
public function test_sync_required_access_replaces_existing_set(): void
{
$holder = Article::create(['title' => 'Holder']);
$a = Article::create(['title' => 'A']);
$b = Article::create(['title' => 'B']);
$c = Article::create(['title' => 'C']);
$holder->addRequiredAccess($a);
$holder->addRequiredAccess($b);
$holder->syncRequiredAccess([$b, $c]);
$links = $holder->requiredAccessLinks()->get();
// Morph columns store IDs as strings (uuidMorphs); compare as strings.
$ids = $links->pluck('required_id')->map(fn($id) => (string) $id)->all();
$expected = [(string) $b->id, (string) $c->id];
$this->assertEqualsCanonicalizing($expected, $ids);
$this->assertCount(2, $links);
}
public function test_sync_required_access_with_empty_clears_all(): void
{
$holder = Article::create(['title' => 'Holder']);
$a = Article::create(['title' => 'A']);
$holder->addRequiredAccess($a);
$holder->syncRequiredAccess([]);
$this->assertCount(0, $holder->requiredAccessLinks()->get());
}
public function test_required_access_targets_resolves_polymorphic_models(): void
{
$holder = Article::create(['title' => 'Holder']);
$a = Article::create(['title' => 'A']);
$b = Article::create(['title' => 'B']);
$holder->addRequiredAccess($a);
$holder->addRequiredAccess($b);
$targets = $holder->requiredAccessTargets();
$this->assertCount(2, $targets);
$this->assertEqualsCanonicalizing(
[$a->id, $b->id],
$targets->pluck('id')->all(),
);
}
// ─── hasUnlockedRequiredAccessFor — the access check ─────────────
public function test_returns_false_when_entity_is_null(): void
{
$holder = Article::create(['title' => 'Holder']);
$target = Article::create(['title' => 'Target']);
$holder->addRequiredAccess($target);
$this->assertFalse($holder->hasUnlockedRequiredAccessFor(null));
}
public function test_returns_false_when_no_required_access_links_exist(): void
{
$holder = Article::create(['title' => 'Holder']);
$user = User::factory()->create();
$this->assertFalse($holder->hasUnlockedRequiredAccessFor($user));
}
public function test_returns_false_when_user_has_no_access_to_any_target(): void
{
$holder = Article::create(['title' => 'Holder']);
$target = Article::create(['title' => 'Target']);
$holder->addRequiredAccess($target);
$user = User::factory()->create();
$this->assertFalse($holder->hasUnlockedRequiredAccessFor($user));
}
public function test_returns_true_when_user_has_direct_access_to_target(): void
{
$holder = Article::create(['title' => 'Holder']);
$target = Article::create(['title' => 'Target']);
$holder->addRequiredAccess($target);
$user = User::factory()->create();
$user->grantAccess($target);
$this->assertTrue($holder->hasUnlockedRequiredAccessFor($user));
}
public function test_returns_true_when_user_has_role_based_access_to_target(): void
{
$holder = Article::create(['title' => 'Holder']);
$target = Article::create(['title' => 'Target']);
$holder->addRequiredAccess($target);
$role = Role::create(['slug' => 'premium']);
$role->grantAccess($target); // role -> target
$user = User::factory()->create();
$user->assignRole($role);
$this->assertTrue($holder->hasUnlockedRequiredAccessFor($user));
}
public function test_returns_true_when_user_has_permission_based_access_to_target(): void
{
$holder = Article::create(['title' => 'Holder']);
$target = Article::create(['title' => 'Target']);
$holder->addRequiredAccess($target);
$permission = Permission::create(['slug' => 'view-target']);
$permission->grantAccess($target); // permission -> target
$user = User::factory()->create();
$user->assignPermission($permission);
$this->assertTrue($holder->hasUnlockedRequiredAccessFor($user));
}
public function test_returns_true_when_only_one_of_multiple_targets_is_unlocked(): void
{
$holder = Article::create(['title' => 'Holder']);
$a = Article::create(['title' => 'A']);
$b = Article::create(['title' => 'B']);
$c = Article::create(['title' => 'C']);
$holder->syncRequiredAccess([$a, $b, $c]);
$user = User::factory()->create();
$user->grantAccess($b); // only b granted
$this->assertTrue($holder->hasUnlockedRequiredAccessFor($user));
}
public function test_returns_false_when_access_to_target_is_expired(): void
{
$holder = Article::create(['title' => 'Holder']);
$target = Article::create(['title' => 'Target']);
$holder->addRequiredAccess($target);
$user = User::factory()->create();
$user->grantAccess($target, expiresAt: Carbon::now()->subDay());
$this->assertFalse($holder->hasUnlockedRequiredAccessFor($user));
}
public function test_returns_true_when_access_expires_in_the_future(): void
{
$holder = Article::create(['title' => 'Holder']);
$target = Article::create(['title' => 'Target']);
$holder->addRequiredAccess($target);
$user = User::factory()->create();
$user->grantAccess($target, expiresAt: Carbon::now()->addDay());
$this->assertTrue($holder->hasUnlockedRequiredAccessFor($user));
}
public function test_does_not_unlock_holder_via_unrelated_access_grants(): void
{
$holder = Article::create(['title' => 'Holder']);
$required = Article::create(['title' => 'Required']);
$unrelated = Article::create(['title' => 'Unrelated']);
$holder->addRequiredAccess($required);
$user = User::factory()->create();
$user->grantAccess($unrelated); // not the required one
$this->assertFalse($holder->hasUnlockedRequiredAccessFor($user));
}
public function test_other_holders_are_not_unlocked_by_a_user_with_one_unlock(): void
{
$holderA = Article::create(['title' => 'Holder A']);
$holderB = Article::create(['title' => 'Holder B']);
$targetA = Article::create(['title' => 'Target A']);
$targetB = Article::create(['title' => 'Target B']);
$holderA->addRequiredAccess($targetA);
$holderB->addRequiredAccess($targetB);
$user = User::factory()->create();
$user->grantAccess($targetA);
$this->assertTrue($holderA->hasUnlockedRequiredAccessFor($user));
$this->assertFalse($holderB->hasUnlockedRequiredAccessFor($user));
}
// ─── performance ─────────────────────────────────────────────────
/**
* The whole point of the SQL fast-path is that adding more
* required-access targets does not multiply the work. Resolving the
* user's role/permission space has a fixed prelude cost (a few
* lookups), and the lock decision itself is one EXISTS query that
* shape must stay constant regardless of target count.
*/
public function test_unlock_check_query_count_does_not_scale_with_target_count(): void
{
$role = Role::create(['slug' => 'premium']);
$smallHolder = Article::create(['title' => 'Small']);
$smallTargets = collect(range(1, 3))->map(fn($i) => Article::create(['title' => "S{$i}"]));
$smallHolder->syncRequiredAccess($smallTargets);
$role->grantAccess($smallTargets->first());
$largeHolder = Article::create(['title' => 'Large']);
$largeTargets = collect(range(1, 50))->map(fn($i) => Article::create(['title' => "L{$i}"]));
$largeHolder->syncRequiredAccess($largeTargets);
$role->grantAccess($largeTargets->first());
$user = User::factory()->create();
$user->assignRole($role);
$countQueries = function ($holder) use ($user) {
$holder = $holder->fresh();
$u = $user->fresh();
DB::flushQueryLog();
DB::enableQueryLog();
$unlocked = $holder->hasUnlockedRequiredAccessFor($u);
$queries = DB::getQueryLog();
DB::disableQueryLog();
$this->assertTrue($unlocked, 'expected ' . $holder->title . ' unlocked');
return count($queries);
};
$smallCount = $countQueries($smallHolder);
$largeCount = $countQueries($largeHolder);
$this->assertSame(
$smallCount,
$largeCount,
"Query count grew from {$smallCount} (3 targets) to {$largeCount} (50 targets) "
. '— hasUnlockedRequiredAccessFor must stay O(1) in target count.',
);
// Sanity bound: prelude + EXISTS check is small and fixed.
$this->assertLessThanOrEqual(
6,
$largeCount,
'unexpectedly many queries (' . $largeCount . ') for unlock check',
);
}
}