My multi-tenant solution

I am working on a multi-tenant SaaS project and was previously looking for some information on how to achieve separation of contexts. By this I mean John from Client A should not be able to view or modify data from Client B regardless of role.

What follows is my implementation of a context filter based on a clientId attribute in the User table of the application. The architecture of this app is such that I keep a clientId foreign key in each table back to the user table to preserve the link between data and client.

I created a base model (in this case, my base Admin model but the concept is the same) that all my models inherit from. While reading the manual I learned that default Scopes can be used to apply a condition to ALL Select queries. The following code alters Select queries to add a WHERE clause specifying that the clientId must equal that of the logged in user:




	public function defaultScope()

	/* defaultScope is used to enforce clientID (site) context. Will only pull data with that user's site ID.

	*  User clientID (site) is set during login process in /components/UserIdentity class

	*/

    { 

        return array(

            'condition'=>"clientID='".Yii::App()->user->site."'",

        );

    }	



Here is the relevant code from my UserIdentity class which uses setState to save the clientID:




	public function authenticate()

	{

	

		$user=User::model()->findByAttributes(array('email'=>$this->username));

			if ($user===null) { // No user found!

			$this->errorCode=self::ERROR_USERNAME_INVALID;

		} else if ($user->password !== SHA1($this->password) ) { // Invalid password!

			$this->errorCode=self::ERROR_PASSWORD_INVALID;

		} else { // username found, password is valid

		    $this->errorCode=self::ERROR_NONE;

		    // Store user cliendID - name it SITE:

		    [b]$this->setState('site', $user->clientId);[/b]

			//Store identity ID

			$this->_id = $user->id;

			//Code to set role based on User table

			switch ($user->isAdmin) {

			case 0: $role = 'user'; break;

			case 1: $role = 'admin'; break;

			default: $role = '';

			}

			$this->setState('role', $role);			

		}

		return !$this->errorCode;

	}



So, a caveat. Default scope only works for SELECTs, not inserts, deletes, updates (see Guide). I can’t allow a user at clientA to create stuff for clientB. In order to prevent that from happening I am using beforeSave() in my base model. What this code does is override the clientId with the ID set at login. No matter what is submitted via a form or request this code overwrites it before the record is saved:




public function beforeSave()

/* Does not allow user to submit clientID with creation form - limits them to editing and creating data

* for own site only. Overriding whatever they submit with their clientID (site) as populated on login. 

*/

{

    $this->clientId = Yii::App()->user->site;

    return true;

}	



Finally, we need to deal with validation rules. In the base model again, I am setting clientId as a safe attribute because no matter what the user does I am using the beforeSave to override. To prevent any funky behavior I set one rule in my base model to mark the clientId as safe:




public function rules()	

{

	return array(			

	array('clientId', 'safe'),

	);

}



Hope this helps someone. I’ve spent a lot of time trying to sort this out. Please let me know if you see any downside to this approach.

I think this is a decent approach. Just some minor points, those variables should be sanitized in the where clause and in the save, especially if site value could be modified (e.g. by some other part of the system or the db itself)

Works very well.

I just use beforeValidate() instead of beforeSave(), because I have validation rules that already need the ‘clientID’ (validation rules like: making sure composite-key is unique).