feat: assignRole context + expiry, roleAssignment(), fillable morph keys

- assignRole() takes optional $context and $expiresAt, stored on the pivot
  row. Called without them it behaves exactly as before.
- roleAssignment($role) returns the member's active pivot row (context,
  expires_at, created_at = when it was given), or null.
- RoleMember $fillable gains member_type and member_id. RoleMember::create()
  used to drop them silently, so a direct row for a model without HasRoles
  could not be written.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Fabian @ Blax Software 2026-10-06 11:48:00 +02:00
parent 5f25ad2bb0
commit 7078bc86ea
3 changed files with 124 additions and 2 deletions

View File

@ -13,6 +13,9 @@ class RoleMember extends MorphPivot
protected $fillable = [ protected $fillable = [
'role_id', 'role_id',
'member', 'member',
// The morph columns: without them RoleMember::create() silently dropped the member.
'member_type',
'member_id',
'context', 'context',
'expires_at', 'expires_at',
'created_at', 'created_at',

View File

@ -69,10 +69,12 @@ trait HasRoles
* Assigns the role to the memberable * Assigns the role to the memberable
* *
* @param int|string|Role $role * @param int|string|Role $role
* @param array $context Stored on the pivot row (e.g. who assigned it and why). Read back with roleAssignment().
* @param \DateTimeInterface|null $expiresAt The assignment stops counting after this moment; null never expires.
* *
* @return $this * @return $this
*/ */
public function assignRole(string|Role $role, int $max_times = 1) public function assignRole(string|Role $role, int $max_times = 1, array $context = [], ?\DateTimeInterface $expiresAt = null)
{ {
if (self::isRoleIdString($role)) { if (self::isRoleIdString($role)) {
$role = config('roles.models.role', \Blax\Roles\Models\Role::class)::find($role); $role = config('roles.models.role', \Blax\Roles\Models\Role::class)::find($role);
@ -91,7 +93,11 @@ trait HasRoles
} }
if ($role instanceof Role) { if ($role instanceof Role) {
$this->roles()->attach($role); $pivot = array_filter([
'context' => $context ?: null,
'expires_at' => $expiresAt,
], fn ($value) => $value !== null);
$this->roles()->attach($role, $pivot);
} else { } else {
throw new \InvalidArgumentException('Role must be a string, numeric ID, or an instance of Role.'); throw new \InvalidArgumentException('Role must be a string, numeric ID, or an instance of Role.');
} }
@ -99,6 +105,37 @@ trait HasRoles
return $this; return $this;
} }
/**
* The member's active assignment of a role: the pivot row with its context,
* expires_at and created_at (when it was given). Null when the member doesn't
* hold the role or the assignment has expired. With several active rows
* (assignRole with $max_times > 1) it returns the newest.
*
* @param int|string|Role $role A role id, a slug, or a Role
*/
public function roleAssignment(string|Role $role): ?\Blax\Roles\Models\RoleMember
{
$roleModel = config('roles.models.role', \Blax\Roles\Models\Role::class);
if (self::isRoleIdString($role)) {
$role = $roleModel::find($role);
} elseif (is_string($role)) {
$role = $roleModel::where('slug', $role)->first();
}
if (! $role) {
return null;
}
$roleMemberModel = config('roles.models.role_member', \Blax\Roles\Models\RoleMember::class);
return $roleMemberModel::query()
->where('role_id', $role->id)
->where('member_type', $this->getMorphClass())
->where('member_id', (string) $this->getKey())
->latest()
->first();
}
/** /**
* Removes the role from the memberable * Removes the role from the memberable
* *

View File

@ -4,6 +4,7 @@ namespace Blax\Roles\Tests\Unit;
use Blax\Roles\Models\Permission; use Blax\Roles\Models\Permission;
use Blax\Roles\Models\Role; use Blax\Roles\Models\Role;
use Blax\Roles\Models\RoleMember;
use Blax\Roles\RolesServiceProvider; use Blax\Roles\RolesServiceProvider;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@ -642,4 +643,85 @@ class HasRolesTest extends TestCase
$this->assertNotNull($membership->expires_at); $this->assertNotNull($membership->expires_at);
} }
// ─── assignRole context + expiry, roleAssignment ─────────────
public function test_assign_role_stores_context_and_expiry(): void
{
$user = User::factory()->create();
$role = Role::create(['name' => 'Muted', 'slug' => 'muted']);
$until = now()->addDays(3)->startOfSecond();
$user->assignRole($role, context: ['by' => 'admin:1', 'reason' => 'spam'], expiresAt: $until);
$assignment = $user->roleAssignment('muted');
$this->assertSame(['by' => 'admin:1', 'reason' => 'spam'], $assignment->context);
$this->assertTrue($assignment->expires_at->equalTo($until));
$this->assertTrue($user->hasRole('muted'));
}
public function test_assign_role_without_context_keeps_the_pivot_fields_empty(): void
{
$user = User::factory()->create();
$role = Role::create(['name' => 'Plain', 'slug' => 'plain']);
$user->assignRole($role);
$row = DB::table(config('roles.table_names.role_member'))->where('role_id', $role->id)->first();
$this->assertNull($row->context);
$this->assertNull($row->expires_at);
}
public function test_assign_role_with_context_still_respects_max_times(): void
{
$user = User::factory()->create();
$role = Role::create(['name' => 'Once', 'slug' => 'once']);
$user->assignRole($role, context: ['n' => 1]);
$user->assignRole($role, context: ['n' => 2]);
$this->assertSame(1, DB::table(config('roles.table_names.role_member'))->where('role_id', $role->id)->count());
$this->assertSame(['n' => 1], $user->roleAssignment($role)->context);
}
public function test_role_assignment_tells_when_the_role_was_given(): void
{
$this->travelTo(now()->subHour());
$user = User::factory()->create();
$role = Role::create(['name' => 'Since', 'slug' => 'since']);
$user->assignRole($role);
$given = now()->startOfSecond();
$this->travelBack();
$this->assertTrue($user->roleAssignment($role->id)->created_at->equalTo($given));
}
public function test_role_assignment_is_null_when_not_held_or_expired(): void
{
$user = User::factory()->create();
$role = Role::create(['name' => 'Gone', 'slug' => 'gone']);
$this->assertNull($user->roleAssignment('gone'));
$this->assertNull($user->roleAssignment('no-such-role'));
$user->assignRole($role, expiresAt: now()->subMinute());
$this->assertNull($user->roleAssignment('gone'));
$this->assertFalse($user->hasRole('gone'));
}
public function test_role_member_can_be_created_for_any_model(): void
{
$user = User::factory()->create();
$role = Role::create(['name' => 'Direct', 'slug' => 'direct']);
RoleMember::create([
'role_id' => $role->id,
'member_type' => $user->getMorphClass(),
'member_id' => (string) $user->id,
'context' => ['source' => 'direct'],
]);
$this->assertTrue($user->hasRole('direct'));
$this->assertSame(['source' => 'direct'], $user->roleAssignment('direct')->context);
}
} }