diff --git a/composer.json b/composer.json index 68009af..e627efe 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "fleetbase/core-api", - "version": "1.5.1", + "version": "1.5.2", "description": "Core Framework and Resources for Fleetbase API", "keywords": [ "fleetbase", diff --git a/seeders/PermissionSeeder.php b/seeders/PermissionSeeder.php index 138882c..3e835ff 100644 --- a/seeders/PermissionSeeder.php +++ b/seeders/PermissionSeeder.php @@ -6,6 +6,7 @@ use Fleetbase\Models\Policy; use Fleetbase\Support\Utils; use Illuminate\Database\Seeder; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; use Illuminate\Support\Str; @@ -21,6 +22,9 @@ public function run() Schema::disableForeignKeyConstraints(); Permission::truncate(); Policy::truncate(); + DB::table('model_has_permissions')->truncate(); + DB::table('model_has_roles')->truncate(); + DB::table('model_has_policies')->truncate(); $actions = ['create', 'update', 'delete', 'view', 'list']; $schemas = Utils::getAuthSchemas(); @@ -29,7 +33,7 @@ public function run() $service = $schema->name; $resources = $schema->resources ?? []; $permissions = $schema->permissions ?? null; - $guard = 'web'; + $guard = 'sanctum'; // first create a wilcard permission for the entire schema $administratorPolicy = Policy::firstOrCreate( @@ -103,11 +107,11 @@ public function run() // create a resource policy for read-only access $readOnlyPolicy = Policy::firstOrCreate( [ - 'name' => Str::studly(data_get($schema, 'policyName')) . 'FullAccess', + 'name' => Str::studly(data_get($schema, 'policyName')) . 'ReadOnly', 'guard_name' => $guard, ], [ - 'name' => Str::studly(data_get($schema, 'policyName')) . 'FullAccess', + 'name' => Str::studly(data_get($schema, 'policyName')) . 'ReadOnly', 'description' => 'Provides read-only access to ' . Str::studly(data_get($schema, 'policyName')) . '.', 'guard_name' => $guard, ] @@ -115,32 +119,6 @@ public function run() // create wilcard permission for service and all resources foreach ($resources as $resource) { - // create a resource policy for full access - $resourceFullAccessPolicy = Policy::firstOrCreate( - [ - 'name' => Str::studly(data_get($schema, 'policyName')) . Str::studly(data_get($resource, 'name')) . 'FullAccess', - 'guard_name' => $guard, - ], - [ - 'name' => Str::studly(data_get($schema, 'policyName')) . Str::studly(data_get($resource, 'name')) . 'FullAccess', - 'description' => 'Provides full access to ' . Str::studly(data_get($schema, 'policyName')) . ' ' . Str::plural(data_get($resource, 'name')) . '.', - 'guard_name' => $guard, - ] - ); - - // create a resource policy for read-only access - $resourceReadOnlyPolicy = Policy::firstOrCreate( - [ - 'name' => Str::studly(data_get($schema, 'policyName')) . Str::studly(data_get($resource, 'name')) . 'FullAccess', - 'guard_name' => $guard, - ], - [ - 'name' => Str::studly(data_get($schema, 'policyName')) . Str::studly(data_get($resource, 'name')) . 'FullAccess', - 'description' => 'Provides read-only access to ' . Str::studly(data_get($schema, 'policyName')) . ' ' . Str::plural(data_get($resource, 'name')) . '.', - 'guard_name' => $guard, - ] - ); - $permission = Permission::firstOrCreate( [ 'name' => $service . ' * ' . data_get($resource, 'name'), @@ -158,11 +136,6 @@ public function run() } catch (\Spatie\Permission\Exceptions\GuardDoesNotMatch $e) { dd($e->getMessage()); } - try { - $resourceFullAccessPolicy->givePermissionTo($permission); - } catch (\Spatie\Permission\Exceptions\GuardDoesNotMatch $e) { - dd($e->getMessage()); - } // output message for permissions creation // $this->output('Created (' . $guard . ') permission: ' . $permission->name); @@ -174,7 +147,7 @@ public function run() if (is_array(data_get($resource, 'remove_actions', null))) { foreach (data_get($resource, 'remove_actions') as $remove) { if (($key = array_search($remove, $actions)) !== false) { - unset($actions[$key]); + unset($resourceActions[$key]); } } } @@ -199,11 +172,6 @@ public function run() } catch (\Spatie\Permission\Exceptions\GuardDoesNotMatch $e) { dd($e->getMessage()); } - try { - $resourceReadOnlyPolicy->givePermissionTo($permission); - } catch (\Spatie\Permission\Exceptions\GuardDoesNotMatch $e) { - dd($e->getMessage()); - } } // output message for permissions creation diff --git a/src/Attributes/SkipAuthorizationCheck.php b/src/Attributes/SkipAuthorizationCheck.php new file mode 100644 index 0000000..1e3c55b --- /dev/null +++ b/src/Attributes/SkipAuthorizationCheck.php @@ -0,0 +1,8 @@ +getErrorMessage($request); + $this->errors = [$message]; + parent::__construct($message, $code, $previous); + } + + public function getErrorMessage(Request $request): string + { + $requiredPermission = Auth::getRequiredPermissionNameFromRequest($request); + if (!$requiredPermission) { + return 'Unauthorized Request'; + } + + return 'User is not authorized to ' . $requiredPermission; + } +} diff --git a/src/Expansions/Response.php b/src/Expansions/Response.php index a24f9e5..87d0689 100644 --- a/src/Expansions/Response.php +++ b/src/Expansions/Response.php @@ -4,6 +4,7 @@ use CompressJson\Core\Compressor; use Fleetbase\Build\Expansion; +use Fleetbase\Support\Auth; use Illuminate\Support\MessageBag; class Response implements Expansion @@ -19,19 +20,12 @@ public static function target() } /** - * Iterates request params until a param is found. + * Responds with a Fleetbase compatible error response. * * @return Closure */ public function error() { - /* - * Returns an error response. - * - * @param array $params - * @param mixed $default - * @return mixed - */ return function ($error, int $statusCode = 400, ?array $data = []) { if ($error instanceof MessageBag) { $error = $error->all(); @@ -47,6 +41,27 @@ public function error() }; } + /** + * Responds with a Fleetbase compatible error response. + * + * @return Closure + */ + public function authorizationError() + { + return function (?array $data = []) { + /* @var \Illuminate\Support\Facades\Response $this */ + $requiredPermission = Auth::getRequiredPermissionNameFromRequest(request()); + $error = 'User is not authorized to ' . $requiredPermission; + + return static::json( + array_merge([ + 'errors' => [$error], + ], $data), + 401 + ); + }; + } + /** * Formats a error response for the consumable API. * @@ -54,13 +69,6 @@ public function error() */ public function apiError() { - /* - * Returns an error response. - * - * @param array $params - * @param mixed $default - * @return mixed - */ return function ($error, int $statusCode = 400, ?array $data = []) { if ($error instanceof MessageBag) { $error = $error->all(); diff --git a/src/Http/Controllers/Internal/v1/GroupController.php b/src/Http/Controllers/Internal/v1/GroupController.php index 5a0fefa..a5f9d9d 100644 --- a/src/Http/Controllers/Internal/v1/GroupController.php +++ b/src/Http/Controllers/Internal/v1/GroupController.php @@ -20,6 +20,13 @@ class GroupController extends FleetbaseController */ public $resource = 'group'; + /** + * The service which this controller belongs to. + * + * @var string + */ + public $service = 'iam'; + /** * Creates a record with request payload. * diff --git a/src/Http/Controllers/Internal/v1/PolicyController.php b/src/Http/Controllers/Internal/v1/PolicyController.php index 0961f33..3179e1f 100644 --- a/src/Http/Controllers/Internal/v1/PolicyController.php +++ b/src/Http/Controllers/Internal/v1/PolicyController.php @@ -17,6 +17,13 @@ class PolicyController extends FleetbaseController */ public $resource = 'policy'; + /** + * The service which this controller belongs to. + * + * @var string + */ + public $service = 'iam'; + /** * Creates a record by an identifier with request payload. * diff --git a/src/Http/Controllers/Internal/v1/RoleController.php b/src/Http/Controllers/Internal/v1/RoleController.php index 494b9ce..b405307 100644 --- a/src/Http/Controllers/Internal/v1/RoleController.php +++ b/src/Http/Controllers/Internal/v1/RoleController.php @@ -5,6 +5,7 @@ use Fleetbase\Exceptions\FleetbaseRequestValidationException; use Fleetbase\Http\Controllers\FleetbaseController; use Fleetbase\Models\Permission; +use Fleetbase\Models\Policy; use Illuminate\Http\Request; class RoleController extends FleetbaseController @@ -16,6 +17,13 @@ class RoleController extends FleetbaseController */ public $resource = 'role'; + /** + * The service which this controller belongs to. + * + * @var string + */ + public $service = 'iam'; + /** * Creates a record by an identifier with request payload. * @@ -25,10 +33,17 @@ public function createRecord(Request $request) { try { $record = $this->model->createRecordFromRequest($request, null, function ($request, &$role) { + // Sync Permissions if ($request->isArray('role.permissions')) { $permissions = Permission::whereIn('id', $request->array('role.permissions'))->get(); $role->syncPermissions($permissions); } + + // Sync Policies + if ($request->isArray('role.policies')) { + $policies = Policy::whereIn('id', $request->array('role.policies'))->get(); + $role->syncPolicies($policies); + } }); return ['role' => new $this->resource($record)]; @@ -50,10 +65,17 @@ public function updateRecord(Request $request, string $id) { try { $record = $this->model->updateRecordFromRequest($request, $id, function ($request, &$role) { + // Sync Permissions if ($request->isArray('role.permissions')) { $permissions = Permission::whereIn('id', $request->array('role.permissions'))->get(); $role->syncPermissions($permissions); } + + // Sync Policies + if ($request->isArray('role.policies')) { + $policies = Policy::whereIn('id', $request->array('role.policies'))->get(); + $role->syncPolicies($policies); + } }); return ['role' => new $this->resource($record)]; diff --git a/src/Http/Controllers/Internal/v1/UserController.php b/src/Http/Controllers/Internal/v1/UserController.php index c36bbe1..f638fde 100644 --- a/src/Http/Controllers/Internal/v1/UserController.php +++ b/src/Http/Controllers/Internal/v1/UserController.php @@ -2,6 +2,7 @@ namespace Fleetbase\Http\Controllers\Internal\v1; +use Fleetbase\Attributes\SkipAuthorizationCheck; use Fleetbase\Events\UserRemovedFromCompany; use Fleetbase\Exceptions\FleetbaseRequestValidationException; use Fleetbase\Exports\UserExport; @@ -15,6 +16,8 @@ use Fleetbase\Models\Company; use Fleetbase\Models\CompanyUser; use Fleetbase\Models\Invite; +use Fleetbase\Models\Permission; +use Fleetbase\Models\Policy; use Fleetbase\Models\Setting; use Fleetbase\Models\User; use Fleetbase\Notifications\UserAcceptedCompanyInvite; @@ -38,6 +41,13 @@ class UserController extends FleetbaseController */ public $resource = 'user'; + /** + * The service which this controller belongs to. + * + * @var string + */ + public $service = 'iam'; + /** * Creates a record with request payload. * @@ -45,7 +55,7 @@ class UserController extends FleetbaseController */ public function createRecord(Request $request) { - // @todo Add user creation validation + // @TODO Add user creation validation try { $record = $this->model->createRecordFromRequest($request, function (&$request, &$input) { // Get user properties @@ -70,6 +80,60 @@ public function createRecord(Request $request) // Assign to user $user->assignCompany($company); + + // Assign role if set + if ($request->filled('user.role')) { + $user->assignSingleRole($request->input('user.role')); + } + + // Sync Permissions + if ($request->isArray('user.permissions')) { + $permissions = Permission::whereIn('id', $request->array('user.permissions'))->get(); + $user->syncPermissions($permissions); + } + + // Sync Policies + if ($request->isArray('user.policies')) { + $policies = Policy::whereIn('id', $request->array('user.policies'))->get(); + $user->syncPolicies($policies); + } + }); + + return ['user' => new $this->resource($record)]; + } catch (\Exception $e) { + return response()->error($e->getMessage()); + } catch (\Illuminate\Database\QueryException $e) { + return response()->error($e->getMessage()); + } catch (FleetbaseRequestValidationException $e) { + return response()->error($e->getErrors()); + } + } + + /** + * Updates a record with request payload. + * + * @return \Illuminate\Http\Response + */ + public function updateRecord(Request $request, string $id) + { + try { + $record = $this->model->updateRecordFromRequest($request, $id, function (&$request, &$user) { + // Assign role if set + if ($request->filled('user.role')) { + $user->assignSingleRole($request->input('user.role')); + } + + // Sync Permissions + if ($request->isArray('user.permissions')) { + $permissions = Permission::whereIn('id', $request->array('user.permissions'))->get(); + $user->syncPermissions($permissions); + } + + // Sync Policies + if ($request->isArray('user.policies')) { + $policies = Policy::whereIn('id', $request->array('user.policies'))->get(); + $user->syncPolicies($policies); + } }); return ['user' => new $this->resource($record)]; @@ -87,6 +151,7 @@ public function createRecord(Request $request) * * @return \Illuminate\Http\Response */ + #[SkipAuthorizationCheck] public function current(Request $request) { $user = $request->user(); @@ -107,6 +172,7 @@ public function current(Request $request) * * @return \Illuminate\Http\Response */ + #[SkipAuthorizationCheck] public function getTwoFactorSettings(Request $request) { $user = $request->user(); @@ -125,6 +191,7 @@ public function getTwoFactorSettings(Request $request) * * @return \Illuminate\Http\Response */ + #[SkipAuthorizationCheck] public function saveTwoFactorSettings(Request $request) { $twoFaSettings = $request->array('twoFaSettings'); @@ -144,6 +211,7 @@ public function saveTwoFactorSettings(Request $request) * * @return \Illuminate\Http\Response */ + #[SkipAuthorizationCheck] public function inviteUser(InviteUserRequest $request) { // $data = $request->input(['name', 'email', 'phone', 'status', 'country', 'date_of_birth']); @@ -201,6 +269,7 @@ public function inviteUser(InviteUserRequest $request) * * @return \Illuminate\Http\Response */ + #[SkipAuthorizationCheck] public function resendInvitation(ResendUserInvite $request) { $user = User::where('uuid', $request->input('user'))->first(); @@ -228,6 +297,7 @@ public function resendInvitation(ResendUserInvite $request) * * @return \Illuminate\Http\Response */ + #[SkipAuthorizationCheck] public function acceptCompanyInvite(AcceptCompanyInvite $request) { $invite = Invite::where('code', $request->input('code'))->with(['subject'])->first(); diff --git a/src/Http/Middleware/AuthorizationGuard.php b/src/Http/Middleware/AuthorizationGuard.php new file mode 100644 index 0000000..d70cd9c --- /dev/null +++ b/src/Http/Middleware/AuthorizationGuard.php @@ -0,0 +1,27 @@ +isNotAdmin() && $requiredPermissions->isNotEmpty() && $user->doesntHavePermissions($requiredPermissions)) { + return response()->authorizationError(); + } + + return $next($request); + } +} diff --git a/src/Http/Resources/Policy.php b/src/Http/Resources/Policy.php index c512702..05567af 100644 --- a/src/Http/Resources/Policy.php +++ b/src/Http/Resources/Policy.php @@ -19,12 +19,12 @@ public function toArray($request) '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, - 'permissions' => $this->serializePermissions($this->permissions), ]; } diff --git a/src/Http/Resources/Role.php b/src/Http/Resources/Role.php index 6a5369a..5b2a6dd 100644 --- a/src/Http/Resources/Role.php +++ b/src/Http/Resources/Role.php @@ -19,9 +19,10 @@ public function toArray($request) '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, - 'permissions' => $this->serializePermissions($this->permissions), ]; } diff --git a/src/Http/Resources/User.php b/src/Http/Resources/User.php index ecd8d23..6636f72 100644 --- a/src/Http/Resources/User.php +++ b/src/Http/Resources/User.php @@ -16,29 +16,53 @@ class User extends FleetbaseResource public function toArray($request) { return [ - 'id' => $this->when(Http::isInternalRequest(), $this->id, $this->public_id), - 'uuid' => $this->when(Http::isInternalRequest(), $this->uuid), - 'company_uuid' => $this->when(Http::isInternalRequest(), $this->company_uuid), - 'public_id' => $this->when(Http::isInternalRequest(), $this->public_id), - 'company' => $this->when(Http::isPublicRequest(), $this->company->public_id), - 'name' => $this->name, - 'username' => $this->username, - 'email' => $this->email, - 'phone' => $this->phone, - 'country' => $this->country, - 'timezone' => $this->timezone, - 'avatar_url' => $this->avatar_url, - 'meta' => $this->meta, - 'type' => $this->type, - 'types' => $this->when(Http::isInternalRequest(), $this->types ?? []), - 'company_name' => $this->when(Http::isInternalRequest(), $this->company_name), - 'session_status' => $this->when(Http::isInternalRequest(), $this->session_status), - 'is_admin' => $this->when(Http::isInternalRequest(), $this->is_admin), - 'is_online' => $this->is_online, - 'last_seen_at' => $this->last_seen_at, - 'last_login' => $this->last_login, - 'updated_at' => $this->updated_at, - 'created_at' => $this->created_at, + 'id' => $this->when(Http::isInternalRequest(), $this->id, $this->public_id), + 'uuid' => $this->when(Http::isInternalRequest(), $this->uuid), + 'company_uuid' => $this->when(Http::isInternalRequest(), $this->company_uuid), + 'public_id' => $this->when(Http::isInternalRequest(), $this->public_id), + 'company' => $this->when(Http::isPublicRequest(), $this->company->public_id), + 'name' => $this->name, + 'username' => $this->username, + 'email' => $this->email, + 'phone' => $this->phone, + 'country' => $this->country, + 'timezone' => $this->timezone, + 'avatar_url' => $this->avatar_url, + 'meta' => $this->meta, + 'role' => $this->when(Http::isInternalRequest(), new Role($this->role), null), + 'policies' => $this->when(Http::isInternalRequest(), Policy::collection($this->policies), []), + 'permissions' => $this->when(Http::isInternalRequest(), $this->serializePermissions($this->permissions), []), + 'type' => $this->type, + 'types' => $this->when(Http::isInternalRequest(), $this->types ?? []), + 'company_name' => $this->when(Http::isInternalRequest(), $this->company_name), + 'session_status' => $this->when(Http::isInternalRequest(), $this->session_status), + 'is_admin' => $this->when(Http::isInternalRequest(), $this->is_admin), + 'is_online' => $this->is_online, + 'last_seen_at' => $this->last_seen_at, + 'last_login' => $this->last_login, + 'updated_at' => $this->updated_at, + 'created_at' => $this->created_at, ]; } + + /** + * Map permissins into the correct format with regard to pivot. + * + * @param \Illuminate\Support\Collection $permissions + */ + public function serializePermissions($permissions): \Illuminate\Support\Collection + { + return $permissions->map( + function ($permission) { + return [ + 'id' => $permission->pivot->permission_id, + 'name' => $permission->name, + 'guard_name' => $permission->guard_name, + 'description' => $permission->description, + 'updated_at' => $permission->updated_at, + 'created_at' => $permission->created_at, + ]; + } + ); + } } diff --git a/src/Models/Permission.php b/src/Models/Permission.php index 90e975d..7310589 100644 --- a/src/Models/Permission.php +++ b/src/Models/Permission.php @@ -6,6 +6,7 @@ use Fleetbase\Traits\HasApiModelBehavior; use Fleetbase\Traits\HasUuid; use Fleetbase\Traits\Searchable; +use Illuminate\Support\Collection; use Spatie\Permission\Models\Permission as BasePermission; class Permission extends BasePermission @@ -62,4 +63,25 @@ public function scopeWithTrashed($query) { return $query; } + + /** + * Finds permissions by their names. + * + * This method takes an array of permission names and returns a collection of + * permission models that match any of the given names. + * + * @param array $names The permission names to search for + * + * @return Collection A collection of permission models + */ + public static function findByNames(array $names = []): Collection + { + return Permission::where(function ($query) use ($names) { + $firstName = array_shift($names); + $query->where('name', $firstName); + foreach ($names as $name) { + $query->orWhere('name', $name); + } + })->get(); + } } diff --git a/src/Models/Policy.php b/src/Models/Policy.php index 8532813..109f012 100644 --- a/src/Models/Policy.php +++ b/src/Models/Policy.php @@ -2,6 +2,7 @@ namespace Fleetbase\Models; +use Fleetbase\Contracts\Policy as PolicyContract; use Fleetbase\Traits\Filterable; use Fleetbase\Traits\HasApiModelBehavior; use Fleetbase\Traits\HasUuid; @@ -9,7 +10,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; use Spatie\Permission\Traits\HasPermissions; -class Policy extends Model +class Policy extends Model implements PolicyContract { use HasUuid; use HasApiModelBehavior; @@ -21,7 +22,7 @@ class Policy extends Model /** @__construct */ public function __construct(array $attributes = []) { - $attributes['guard_name'] = $attributes['guard_name'] ?? config('auth.defaults.guard'); + $attributes['guard_name'] = $attributes['guard_name'] ?? config('auth.defaults.guard', 'sanctum'); parent::__construct($attributes); } @@ -45,7 +46,7 @@ public function __construct(array $attributes = []) * * @var string */ - public $guard_name = 'api'; + public $guard_name = 'sanctum'; /** * The column to use for generating uuid. @@ -137,12 +138,57 @@ public function getTypeAttribute() } /** - * Default guard should be `web`. + * Default guard should be `sanctum`. * * @return void */ public function setGuardNameAttribute() { - $this->attributes['guard_name'] = 'web'; + $this->attributes['guard_name'] = 'sanctum'; + } + + /** + * Find a policy by its name and guard name. + * + * @param string|null $guardName + * + * @return \Fleebase\Models\Policy + * + * @throws \Fleetbase\Exceptions\PolicyDoesNotExist + */ + public static function findByName(string $name, $guardName): self + { + return static::where(['name' => $name, 'guard_name' => $guardName])->first(); + } + + /** + * Find a policy by its id and guard name. + * + * @param string|null $guardName + * + * @return \Fleebase\Models\Policy + * + * @throws \Fleetbase\Exceptions\PolicyDoesNotExist + */ + public static function findById(string $id, $guardName): self + { + return static::where(['id' => $id, 'guard_name' => $guardName])->first(); + } + + /** + * Find or create a policy by its name and guard name. + * + * @param string|null $guardName + * + * @return \Fleebase\Policy + */ + public static function findOrCreate(string $name, $guardName): self + { + $policy = static::findByName($name, $guardName); + if (!$policy) { + $policy = static::create(['name' => $name, 'guard_name' => $guardName]); + } + + return $policy; } } diff --git a/src/Models/Role.php b/src/Models/Role.php index d2a0c93..c422374 100644 --- a/src/Models/Role.php +++ b/src/Models/Role.php @@ -40,6 +40,13 @@ class Role extends BaseRole */ public $keyType = 'string'; + /** + * The default guard for this model. + * + * @var string + */ + public $guard_name = 'sanctum'; + /** * Indicates if the IDs are auto-incrementing. * @@ -105,12 +112,12 @@ public function setPermissionsAttribute() } /** - * Default guard should be `web`. + * Default guard should be `sanctum`. * * @return void */ public function setGuardNameAttribute() { - $this->attributes['guard_name'] = 'web'; + $this->attributes['guard_name'] = 'sanctum'; } } diff --git a/src/Models/User.php b/src/Models/User.php index d5fb6a2..06948b4 100644 --- a/src/Models/User.php +++ b/src/Models/User.php @@ -13,6 +13,7 @@ use Fleetbase\Traits\HasApiModelBehavior; use Fleetbase\Traits\HasCacheableAttributes; use Fleetbase\Traits\HasMetaAttributes; +use Fleetbase\Traits\HasPolicies; use Fleetbase\Traits\HasPresence; use Fleetbase\Traits\HasPublicId; use Fleetbase\Traits\HasUuid; @@ -22,6 +23,8 @@ use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Illuminate\Support\Carbon; +use Illuminate\Support\Collection; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Str; use Laravel\Sanctum\HasApiTokens; @@ -40,6 +43,7 @@ class User extends Authenticatable use Searchable; use Notifiable; use HasRoles; + use HasPolicies; use HasApiTokens; use HasSlug; use HasApiModelBehavior; @@ -95,6 +99,13 @@ class User extends Authenticatable */ protected $publicIdType = 'user'; + /** + * The default guard for this model. + * + * @var string + */ + public $guard_name = 'sanctum'; + /** * The attributes that can be queried. * @@ -220,42 +231,28 @@ protected static function boot() /** * The company this user belongs to. - * - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ - public function company() + public function company(): \Illuminate\Database\Eloquent\Relations\BelongsTo { return $this->belongsTo(Company::class); } - /** - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo - */ - public function avatar() + public function avatar(): \Illuminate\Database\Eloquent\Relations\BelongsTo { return $this->belongsTo(File::class); } - /** - * @return \Illuminate\Database\Eloquent\Relations\HasMany - */ - public function devices() + public function devices(): \Illuminate\Database\Eloquent\Relations\HasMany { return $this->hasMany(UserDevice::class); } - /** - * @return \Illuminate\Database\Eloquent\Relations\HasMany - */ - public function companies() + public function companies(): \Illuminate\Database\Eloquent\Relations\HasMany { return $this->hasMany(CompanyUser::class, 'user_uuid'); } - /** - * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany - */ - public function groups() + public function groups(): \Illuminate\Database\Eloquent\Relations\HasManyThrough { return $this->hasManyThrough(Group::class, GroupUser::class, 'user_uuid', 'uuid', 'uuid', 'group_uuid'); } @@ -341,6 +338,11 @@ public function assignCompanyFromId(?string $id): User return $this; } + public function getRoleAttribute(): ?Role + { + return $this->roles()->first(); + } + /** * @return string */ @@ -850,4 +852,75 @@ public function getIsOnlineAttribute() { return $this->isOnline(); } + + /** + * Assign a single role to the user, removing any existing roles. + * + * This method deletes all existing roles assigned to the user and then assigns the specified role. + * + * @param string|int|\Spatie\Permission\Models\Role $role The role to assign to the user (can be a role name, ID, or instance) + * + * @return User The assigned role instance + */ + public function assignSingleRole($role): self + { + DB::table('model_has_roles')->where('model_uuid', $this->uuid)->delete(); + + return $this->assignRole($role); + } + + /** + * Return all the permissions the model has, both directly and via roles and policies. + */ + public function getAllPermissions() + { + /** @var \Illuminate\Database\Eloquent\Model|\Spatie\Permission\Traits\HasPermissions $this */ + /** @var Collection $permissions */ + $permissions = $this->permissions; + + if (method_exists($this, 'roles')) { + $permissions = $permissions->merge($this->getPermissionsViaRoles()); + } + + if (method_exists($this, 'policies')) { + $permissions = $permissions->merge($this->getPermissionsViaPolicies()); + $permissions = $permissions->merge($this->getPermissionsViaRolePolicies()); + } + + return $permissions->sort()->values(); + } + + /** + * Checks if the user has any of the given permissions. + * + * This method checks if the user's attached permissions intersect with the given permissions. + * If there is at least one matching permission, the method returns true. + * + * @param Collection|array $permissions The permissions to check against + * + * @return bool True if the user has any of the given permissions, false otherwise + */ + public function hasPermissions(Collection|array $permissions): bool + { + $attachedPermissions = $this->getAllPermissions(); + + return $attachedPermissions->filter(function ($permission) use ($permissions) { + return $permissions->contains($permission); + })->isNotEmpty(); + } + + /** + * Checks if the user does not have any of the given permissions. + * + * This method checks if the user's attached permissions do not intersect with the given permissions. + * If there are no matching permissions, the method returns true. + * + * @param Collection|array $permissions The permissions to check against + * + * @return bool True if the user does not have any of the given permissions, false otherwise + */ + public function doesntHavePermissions(Collection|array $permissions): bool + { + return !$this->hasPermissions($permissions); + } } diff --git a/src/Providers/CoreServiceProvider.php b/src/Providers/CoreServiceProvider.php index 7677393..8856565 100644 --- a/src/Providers/CoreServiceProvider.php +++ b/src/Providers/CoreServiceProvider.php @@ -41,6 +41,7 @@ class CoreServiceProvider extends ServiceProvider \Illuminate\Session\Middleware\StartSession::class, 'auth:sanctum', \Fleetbase\Http\Middleware\SetupFleetbaseSession::class, + \Fleetbase\Http\Middleware\AuthorizationGuard::class, \Fleetbase\Http\Middleware\TrackPresence::class, \Spatie\ResponseCache\Middlewares\CacheResponse::class, \Fleetbase\Http\Middleware\ClearCacheAfterDelete::class, @@ -239,7 +240,7 @@ public function mergeConfigFromSettings() if ($value) { // some settings should set env variables to be accessed throughout entire application if (in_array($settingsKey, array_keys($putsenv))) { - $environmentVariables = $putsenv[$settingsKey]; + $environmentVariables = $putsenv[$settingsKey] ?? ''; foreach ($environmentVariables as $configEnvKey => $envKey) { // hack fix for aws set envs @@ -254,7 +255,7 @@ public function mergeConfigFromSettings() // only set if env variable is not set already if ($doesntHaveEnvSet && $hasValue) { - putenv($envKey . '="' . data_get($value, $configEnvKey) . '"'); + putenv($envKey . '="' . data_get($value, $configEnvKey, '') . '"'); } } } diff --git a/src/Support/ActionMapper.php b/src/Support/ActionMapper.php new file mode 100644 index 0000000..9f2d830 --- /dev/null +++ b/src/Support/ActionMapper.php @@ -0,0 +1,113 @@ + 'create', + 'updateRecord' => 'update', + 'deleteRecord' => 'delete', + 'findRecord' => 'view', + 'queryRecord' => 'list', + ]; + + /** + * Map of HTTP methods to permission actions. + * + * @var array + */ + private const METHOD_MAP = [ + 'POST' => 'create', + 'PUT' => 'update', + 'PATCH' => 'update', + 'DELETE' => 'delete', + 'GET' => 'view', + ]; + + /** + * Maps a controller action to a permission action based on the request method. + * + * This method takes a controller action and a request method, and returns the + * corresponding permission action. If the controller action is found in the + * ACTION_MAP, it returns the mapped permission action. Otherwise, it returns + * the mapped permission action from the METHOD_MAP. + * + * @param string $method The controller action + * @param string $requestMethod The request method + * + * @return string|null The mapped permission action, or null if not found + */ + public function mapAction(string $method, string $requestMethod): ?string + { + return self::ACTION_MAP[$method] ?? self::METHOD_MAP[$requestMethod]; + } + + /** + * Maps a controller action to a permission action based on the request method using a static instance. + * + * This method is a static wrapper around the `mapAction` method, which allows + * for mapping a controller action to a permission action without having to + * instantiate the `ActionMapper` class. + * + * @param string $method The controller action + * @param string $requestMethod The request method + * + * @return string|null The mapped permission action, or null if not found + */ + public static function getAction(string $method, string $requestMethod): ?string + { + return app(static::class)->mapAction($method, $requestMethod); + } + + /** + * Maps a controller action to a permission action based on the request method from a request object. + * + * This method takes a request object, extracts the controller action and request + * method, and returns the corresponding permission action using the `getAction` + * method. + * + * @param Request $request The request object + * + * @return string|null The mapped permission action, or null if not found + */ + public static function getFromRequest(Request $request): ?string + { + $route = $request->route(); + $controllerNamespace = $route->getAction('controller'); + [, $method] = explode('@', $controllerNamespace); + + return static::getAction($method, $request->method()); + } + + /** + * Resolves a permission action from a request object. + * + * This method is a static wrapper around the `getFromRequest` method, which + * allows for resolving a permission action from a request object without + * having to instantiate the `ActionMapper` class. + * + * @param Request $request The request object + * + * @return string|null The resolved permission action, or null if not found + */ + public static function resolve(Request $request): ?string + { + return static::getFromRequest($request); + } +} diff --git a/src/Support/Auth.php b/src/Support/Auth.php index 4b63b01..7db260e 100644 --- a/src/Support/Auth.php +++ b/src/Support/Auth.php @@ -2,11 +2,14 @@ namespace Fleetbase\Support; +use Fleetbase\Attributes\SkipAuthorizationCheck; use Fleetbase\Models\ApiCredential; use Fleetbase\Models\Company; use Fleetbase\Models\CompanyUser; +use Fleetbase\Models\Permission; use Fleetbase\Models\User; use Illuminate\Http\Request; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Auth as Authentication; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Str; @@ -230,8 +233,16 @@ public static function getCompanyFromRequest(Request $request): Company * * @return User|null returns an instance of the User model if authenticated, or null if no user is authenticated */ - public static function getUserFromSession() + public static function getUserFromSession(?Request $request = null): ?User { + // If request passed try to resolve directly from the request + if ($request) { + $user = $request->user(); + if ($user instanceof User) { + return $user; + } + } + // Attempt to retrieve the user using the extended Auth class method $user = auth()->user(); if ($user instanceof User) { @@ -293,4 +304,48 @@ public static function getCompanySessionForUser(User $user): ?Company return null; } + + /** + * Resolves the required permissions from the given request. + * + * This method resolves the controller, action, and resource from the request, + * and then constructs the permission names based on the service, action, and + * resource. It then uses the Permission model to find the permissions that + * match the constructed names. + * + * @param Request $request The HTTP request + * + * @return Collection A collection of permission models + */ + public static function resolvePermissionsFromRequest(Request $request): Collection + { + // If method has skip authorization check + if (ControllerResolver::methodHasAttribute($request, SkipAuthorizationCheck::class)) { + return collect(); + } + + $controller = ControllerResolver::resolve($request); + if (!method_exists($controller, 'getResourceSingularName')) { + return collect(); + } + + $action = ActionMapper::resolve($request); + $resource = $controller->getResourceSingularName(); + $service = $controller->getService(); + + $permissionName = implode(' ', [$service, $action, $resource]); + $permissionWildcardName = implode(' ', [$service, '*', $resource]); + $permissionWildcardServiceName = implode(' ', [$service, '*']); + + return Permission::findByNames([$permissionName, $permissionWildcardName, $permissionWildcardServiceName]); + } + + public static function getRequiredPermissionNameFromRequest(Request $request): string + { + $controller = ControllerResolver::resolve($request); + $action = ActionMapper::resolve($request); + $resource = $controller->getResourceSingularName(); + + return implode(' ', [$action, $resource]); + } } diff --git a/src/Support/ControllerResolver.php b/src/Support/ControllerResolver.php new file mode 100644 index 0000000..faa2e55 --- /dev/null +++ b/src/Support/ControllerResolver.php @@ -0,0 +1,80 @@ +route(); + $controllerNamespace = $route->getAction('controller'); + [$controller] = explode('@', $controllerNamespace); + + return app($controller); + } + + /** + * Resolves a controller instance from a given request using a static instance. + * + * This method is a static wrapper around the `resolveController` method, which + * allows for resolving a controller instance without having to instantiate + * the `ControllerResolver` class. + * + * @param Request $request The request object + * + * @return Controller The resolved controller instance + */ + public static function resolve(Request $request): Controller + { + return app(static::class)->resolveController($request); + } + + /** + * Checks if a controller method has a specific attribute. + * + * This function resolves the controller instance and method from the given request, + * and then checks if the method has an attribute with the given name. + * + * @param Request $request The request object + * @param string $attribute The fully qualified class name of the attribute to check for + * + * @return bool True if the method has the attribute, false otherwise + */ + public static function methodHasAttribute(Request $request, $attribute): bool + { + $controller = static::resolve($request); + $method = $request->route()->getActionMethod(); + $reflectionMethod = new \ReflectionMethod($controller, $method); + $attributes = $reflectionMethod->getAttributes(); + + $skipAuthorizationCheck = false; + foreach ($attributes as $attr) { + if ($attr->getName() === $attribute) { + $skipAuthorizationCheck = true; + break; + } + } + + return $skipAuthorizationCheck; + } +} diff --git a/src/Support/Http.php b/src/Support/Http.php index b3715a4..8e3e7c5 100644 --- a/src/Support/Http.php +++ b/src/Support/Http.php @@ -42,7 +42,7 @@ public static function useSort($sort): array } $param = $sort; - $direction = 'desc'; + $direction = 'asc'; if (Str::startsWith($sort, '-')) { $direction = Str::startsWith($sort, '-') ? 'desc' : 'asc'; diff --git a/src/Traits/HasApiControllerBehavior.php b/src/Traits/HasApiControllerBehavior.php index 183f31e..5f1eff5 100644 --- a/src/Traits/HasApiControllerBehavior.php +++ b/src/Traits/HasApiControllerBehavior.php @@ -52,6 +52,13 @@ trait HasApiControllerBehavior */ public $resource; + /** + * The target Service the controller belongs to. + * + * @var string + */ + public $service; + /** * The target API Filter. * @@ -117,6 +124,7 @@ public function setApiModel(?Model $model = null, string $namespace = '\\Fleetba $this->modelClassName = $modelName = Utils::getModelClassName($model ?? $this->resource, $namespace); $this->model = $model = Resolve::instance($modelName); $this->resource = $this->getApiResourceForModel($model, $namespace); + $this->service = $this->getApiServiceFromNamespace($namespace); $this->request = $this->getApiRequestForModel($model, $namespace); $this->resourcePluralName = $model->getPluralName(); $this->resourceSingularlName = $model->getSingularName(); @@ -146,6 +154,48 @@ public function setApiFormRequest($request) $this->request = is_object($request) ? get_class($request) : $request; } + /** + * Returns the API service name associated with the given namespace or the current class. + * + * If no namespace is provided, it defaults to the current class namespace. + * If the service is not already set, it is generated from the namespace using the getServiceNameFromNamespace method. + * + * @param string|null $namespace The namespace to generate the service name from (optional) + * + * @return string The API service name + */ + public function getApiServiceFromNamespace(?string $namespace = null) + { + $namespace = $namespace ?? get_class($this); + $service = $this->service; + + if (!$service) { + $service = static::getServiceNameFromNamespace($namespace); + } + + return $service; + } + + /** + * Generates a slugified service name from a given namespace. + * + * The service name is generated by taking the first or second segment of the namespace (depending on the number of segments), + * slugifying it by inserting dashes before uppercase letters, and converting it to lowercase. + * + * @param string $namespace The namespace to generate the service name from + * + * @return string The generated service name + */ + private function getServiceNameFromNamespace(string $namespace) + { + $segments = explode('\\', $namespace); + $targetSegment = count($segments) < 2 ? $segments[0] : $segments[1]; + $slugifiedSegment = preg_replace('/(?<=[a-z])(?=[A-Z])/', '-', $targetSegment); + $slugifiedSegment = strtolower($slugifiedSegment); + + return $slugifiedSegment; + } + /** * Resolves the api resource for this model. * @@ -182,6 +232,31 @@ public function getApiRequestForModel(Model $model, ?string $namespace = null) return $request; } + /** + * Gets the singular name of the resource. + * + * Returns the singular name of the resource, e.g. "user" for a UserController. + * + * @return string The singular name of the resource + */ + public function getResourceSingularName(): string + { + return $this->resourceSingularlName; + } + + /** + * Gets the service associated with the controller. + * + * Returns the fully qualified name of the service namespace that is used by + * the controller to perform business logic operations. + * + * @return string The fully qualified name of the service + */ + public function getService(): string + { + return $this->service; + } + /** * Resolves the resource form request and validates. * diff --git a/src/Traits/HasPolicies.php b/src/Traits/HasPolicies.php index adb759a..c63f7d8 100644 --- a/src/Traits/HasPolicies.php +++ b/src/Traits/HasPolicies.php @@ -2,18 +2,20 @@ namespace Fleetbase\Traits; -use Fleetbase\Contracts\Policy; +use Fleetbase\Models\Permission; +use Fleetbase\Models\Policy; +use Fleetbase\Models\Role; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Support\Collection; -use Spatie\Permission\PermissionRegistrar; +use Illuminate\Support\Str; use Spatie\Permission\Traits\HasPermissions; trait HasPolicies { use HasPermissions; - private $policyClass; + private $policyClass = Policy::class; public static function bootHasPolicies() { @@ -28,11 +30,7 @@ public static function bootHasPolicies() public function getPolicyClass() { - if (!isset($this->policyClass)) { - $this->policyClass = app(PermissionRegistrar::class)->getPolicyClass(); - } - - return $this->policyClass; + return app($this->policyClass); } /** @@ -40,11 +38,12 @@ public function getPolicyClass() */ public function policies(): BelongsToMany { + /** @var \Illuminate\Database\Eloquent\Model|HasPermissions $this */ return $this->morphToMany( - \Fleetbase\Models\Policy::class, + Policy::class, 'model', 'model_has_policies', - 'model_uuid', + config('permission.column_names.model_morph_key'), 'policy_id' ); } @@ -92,18 +91,18 @@ public function assignPolicy(...$policies) { $policies = collect($policies) ->flatten() - ->map(function ($role) { - if (empty($role)) { + ->map(function ($policy) { + if (empty($policy)) { return false; } - return $this->getStoredPolicy($role); + return $this->getStoredPolicy($policy); }) - ->filter(function ($role) { - return $role instanceof Policy; + ->filter(function ($policy) { + return $policy instanceof Policy; }) - ->each(function ($role) { - $this->ensureModelSharesGuard($role); + ->each(function ($policy) { + $this->ensureModelSharesGuard($policy); }) ->map->id ->all(); @@ -256,19 +255,19 @@ public function getPolicyNames(): Collection return $this->policies->pluck('name'); } - protected function getStoredPolicy($role): Policy + protected function getStoredPolicy($policy): Policy { $policyClass = $this->getPolicyClass(); - if (is_numeric($role)) { - return $policyClass->findById($role, $this->getDefaultGuardName()); + if (Str::isUuid($policy)) { + return $policyClass->findById($policy, $this->getDefaultGuardName()); } - if (is_string($role)) { - return $policyClass->findByName($role, $this->getDefaultGuardName()); + if (is_string($policy)) { + return $policyClass->findByName($policy, $this->getDefaultGuardName()); } - return $role; + return $policy; } protected function _convertPipeToArray(string $pipeString) @@ -292,4 +291,36 @@ protected function _convertPipeToArray(string $pipeString) return explode('|', trim($pipeString, $quoteCharacter)); } + + public function getPermissionsViaRolePolicies() + { + if (is_a($this, Policy::class) || is_a($this, Role::class) || is_a($this, Permission::class)) { + return collect(); + } + + /** @var \Illuminate\Database\Eloquent\Model|HasPermissions $this */ + $roles = $this->loadMissing(['roles', 'roles.policies', 'roles.policies.permissions'])->roles; + $rolePolicies = collect(); + foreach ($roles as $role) { + $rolePolicies = $rolePolicies->merge($role->policies); + } + + return $rolePolicies->flatMap(fn ($policy) => $policy->permissions) + ->sort()->values(); + } + + /** + * Return all the permissions the model has via policies. + */ + public function getPermissionsViaPolicies() + { + if (is_a($this, Policy::class) || is_a($this, Permission::class)) { + return collect(); + } + + /** @var \Illuminate\Database\Eloquent\Model|HasPermissions $this */ + return $this->loadMissing('policies', 'policies.permissions') + ->policies->flatMap(fn ($policy) => $policy->permissions) + ->sort()->values(); + } }