AM access capabilities
This commit is contained in:
parent
477405c6ec
commit
7878069c0b
|
|
@ -8,6 +8,7 @@ return [
|
|||
'permission' => \Blax\Roles\Models\Permission::class,
|
||||
'permission_usage' => \Blax\Roles\Models\PermissionUsage::class,
|
||||
'permission_member' => \Blax\Roles\Models\PermissionMember::class,
|
||||
'access' => \Blax\Roles\Models\Access::class,
|
||||
],
|
||||
|
||||
'table_names' => [
|
||||
|
|
@ -17,6 +18,7 @@ return [
|
|||
'roles' => 'roles',
|
||||
'role_member' => 'role_members',
|
||||
'role_permission' => 'role_permissions',
|
||||
'accesses' => 'accesses',
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
<?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'));
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
<?php
|
||||
|
||||
namespace Blax\Roles\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Access extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'entity_id',
|
||||
'entity_type',
|
||||
'accessible_id',
|
||||
'accessible_type',
|
||||
'context',
|
||||
'expires_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'context' => 'array',
|
||||
'expires_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function __construct(array $attributes = [])
|
||||
{
|
||||
parent::__construct($attributes);
|
||||
|
||||
$this->table = config('roles.table_names.accesses') ?: parent::getTable();
|
||||
}
|
||||
|
||||
/**
|
||||
* The entity that owns this access (User, Role, or Permission).
|
||||
*/
|
||||
public function entity()
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
|
||||
/**
|
||||
* The target model this access grants access to (e.g. Lection, Scenario).
|
||||
*/
|
||||
public function accessible()
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope to only active (non-expired) access entries.
|
||||
*/
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where(function ($q) {
|
||||
$q->whereNull('expires_at')
|
||||
->orWhere('expires_at', '>', now());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope to only expired access entries.
|
||||
*/
|
||||
public function scopeExpired($query)
|
||||
{
|
||||
return $query->where('expires_at', '<=', now());
|
||||
}
|
||||
}
|
||||
|
|
@ -2,10 +2,12 @@
|
|||
|
||||
namespace Blax\Roles\Models;
|
||||
|
||||
use Blax\Roles\Traits\HasAccess;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Permission extends Model
|
||||
{
|
||||
use HasAccess;
|
||||
protected $fillable = [
|
||||
'slug',
|
||||
'description',
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ class RolesServiceProvider extends \Illuminate\Support\ServiceProvider
|
|||
|
||||
$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');
|
||||
}
|
||||
|
||||
|
|
@ -73,5 +74,6 @@ class RolesServiceProvider extends \Illuminate\Support\ServiceProvider
|
|||
$this->app->bind(\Blax\Roles\Models\Permission::class, fn($app) => $app->make($app->config['roles.models.permission']));
|
||||
$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']));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,290 @@
|
|||
<?php
|
||||
|
||||
namespace Blax\Roles\Traits;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
trait HasAccess
|
||||
{
|
||||
/**
|
||||
* Get all access entries directly owned by this entity.
|
||||
*/
|
||||
public function accesses()
|
||||
{
|
||||
return $this->morphMany(config('roles.models.access'), 'entity');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this entity has access to a specific model.
|
||||
*
|
||||
* Resolves through:
|
||||
* 1. Direct access (entity = this model)
|
||||
* 2. Role-based access (entity = any role this model has) — if HasRoles is used
|
||||
* 3. Permission-based access (entity = any permission this model has) — if HasPermissions is used
|
||||
*
|
||||
* @param string|Model $accessible Model instance or class name
|
||||
* @param int|string|null $id Required when $accessible is a class name
|
||||
*/
|
||||
public function hasAccess(string|Model $accessible, int|string|null $id = null): bool
|
||||
{
|
||||
[$accessibleType, $accessibleId] = $this->resolveAccessibleArguments($accessible, $id);
|
||||
|
||||
$accessModel = config('roles.models.access');
|
||||
$table = (new $accessModel)->getTable();
|
||||
|
||||
$query = DB::table($table)
|
||||
->where('accessible_type', $accessibleType)
|
||||
->where('accessible_id', $accessibleId)
|
||||
->where(function ($q) {
|
||||
$q->whereNull('expires_at')
|
||||
->orWhere('expires_at', '>', now());
|
||||
});
|
||||
|
||||
// Build OR conditions for all entity sources
|
||||
$query->where(function ($q) {
|
||||
// 1. Direct access
|
||||
$q->where(function ($sub) {
|
||||
$sub->where('entity_type', $this->getMorphClass())
|
||||
->where('entity_id', $this->getKey());
|
||||
});
|
||||
|
||||
// 2. Via roles (if this model uses HasRoles)
|
||||
$roleIds = $this->resolveAccessRoleIds();
|
||||
if ($roleIds !== null && $roleIds->isNotEmpty()) {
|
||||
$roleMorphClass = (new (config('roles.models.role')))->getMorphClass();
|
||||
$q->orWhere(function ($sub) use ($roleMorphClass, $roleIds) {
|
||||
$sub->where('entity_type', $roleMorphClass)
|
||||
->whereIn('entity_id', $roleIds);
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Via permissions (if this model uses HasPermissions)
|
||||
$permissionIds = $this->resolveAccessPermissionIds();
|
||||
if ($permissionIds !== null && $permissionIds->isNotEmpty()) {
|
||||
$permMorphClass = (new (config('roles.models.permission')))->getMorphClass();
|
||||
$q->orWhere(function ($sub) use ($permMorphClass, $permissionIds) {
|
||||
$sub->where('entity_type', $permMorphClass)
|
||||
->whereIn('entity_id', $permissionIds);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return $query->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* Grant this entity access to a specific model.
|
||||
*
|
||||
* @param Model $accessible The target model
|
||||
* @param array|null $context Optional JSON context
|
||||
* @param Carbon|null $expiresAt Optional expiration
|
||||
* @return Model The created or existing Access entry
|
||||
*/
|
||||
public function grantAccess(Model $accessible, ?array $context = null, ?Carbon $expiresAt = null): Model
|
||||
{
|
||||
$accessModel = config('roles.models.access');
|
||||
|
||||
return $accessModel::firstOrCreate([
|
||||
'entity_type' => $this->getMorphClass(),
|
||||
'entity_id' => $this->getKey(),
|
||||
'accessible_type' => $accessible->getMorphClass(),
|
||||
'accessible_id' => $accessible->getKey(),
|
||||
], [
|
||||
'context' => $context,
|
||||
'expires_at' => $expiresAt,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke this entity's access to a specific model.
|
||||
*
|
||||
* @param string|Model $accessible Model instance or class name
|
||||
* @param int|string|null $id Required when $accessible is a class name
|
||||
* @return int Number of deleted access entries
|
||||
*/
|
||||
public function revokeAccess(string|Model $accessible, int|string|null $id = null): int
|
||||
{
|
||||
[$accessibleType, $accessibleId] = $this->resolveAccessibleArguments($accessible, $id);
|
||||
|
||||
return $this->accesses()
|
||||
->where('accessible_type', $accessibleType)
|
||||
->where('accessible_id', $accessibleId)
|
||||
->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke all direct accesses for this entity, optionally filtered by accessible type.
|
||||
*
|
||||
* @param string|null $accessibleType Optional model class to filter by
|
||||
* @return int Number of deleted access entries
|
||||
*/
|
||||
public function revokeAllAccess(?string $accessibleType = null): int
|
||||
{
|
||||
$query = $this->accesses();
|
||||
|
||||
if ($accessibleType) {
|
||||
$morphClass = (new $accessibleType)->getMorphClass();
|
||||
$query->where('accessible_type', $morphClass);
|
||||
}
|
||||
|
||||
return $query->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active Access entries this entity can access (direct + roles + permissions).
|
||||
*
|
||||
* @param string|null $accessibleType Optional model class to filter by
|
||||
* @return Collection Collection of Access model instances
|
||||
*/
|
||||
public function allAccess(?string $accessibleType = null): Collection
|
||||
{
|
||||
$accessModel = config('roles.models.access');
|
||||
|
||||
$query = $accessModel::query()
|
||||
->active()
|
||||
->where(function ($q) {
|
||||
// Direct
|
||||
$q->where(function ($sub) {
|
||||
$sub->where('entity_type', $this->getMorphClass())
|
||||
->where('entity_id', $this->getKey());
|
||||
});
|
||||
|
||||
// Via roles
|
||||
$roleIds = $this->resolveAccessRoleIds();
|
||||
if ($roleIds !== null && $roleIds->isNotEmpty()) {
|
||||
$roleMorphClass = (new (config('roles.models.role')))->getMorphClass();
|
||||
$q->orWhere(function ($sub) use ($roleMorphClass, $roleIds) {
|
||||
$sub->where('entity_type', $roleMorphClass)
|
||||
->whereIn('entity_id', $roleIds);
|
||||
});
|
||||
}
|
||||
|
||||
// Via permissions
|
||||
$permissionIds = $this->resolveAccessPermissionIds();
|
||||
if ($permissionIds !== null && $permissionIds->isNotEmpty()) {
|
||||
$permMorphClass = (new (config('roles.models.permission')))->getMorphClass();
|
||||
$q->orWhere(function ($sub) use ($permMorphClass, $permissionIds) {
|
||||
$sub->where('entity_type', $permMorphClass)
|
||||
->whereIn('entity_id', $permissionIds);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if ($accessibleType) {
|
||||
$morphClass = (new $accessibleType)->getMorphClass();
|
||||
$query->where('accessible_type', $morphClass);
|
||||
}
|
||||
|
||||
return $query->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all accessible IDs of a specific model type.
|
||||
*
|
||||
* @param string $modelClass The model class to get IDs for
|
||||
* @return Collection Collection of accessible IDs
|
||||
*/
|
||||
public function accessibleIds(string $modelClass): Collection
|
||||
{
|
||||
return $this->allAccess($modelClass)
|
||||
->pluck('accessible_id')
|
||||
->unique()
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync accesses for a specific accessible type.
|
||||
*
|
||||
* Replaces all direct accesses for the given type with the new set.
|
||||
* Only affects accesses owned by THIS entity (not role/permission inherited ones).
|
||||
*
|
||||
* @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
|
||||
*/
|
||||
public function syncAccess(string $accessibleType, array $ids, ?array $context = null, ?Carbon $expiresAt = null): void
|
||||
{
|
||||
$morphClass = (new $accessibleType)->getMorphClass();
|
||||
|
||||
// Remove accesses not in the new set
|
||||
$this->accesses()
|
||||
->where('accessible_type', $morphClass)
|
||||
->whereNotIn('accessible_id', $ids)
|
||||
->delete();
|
||||
|
||||
// Add missing accesses
|
||||
$existing = $this->accesses()
|
||||
->where('accessible_type', $morphClass)
|
||||
->pluck('accessible_id')
|
||||
->toArray();
|
||||
|
||||
$toCreate = array_diff($ids, $existing);
|
||||
|
||||
foreach ($toCreate as $id) {
|
||||
$this->accesses()->create([
|
||||
'accessible_type' => $morphClass,
|
||||
'accessible_id' => $id,
|
||||
'context' => $context,
|
||||
'expires_at' => $expiresAt,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the accessible type and ID from flexible arguments.
|
||||
*
|
||||
* @return array{0: string, 1: int|string}
|
||||
*/
|
||||
protected function resolveAccessibleArguments(string|Model $accessible, int|string|null $id = null): array
|
||||
{
|
||||
if ($accessible instanceof Model) {
|
||||
return [$accessible->getMorphClass(), $accessible->getKey()];
|
||||
}
|
||||
|
||||
// $accessible is a class name string
|
||||
if ($id === null) {
|
||||
throw new \InvalidArgumentException('An ID must be provided when $accessible is a class name.');
|
||||
}
|
||||
|
||||
return [(new $accessible)->getMorphClass(), $id];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get role IDs for resolving access through roles.
|
||||
* Returns null if this model doesn't use roles.
|
||||
*/
|
||||
protected function resolveAccessRoleIds(): ?Collection
|
||||
{
|
||||
if (! method_exists($this, 'roles')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$roleMemberTable = config('roles.table_names.role_member', 'role_members');
|
||||
|
||||
return DB::table($roleMemberTable)
|
||||
->where('member_id', $this->getKey())
|
||||
->where('member_type', $this->getMorphClass())
|
||||
->where(function ($q) {
|
||||
$q->whereNull('expires_at')
|
||||
->orWhere('expires_at', '>', now());
|
||||
})
|
||||
->pluck('role_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get permission IDs for resolving access through permissions.
|
||||
* Returns null if this model doesn't use permissions.
|
||||
*/
|
||||
protected function resolveAccessPermissionIds(): ?Collection
|
||||
{
|
||||
if (! method_exists($this, 'permissions')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->permissions()->pluck('id');
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ use Illuminate\Support\Facades\DB;
|
|||
|
||||
trait HasPermissions
|
||||
{
|
||||
use HasAccess;
|
||||
/**
|
||||
* Check if the entity has a specific permission.
|
||||
*
|
||||
|
|
|
|||
Loading…
Reference in New Issue