From 2ba9ae154252b43477a758f523cbf315345fa1d4 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 28 Aug 2024 17:19:14 +0800 Subject: [PATCH] improved IAM controls and directives --- ...service_column_to_authorization_tables.php | 46 +++ ...4_08_27_090558_create_directives_table.php | 35 +++ src/Auth/Schemas/Developers.php | 50 ++++ src/Auth/Schemas/IAM.php | 56 ++++ src/Console/Commands/CreatePermissions.php | 261 +++++++++++++++++- src/Console/Commands/ForceResetDatabase.php | 60 ++++ src/Expansions/Builder.php | 42 +++ src/Expansions/Route.php | 1 + .../Internal/v1/AuthController.php | 17 ++ .../Internal/v1/OnboardController.php | 3 + src/Http/Filter/PolicyFilter.php | 17 ++ src/Http/Filter/RoleFilter.php | 23 +- src/Http/Filter/UserFilter.php | 7 + src/Http/Resources/Policy.php | 24 +- src/Http/Resources/Role.php | 23 +- src/Models/Directive.php | 91 ++++++ src/Models/Policy.php | 2 +- src/Models/Role.php | 37 +++ src/Models/User.php | 24 +- src/Providers/CoreServiceProvider.php | 1 + src/Support/Auth.php | 101 +++++++ src/Support/DirectiveParser.php | 81 ++++++ src/Support/QueryOptimizer.php | 221 +++++++++++++++ src/Traits/HasApiControllerBehavior.php | 6 +- src/Traits/HasApiModelBehavior.php | 49 +++- src/Traits/HasPolicies.php | 42 +++ 26 files changed, 1275 insertions(+), 45 deletions(-) create mode 100644 migrations/2024_08_27_063135_add_service_column_to_authorization_tables.php create mode 100644 migrations/2024_08_27_090558_create_directives_table.php create mode 100644 src/Console/Commands/ForceResetDatabase.php create mode 100644 src/Models/Directive.php create mode 100644 src/Support/DirectiveParser.php create mode 100644 src/Support/QueryOptimizer.php diff --git a/migrations/2024_08_27_063135_add_service_column_to_authorization_tables.php b/migrations/2024_08_27_063135_add_service_column_to_authorization_tables.php new file mode 100644 index 0000000..5ea79bf --- /dev/null +++ b/migrations/2024_08_27_063135_add_service_column_to_authorization_tables.php @@ -0,0 +1,46 @@ +string('service')->nullable()->index()->after('guard_name'); + }); + + Schema::table('policies', function (Blueprint $table) { + $table->string('service')->nullable()->index()->after('guard_name'); + }); + + Schema::table('roles', function (Blueprint $table) { + $table->string('service')->nullable()->index()->after('guard_name'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('permissions', function (Blueprint $table) { + $table->dropIndex(['service']); + $table->dropColumn('service'); + }); + + Schema::table('policies', function (Blueprint $table) { + $table->dropIndex(['service']); + $table->dropColumn('service'); + }); + + Schema::table('roles', function (Blueprint $table) { + $table->dropIndex(['service']); + $table->dropColumn('service'); + }); + } +}; diff --git a/migrations/2024_08_27_090558_create_directives_table.php b/migrations/2024_08_27_090558_create_directives_table.php new file mode 100644 index 0000000..53f64bc --- /dev/null +++ b/migrations/2024_08_27_090558_create_directives_table.php @@ -0,0 +1,35 @@ +increments('id'); + $table->uuid('uuid')->nullable()->index(); + $table->foreignUuid('company_uuid')->nullable()->index()->references('uuid')->on('companies'); + $table->foreignUuid('permission_uuid')->nullable()->index()->references('id')->on('permissions'); + $table->string('subject_type')->nullable(); + $table->uuid('subject_uuid')->nullable(); + $table->index(['subject_type', 'subject_uuid']); + $table->mediumText('key')->nullable(); + $table->json('rules')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('directives'); + } +}; diff --git a/src/Auth/Schemas/Developers.php b/src/Auth/Schemas/Developers.php index ccf32c8..60cc2e0 100644 --- a/src/Auth/Schemas/Developers.php +++ b/src/Auth/Schemas/Developers.php @@ -47,4 +47,54 @@ class Developers 'remove_actions' => ['create', 'update', 'delete'], ], ]; + + /** + * Policies provided by this schema. + */ + public array $policies = [ + [ + 'name' => 'FLBDeveloper', + 'description' => 'Policy for developers to create api credentials, webhooks and view logs.', + 'permissions' => [ + 'view extension', + '* api-key', + '* webhook', + '* event', + '* log', + '* socket', + ], + ], + [ + 'name' => 'FLBDevProjectManager', + 'description' => 'Policy for view and read access to development resources.', + 'permissions' => [ + 'view extension', + 'see api-key', + 'list api-key', + 'view api-key', + 'see webhook', + 'list webhook', + 'view webhook', + 'see event', + 'list event', + 'view event', + 'see log', + 'list log', + 'view log', + ], + ], + ]; + + /** + * Roles provided by this schema. + */ + public array $roles = [ + [ + 'name' => 'Fleetbase Developer', + 'description' => 'Role for developers to create api credentials, webhooks and view real time events and logs.', + 'policies' => [ + 'FLBDeveloper', + ], + ], + ]; } diff --git a/src/Auth/Schemas/IAM.php b/src/Auth/Schemas/IAM.php index c70e1a3..b794c1b 100644 --- a/src/Auth/Schemas/IAM.php +++ b/src/Auth/Schemas/IAM.php @@ -45,4 +45,60 @@ class IAM 'actions' => ['export'], ], ]; + + /** + * Policies provided by this schema. + */ + public array $policies = [ + [ + 'name' => 'UserManager', + 'description' => 'Policy for managing users, roles and groups.', + 'permissions' => [ + 'see extension', + '* user', + '* role', + '* group', + ], + ], + [ + 'name' => 'PolicyManager', + 'description' => 'Policy for managing policies and roles.', + 'permissions' => [ + 'see extension', + '* policy', + '* role', + ], + ], + ]; + + /** + * Roles provided by this schema. + */ + public array $roles = [ + [ + 'name' => 'IAM User Manager', + 'description' => 'Role for managing users, roles, and groups.', + 'policies' => [ + 'UserManager', + ], + ], + [ + 'name' => 'IAM Policy Manager', + 'description' => 'Role for managing users, roles, and groups.', + 'policies' => [ + 'PolicyManager', + ], + ], + [ + 'name' => 'IAM Administrator', + 'description' => 'Role for managing all users, roles, groups and policies.', + 'permissions' => [ + 'see extension', + '* user', + '* group', + '* role', + '* policy', + ], + ], + ]; } diff --git a/src/Console/Commands/CreatePermissions.php b/src/Console/Commands/CreatePermissions.php index e64f3e9..eb9c2f0 100644 --- a/src/Console/Commands/CreatePermissions.php +++ b/src/Console/Commands/CreatePermissions.php @@ -2,14 +2,19 @@ namespace Fleetbase\Console\Commands; +use Fleetbase\Models\Directive; use Fleetbase\Models\Permission; use Fleetbase\Models\Policy; +use Fleetbase\Models\Role; use Fleetbase\Support\Utils; use Illuminate\Console\Command; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; use Illuminate\Support\Str; use Spatie\Permission\Exceptions\GuardDoesNotMatch; +use Spatie\Permission\Exceptions\PermissionDoesNotExist; class CreatePermissions extends Command { @@ -51,14 +56,21 @@ public function handle() foreach ($schemas as $schema) { $service = $schema->name; $resources = $schema->resources ?? []; - $permissions = $schema->permissions ?? null; + $permissions = $schema->permissions ?? []; + $policies = $schema->policies ?? []; + $roles = $schema->roles ?? []; $guard = 'sanctum'; // Add visibility permission for service - $visibilityPermission = Permission::firstOrCreate( + $visibilityPermission = Permission::updateOrCreate( + [ + 'name' => $service . ' see extension', + 'guard_name' => $guard, + ], [ 'name' => $service . ' see extension', 'guard_name' => $guard, + 'service' => $service, ] ); @@ -66,11 +78,16 @@ public function handle() $this->info('Created permission: ' . $visibilityPermission->name); // First create a wilcard permission for the entire schema - $administratorPolicy = Policy::firstOrCreate( + $administratorPolicy = Policy::updateOrCreate( + [ + 'name' => 'AdministratorAccess', + 'guard_name' => $guard, + ], [ 'name' => 'AdministratorAccess', 'guard_name' => $guard, - 'description' => 'Provides full access to Fleetbase extensions and resources.', + 'description' => 'Policy for full access to Fleetbase extensions and resources.', + 'service' => $service, ] ); @@ -78,7 +95,7 @@ public function handle() $administratorPolicy->givePermissionTo($visibilityPermission); // Create wildcard permission for service - $permission = Permission::firstOrCreate( + $permission = Permission::updateOrCreate( [ 'name' => $service . ' *', 'guard_name' => $guard, @@ -86,6 +103,7 @@ public function handle() [ 'name' => $service . ' *', 'guard_name' => $guard, + 'service' => $service, ] ); @@ -102,7 +120,7 @@ public function handle() // Check if schema has direct permissions to add if (is_array($permissions)) { foreach ($permissions as $action) { - $permission = Permission::firstOrCreate( + $permission = Permission::updateOrCreate( [ 'name' => $service . ' ' . $action, 'guard_name' => $guard, @@ -110,6 +128,7 @@ public function handle() [ 'name' => $service . ' ' . $action, 'guard_name' => $guard, + 'service' => $service, ] ); @@ -126,15 +145,16 @@ public function handle() } // Create a resource policy for full access - $fullAccessPolicy = Policy::firstOrCreate( + $fullAccessPolicy = Policy::updateOrCreate( [ 'name' => Str::studly(data_get($schema, 'policyName')) . 'FullAccess', 'guard_name' => $guard, ], [ 'name' => Str::studly(data_get($schema, 'policyName')) . 'FullAccess', - 'description' => 'Provides full access to ' . Str::studly(data_get($schema, 'policyName')) . '.', + 'description' => 'Policy for full access to ' . Str::studly(data_get($schema, 'policyName')) . '.', 'guard_name' => $guard, + 'service' => $service, ] ); @@ -142,15 +162,16 @@ public function handle() $fullAccessPolicy->givePermissionTo($visibilityPermission); // Create a resource policy for read-only access - $readOnlyPolicy = Policy::firstOrCreate( + $readOnlyPolicy = Policy::updateOrCreate( [ 'name' => Str::studly(data_get($schema, 'policyName')) . 'ReadOnly', 'guard_name' => $guard, ], [ 'name' => Str::studly(data_get($schema, 'policyName')) . 'ReadOnly', - 'description' => 'Provides read-only access to ' . Str::studly(data_get($schema, 'policyName')) . '.', + 'description' => 'Policy for read-only access to ' . Str::studly(data_get($schema, 'policyName')) . '.', 'guard_name' => $guard, + 'service' => $service, ] ); @@ -159,7 +180,7 @@ public function handle() // Create wilcard permission for service and all resources foreach ($resources as $resource) { - $permission = Permission::firstOrCreate( + $permission = Permission::updateOrCreate( [ 'name' => $service . ' * ' . data_get($resource, 'name'), 'guard_name' => $guard, @@ -167,6 +188,7 @@ public function handle() [ 'name' => $service . ' * ' . data_get($resource, 'name'), 'guard_name' => $guard, + 'service' => $service, ] ); @@ -194,7 +216,7 @@ public function handle() // Create action permissions foreach ($resourceActions as $action) { - $permission = Permission::firstOrCreate( + $permission = Permission::updateOrCreate( [ 'name' => $service . ' ' . $action . ' ' . data_get($resource, 'name'), 'guard_name' => $guard, @@ -202,6 +224,7 @@ public function handle() [ 'name' => $service . ' ' . $action . ' ' . data_get($resource, 'name'), 'guard_name' => $guard, + 'service' => $service, ] ); @@ -214,14 +237,226 @@ public function handle() } } - // output message for permissions creation + // Add resource specific action permission to administrator policy + try { + $administratorPolicy->givePermissionTo($permission); + } catch (GuardDoesNotMatch $e) { + return $this->error($e->getMessage()); + } + + // Output message for permissions creation $this->info('Created permission: ' . $permission->name); } } + + // Create administrator role + $adminitratorRole = Role::updateOrCreate( + [ + 'name' => 'Administrator', + 'guard_name' => $guard, + ], + [ + 'name' => 'Administrator', + 'guard_name' => $guard, + 'description' => 'Role for full administrator access to an organization', + ] + ); + + // Assign administrator policy to admin role + $adminitratorRole->assignPolicy($administratorPolicy); + + // Create policies if schema has provided + foreach ($policies as $policyScheme) { + $policy = Policy::updateOrCreate( + [ + 'name' => data_get($policyScheme, 'name'), + 'guard_name' => $guard, + ], + [ + 'name' => data_get($policyScheme, 'name'), + 'guard_name' => $guard, + 'description' => data_get($policyScheme, 'description'), + 'service' => $service, + ] + ); + + $policyPermissions = data_get($policyScheme, 'permissions', []); + $policyDirectives = data_get($policyScheme, 'directives', []); + + $this->assignPermissions($policy, $service, $guard, $policyPermissions); + $this->createDirectives($policy, $service, $guard, $policyDirectives); + $this->info('New Policy for service ' . $service . ' created as ' . $policy->name); + } + + // Create roles if schema has provided + foreach ($roles as $roleSchema) { + $role = Role::updateOrCreate( + [ + 'name' => data_get($roleSchema, 'name'), + 'guard_name' => $guard, + ], + [ + 'name' => data_get($roleSchema, 'name'), + 'guard_name' => $guard, + 'description' => data_get($roleSchema, 'description'), + 'service' => $service, + ] + ); + + $rolePolicies = data_get($roleSchema, 'policies', []); + $rolePermissions = data_get($roleSchema, 'permissions', []); + $roleDirectives = data_get($roleSchema, 'directives', []); + + $this->assignPolicies($role, $guard, $rolePolicies); + $this->assignPermissions($role, $service, $guard, $rolePermissions); + $this->createDirectives($role, $service, $guard, $roleDirectives); + + // Inform + $this->info('New Role for service ' . $service . ' created as ' . $role->name); + } } if ($reset) { Schema::enableForeignKeyConstraints(); } } + + /** + * Creates and assigns directives to a given subject based on the provided service, guard, and directives array. + * + * This method iterates over the provided directives array, validates the permission names, looks up the corresponding + * permissions, and then creates or updates directives in the database. It handles shorthand permission names by + * prefixing them with the service name if necessary. If a directive's permission name does not conform to the expected + * format, or if the permission cannot be found, the method logs an error and continues processing the remaining directives. + * + * @param Model $subject The subject (e.g., role, policy) to which the directives belong. + * @param string $service the service name used as a prefix for shorthand permission names + * @param string $guard the guard name associated with the permissions + * @param array $directives an associative array where the key is the permission name and the value is an array of rules + * + * @return Collection a collection of created or updated Directive instances + */ + public function createDirectives(Model $subject, string $service, string $guard, array $directives = []): Collection + { + $directiveRecords= collect(); + if (empty($directives)) { + return $directiveRecords; + } + + foreach ($directives as $permission => $rules) { + // dd($permission, $rules); + // role permission names can be shorthanded by excluding the service since the schema provides the service name + $permissionName = Str::startsWith($permission, $service) ? $permission : $service . ' ' . $permission; + + // next we validate the permission name + $permissionNameSegmentsCount = count(explode(' ', $permissionName)); + if ($permissionNameSegmentsCount !== 3) { + $this->error('Invalid directive provided by ' . Str::singular($subject->getTable()) . ' (' . $subject->name . ') in Schema for ' . $service . '; found ' . $permissionName . ' which has ' . $permissionNameSegmentsCount . ' but should be 3 segments.'); + continue; + } + + // lookup permission record + try { + $permissionRecord = Permission::findByName($permissionName, $guard); + } catch (PermissionDoesNotExist|\Exception $e) { + $this->error($e->getMessage()); + continue; + } + + // Create the directive + $directive = Directive::updateOrCreate( + [ + 'permission_uuid' => $permissionRecord->id, + 'subject_uuid' => $subject->{$subject->getKeyName()}, + 'key' => Directive::createKey($rules), + ], + [ + 'permission_uuid' => $permissionRecord->id, + 'subject_type' => Utils::getMutationType($subject), + 'subject_uuid' => $subject->{$subject->getKeyName()}, + 'key' => Directive::createKey($rules), + 'rules' => $rules, + ] + ); + + // Inform + $this->info('Created directive for ' . Str::singular($subject->getTable()) . ' (' . $subject->name . ') as ' . $directive->key); + + // Add the directive + $directiveRecords->push($directive); + } + + return $directiveRecords; + } + + /** + * Assigns permissions to a given subject based on the provided service, guard, and permissions array. + * + * This method processes each permission in the provided array, validates its name, and looks up the corresponding + * permission record. It handles shorthand permission names by prefixing them with the service name if necessary. + * If a permission name does not conform to the expected format, or if the permission cannot be found, the method + * logs an error and continues processing the remaining permissions. Valid permissions are then assigned to the subject. + * + * @param Model $subject The subject (e.g., role, policy) to which the permissions will be assigned. + * @param string $service the service name used as a prefix for shorthand permission names + * @param string $guard the guard name associated with the permissions + * @param array $permissions an array of permission names to be assigned to the subject + * + * @return Model the subject with the assigned permissions + */ + public function assignPermissions(Model $subject, string $service, string $guard, array $permissions = []): Model + { + foreach ($permissions as $permissionName) { + // role permission names can be shorthanded by excluding the service since the schema provides the service name + $permissionName = Str::startsWith($permissionName, $service) ? $permissionName : $service . ' ' . $permissionName; + // next we validate the permission name + $permissionNameSegmentsCount = count(explode(' ', $permissionName)); + if ($permissionNameSegmentsCount !== 3) { + $this->error('Invalid permission provided by ' . Str::singular($subject->getTable()) . ' (' . $subject->name . ') in Schema for ' . $service . '; found ' . $permissionName . ' which has ' . $permissionNameSegmentsCount . ' but should be 3 segments.'); + continue; + } + // lookup the permission record by name + try { + $permissionRecord = Permission::findByName($permissionName, $guard); + } catch (PermissionDoesNotExist|\Exception $e) { + $this->error($e->getMessage()); + continue; + } + + // apply the permission to the policy + $subject->givePermissionTo($permissionRecord); + } + + return $subject; + } + + /** + * Assigns policies to a given subject based on the provided guard and policies array. + * + * This method processes each policy in the provided array, looks up the corresponding policy record, + * and assigns it to the subject. If the policy cannot be found, the method logs an error and continues + * processing the remaining policies. Valid policies are then assigned to the subject. + * + * @param Model $subject The subject (e.g., role, policy) to which the policies will be assigned. + * @param string $guard the guard name associated with the policies + * @param array $policies an array of policy names to be assigned to the subject + * + * @return Model the subject with the assigned policies + */ + public function assignPolicies(Model $subject, string $guard, array $policies = []): Model + { + foreach ($policies as $policyName) { + // lookup the policy record by name + try { + $policyRecord = Policy::findByName($policyName, $guard); + } catch (\Exception $e) { + $this->error($e->getMessage()); + continue; + } + // apply the policy to the role + $subject->assignPolicy($policyRecord); + } + + return $subject; + } } diff --git a/src/Console/Commands/ForceResetDatabase.php b/src/Console/Commands/ForceResetDatabase.php new file mode 100644 index 0000000..256f87b --- /dev/null +++ b/src/Console/Commands/ForceResetDatabase.php @@ -0,0 +1,60 @@ +option('connection') ?: config('database.default'); + + // Set the connection for the schema builder and DB facade + $schema = Schema::connection($connection); + $db = DB::connection($connection); + + $this->info("Using connection: {$connection}"); + + // Disable foreign key constraints + $schema->disableForeignKeyConstraints(); + + // Get all table names + $tables = $db->getDoctrineSchemaManager()->listTableNames(); + + // Delete all tables + foreach ($tables as $table) { + $schema->drop($table); + $this->info("Dropped table: {$table}"); + } + + // Re-enable foreign key constraints + $schema->enableForeignKeyConstraints(); + + $this->info('All tables have been dropped successfully.'); + + return Command::SUCCESS; + } +} diff --git a/src/Expansions/Builder.php b/src/Expansions/Builder.php index 3d15059..2a464d6 100644 --- a/src/Expansions/Builder.php +++ b/src/Expansions/Builder.php @@ -3,6 +3,7 @@ namespace Fleetbase\Expansions; use Fleetbase\Build\Expansion; +use Fleetbase\Support\Auth; use Fleetbase\Support\Http; use Illuminate\Support\Arr; use Illuminate\Support\Facades\DB; @@ -187,4 +188,45 @@ public function applySortFromRequest() return $this; }; } + + /** + * Macro to apply directives to the query builder instance based on the current authenticated user's permissions. + * + * This macro allows directives to be automatically applied to a query builder instance. When invoked, + * it retrieves the appropriate directives based on the current authenticated user's context and permissions, + * and applies them to the query. This is particularly useful for dynamically enforcing permission-based + * constraints on queries within your application. + * + * @return \Illuminate\Database\Eloquent\Builder the query builder instance with the applied directives + */ + public function applyDirectives() + { + return function () { + /** @var \Illuminate\Database\Eloquent\Builder $this */ + return Auth::applyDirectivesToQuery($this); + }; + } + + /** + * Macro to apply directives to the query builder based on specific permissions. + * + * This macro retrieves directives associated with the given permission names and applies + * them to the query builder instance. It allows for dynamically enforcing permission-based + * constraints on queries, tailored to the specific permissions provided. + * + * @return \Illuminate\Database\Eloquent\Builder the query builder instance with the applied directives + */ + public function applyDirectivesForPermissions() + { + return function (string|array $names = []) { + /** @var \Illuminate\Database\Eloquent\Builder $this */ + $names = is_string($names) ? [$names] : $names; + $directives = Auth::getDirectivesForPermissions($names); + foreach ($directives as $directive) { + $directive->apply($this); + } + + return $this; + }; + } } diff --git a/src/Expansions/Route.php b/src/Expansions/Route.php index 6f688a1..9e9cca7 100644 --- a/src/Expansions/Route.php +++ b/src/Expansions/Route.php @@ -154,6 +154,7 @@ function ($router) use ($registerProtectedFn) { $router->post('create-organization', 'AuthController@createOrganization'); $router->get('session', 'AuthController@session'); $router->get('organizations', 'AuthController@getUserOrganizations'); + $router->get('services', 'AuthController@services'); if (is_callable($registerProtectedFn)) { $registerProtectedFn($router); diff --git a/src/Http/Controllers/Internal/v1/AuthController.php b/src/Http/Controllers/Internal/v1/AuthController.php index 4e50703..d51fe58 100644 --- a/src/Http/Controllers/Internal/v1/AuthController.php +++ b/src/Http/Controllers/Internal/v1/AuthController.php @@ -622,4 +622,21 @@ public function createOrganization(Request $request) return new Organization($company); } + + /** + * Returns all authorization services which provide schemas. + * + * @return \Illuminate\Http\Response + */ + public function services() + { + $schemas = Utils::getAuthSchemas(); + $services = []; + + foreach ($schemas as $schema) { + $services[] = $schema->name; + } + + return response()->json(array_unique($services)); + } } diff --git a/src/Http/Controllers/Internal/v1/OnboardController.php b/src/Http/Controllers/Internal/v1/OnboardController.php index e630ea1..ac755fc 100644 --- a/src/Http/Controllers/Internal/v1/OnboardController.php +++ b/src/Http/Controllers/Internal/v1/OnboardController.php @@ -67,6 +67,9 @@ public function createAccount(OnboardRequest $request) // set the user type $user->setUserType($isAdmin ? 'admin' : 'user'); + // assign admin role + $user->assignSingleRole('Administrator'); + // create company $company = new Company(['name' => $request->input('organization_name')]); $company->setOwner($user)->save(); diff --git a/src/Http/Filter/PolicyFilter.php b/src/Http/Filter/PolicyFilter.php index 3a339db..d648382 100644 --- a/src/Http/Filter/PolicyFilter.php +++ b/src/Http/Filter/PolicyFilter.php @@ -6,6 +6,10 @@ class PolicyFilter extends Filter { public function queryForInternal() { + if ($this->request->filled('type')) { + return; + } + $this->builder->where( function ($query) { $query->where('company_uuid', $this->session->get('company'))->orWhereNull('company_uuid'); @@ -17,4 +21,17 @@ public function query(?string $query) { $this->builder->search($query); } + + public function type(?string $type) + { + switch ($type) { + case 'flb-managed': + $this->builder->whereNull('company_uuid'); + break; + case 'org-managed': + default: + $this->builder->where('company_uuid', $this->session->get('company')); + break; + } + } } diff --git a/src/Http/Filter/RoleFilter.php b/src/Http/Filter/RoleFilter.php index cb12e30..ab452e3 100644 --- a/src/Http/Filter/RoleFilter.php +++ b/src/Http/Filter/RoleFilter.php @@ -6,11 +6,32 @@ class RoleFilter extends Filter { public function queryForInternal() { - $this->builder->where('company_uuid', $this->session->get('company')); + if ($this->request->filled('type')) { + return; + } + + $this->builder->where( + function ($query) { + $query->where('company_uuid', $this->session->get('company'))->orWhereNull('company_uuid'); + } + ); } public function query(?string $query) { $this->builder->search($query); } + + public function type(?string $type) + { + switch ($type) { + case 'flb-managed': + $this->builder->whereNull('company_uuid'); + break; + case 'org-managed': + default: + $this->builder->where('company_uuid', $this->session->get('company')); + break; + } + } } diff --git a/src/Http/Filter/UserFilter.php b/src/Http/Filter/UserFilter.php index 3304365..5845a0d 100644 --- a/src/Http/Filter/UserFilter.php +++ b/src/Http/Filter/UserFilter.php @@ -44,4 +44,11 @@ public function email(?string $email) { $this->builder->searchWhere('email', $email); } + + public function role(?string $roleId) + { + $this->builder->whereHas('roles', function ($query) use ($roleId) { + $query->where('id', $roleId); + }); + } } diff --git a/src/Http/Resources/Policy.php b/src/Http/Resources/Policy.php index 05567af..eec1c14 100644 --- a/src/Http/Resources/Policy.php +++ b/src/Http/Resources/Policy.php @@ -14,17 +14,18 @@ class Policy extends FleetbaseResource public function toArray($request) { return [ - 'id' => $this->id, - 'company_uuid' => $this->company_uuid, - 'name' => $this->name, - 'guard_name' => $this->guard_name, - 'description' => $this->description, - 'permissions' => $this->serializePermissions($this->permissions), - 'type' => $this->type, - 'is_mutable' => $this->is_mutable, - 'is_deletable' => $this->is_deletable, - 'updated_at' => $this->updated_at, - 'created_at' => $this->created_at, + 'id' => $this->id, + 'company_uuid' => $this->company_uuid, + 'name' => $this->name, + 'guard_name' => $this->guard_name, + 'description' => $this->description, + 'permissions' => $this->serializePermissions($this->permissions), + 'type' => $this->type, + 'service' => $this->service, + 'is_mutable' => $this->is_mutable, + 'is_deletable' => $this->is_deletable, + 'updated_at' => $this->updated_at, + 'created_at' => $this->created_at, ]; } @@ -42,6 +43,7 @@ function ($permission) { 'name' => $permission->name, 'guard_name' => $permission->guard_name, 'description' => $permission->description, + 'service' => $permission->service, 'updated_at' => $permission->updated_at, 'created_at' => $permission->created_at, ]; diff --git a/src/Http/Resources/Role.php b/src/Http/Resources/Role.php index 5b2a6dd..3d2b8b8 100644 --- a/src/Http/Resources/Role.php +++ b/src/Http/Resources/Role.php @@ -14,15 +14,19 @@ class Role extends FleetbaseResource public function toArray($request) { return [ - 'id' => $this->id, - 'company_uuid' => $this->company_uuid, - 'name' => $this->name, - 'guard_name' => $this->guard_name, - 'description' => $this->description, - 'policies' => Policy::collection($this->policies), - 'permissions' => $this->serializePermissions($this->permissions), - 'updated_at' => $this->updated_at, - 'created_at' => $this->created_at, + 'id' => $this->id, + 'company_uuid' => $this->company_uuid, + 'name' => $this->name, + 'guard_name' => $this->guard_name, + 'description' => $this->description, + 'policies' => Policy::collection($this->policies), + 'permissions' => $this->serializePermissions($this->permissions), + 'type' => $this->type, + 'service' => $this->service, + 'is_mutable' => $this->is_mutable, + 'is_deletable' => $this->is_deletable, + 'updated_at' => $this->updated_at, + 'created_at' => $this->created_at, ]; } @@ -40,6 +44,7 @@ function ($permission) { 'name' => $permission->name, 'guard_name' => $permission->guard_name, 'description' => $permission->description, + 'service' => $permission->service, 'updated_at' => $permission->updated_at, 'created_at' => $permission->created_at, ]; diff --git a/src/Models/Directive.php b/src/Models/Directive.php new file mode 100644 index 0000000..7f7a3fb --- /dev/null +++ b/src/Models/Directive.php @@ -0,0 +1,91 @@ + Json::class, + ]; + + /** + * Get the company that owns the directive. + */ + public function company(): BelongsTo + { + return $this->belongsTo(Company::class, 'company_uuid', 'uuid'); + } + + /** + * Get the permission associated with the directive. + */ + public function permission(): BelongsTo + { + return $this->belongsTo(Permission::class, 'permission_id'); + } + + /** + * Get the subject that this directive belongs to. + */ + public function subject(): MorphTo + { + return $this->morphTo(__FUNCTION__, 'subject_type', 'subject_uuid'); + } + + /** + * Apply the directive's rules to a given query. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * + * @return \Illuminate\Database\Eloquent\Builder + */ + public function apply($query) + { + return DirectiveParser::apply($query, $this->rules); + } + + /** + * Create a unique key based on the rules. + */ + public static function createKey(array $rules = []): string + { + $ruleString = implode(':', $rules); + + return Crypt::encryptString($ruleString); + } +} diff --git a/src/Models/Policy.php b/src/Models/Policy.php index 109f012..4c32f52 100644 --- a/src/Models/Policy.php +++ b/src/Models/Policy.php @@ -74,7 +74,7 @@ public function __construct(array $attributes = []) * * @var array */ - protected $fillable = ['company_uuid', 'name', 'guard_name', 'description']; + protected $fillable = ['company_uuid', 'name', 'guard_name', 'service', 'description']; /** * The guarded attributes. diff --git a/src/Models/Role.php b/src/Models/Role.php index c422374..85eca04 100644 --- a/src/Models/Role.php +++ b/src/Models/Role.php @@ -82,6 +82,13 @@ class Role extends BaseRole */ protected $with = ['permissions']; + /** + * Dynamic attributes that are appended to object. + * + * @var array + */ + protected $appends = ['type', 'is_mutable', 'is_deletable']; + /** * Hotfix for tiemstamps bug. * @@ -120,4 +127,34 @@ public function setGuardNameAttribute() { $this->attributes['guard_name'] = 'sanctum'; } + + /** + * Check if the company_uuid attribute is set. + * + * @return bool + */ + public function getIsMutableAttribute() + { + return isset($this->company_uuid); + } + + /** + * Check if the company_uuid attribute is set. + * + * @return bool + */ + public function getIsDeletableAttribute() + { + return isset($this->company_uuid); + } + + /** + * Get the type of attribute based on the company_uuid. + * + * @return string + */ + public function getTypeAttribute() + { + return empty($this->company_uuid) ? 'FLB Managed' : 'Organization Managed'; + } } diff --git a/src/Models/User.php b/src/Models/User.php index 06948b4..77d93d9 100644 --- a/src/Models/User.php +++ b/src/Models/User.php @@ -434,24 +434,36 @@ public function getDriverUuidAttribute() /** * Checks if the user is admin. - * - * @return bool */ - public function isAdmin() + public function isAdmin(): bool { return $this->type === 'admin'; } /** * Checks if the user is NOT admin. - * - * @return bool */ - public function isNotAdmin() + public function isNotAdmin(): bool { return $this->type !== 'admin'; } + /** + * Checks if the user is NOT admin. + */ + public function isType(string $type): bool + { + return $this->type === $type; + } + + /** + * Checks if the user is NOT admin. + */ + public function isNotType(string $type): bool + { + return $this->type !== $type; + } + /** * Adds a boolean dynamic property to check if user is an admin. * diff --git a/src/Providers/CoreServiceProvider.php b/src/Providers/CoreServiceProvider.php index 6fabdaf..6405c68 100644 --- a/src/Providers/CoreServiceProvider.php +++ b/src/Providers/CoreServiceProvider.php @@ -63,6 +63,7 @@ class CoreServiceProvider extends ServiceProvider * @var array */ public $commands = [ + \Fleetbase\Console\Commands\ForceResetDatabase::class, \Fleetbase\Console\Commands\CreateDatabase::class, \Fleetbase\Console\Commands\SeedDatabase::class, \Fleetbase\Console\Commands\MigrateSandbox::class, diff --git a/src/Support/Auth.php b/src/Support/Auth.php index c9d50a0..b912d37 100644 --- a/src/Support/Auth.php +++ b/src/Support/Auth.php @@ -6,7 +6,10 @@ use Fleetbase\Models\ApiCredential; use Fleetbase\Models\Company; use Fleetbase\Models\CompanyUser; +use Fleetbase\Models\Directive; use Fleetbase\Models\Permission; +use Fleetbase\Models\Policy; +use Fleetbase\Models\Role; use Fleetbase\Models\User; use Illuminate\Http\Request; use Illuminate\Support\Collection; @@ -345,6 +348,104 @@ public static function resolvePermissionsFromRequest(Request $request): Collecti return Permission::findByNames([$permissionName, $permissionWildcardName, $permissionWildcardServiceName]); } + /** + * Retrieves a collection of directives associated with the permissions extracted from the given request. + * + * This method first resolves the user from the session and then extracts the permissions from the request. + * It then queries the `Directive` model to retrieve all directives that correspond to those permissions, + * loading the related `subject` (either a `Policy` or `Role`). After retrieving the directives, it filters + * them based on whether the user has the associated policy or role assigned. The resulting collection + * contains only the directives applicable to the current user. + * + * @param Request $request the HTTP request instance from which to resolve permissions + * + * @return Collection a collection of `Directive` models that are associated with the resolved permissions and applicable to the current user + */ + public static function getDirectivesFromRequest(Request $request): Collection + { + $user = static::getUserFromSession(); + $permissions = static::resolvePermissionsFromRequest($request); + $directives = Directive::whereIn('permission_uuid', $permissions->pluck('id')) + ->with(['subject']) + ->get() + ->filter( + function ($directive) use ($user) { + if ($directive->subject instanceof Policy) { + return $user->hasPolicyAssigned($directive->subject); + } + + if ($directive->subject instanceof Role) { + return $user->hasRole($directive->subject); + } + + return false; + } + ); + + return $directives; + } + + /** + * Retrieves a collection of directives associated with the specified permissions. + * + * This method resolves the user from the session and looks up the specified permissions by name. + * It then queries the `Directive` model to retrieve all directives that correspond to those permissions, + * loading the related `subject` (either a `Policy` or `Role`). After retrieving the directives, it filters + * them based on whether the user has the associated policy or role assigned. The resulting collection + * contains only the directives that are applicable to the current user. + * + * @param array $names an array of permission names to look up and retrieve directives for + * + * @return Collection a collection of `Directive` models that are associated with the specified permissions and applicable to the current user + */ + public static function getDirectivesForPermissions(array $names = []): Collection + { + $user = static::getUserFromSession(); + $permissions = Permission::findByNames($names); + $directives = Directive::whereIn('permission_uuid', $permissions->pluck('id')) + ->with(['subject']) + ->get() + ->filter( + function ($directive) use ($user) { + if ($directive->subject instanceof Policy) { + return $user->hasPolicyAssigned($directive->subject); + } + + if ($directive->subject instanceof Role) { + return $user->hasRole($directive->subject); + } + + return false; + } + ); + + return $directives; + } + + /** + * Applies directives to a query builder instance based on the permissions extracted from the request. + * + * This method retrieves directives associated with the current request and applies each directive + * to the given query builder instance. If a request is not explicitly provided, the current request + * is used by default. The method is typically used to enforce permissions and constraints dynamically + * on a query based on the user's context or permissions. + * + * @param \Illuminate\Database\Eloquent\Builder $builder the query builder instance to which the directives will be applied + * @param \Illuminate\Http\Request|null $request An optional HTTP request instance. If not provided, the current request is used. + * + * @return \Illuminate\Database\Eloquent\Builder the query builder with the applied directives + */ + public static function applyDirectivesToQuery($builder, ?Request $request = null) + { + $request = $request instanceof Request ? $request : request(); + $directives = static::getDirectivesFromRequest($request); + foreach ($directives as $directive) { + $directive->apply($builder); + } + + return $builder; + } + /** * Generates the required permission name based on the provided request. * diff --git a/src/Support/DirectiveParser.php b/src/Support/DirectiveParser.php new file mode 100644 index 0000000..0b9757e --- /dev/null +++ b/src/Support/DirectiveParser.php @@ -0,0 +1,81 @@ +applyDirective($query, $directive); + } + + /** + * Apply a single directive to the Eloquent query builder. + */ + public function applyDirective(Builder $query, array $directive): Builder + { + $method = array_shift($directive); // Extract the Eloquent method (e.g., 'where', 'whereHas') + + // Special handling for 'whereHas' and similar methods requiring a closure + if ($method === 'whereHas' || $method === 'orWhereHas') { + $relation = array_shift($directive); + $query = $query->$method($relation, function (Builder $query) use ($directive, $relation) { + $qualifiedDirective = $this->qualifyDirective($directive, $relation); + $this->applyDirective($query, $qualifiedDirective); + }); + } else { + $parameters = $this->parseParameters($directive); + if (method_exists($query, $method)) { + $query = $query->$method(...$parameters); + } + } + + return $query; + } + + /** + * Qualify the columns in the directive to avoid ambiguity. + */ + protected function qualifyDirective(array $directive, string $relation): array + { + return array_map(function ($item) use ($relation) { + if (is_string($item) && strpos($item, '.') === false) { + // Qualify the column with the relation name if it's a column name without qualification + return "{$relation}.{$item}"; + } + + return $item; + }, $directive); + } + + /** + * Parse parameters and replace placeholders with actual values. + */ + protected function parseParameters(array $parameters): array + { + return array_map(function ($parameter) { + if (is_string($parameter)) { + if (strpos($parameter, 'session.') === 0) { + $sessionKey = str_replace('session.', '', $parameter); + + return Session::get($sessionKey); + } + + if (strpos($parameter, 'self.') === 0) { + $attributeKey = str_replace('self.', '', $parameter); + + return Auth::user()->{$attributeKey}; + } + } + + return $parameter; + }, $parameters); + } +} diff --git a/src/Support/QueryOptimizer.php b/src/Support/QueryOptimizer.php new file mode 100644 index 0000000..f051e57 --- /dev/null +++ b/src/Support/QueryOptimizer.php @@ -0,0 +1,221 @@ +getQuery()->wheres; + + // Track bindings separately to ensure they are not lost or mismatched + $bindings = $query->getQuery()->bindings['where']; + $uniqueBindings = []; + $processedBindings = []; + $index = 0; + + // dump($wheres, $bindings); + + // Filter out duplicate where clauses + $uniqueWheres = collect($wheres)->unique(function ($where, $key) use (&$bindings, &$uniqueBindings, &$processedBindings, &$index) { + $normalized = static::normalizeWhereClause($where); + $decoded = static::decodeNormalized($normalized); + + // Handle 'Exists' and 'NotExists' clauses by returning them as unique without duplication + if ($decoded['type'] === 'Exists' || $decoded['type'] === 'NotExists') { + // Check if has no wheres with values + $containsWhereWithValue = collect($decoded['wheres'])->contains(function ($decodedWhere) { + return isset($decodedWhere['value']) || isset($decodedWhere['values']); + }); + + if (!$containsWhereWithValue) { + $index++; + + return $normalized; + } + } + + // Has nested where values + $isNested = in_array($decoded['type'], ['Exists', 'NotExists', 'Nested']); + + // Check if this normalized clause already exists + if (!isset($uniqueBindings[$normalized])) { + // If nested, ensure bindings remain for each nested clause + if ($isNested && is_array($decoded['wheres'])) { + foreach ($decoded['wheres'] as $i => $decodedWhere) { + $doesntHaveValue = !isset($decodedWhere['value']) && !isset($decodedWhere['values']); + if ($doesntHaveValue) { + continue; + } + + // If values store the $decodedWhere for each value + if (isset($decodedWhere['values'])) { + $decodedWhereValues = json_decode($decodedWhere['values']); + foreach ($decodedWhereValues as $decodedWhereValue) { + $decodedWhereKey = [...$decodedWhere, '_value' => $bindings[$index]]; + $uniqueBindings[json_encode($decodedWhereKey)] = $bindings[$index] ?? null; + $processedBindings[] = $bindings[$index] ?? null; + $index++; + } + continue; + } + + $uniqueBindings[json_encode($decodedWhere)] = $bindings[$index] ?? null; + $processedBindings[] = $bindings[$index] ?? null; + $index++; + } + } else { + // If it's unique, save the binding and mark it as processed + $uniqueBindings[$normalized] = $bindings[$index] ?? null; + $processedBindings[] = $bindings[$index] ?? null; + $index++; + } + } + + return $normalized; + })->values()->all(); + + // Get unique bindings + $uniqueBindings = array_filter(array_values($uniqueBindings)); + + // dd($uniqueWheres, $uniqueBindings); + + // Reset the original wheres and replace them with the unique ones + $query->getQuery()->wheres = $uniqueWheres; + + // Replace the bindings with the unique ones + $query->getQuery()->bindings['where'] = $uniqueBindings; + + return $query; + } + + /** + * Normalizes a where clause to create a unique key for comparison. + * + * This method converts a where clause into a JSON string that serves as a unique identifier. + * It handles various types of where clauses, including nested queries, 'Exists', 'NotExists', + * and others, ensuring that each clause can be uniquely identified and compared. + * + * @param array $where the where clause to normalize + * + * @return string a JSON-encoded string that uniquely represents the where clause + */ + protected static function normalizeWhereClause(array $where): string + { + switch ($where['type']) { + case 'Nested': + // Recursively normalize the nested query + $nestedWheres = collect($where['query']->wheres)->map(function ($nestedWhere) { + return static::normalizeWhereClause($nestedWhere); + })->all(); + + return json_encode([ + 'type' => $where['type'], + 'wheres' => $nestedWheres, + 'boolean' => $where['boolean'], + ]); + + case 'Basic': + return json_encode([ + 'type' => $where['type'], + 'column' => $where['column'] ?? '', + 'operator'=> $where['operator'] ?? '=', + 'value' => $where['value'] instanceof Expression ? (string) $where['value'] : json_encode($where['value']), + 'boolean' => $where['boolean'] ?? 'and', + ]); + + case 'In': + case 'NotIn': + return json_encode([ + 'type' => $where['type'], + 'column' => $where['column'] ?? '', + 'values' => json_encode($where['values'] ?? []), + 'boolean' => $where['boolean'] ?? 'and', + ]); + + case 'Null': + case 'NotNull': + return json_encode([ + 'type' => $where['type'], + 'column' => $where['column'] ?? '', + 'boolean' => $where['boolean'] ?? 'and', + ]); + + case 'Between': + return json_encode([ + 'type' => $where['type'], + 'column' => $where['column'] ?? '', + 'values' => json_encode($where['values'] ?? []), + 'boolean' => $where['boolean'] ?? 'and', + ]); + + case 'Exists': + case 'NotExists': + // Recursively normalize the nested subquery within Exists/NotExists clauses + $subqueryWheres = collect($where['query']->wheres)->map(function ($subWhere) { + return static::normalizeWhereClause($subWhere); + })->all(); + + return json_encode([ + 'type' => $where['type'], + 'wheres' => $subqueryWheres, + 'boolean' => $where['boolean'] ?? 'and', + ]); + + case 'Raw': + return json_encode([ + 'type' => $where['type'], + 'sql' => $where['sql'] ?? '', + 'boolean' => $where['boolean'] ?? 'and', + ]); + + default: + // Handle any other types of where clauses if necessary + return json_encode($where); + } + } + + /** + * Decodes a normalized where clause back into an array. + * + * This method takes a JSON-encoded where clause string and decodes it back into + * an associative array. It also handles decoding nested where clauses, ensuring + * that the structure is preserved for further processing. + * + * @param string $normalized the JSON-encoded where clause + * + * @return array the decoded where clause as an associative array + */ + protected static function decodeNormalized(string $normalized): array + { + $decoded = json_decode($normalized, true); + if (isset($decoded['wheres']) && is_array($decoded['wheres'])) { + $decoded['wheres'] = array_map(function ($whereJson) { + return json_decode($whereJson, true); + }, $decoded['wheres']); + } + + return $decoded; + } +} diff --git a/src/Traits/HasApiControllerBehavior.php b/src/Traits/HasApiControllerBehavior.php index 27d2b7d..2263f5b 100644 --- a/src/Traits/HasApiControllerBehavior.php +++ b/src/Traits/HasApiControllerBehavior.php @@ -487,10 +487,12 @@ public function deleteRecord($id, Request $request) { if (Http::isInternalRequest($request)) { $key = $this->model->getKeyName(); - $dataModel = $this->model->where($key, $id)->first(); + $builder = $this->model->where($key, $id); } else { - $dataModel = $this->model->wherePublicId($id)->first(); + $builder = $this->model->wherePublicId($id); } + $builder = $this->model->applyDirectivesToQuery($request, $builder); + $dataModel = $builder->first(); if ($dataModel) { $dataModel->delete(); diff --git a/src/Traits/HasApiModelBehavior.php b/src/Traits/HasApiModelBehavior.php index 772d135..358b59d 100644 --- a/src/Traits/HasApiModelBehavior.php +++ b/src/Traits/HasApiModelBehavior.php @@ -2,7 +2,9 @@ namespace Fleetbase\Traits; +use Fleetbase\Support\Auth; use Fleetbase\Support\Http; +use Fleetbase\Support\QueryOptimizer; use Fleetbase\Support\Resolve; use Fleetbase\Support\Utils; use Illuminate\Http\JsonResponse; @@ -221,14 +223,16 @@ public function createRecordFromRequest($request, ?callable $onBefore = null, ?c */ public function updateRecordFromRequest(Request $request, $id, ?callable $onBefore = null, ?callable $onAfter = null, array $options = []) { - $record = $this->where(function ($q) use ($id) { + $builder = $this->where(function ($q) use ($id) { $publicIdColumn = $this->getQualifiedPublicId(); $q->where($this->getQualifiedKeyName(), $id); if ($this->isColumn($publicIdColumn)) { $q->orWhere($publicIdColumn, $id); } - })->first(); + }); + $builder = $this->applyDirectivesToQuery($request, $builder); + $record = $builder->first(); if (!$record) { throw new \Exception($this->getApiHumanReadableName() . ' not found'); @@ -596,6 +600,7 @@ public function getById($id, Request $request) $builder = $this->withCounts($request, $builder); $builder = $this->withRelationships($request, $builder); $builder = $this->applySorts($request, $builder); + $builder = $this->applyDirectivesToQuery($request, $builder); return $builder->first(); } @@ -656,10 +661,50 @@ public function searchBuilder(Request $request, $columns = ['*']) $builder = $this->withRelationships($request, $builder); $builder = $this->withCounts($request, $builder); $builder = $this->applySorts($request, $builder); + $builder = $this->applyDirectivesToQuery($request, $builder); + + return $builder; + } + + /** + * Applies all authorization directives from the request to the given query builder. + * + * This method retrieves directives from the request using the `Auth::getDirectivesFromRequest` method, + * then iterates over each directive and applies it to the provided query builder. The directives modify + * the query to enforce the appropriate access controls based on the authenticated user's permissions. + * + * @param Request $request the HTTP request containing the authorization directives + * @param \Illuminate\Database\Eloquent\Builder $builder the query builder instance to which the directives will be applied + * + * @return \Illuminate\Database\Eloquent\Builder the modified query builder with all directives applied + */ + public function applyDirectivesToQuery(Request $request, $builder) + { + $directives = Auth::getDirectivesFromRequest($request); + foreach ($directives as $directive) { + $directive->apply($builder); + } return $builder; } + /** + * Optimizes the given query builder by removing duplicate where clauses. + * + * This method takes a query builder instance and passes it to the QueryOptimizer, + * which processes the query to remove any duplicate where clauses while ensuring + * that the associated bindings are correctly managed. This optimization helps in + * improving query performance and avoiding potential issues with redundant conditions. + * + * @param \Illuminate\Database\Eloquent\Builder $builder the query builder instance to optimize + * + * @return \Illuminate\Database\Eloquent\Builder the optimized query builder with unique where clauses + */ + public function optimizeQuery($builder) + { + return QueryOptimizer::removeDuplicateWheres($builder); + } + /** * Applies custom filters to the search query based on the request parameters. * diff --git a/src/Traits/HasPolicies.php b/src/Traits/HasPolicies.php index c63f7d8..14d8102 100644 --- a/src/Traits/HasPolicies.php +++ b/src/Traits/HasPolicies.php @@ -323,4 +323,46 @@ public function getPermissionsViaPolicies() ->policies->flatMap(fn ($policy) => $policy->permissions) ->sort()->values(); } + + /** + * Retrieves all policies associated with the user, including those through direct assignment and roles. + * + * This method loads all related policies and roles for the user, as well as policies associated with those roles. + * It merges all these policies into a single collection, providing a comprehensive list of policies that the user + * is associated with, either directly or through their roles. + * + * @return Collection a collection of all `Policy` models associated with the user, including those through roles + */ + public function getAllPolicies(): Collection + { + $this->loadMissing('policies', 'roles', 'roles.policies'); + $allPolicies = collect(); + + $allPolicies = $allPolicies->merge($this->policies); + foreach ($this->roles as $role) { + $allPolicies = $allPolicies->merge($role->policies); + } + + return $allPolicies; + } + + /** + * Checks if the user has the specified policy assigned, either directly or through a role. + * + * This method retrieves all policies associated with the user, including those assigned directly and those + * assigned through roles. It then checks if the specified policy is within this collection of policies. The method + * returns `true` if the policy is assigned to the user, and `false` otherwise. + * + * @param Policy $policy the `Policy` model to check against the user's assigned policies + * + * @return bool `True` if the policy is assigned to the user, `false` otherwise + */ + public function hasPolicyAssigned(Policy $policy): bool + { + $policies = $this->getAllPolicies(); + + return $policies->contains(function ($anyPolicy) use ($policy) { + return $policy->id === $anyPolicy->id; + }); + } }