feat: Enhance access management with source tracking and revocation

- Added source_id and source_type fields to the Access model to track the origin of access grants.
- Implemented source relationship in the Access model for better access management.
- Introduced revokeBySource method to delete access entries based on their source.
- Updated grantAccess and revokeAccess methods to handle source parameters for more granular control.
- Added RevokesAccessOnDelete trait to automatically revoke access when the source model is deleted.
- Created SourceAccessesRevoked event to notify when access grants are revoked due to source deletion.
- Enhanced tests to cover new source-related functionality and ensure proper behavior during access management.
- Updated RolesServiceProvider to support auto-loading migrations based on configuration.
- Added migration files for creating roles and access tables, including source columns for existing installations.
This commit is contained in:
Fabian @ Blax Software 2026-04-26 09:54:57 +02:00
parent 01cff931bc
commit 20d94caa33
16 changed files with 926 additions and 153 deletions

View File

@ -2,6 +2,23 @@
return [
/*
* Whether the package should auto-run its migrations.
*
* Default: true fresh installs work plug-and-play (composer require +
* php artisan migrate). The package's own migrations live in vendor/ and
* are auto-loaded.
*
* Set to false if you have already published migrations to your project's
* database/migrations directory and want to manage the schema yourself.
* If you publish *and* leave this true, Laravel's migrator will see the
* same filenames in both locations and run each migration once but
* that requires the published filename to match the source filename. If
* you've published with a different timestamp prefix, disable this flag
* to avoid re-runs.
*/
'run_migrations' => true,
'models' => [
'role' => \Blax\Roles\Models\Role::class,
'role_member' => \Blax\Roles\Models\RoleMember::class,

View File

@ -0,0 +1,90 @@
<?php
namespace Blax\Roles\Migrations;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* Idempotent: each Schema::create is gated by Schema::hasTable so running
* after composer-update on a project that already has the tables (via a
* previously published copy of this migration) is a no-op rather than a
* failure. This keeps `php artisan migrate --force` safe to run as part
* of an existing deploy pipeline.
*/
public function up(): void
{
if (! Schema::hasTable(config('roles.table_names.permissions'))) {
Schema::create(config('roles.table_names.permissions'), function (Blueprint $table) {
$table->id();
$table->string('slug')->unique();
$table->string('description')->nullable();
$table->timestamps();
});
}
if (! Schema::hasTable(config('roles.table_names.permission_member'))) {
Schema::create(config('roles.table_names.permission_member'), function (Blueprint $table) {
$table->id();
$table->foreignId('permission_id')->constrained('permissions')->onDelete('cascade');
$table->morphs('member');
$table->json('context')->nullable();
$table->timestamp('expires_at')->nullable();
$table->timestamps();
});
}
if (! Schema::hasTable(config('roles.table_names.permission_usage'))) {
Schema::create(config('roles.table_names.permission_usage'), function (Blueprint $table) {
$table->id();
$table->foreignId('permission_id')->constrained('permissions')->onDelete('cascade');
$table->float('usage', 8)->default(1);
$table->morphs('user');
$table->json('context')->nullable();
$table->timestamps();
});
}
if (! Schema::hasTable(config('roles.table_names.roles'))) {
Schema::create(config('roles.table_names.roles'), function (Blueprint $table) {
$table->id();
$table->foreignId('parent_id')
->nullable()
->constrained('roles')
->onDelete('set null');
$table->string('name')->nullable();
$table->string('slug')->unique();
$table->string('description')->nullable();
$table->timestamps();
});
}
if (! Schema::hasTable(config('roles.table_names.role_member'))) {
Schema::create(config('roles.table_names.role_member'), function (Blueprint $table) {
$table->id();
$table->foreignId('role_id')->constrained('roles')->onDelete('cascade');
$table->morphs('member');
$table->json('context')->nullable();
$table->timestamp('expires_at')->nullable();
$table->timestamps();
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists(config('roles.table_names.role_member'));
Schema::dropIfExists(config('roles.table_names.roles'));
Schema::dropIfExists(config('roles.table_names.permission_usage'));
Schema::dropIfExists(config('roles.table_names.permission_member'));
Schema::dropIfExists(config('roles.table_names.permissions'));
}
};

View File

@ -0,0 +1,50 @@
<?php
namespace Blax\Roles\Migrations;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* Idempotent so it's safe to auto-run on a project that already has the
* accesses table (created by a previously published copy). Source columns
* are added via the separate add_source migration on existing installs.
*/
public function up(): void
{
$table = config('roles.table_names.accesses', 'accesses');
if (Schema::hasTable($table)) {
return;
}
Schema::create($table, function (Blueprint $blueprint) {
$blueprint->id();
$blueprint->morphs('entity'); // Who has the access (User, Role, Permission)
$blueprint->morphs('accessible'); // What they have access to (Lection, Scenario, etc.)
$blueprint->nullableMorphs('source'); // What conferred this access (Subscription, Order, etc.)
$blueprint->json('context')->nullable();
$blueprint->timestamp('expires_at')->nullable();
$blueprint->timestamps();
// No DB-level unique here. SQL engines treat NULL as distinct, so a
// constraint covering source_* would not enforce idempotency for
// null-source rows. Idempotency is handled at the code level
// (updateOrCreate keyed on entity + accessible + source).
$blueprint->index(['entity_type', 'entity_id', 'accessible_type', 'accessible_id'], 'access_lookup');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists(config('roles.table_names.accesses', 'accesses'));
}
};

View File

@ -0,0 +1,92 @@
<?php
namespace Blax\Roles\Migrations;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* Upgrade-path migration: adds `source_type`/`source_id` morph columns to
* the existing accesses table so grants can be tied to a conferring
* source model (Subscription, Order, etc.) and cleaned up when that
* source is removed.
*
* Also drops the old single-row uniqueness constraint, since a single
* (entity, accessible) pair can now have multiple rows distinguished by
* source. Idempotency is enforced at the code level by `grantAccess`.
*
* Idempotent: runs cleanly on installs that already have source columns
* (e.g. a fresh install where the create migration already added them
* via the auto-load order). Safe to re-run.
*/
public function up(): void
{
$table = config('roles.table_names.accesses', 'accesses');
// Drop the old uniqueness in its own statement so a missing index
// (fresh install or already-dropped) doesn't abort the rest. Each
// Blueprint flush is its own transaction-ish unit.
try {
Schema::table($table, function (Blueprint $blueprint) {
$blueprint->dropUnique('access_unique');
});
} catch (\Throwable $e) {
// index doesn't exist — fine.
}
if (! Schema::hasColumn($table, 'source_id')) {
Schema::table($table, function (Blueprint $blueprint) {
$blueprint->nullableMorphs('source');
});
}
try {
Schema::table($table, function (Blueprint $blueprint) {
$blueprint->index(
['entity_type', 'entity_id', 'accessible_type', 'accessible_id'],
'access_lookup'
);
});
} catch (\Throwable $e) {
// index already exists — fine.
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
$table = config('roles.table_names.accesses', 'accesses');
try {
Schema::table($table, function (Blueprint $blueprint) {
$blueprint->dropIndex('access_lookup');
});
} catch (\Throwable $e) {
// ignore
}
if (Schema::hasColumn($table, 'source_id')) {
Schema::table($table, function (Blueprint $blueprint) {
$blueprint->dropMorphs('source');
});
}
try {
Schema::table($table, function (Blueprint $blueprint) {
$blueprint->unique(
['entity_type', 'entity_id', 'accessible_type', 'accessible_id'],
'access_unique'
);
});
} catch (\Throwable $e) {
// ignore
}
}
};

View File

@ -1,36 +0,0 @@
<?php
namespace Blax\Roles\Migrations;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create(config('roles.table_names.accesses', 'accesses'), function (Blueprint $table) {
$table->id();
$table->morphs('entity'); // Who has the access (User, Role, Permission)
$table->morphs('accessible'); // What they have access to (Lection, Scenario, etc.)
$table->json('context')->nullable();
$table->timestamp('expires_at')->nullable();
$table->timestamps();
// Prevent duplicate access entries
$table->unique(['entity_type', 'entity_id', 'accessible_type', 'accessible_id'], 'access_unique');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists(config('roles.table_names.accesses', 'accesses'));
}
};

View File

@ -1,79 +0,0 @@
<?php
namespace Blax\Roles\Migrations;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// Permission
Schema::create(config('roles.table_names.permissions'), function (Blueprint $table) {
$table->id();
$table->string('slug')->unique();
$table->string('description')->nullable();
$table->timestamps();
});
// PermissionMember
Schema::create(config('roles.table_names.permission_member'), function (Blueprint $table) {
$table->id();
$table->foreignId('permission_id')->constrained('permissions')->onDelete('cascade');
$table->morphs('member');
$table->json('context')->nullable();
$table->timestamp('expires_at')->nullable();
$table->timestamps();
});
// PermissionUsage
Schema::create(config('roles.table_names.permission_usage'), function (Blueprint $table) {
$table->id();
$table->foreignId('permission_id')->constrained('permissions')->onDelete('cascade');
$table->float('usage', 8)->default(1);
$table->morphs('user');
$table->json('context')->nullable();
$table->timestamps();
});
// Role
Schema::create(config('roles.table_names.roles'), function (Blueprint $table) {
$table->id();
$table->foreignId('parent_id')
->nullable()
->constrained('roles')
->onDelete('set null');
$table->string('name')->nullable();
$table->string('slug')->unique();
$table->string('description')->nullable();
$table->timestamps();
});
// RoleMember
Schema::create(config('roles.table_names.role_member'), function (Blueprint $table) {
$table->id();
$table->foreignId('role_id')->constrained('roles')->onDelete('cascade');
$table->morphs('member');
$table->json('context')->nullable();
$table->timestamp('expires_at')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists(config('roles.table_names.role_members'));
Schema::dropIfExists(config('roles.table_names.roles'));
Schema::dropIfExists(config('roles.table_names.permission_usage'));
Schema::dropIfExists(config('roles.table_names.permission_member'));
Schema::dropIfExists(config('roles.table_names.permissions'));
}
};

View File

@ -0,0 +1,24 @@
<?php
namespace Blax\Roles\Events;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Events\Dispatchable;
/**
* Fired after a cascade cleanup of access rows tied to a source model
* (e.g. when a subscription is canceled and all its grants are revoked).
*
* Listen to this if you need to log, audit, or notify when grants are
* removed in bulk because their source disappeared.
*/
class SourceAccessesRevoked
{
use Dispatchable;
public function __construct(
public Model $source,
public int $count,
) {
}
}

View File

@ -2,17 +2,21 @@
namespace Blax\Roles\Models;
use Blax\Roles\Events\SourceAccessesRevoked;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
class Access extends Model
{
use HasUuids;
protected $fillable = [
'entity_id',
'entity_type',
'accessible_id',
'accessible_type',
'source_id',
'source_type',
'context',
'expires_at',
];
@ -45,6 +49,15 @@ class Access extends Model
return $this->morphTo();
}
/**
* The model that conferred this access (e.g. Subscription, Order, ProductPrice).
* Null for manual / lifetime grants.
*/
public function source()
{
return $this->morphTo();
}
/**
* Scope to only active (non-expired) access entries.
*/
@ -63,4 +76,34 @@ class Access extends Model
{
return $query->where('expires_at', '<=', now());
}
/**
* Scope to entries conferred by a specific source model.
*/
public function scopeFromSource($query, Model $source)
{
return $query
->where('source_type', $source->getMorphClass())
->where('source_id', $source->getKey());
}
/**
* Delete every access conferred by the given source model.
*
* Use this from an observer / trait / queued listener when the source
* (subscription, order, role membership, ) goes away. Fires the
* SourceAccessesRevoked event so other listeners can react.
*
* @return int Number of deleted access entries
*/
public static function revokeBySource(Model $source): int
{
$count = static::query()->fromSource($source)->delete();
if ($count > 0) {
event(new SourceAccessesRevoked($source, $count));
}
return $count;
}
}

View File

@ -26,9 +26,25 @@ class RolesServiceProvider extends \Illuminate\Support\ServiceProvider
{
$this->offerPublishing();
$this->registerMigrations();
$this->registerModelBindings();
}
/**
* Auto-load the package's migrations so fresh installs work without
* publishing. Disabled via `roles.run_migrations = false` for projects
* that prefer to publish + manage migrations themselves.
*/
protected function registerMigrations(): void
{
if (! config('roles.run_migrations', true)) {
return;
}
$this->loadMigrationsFrom(__DIR__ . '/../database/migrations');
}
/**
* Set up the publishing of configuration files.
*
@ -46,25 +62,17 @@ class RolesServiceProvider extends \Illuminate\Support\ServiceProvider
__DIR__ . '/../config/roles.php' => $this->app->configPath('roles.php'),
], 'roles-config');
$this->publishes([
__DIR__ . '/../database/migrations/create_blax_role_tables.php.stub' => $this->getMigrationFileName('create_blax_role_tables.php'),
__DIR__ . '/../database/migrations/create_blax_access_table.php.stub' => $this->getMigrationFileName('create_blax_access_table.php'),
], 'roles-migrations');
}
/**
* Returns existing migration file if found, else uses the current timestamp.
*/
protected function getMigrationFileName(string $migrationFileName): string
{
$timestamp = date('Y_m_d_His');
$filesystem = $this->app->make(\Illuminate\Filesystem\Filesystem::class);
return \Illuminate\Support\Collection::make([$this->app->databasePath() . DIRECTORY_SEPARATOR . 'migrations' . DIRECTORY_SEPARATOR])
->flatMap(fn($path) => $filesystem->glob($path . '*_' . $migrationFileName))
->push($this->app->databasePath() . "/migrations/{$timestamp}_{$migrationFileName}")
->first();
// Publish migrations to the host project keeping the same filename as
// the source file. That filename is what Laravel's migrator records in
// the `migrations` table, so any migration that has already run via
// auto-load will be marked as run for the published copy too — no
// duplicate execution.
$migrationsPath = __DIR__ . '/../database/migrations';
$publishMap = [];
foreach (glob($migrationsPath . '/*.php') as $sourcePath) {
$publishMap[$sourcePath] = $this->app->databasePath('migrations/' . basename($sourcePath));
}
$this->publishes($publishMap, 'roles-migrations');
}
protected function registerModelBindings(): void

View File

@ -78,17 +78,27 @@ trait HasAccess
/**
* Grant this entity access to a specific model.
*
* Uses updateOrCreate so that re-granting access (e.g., after a renewal
* purchase) refreshes the expires_at and context even when an existing
* (possibly expired) record already exists.
* Idempotent per (entity, accessible, source) tuple re-granting with the
* same source refreshes `expires_at` and `context`. Different sources for
* the same accessible coexist as separate rows, so the holder doesn't lose
* access when one source goes away (e.g. a subscription ends but a lifetime
* purchase remains).
*
* @param Model $accessible The target model
* @param array|null $context Optional JSON context
* @param Carbon|null $expiresAt Optional expiration
* @param Model|null $source Optional source model (Subscription, Order, )
* that conferred this access. When the source
* is removed (see RevokesAccessOnDelete or
* Access::revokeBySource), this row is cleaned up.
* @return Model The created or updated Access entry
*/
public function grantAccess(Model $accessible, ?array $context = null, ?Carbon $expiresAt = null): Model
{
public function grantAccess(
Model $accessible,
?array $context = null,
?Carbon $expiresAt = null,
?Model $source = null,
): Model {
$accessModel = config('roles.models.access');
return $accessModel::updateOrCreate([
@ -96,6 +106,8 @@ trait HasAccess
'entity_id' => $this->getKey(),
'accessible_type' => $accessible->getMorphClass(),
'accessible_id' => $accessible->getKey(),
'source_type' => $source?->getMorphClass(),
'source_id' => $source?->getKey(),
], [
'context' => $context,
'expires_at' => $expiresAt,
@ -105,27 +117,41 @@ trait HasAccess
/**
* Revoke this entity's access to a specific model.
*
* If $source is provided, only the row matching that exact source is
* removed; other source-bound or null-source grants for the same
* accessible are left intact. Pass $source = null (default) to revoke
* every grant for this (entity, accessible).
*
* @param string|Model $accessible Model instance or class name
* @param int|string|null $id Required when $accessible is a class name
* @param Model|null $source Optional source model to scope the revoke to
* @return int Number of deleted access entries
*/
public function revokeAccess(string|Model $accessible, int|string|null $id = null): int
public function revokeAccess(string|Model $accessible, int|string|null $id = null, ?Model $source = null): int
{
[$accessibleType, $accessibleId] = $this->resolveAccessibleArguments($accessible, $id);
return $this->accesses()
$query = $this->accesses()
->where('accessible_type', $accessibleType)
->where('accessible_id', $accessibleId)
->delete();
->where('accessible_id', $accessibleId);
if ($source !== null) {
$query->where('source_type', $source->getMorphClass())
->where('source_id', $source->getKey());
}
return $query->delete();
}
/**
* Revoke all direct accesses for this entity, optionally filtered by accessible type.
* Revoke all direct accesses for this entity, optionally filtered by
* accessible type and/or source.
*
* @param string|null $accessibleType Optional model class to filter by
* @param Model|null $source Optional source model to filter by
* @return int Number of deleted access entries
*/
public function revokeAllAccess(?string $accessibleType = null): int
public function revokeAllAccess(?string $accessibleType = null, ?Model $source = null): int
{
$query = $this->accesses();
@ -134,9 +160,27 @@ trait HasAccess
$query->where('accessible_type', $morphClass);
}
if ($source !== null) {
$query->where('source_type', $source->getMorphClass())
->where('source_id', $source->getKey());
}
return $query->delete();
}
/**
* Revoke every direct access on this entity that was conferred by the
* given source. Useful when a single subscription should be torn down
* while leaving lifetime / manual grants alone.
*/
public function revokeAccessBySource(Model $source): int
{
return $this->accesses()
->where('source_type', $source->getMorphClass())
->where('source_id', $source->getKey())
->delete();
}
/**
* Get all active Access entries this entity can access (direct + roles + permissions).
*
@ -205,24 +249,40 @@ trait HasAccess
* Replaces all direct accesses for the given type with the new set.
* Only affects accesses owned by THIS entity (not role/permission inherited ones).
*
* If $source is provided, the sync is scoped to that source only rows
* with matching source_type/source_id are removed/replaced, leaving rows
* from other sources (or null source) untouched. This lets a subscription
* refresh its grants without clobbering a user's lifetime purchases.
*
* @param string $accessibleType The model class
* @param array $ids Array of model IDs to sync
* @param array|null $context Optional context for new entries
* @param Carbon|null $expiresAt Optional expiration for new entries
* @param Model|null $source Optional source scoping
*/
public function syncAccess(string $accessibleType, array $ids, ?array $context = null, ?Carbon $expiresAt = null): void
{
public function syncAccess(
string $accessibleType,
array $ids,
?array $context = null,
?Carbon $expiresAt = null,
?Model $source = null,
): void {
$morphClass = (new $accessibleType)->getMorphClass();
// Remove accesses not in the new set
$this->accesses()
$scoped = fn($q) => $source
? $q->where('source_type', $source->getMorphClass())
->where('source_id', $source->getKey())
: $q->whereNull('source_type')->whereNull('source_id');
// Remove accesses not in the new set (within this source scope)
$scoped($this->accesses()
->where('accessible_type', $morphClass)
->whereNotIn('accessible_id', $ids)
->whereNotIn('accessible_id', $ids))
->delete();
// Add missing accesses
$existing = $this->accesses()
->where('accessible_type', $morphClass)
// Add missing accesses (within this source scope)
$existing = $scoped($this->accesses()
->where('accessible_type', $morphClass))
->pluck('accessible_id')
->toArray();
@ -232,6 +292,8 @@ trait HasAccess
$this->accesses()->create([
'accessible_type' => $morphClass,
'accessible_id' => $id,
'source_type' => $source?->getMorphClass(),
'source_id' => $source?->getKey(),
'context' => $context,
'expires_at' => $expiresAt,
]);

View File

@ -0,0 +1,45 @@
<?php
namespace Blax\Roles\Traits;
/**
* Apply this trait to any model that can be the source of an access grant
* (Subscription, Order, RoleMember, etc.) and all access rows referencing it
* via the polymorphic `source` columns will be deleted automatically when
* the source model is deleted.
*
* If the model uses Laravel's SoftDeletes you'll get cleanup on hard delete
* by default. Override `revokesAccessOnSoftDelete()` to also clean up on
* soft delete.
*
* Prefer this trait when you want the cleanup wired up implicitly. If you'd
* rather wire it through a Laravel observer or listen for a domain event,
* use `Access::revokeBySource($model)` directly same effect.
*/
trait RevokesAccessOnDelete
{
public static function bootRevokesAccessOnDelete(): void
{
static::deleted(function ($model) {
if (
method_exists($model, 'isForceDeleting')
&& ! $model->isForceDeleting()
&& ! $model->revokesAccessOnSoftDelete()
) {
return;
}
config('roles.models.access', \Blax\Roles\Models\Access::class)::revokeBySource($model);
});
}
/**
* Whether revoking should also fire on soft-delete.
* Defaults to false (only hard deletes cascade) soft-deleted sources
* may legitimately come back.
*/
public function revokesAccessOnSoftDelete(): bool
{
return false;
}
}

View File

@ -0,0 +1,347 @@
<?php
namespace Blax\Roles\Tests\Unit;
use Blax\Roles\Events\SourceAccessesRevoked;
use Blax\Roles\Models\Access;
use Blax\Roles\RolesServiceProvider;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Orchestra\Testbench\TestCase;
use Workbench\App\Models\Article;
use Workbench\App\Models\Subscription;
use Workbench\App\Models\User;
/**
* Coverage for source-tied grants: subscription cascade cleanup, lifetime +
* subscription coexistence, manual grants surviving cascades, sync scoping,
* and renewal idempotency.
*/
class HasAccessSourceTest 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' => '',
]);
// Tests use workbench-specific UUID-aware migrations; disable the
// package's auto-load so the same tables aren't created twice.
$app['config']->set('roles.run_migrations', false);
}
protected function defineDatabaseMigrations(): void
{
$this->loadMigrationsFrom(__DIR__ . '/../../workbench/database/migrations');
}
// ─── grantAccess with source ─────────────────────────────────
public function test_grant_access_records_source(): void
{
$user = User::factory()->create();
$article = Article::create(['title' => 'A']);
$sub = Subscription::create(['name' => 'monthly']);
$access = $user->grantAccess($article, null, null, $sub);
$this->assertEquals($sub->getMorphClass(), $access->source_type);
$this->assertEquals($sub->id, $access->source_id);
}
public function test_grant_access_without_source_leaves_source_null(): void
{
$user = User::factory()->create();
$article = Article::create(['title' => 'Lifetime']);
$access = $user->grantAccess($article);
$this->assertNull($access->source_type);
$this->assertNull($access->source_id);
}
public function test_grant_access_source_relationship_resolves(): void
{
$user = User::factory()->create();
$article = Article::create(['title' => 'A']);
$sub = Subscription::create(['name' => 'm']);
$access = $user->grantAccess($article, null, null, $sub);
$this->assertInstanceOf(Subscription::class, $access->source);
$this->assertEquals($sub->id, $access->source->id);
}
// ─── multiple sources for the same accessible coexist ────────
public function test_lifetime_and_subscription_grants_for_same_accessible_coexist(): void
{
$user = User::factory()->create();
$article = Article::create(['title' => 'Both']);
$sub = Subscription::create(['name' => 'pro']);
$lifetime = $user->grantAccess($article); // null source
$subbed = $user->grantAccess($article, null, now()->addDays(30), $sub);
$this->assertNotEquals($lifetime->id, $subbed->id);
$this->assertEquals(2, $user->accesses()->count());
}
public function test_two_subscription_sources_for_same_accessible_create_two_rows(): void
{
$user = User::factory()->create();
$article = Article::create(['title' => 'Overlap']);
$sub1 = Subscription::create(['name' => 's1']);
$sub2 = Subscription::create(['name' => 's2']);
$a1 = $user->grantAccess($article, null, now()->addDays(30), $sub1);
$a2 = $user->grantAccess($article, null, now()->addDays(30), $sub2);
$this->assertNotEquals($a1->id, $a2->id);
$this->assertEquals(2, $user->accesses()->count());
}
// ─── renewal idempotency ─────────────────────────────────────
public function test_renewing_same_source_updates_existing_row(): void
{
$user = User::factory()->create();
$article = Article::create(['title' => 'Renew']);
$sub = Subscription::create(['name' => 'm']);
$first = $user->grantAccess($article, null, now()->addDays(30), $sub);
$second = $user->grantAccess($article, null, now()->addDays(60), $sub);
$this->assertEquals($first->id, $second->id);
$this->assertEquals(1, $user->accesses()->count());
$this->assertTrue($second->expires_at->greaterThan($first->expires_at->copy()->addDays(20)));
}
public function test_renewal_after_expiry_reactivates_grant(): void
{
$user = User::factory()->create();
$article = Article::create(['title' => 'Reactivate']);
$sub = Subscription::create(['name' => 'm']);
$user->grantAccess($article, null, Carbon::now()->subDay(), $sub);
$this->assertFalse($user->hasAccess($article));
$user->grantAccess($article, null, Carbon::now()->addDays(30), $sub);
$this->assertTrue($user->hasAccess($article));
$this->assertEquals(1, $user->accesses()->count());
}
// ─── source cascade cleanup ──────────────────────────────────
public function test_deleting_source_revokes_subscription_grants_only(): void
{
$user = User::factory()->create();
$articleA = Article::create(['title' => 'A']);
$articleB = Article::create(['title' => 'B']);
$sub = Subscription::create(['name' => 'm']);
$user->grantAccess($articleA); // lifetime — survives
$user->grantAccess($articleA, null, now()->addDays(30), $sub); // subbed
$user->grantAccess($articleB, null, now()->addDays(30), $sub); // subbed
$sub->delete();
$this->assertTrue($user->hasAccess($articleA), 'lifetime grant must survive');
$this->assertFalse($user->hasAccess($articleB), 'subscription-only grant must be revoked');
$this->assertEquals(1, $user->accesses()->count());
}
public function test_cascade_does_not_touch_other_subscriptions(): void
{
$user = User::factory()->create();
$article = Article::create(['title' => 'shared']);
$subA = Subscription::create(['name' => 'a']);
$subB = Subscription::create(['name' => 'b']);
$user->grantAccess($article, null, now()->addDays(30), $subA);
$user->grantAccess($article, null, now()->addDays(30), $subB);
$subA->delete();
$this->assertTrue($user->hasAccess($article));
$this->assertEquals(1, $user->accesses()->count());
}
public function test_cascade_dispatches_event(): void
{
Event::fake([SourceAccessesRevoked::class]);
$user = User::factory()->create();
$article = Article::create(['title' => 'evt']);
$sub = Subscription::create(['name' => 'm']);
$user->grantAccess($article, null, now()->addDays(30), $sub);
$sub->delete();
Event::assertDispatched(
SourceAccessesRevoked::class,
fn(SourceAccessesRevoked $e) => $e->source->is($sub) && $e->count === 1,
);
}
public function test_cascade_event_not_dispatched_when_no_rows_match(): void
{
Event::fake([SourceAccessesRevoked::class]);
$sub = Subscription::create(['name' => 'unused']);
$sub->delete();
Event::assertNotDispatched(SourceAccessesRevoked::class);
}
// ─── static helper / observer / trait parity ─────────────────
public function test_static_revoke_by_source_helper(): void
{
$user = User::factory()->create();
$article = Article::create(['title' => 'S']);
$sub = Subscription::create(['name' => 'm']);
$user->grantAccess($article, null, now()->addDays(30), $sub);
$deleted = Access::revokeBySource($sub);
$this->assertEquals(1, $deleted);
$this->assertFalse($user->hasAccess($article));
}
// ─── revokeAccess scoped by source ───────────────────────────
public function test_revoke_access_with_source_filter_only_removes_that_source(): void
{
$user = User::factory()->create();
$article = Article::create(['title' => 'Targeted']);
$sub = Subscription::create(['name' => 'm']);
$user->grantAccess($article); // lifetime
$user->grantAccess($article, null, now()->addDays(30), $sub);
$user->revokeAccess($article, null, $sub);
$this->assertTrue($user->hasAccess($article));
$this->assertEquals(1, $user->accesses()->count());
}
public function test_revoke_access_without_source_filter_removes_all_for_accessible(): void
{
$user = User::factory()->create();
$article = Article::create(['title' => 'AllOfIt']);
$sub = Subscription::create(['name' => 'm']);
$user->grantAccess($article);
$user->grantAccess($article, null, now()->addDays(30), $sub);
$deleted = $user->revokeAccess($article);
$this->assertEquals(2, $deleted);
$this->assertFalse($user->hasAccess($article));
}
public function test_revoke_access_by_source_method(): void
{
$user = User::factory()->create();
$a1 = Article::create(['title' => 'X']);
$a2 = Article::create(['title' => 'Y']);
$sub = Subscription::create(['name' => 'm']);
$user->grantAccess($a1); // lifetime — survives
$user->grantAccess($a1, null, now()->addDays(30), $sub);
$user->grantAccess($a2, null, now()->addDays(30), $sub);
$deleted = $user->revokeAccessBySource($sub);
$this->assertEquals(2, $deleted);
$this->assertTrue($user->hasAccess($a1)); // via lifetime
$this->assertFalse($user->hasAccess($a2));
}
// ─── revokeAllAccess source filter ───────────────────────────
public function test_revoke_all_access_filtered_by_source(): void
{
$user = User::factory()->create();
$a1 = Article::create(['title' => 'Filt1']);
$a2 = Article::create(['title' => 'Filt2']);
$sub = Subscription::create(['name' => 'm']);
$user->grantAccess($a1);
$user->grantAccess($a1, null, now()->addDays(30), $sub);
$user->grantAccess($a2, null, now()->addDays(30), $sub);
$deleted = $user->revokeAllAccess(Article::class, $sub);
$this->assertEquals(2, $deleted);
$this->assertTrue($user->hasAccess($a1)); // lifetime survives
$this->assertFalse($user->hasAccess($a2));
}
// ─── syncAccess source scoping ───────────────────────────────
public function test_sync_access_scoped_to_source_does_not_touch_lifetime(): void
{
$user = User::factory()->create();
$a1 = Article::create(['title' => 'L1']);
$a2 = Article::create(['title' => 'S1']);
$a3 = Article::create(['title' => 'S2']);
$sub = Subscription::create(['name' => 'm']);
$user->grantAccess($a1); // lifetime
$user->grantAccess($a2, null, now()->addDays(30), $sub); // initial sub grant
// Subscription's bundle changes from [a2] to [a3]
$user->syncAccess(Article::class, [$a3->id], null, now()->addDays(30), $sub);
$this->assertTrue($user->hasAccess($a1), 'lifetime untouched');
$this->assertFalse($user->hasAccess($a2), 'old sub grant removed');
$this->assertTrue($user->hasAccess($a3), 'new sub grant added');
}
public function test_sync_access_without_source_only_touches_null_source_rows(): void
{
$user = User::factory()->create();
$a1 = Article::create(['title' => 'M1']);
$a2 = Article::create(['title' => 'M2']);
$sub = Subscription::create(['name' => 'm']);
$user->grantAccess($a1); // null source
$user->grantAccess($a2, null, now()->addDays(30), $sub); // sub source
$user->syncAccess(Article::class, []); // wipe null-source grants
$this->assertFalse($user->hasAccess($a1));
$this->assertTrue($user->hasAccess($a2), 'sub-source grant must be untouched');
}
// ─── scope helpers on Access ─────────────────────────────────
public function test_from_source_scope_filters_correctly(): void
{
$user = User::factory()->create();
$article = Article::create(['title' => 'Scope']);
$sub = Subscription::create(['name' => 'm']);
$user->grantAccess($article);
$user->grantAccess($article, null, now()->addDays(30), $sub);
$rows = Access::query()->fromSource($sub)->get();
$this->assertCount(1, $rows);
$this->assertEquals($sub->id, $rows->first()->source_id);
}
}

View File

@ -30,6 +30,9 @@ class HasAccessTest extends TestCase
'database' => ':memory:',
'prefix' => '',
]);
// Tests use workbench-specific UUID-aware migrations; disable the
// package's auto-load so the same tables aren't created twice.
$app['config']->set('roles.run_migrations', false);
}
protected function defineDatabaseMigrations(): void
@ -228,6 +231,7 @@ class HasAccessTest extends TestCase
// Manually insert expired role membership
DB::table(config('roles.table_names.role_member'))->insert([
'id' => (string) \Illuminate\Support\Str::uuid(),
'role_id' => $role->id,
'member_id' => $user->id,
'member_type' => $user->getMorphClass(),

View File

@ -27,6 +27,7 @@ class HasPermissionsTest extends TestCase
'database' => ':memory:',
'prefix' => '',
]);
$app['config']->set('roles.run_migrations', false);
}
protected function defineDatabaseMigrations(): void

View File

@ -27,6 +27,7 @@ class HasRolesTest extends TestCase
'database' => ':memory:',
'prefix' => '',
]);
$app['config']->set('roles.run_migrations', false);
}
protected function defineDatabaseMigrations(): void

