From 2ca17ba91493ab83fae6f316935b870eea191dd9 Mon Sep 17 00:00:00 2001 From: "Fabian @ Blax Software" Date: Mon, 27 Apr 2026 12:35:09 +0200 Subject: [PATCH] feat: add HasRequiredAccess trait for OR-combined entity dependencies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- config/roles.php | 2 + ..._000001_create_required_accesses_table.php | 48 +++ src/Models/RequiredAccess.php | 50 +++ src/RolesServiceProvider.php | 1 + src/Traits/HasAccess.php | 10 +- src/Traits/HasRequiredAccess.php | 198 ++++++++++ tests/Unit/HasRequiredAccessTest.php | 340 ++++++++++++++++++ 7 files changed, 647 insertions(+), 2 deletions(-) create mode 100644 database/migrations/2026_04_27_000001_create_required_accesses_table.php create mode 100644 src/Models/RequiredAccess.php create mode 100644 src/Traits/HasRequiredAccess.php create mode 100644 tests/Unit/HasRequiredAccessTest.php diff --git a/config/roles.php b/config/roles.php index 3c400d9..fdb27bd 100644 --- a/config/roles.php +++ b/config/roles.php @@ -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', ], ]; diff --git a/database/migrations/2026_04_27_000001_create_required_accesses_table.php b/database/migrations/2026_04_27_000001_create_required_accesses_table.php new file mode 100644 index 0000000..be71230 --- /dev/null +++ b/database/migrations/2026_04_27_000001_create_required_accesses_table.php @@ -0,0 +1,48 @@ +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')); + } +}; diff --git a/src/Models/RequiredAccess.php b/src/Models/RequiredAccess.php new file mode 100644 index 0000000..8f66468 --- /dev/null +++ b/src/Models/RequiredAccess.php @@ -0,0 +1,50 @@ +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(); + } +} diff --git a/src/RolesServiceProvider.php b/src/RolesServiceProvider.php index 4e6599c..c55149c 100644 --- a/src/RolesServiceProvider.php +++ b/src/RolesServiceProvider.php @@ -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'])); } } diff --git a/src/Traits/HasAccess.php b/src/Traits/HasAccess.php index 6a2bf9e..f567a07 100644 --- a/src/Traits/HasAccess.php +++ b/src/Traits/HasAccess.php @@ -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; diff --git a/src/Traits/HasRequiredAccess.php b/src/Traits/HasRequiredAccess.php new file mode 100644 index 0000000..872a401 --- /dev/null +++ b/src/Traits/HasRequiredAccess.php @@ -0,0 +1,198 @@ +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 $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(); + } +} diff --git a/tests/Unit/HasRequiredAccessTest.php b/tests/Unit/HasRequiredAccessTest.php new file mode 100644 index 0000000..e2f6277 --- /dev/null +++ b/tests/Unit/HasRequiredAccessTest.php @@ -0,0 +1,340 @@ +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', + ); + } +}