Approach to Multi-Tenant Application using a Behavior

For a current project I needed a solution to Multi-Tenancy using Yii Framework. So that users belonging to tenant A are not able to access any models owned by tenant B.

Basically I got inspired by this post, an approach to Mutli-Tenancy using defaultScope() of CActiveRecord. Thus I used a Tenant-ID-Field on all my tables, which should be divided by tenant. My User-Class fetches this Tenant-ID on login and publishes it via a getter. I will not post this code. For an exampe code how to implement your user class please refer to the original post.

However I used a slightly different approach on my model-classes. Instead of inheriting from a base class I created a behavior.

As I cannot modify the defaultScope() method using a behavior I needed to rely on Events. Thus as a base class for my behavior I use the CActiveRecordBehavior class. Inherriting from this I can react on the events BeforeFind and BeforeSave. Here is my code:




class MultitenantBehavior extends CActiveRecordBehavior {

    

    public function beforeFind($event) {

        //restrict queries to the actual tenant by manipulating the model's DbCriteria

        $c=$this->owner->getDbCriteria();

        $condition=$c->condition;

        if(strlen($condition)>0) {

            $condition='('.$condition.') AND '

        }

        $condition.='tenantId = '.Yii::app()->user->tenantId;

        $c->condition=$condition;   

    }

    

    

    public function beforeSafe($event) {

        //tie this model to the actual tenant by setting the tenantid attribute

 		$this->owner->tenantId=Yii::app()->user->tenantId;

    }

}






In my code you see to filter all results by find methods I overwrite the beforeFind method. In this I modify the condition property of the DbCriteria instance of the owner of this behavior. This is assumed to be an instance of ActiveRecord. This is technically the same as setting the defaultScope().

To make sure all inserts, updates and deletes set the right Tenant-Id I overwrite the beforeSafe method. In this I set the owners attribute tenant_id. This is the same approach as in the original post.

Now if one of model-classes should be filtered by Tenant I just add the following lines of code to it:




        public function behaviors(){

        return array('Multitenant'=>'path.to.MultitenantBehavior');

    }






The behaviors function is an empty function of CActiveRecord. It can be used in model class to return an array of behaviors, which are by default attached to this model. This ensures that my TenantBehavior is automatically attached on the initialization of an instance of my models.

very nice approach! thanks for sharing it!

You didn’t link to another post but I believe it was referring to my multi tenancy solution that I posted last year.

One thing that I’ve learned since then is to slighly modify your beforeSave() to return the parent.


public function beforeSafe($event) {

        //tie this model to the actual account by setting the model's tbl_account_id attribute

        $this->owner->tenantId=Yii::app()->user->tenantId;

        return parent::beforeSave();

    }



@waitforit: Actually I linked your post. But the forum logic erased all links from my first post.

Maybe it works to edit my post, if not here is the link to the original post.

Are you sure this necessary in a Behavior? The parent beforeSave method of the CActiveRecordBehavior in this case is an empyt function returning no value (see here).

@BenB,

I like how you have done this. When i try to implement this i run in to problems when a user logs in the beforeSafe()

function gets called and then appends the where clause. I need the where clause not to work when logging into the application so it can set the tennantid var.

Add a conditional.

if(!Yii::App()->isGuest)

then …

Thanks waitforit!! so i could use the beforeSave() like the beforeFind() to make sure that all inserts and updates to the tables get appended with a tenantid ? I have tried and doesn’t seem to be working.

I got it working. Thanks again.

Natron797

I implemented something similar and I can tell you that beforeFind() is not invoked, if you access models using relations.

I.e. if you have a model A you restrict acces to, A::model->findAll(…) will work fine. However, if you have another model B (which can also be restricted - that actually doesn’t matter) and you do B::model()->with(‘relationToA’)->findAll(…), the models of A will be selected without your limitation. If model B isn’t restricted, the users see content they’re not supposed to see.

What works is using the defaultScope() - the downside is that the developer has to set the defaultScope() for each model because it isn’t applyable by behaviors.

Another approach to this problem are restrictions on models with a permission system. I prefer ACL and wrote my own extension for that, docs are here.

