From 7878069c0b8168d6f37261c1432f31aece50e47c Mon Sep 17 00:00:00 2001 From: "Fabian @ Blax Software" Date: Mon, 23 Feb 2026 11:16:27 +0100 Subject: [PATCH] AM access capabilities --- config/roles.php | 2 + .../create_blax_access_table.php.stub | 36 +++ src/Models/Access.php | 64 ++++ src/Models/Permission.php | 2 + src/RolesServiceProvider.php | 2 + src/Traits/HasAccess.php | 290 ++++++++++++++++++ src/Traits/HasPermissions.php | 1 + 7 files changed, 397 insertions(+) create mode 100644 database/migrations/create_blax_access_table.php.stub create mode 100644 src/Models/Access.php create mode 100644 src/Traits/HasAccess.php diff --git a/config/roles.php b/config/roles.php index 2c1f515..e98951a 100644 --- a/config/roles.php +++ b/config/roles.php @@ -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', ], ]; diff --git a/database/migrations/create_blax_access_table.php.stub b/database/migrations/create_blax_access_table.php.stub new file mode 100644 index 0000000..78b474f --- /dev/null +++ b/database/migrations/create_blax_access_table.php.stub @@ -0,0 +1,36 @@ +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')); + } +}; diff --git a/src/Models/Access.php b/src/Models/Access.php new file mode 100644 index 0000000..aeaba98 --- /dev/null +++ b/src/Models/Access.php @@ -0,0 +1,64 @@ + '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()); + } +} diff --git a/src/Models/Permission.php b/src/Models/Permission.php index 952fc89..77fa921 100644 --- a/src/Models/Permission.php +++ b/src/Models/Permission.php @@ -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', diff --git a/src/RolesServiceProvider.php b/src/RolesServiceProvider.php index ed858da..99c80c9 100644 --- a/src/RolesServiceProvider.php +++ b/src/RolesServiceProvider.php @@ -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'])); } } diff --git a/src/Traits/HasAccess.php b/src/Traits/HasAccess.php new file mode 100644 index 0000000..683efdd --- /dev/null +++ b/src/Traits/HasAccess.php @@ -0,0 +1,290 @@ +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'); + } +} diff --git a/src/Traits/HasPermissions.php b/src/Traits/HasPermissions.php index 16a2845..b4c24fd 100644 --- a/src/Traits/HasPermissions.php +++ b/src/Traits/HasPermissions.php @@ -7,6 +7,7 @@ use Illuminate\Support\Facades\DB; trait HasPermissions { + use HasAccess; /** * Check if the entity has a specific permission. *