Working with Yii framework I've got a question which I can not solve on my own.
How can I nicely check access to certain attributes of a model?
Context:
I use RBAC, a user may have multiple roles at the same time
I have a model with a bunch of attributes (for example, let's call it Profile)
Some attributes are allowed to be edited by any registered user (email, age, favorite color, etc.), and some - only by the users with some specific role (is_banned - can be changed by moderator or administrator, balance - can be changed only by administrators, etc.)
What I've done already:
At the moment I see only one possible way to accomplish this task:
function actionUpdate($id)
{
$model = Profile::model()->findByPk($id);
if (!$this->user->checkAccess('editProfile')) {
throw new AccessDeniedException();
}
if (isset($_POST['is_banned'])) {
if (!$this->user->checkAccess('toggleBan')) {
unset($_POST['is_banned']);
}
}
if (isset($_POST['balance'])) {
if (!$this->user->checkAccess('changeBalance')) {
unset($_POST['balance']);
}
}
$model->setAttributes($_POST);
$model->save();
}
Are there some better ways to solve such task? Thanks.
Consider using model scenarios (http://www.yiiframework.com/wiki/266/understanding-scenarios/):
Add scenario condition to your model rules:
public function rules()
{
return array(
array('balance', 'integer', 'on' => 'admin'),
);
}
Set model scenario in controller:
if ($this->user->checkAccess('admin')) {
$model->scenario = 'admin';
}
Now attributes that available by scenario are saved only.
Consider also using scenarios in search models.
Related
I have a Location Model, which contains two properties: ID and Name.
To edit this Model, I have set up this route:
Route::get('administration/location/{location}/edit', 'LocationController#edit')->name('location.edit');
I set up very simple permissions: In the AuthServiceProvider I am checking in the boot method the following
Gate::before(function ($user, $permission) {
if ($user->permissions->pluck('name')->contains($permission)) {
return true;
}
});
Where permission is a Model that contains an ID and a name, mapped via a permission_user table.
I have these permissions set up:
edit_los_angeles
edit_new_york
edit_boston
plenty_of_other_permissions_not_related_to_location
After all this rambling, my actual question:
How can I tie these permissions to the edit the location?
The problem that I am facing is, that a given user is not allowed to edit all locations but may only be allowed to edit one location. Only the user with permission edit_los_angeles would be allowed to edit the Location with the name Los Angeles.
So I cannot group this into one permission like edit_location and add this to my route ->middleware('can:edit_location').
Instead, I would need something like this, I guess:
Route::get('administration/location/{location}/edit', 'LocationController#edit')->name('location.edit')->middleware('can:edit_los_angeles');
Route::get('administration/location/{location}/edit', 'LocationController#edit')->name('location.edit')->middleware('can:edit_new_york');
Route::get('administration/location/{location}/edit', 'LocationController#edit')->name('location.edit')->middleware('can:edit_boston');
...obviously this would not work.
What would be your approach to tackle this dilemma? :-)
Maybe I am doing something completely wrong and there is a better Laravel-Way of doing this?
Thank you very much for your help in advance!
I am using Laravel 6.0 :-)
Two assumption for my approach to work, use model binding in the controller (you should do that no matter what). Secondly there needs to be a relation between location and the permission it needs, something similar to the slug you suggested.
Your controller function would look something like this. Adding a FormRequest is a good approach for doing this logic.
class LocationController {
public function edit(EditLocationRequest $request, Location $location) { // implicit model binding
...
}
}
For ease of use, i would also make a policy.
class LocationPolicy
{
public function edit(User $user, Location $location) {
return $user->permissions->pluck('name')
->contains($location->permission_slug); // assuming we have a binding
}
}
Remember to register policy in the AuthServiceProvider.php.
protected $policies = [
Location::class => LocationPolicy::class,
];
Now in your form request consume the policy in the authorize method. From here you are in a request context, you can access user on $this->user() and you can access all models that are model binding on their name for example $this->location.
class EditLocationRequest
{
public function authorize(): bool
{
return $this->user()->can('edit', $this->location);
}
}
Now you should be able to only have a single route definition.
Route::get('administration/location/{location}/edit', 'LocationController#edit')->name('location.edit');
EDIT
Withouth the form request if you use the trait AuthorizesRequests you can do the following. This will throw an AuthorizationException of it fails.
use AuthorizesRequests;
public function edit() {
$this->authorize('edit', $location);
}
If you have a requirement based upon the location relationship, then you will need to capture that relationship in the data. A good starting point to this would be to add a pivot table specific for these editing permissions. Consider a table, location_permissions, with a user_id and a location_id. You could then modify or add permission middleware to do a check for a record in this table once you have a specific user and location.
Edit: to answer the question about implementation of middleware,
The crux of the implementation would likely be solved by defining a relationship on the user model to location via this new pivot table.
I would recommend then adding an additional method which consumes the new locations relationship to the model along the lines of
public function canEditLocation(Location $location): bool {
return $this->locations
->where('location_id', '=', $location->id)
->count() > 0;
}
And the actual middleware something along these lines:
public function handle($request, Closure $next, $location)
{
if (! $request->user()->canEditLocation($location)) {
\\handle failed permission as appropriate here.
}
return $next($request);
}
My middleware parameters knowledge is rusty, but I believe that is correct as defined at https://laravel.com/docs/master/middleware#middleware-parameters
So what i'm basically trying to do is refactor my long bit of code to something more simpler. I found this snippet of code at this website and I don't really understand what's going on inside the code. I don't think that this snippet of code will work considering I am using different policies and methods then what's standard.
Code Snippet From Site:
//PermissionsServiceProvider.php
public function boot()
{
Permission::get()->map(function($permission){
Gate::define($permission->slug, function($user) use ($permission){
return $user->hasPermissionTo($permission);
});
});
}
Can someone please explain what exactly is going on in this bit of code?
My Code:
// Posts Policy
Gate::define('post.view', 'App\Policies\Blog\PostsPolicy#view');
Gate::define('post.create', 'App\Policies\Blog\PostsPolicy#create');
Gate::define('post.update', 'App\Policies\Blog\PostsPolicy#update');
Gate::define('post.delete', 'App\Policies\Blog\PostsPolicy#delete');
Gate::define('post.publish', 'App\Policies\Blog\PostsPolicy#publish');
Gate::define('post.edit', 'App\Policies\Blog\PostsPolicy#edit');
Gate::define('post.global', 'App\Policies\Blog\PostsPolicy#global');
// Categories Policy
Gate::define('category.view', 'App\Policies\Blog\CategoriesPolicy#view');
Gate::define('category.create', 'App\Policies\Blog\CategoriesPolicy#create');
Gate::define('category.update', 'App\Policies\Blog\CategoriesPolicy#update');
Gate::define('category.delete', 'App\Policies\Blog\CategoriesPolicy#delete');
Gate::define('category.edit', 'App\Policies\Blog\CategoriesPolicy#edit');
Gate::define('category.global', 'App\Policies\Blog\CategoriesPolicy#global');
// Tags Policy
Gate::define('tag.view', 'App\Policies\Blog\TagsPolicy#view');
Gate::define('tag.create', 'App\Policies\Blog\TagsPolicy#create');
Gate::define('tag.update', 'App\Policies\Blog\TagsPolicy#update');
Gate::define('tag.delete', 'App\Policies\Blog\TagsPolicy#delete');
Gate::define('tag.edit', 'App\Policies\Blog\TagsPolicy#edit');
Gate::define('tag.global', 'App\Policies\Blog\TagsPolicy#global');
// Parts Section Policy
Gate::define('part.section.view', 'App\Policies\Parts\PartSectionsPolicy#view');
Gate::define('part.section.create', 'App\Policies\Parts\PartSectionsPolicy#create');
Gate::define('part.section.update', 'App\Policies\Parts\PartSectionsPolicy#update');
Gate::define('part.section.delete', 'App\Policies\Parts\PartSectionsPolicy#delete');
Gate::define('part.section.edit', 'App\Policies\Parts\PartSectionsPolicy#edit');
Gate::define('part.section.global', 'App\Policies\Parts\PartSectionsPolicy#global');
// Parts Policy
Gate::define('part.view', 'App\Policies\Parts\PartsPolicy#view');
Gate::define('part.create', 'App\Policies\Parts\PartsPolicy#create');
Gate::define('part.update', 'App\Policies\Parts\PartsPolicy#update');
Gate::define('part.delete', 'App\Policies\Parts\PartsPolicy#delete');
Gate::define('part.edit', 'App\Policies\Parts\PartsPolicy#edit');
Gate::define('part.global', 'App\Policies\Parts\PartsPolicy#global');
// Admin Management Policy
Gate::define('admin.global', 'App\Policies\AdminManagementPolicy#global');
// User Management Policy
Gate::define('user.global', 'App\Policies\UserManagementPolicy#global');
Is there a way to do this as a foreach loop from my permissions table? Here's some Pseudo code:
foreach($permissions as $permission) {
Gate::define($permission->slug, 'App\Policies\' . $permission->category . 'Policy#' . $permission->name);
}
Question: Any way to make my code more compact and easier to read like the code snippet from the website?
First of all, the author of that article did not use policies at all, he created a permissions table and then bound the permissions he created to laravel gates by the code snippet
Permission::get()->map(function($permission){
Gate::define($permission->slug, function($user) use ($permission){
return $user->hasPermissionTo($permission);
});
});
Let's break it line by line
Permission::get() // Query all permissions defined in permissions database table
->map(function($permission){ // Foreach permission do the following
Gate::define($permission->slug, // Create new gate with the permission slug
function($user) use ($permission){
return $user->hasPermissionTo($permission); // the user table has many to many relation with permissions table, here we only check if $user is associated with $permission
});
});
To make your code more dynamic, I suggest you to do the following:
Database structure
Create permission database table
Create roles database table
Create permission_role pivot database table
Create role_user pivot database table
Define Relationships
Role has many permissions ( many to many relationship, define it with belongsToMany )
Permission belongs to many roles ( many to many relationship, define it with belongsToMany )
User has many roles ( many to many relationship, define it with belongsToMany )
Reduce the number of global permissions
By utilising Gate::before you can allow specific user who has global or root permission to authorise all defined abilities:
Gate::before(function ($user, $ability) {
if ($user->hasPermission('root-access')) {
return true;
}
});
If you implement the database permissions you no longer need to create policies for every model, and the gates will be defined using the above code dynamically.
Personally, your existing code is fine. It works. It is readable. While it might become more verbose as your app grows, it also might not. So why improve it?
That said, here are some ideas. Most of your code is a mapping between permission and policy implementation. For example 'part.view' maps to 'App\Policies\Parts\PartsPolicy#view. The "weight" of this mapping can't be removed: it can only be moved.
You might consider moving it to a simpler configuration file, something that looks like this:
// config/permission-map.php
<?php return [
'post.view' => 'App\Policies\Blog\PostsPolicy#view',
'post.create' => 'App\Policies\Blog\PostsPolicy#create',
'post.update' => 'App\Policies\Blog\PostsPolicy#update',
'post.delete' => 'App\Policies\Blog\PostsPolicy#delete',
// etc...
];
Then in your boot you read that config and iterate:
// boot permissions
$permission_map = require_once('config/permission_map.php');
foreach ($permission_map as $permission => $policy_implementation) {
Gate::define($permission, $policy_implementation);
}
Advantage: adding a new policy mapping only changes the most salient information, and you don't have to think about how to make that mapping happen - today that is Gate::define but tomorrow maybe it's Sentry::policy. Additionally, by separating data from the code, you can test the code more freely.
Another approach could be annotations: in the DocBlock of your policy implementation, you write your own annotation syntax, which you then parse and compile into the configuration file. Along the lines of
namespace App\Policies\Blog;
class PostsPolicy {
/**
* #permission post.view
*/
public function view() { /* ... */ }
}
I, personally, am not a fan of this: it adds a layer of inner framework whose value I find it hard to measure.
What would be the best way to create a relationship if it doesn’t exist already, within Eloquent, or at least a central location.
This is my dilemma. A User must have a Customer model relationship. If for whatever reason that customer record doesn’t exist (some bug that stopped it from being created) - I don’t want it to throw errors when I try to retrieve it, but I also request the customer object in multiple locations so I don’t want to test for existence in all those places.
I thought of trying the following in the User model:
public function getCustomerAttribute($value) {
// check $value and create if null
}
But that doesn’t work on relationships, $value is null.
EDIT
I already create a customer upon user creation, but I have come across a situation where it wasn't created and caused exceptions in many places, so I want to fallback.
User::created(function($user) {
$customer = Customer::create([
'user_id' => $user->id
]);
});
Is it possible for you to assume when a user is created that a customer needs to be created as well? If the rest of your system depends on this assumption I would make a model event.
use App\{User, Customer}; // assuming php7.0
UserServiceProvider extends ServiceProvider
{
/**
* Boot
*/
public function boot()
{
// on a side note, we're using "created" not "creating" because the $user->id needs to exist in order to save the relationship.
User::created(function($user) {
$customer = Customer::create([
'user_id' => $user->id
]);
});
}
}
Just getting to grips with Laravel 4.2 and eloquent. I've been watching the Laravel from Scratch casts on laracasts.com, particularly the lessons on validation and the follow up refactoring. The examples used throughout those lessons deal with a relatively basic user model whereby there are only 2 fields, username and password. My user model contains many more fields and my registration form asks for the user to re-enter/confirm the password they have entered.
It seems to be recommended that the process of validating user input should be done within the model, which makes total sense. So just like that tutorial I have gone ahead and added an isValid method to my model to validate user input on my registration form. I fill my user model based on the input like this:
$input = Input::all();
if (!$this->user->fill($input)->isValid()) {
return Redirect::back()->withInput()->withErrors($this->user->errors);
}
So I've written my rules and got the validation working and I am now ready to save the user's input to the database. However, since I've filled my model with the entire user input, the user model instance now contains an attribute of confirm_password and calling $user->save(); gives me an error (Since I don't have this field in my database table). In addition, since I have just passed in the user input to validate, the password there is not hashed either.
What would be the best approach to take with regards to validating user input VS having the model actually represent the database table? I know there are ways I could get around all this by doing things like moving the validation outside the model and perhaps just let the model store the validations rules etc. but I can looking for advice on the best practice.
Thanks
You may remove it before saving, for example:
$input = Input::all();
if (!$this->user->fill($input)->isValid()) {
return Redirect::back()->withInput()->withErrors($this->user->errors);
}
else {
unset($this->user->attributes['confirm_password']);
$this->user->save();
}
This may work but not the proper way for doing it. You may also use a saving event like:
// Goes in to your model
protected static function boot()
{
parent::boot();
static::saving(function($model) {
unset($model->attributes['confirm_password']);
});
}
Since you are validationg inside your model then you may trigger the validation on saving event, like:
protected static function boot()
{
parent::boot();
static::saving(function($model) {
if($model->isValid()) {
unset($model->attributes['confirm_password']);
return true;
}
return false;
});
}
There are nicer ways to accomplish this same thing.
Restrict your Input values. You can pass Input::all() to your validator and still do this.
$input = Input::only('username', 'password');
// – OR –
$input = Input::except('confirm_password');
Add $fillable to your User model.
class User extends Eloquent {
protected $fillable = array('id', 'name', 'email', 'password');
}
Then you can populate the database from the Input and only the columns in the fillable array will be populated. Make sure you have good validation rules if you try this.
$user = User::create(Input::all());
This will accomplish what you are trying to do without unsetting Input values or adding Model events.
Are there any events that fire when a new model relation is created? e.g.
$comment = Comment::create( array('title' => 'hello world!') );
$post->comments()->save($comment);
are there any events that are fired when the comment to post relation is saved? This would be very handy in certain use cases.
As an example in my app i have users and organisations. When i add a user to an organisation:
$user->organisations()->save($organisation);
i would love this to fire an event where i could bind the user to the organisations permission groups.
I think you can register Model Observers.
From the docs:
class UserObserver {
public function saving($model)
{
//
}
public function saved($model)
{
//
}
}
User::observe(new UserObserver);
http://four.laravel.com/docs/eloquent#model-observers
I have never used them, and I am not certain if they are necessary for your scenario, but I think this is the way to go if you really want to do it.