The flexibility is higher - so you can assign users to several tenants, accumulating the permissions of the user. As the permission system is entirely independent of your database, you don’t have to add your own logic or structures into your design.

Note that what you described above works with my extension out of the box - you don’t need to adjust things. (by default, nobody but the owner has access on his own objects - that includes RUD)

Regards,

I am trying to implement this, using the same behavior code that you provide, but I’m getting an error:

Undefined property: EWebUser::$orgId (btw, I’m using “orgId” instead of “tenantId”.)

So, it’s not able to access my “EWebUser” class, below.


<?php 

class EWebUser extends CWebUser{

 

    protected $_model;

    

    


	function getName(){

		$user = $this->loadUser($this->id);

		return $user->name;

	} 

	

 	

 	function getOrgId(){

 		$org = $this->loadUser($this->id);

 		return $org->orgId; 	

 	}

 	

 	function getIsAdmin(){

 		$admin = $this->loadUser($this->id);

 		return $admin->admin; 	

 	}

 	

 

    

    protected function loadUser()

    {

        if ( $this->_model === null ) {

                $this->_model = Users::model()->findByPk( $this->id );

        }

        return $this->_model;

    }

    

Any ideas?

Try declaring your $orgId var

Thanks for your response.

Declaring the variable (“protected $orgId”) doesn’t help - same error.

fwiw, when I declare it and give it a value, like the below, does "work", in that if my orgId is 1, the behavior is added properly, and there are no errors. So, the problem is in the getOrgId function …


public $orgId = 1;

I’ll keep digging.

are you setting the var when they login?

No, the methods are simply available in Yii::app()->user, I can use getName by doing Yii::app()->user->name. These methods work elsewhere, like in my view files.

fwiw, if I cheat, and put "return 1" at the top of my getOrgId method, it does return "1".

btw, here’s my “MultitenantBehavior extends CActiveRecordBehavior” content:

class MultitenantBehavior extends CActiveRecordBehavior {

public function beforeFind(&#036;event) {


   


    //filter out non logged in users, removing the below line will break login


	//if(user()-&gt;isGuest()){return}





    //restrict queries to the actual tenant by manipulating the model's DbCriteria


    &#036;c=&#036;this-&gt;owner-&gt;getDbCriteria();


    &#036;condition=&#036;c-&gt;condition;


    if(strlen(&#036;condition)&gt;0) {


        &#036;condition='('.&#036;condition.') AND ';


    }


    &#036;condition.='orgId = '.user()-&gt;orgId;


    &#036;c-&gt;condition=&#036;condition;   


}








public function beforeSave(&#036;event) {


    //tie this model to the actual tenant by setting the tenantid attribute


            &#036;this-&gt;owner-&gt;orgId=Yii::app()-&gt;user-&gt;orgId;


}

}

Please note that I have a global shortcut file that shortens Yii:app()->getUser() to just "user()" - works fine. Just in case, I did try this with the full name, with the same result.

try putting the orgId var into a session when you set it and then use that session in the class MultitenantBehavior. this is what i had to do to get it to work. It’s all coming back to me now. Let me know if that worked.

I added this line in the MultitenantBehavior file:


if(empty(user()->orgId)){return;}

in place of this


if(user()->isGuest()){return}

and that fixed it.

It seems like the problem was occurring at login. I confused myself as I constantly changed the code around, after logging in.

Thanks for your help, I really appreciate it.

Anytime. Glad you got it fixed!!

I’m going to be “that guy” and post another question. I’ve got this working, almost. It displays the right data, but the pagination text reads “displaying 1-6 of 7 results”, the 7th result belonging to another “tenant”. So, the “paginator” is counting all the records in the model. How can I apply the behavior to this:


$model=new Users('search');

I tried a function similarly named to the above, and called it “beforeSearch”, but no success … I’ll let you know if I figure this out.

I tried using default scope, and that seems to work better, but only works with SELECT statements, though.

My project is becoming a patchwork … is there anyone else trying to setup a MultiTenant architecture finding this, too? Or, am I doing things the wrong way …

Basically, now I’m looking at needing Default Scope for Selects, and Behavior for Saves …

Another problem with filtering records with behavior and beforeFind(), seems to be that the resulting CGridView’s pagination is not working properly.