View File

@ -0,0 +1,104 @@
<?php
namespace Blax\Roles\Tests\Unit;
use Blax\Roles\RolesServiceProvider;
use Illuminate\Support\Facades\Schema;
use Orchestra\Testbench\TestCase;
/**
* Verifies the plug-and-play migration story:
* - With `roles.run_migrations = true` (default), the package's own
* migrations auto-run on `migrate`, creating every expected table.
* - With the flag flipped to false, no tables are created the host project
* is in charge of publishing + running them.
*/
class MigrationAutoLoadTest extends TestCase
{
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' => '',
]);
}
public function test_auto_load_creates_all_tables_on_migrate(): void
{
config()->set('roles.run_migrations', true);
$this->artisan('migrate')->assertExitCode(0);
$this->assertTrue(Schema::hasTable('roles'));
$this->assertTrue(Schema::hasTable('role_members'));
$this->assertTrue(Schema::hasTable('permissions'));
$this->assertTrue(Schema::hasTable('permission_members'));
$this->assertTrue(Schema::hasTable('permission_usages'));
$this->assertTrue(Schema::hasTable('accesses'));
}
public function test_auto_load_includes_source_columns_on_accesses(): void
{
config()->set('roles.run_migrations', true);
$this->artisan('migrate')->assertExitCode(0);
$this->assertTrue(Schema::hasColumn('accesses', 'source_type'));
$this->assertTrue(Schema::hasColumn('accesses', 'source_id'));
}
/**
* Production upgrade simulation: project already has the legacy access
* table (no source columns) from a previously published create migration.
* The new package version is composer-updated and `php artisan migrate`
* runs in the deploy pipeline. The auto-load create migrations must
* no-op (idempotent) and the add_source migration must add the columns.
*/
public function test_upgrade_with_existing_tables_runs_cleanly(): void
{
config()->set('roles.run_migrations', true);
// Pre-create the old-style accesses table (no source columns) and
// the role tables, simulating a project that ran a previously
// published copy of these migrations.
\Illuminate\Support\Facades\Schema::create('permissions', function ($t) {
$t->id();
$t->string('slug')->unique();
$t->timestamps();
});
\Illuminate\Support\Facades\Schema::create('roles', function ($t) {
$t->id();
$t->string('slug')->unique();
$t->timestamps();
});
\Illuminate\Support\Facades\Schema::create('accesses', function ($t) {
$t->id();
$t->morphs('entity');
$t->morphs('accessible');
$t->json('context')->nullable();
$t->timestamp('expires_at')->nullable();
$t->timestamps();
$t->unique(['entity_type', 'entity_id', 'accessible_type', 'accessible_id'], 'access_unique');
});
// The deploy pipeline's `php artisan migrate --force` step:
$this->artisan('migrate', ['--force' => true])->assertExitCode(0);
// Old-style accesses table now has source columns added.
$this->assertTrue(Schema::hasColumn('accesses', 'source_id'));
$this->assertTrue(Schema::hasColumn('accesses', 'source_type'));
}
// Note: the disabled-flag case (run_migrations = false) is exercised by
// every other test in this suite — they all set the flag false in
// defineEnvironment and rely on workbench migrations instead. If the
// package were auto-loading despite the flag, those tests would error on
// "table already exists" because workbench creates the same tables.
}