2011S

Data Separation Into Realms

The other day I explained the problem I had: when you build a SaaS service, each customer’s data must be seen by him and him only. It would be a disaster if one of your customers could see it’s competitors data so this has to be taken seriously.

I call these “realms” though I have no idea if this is a standard term for this or not, but to me it represents the fact that you lock each piece of data into separate parts that can not be accessed from one another.

So all my data, all the entity classes and all the database tables have a special field that makes an association from this entity to the realm (a client company of the product in this case).

The search for Doctrine filters

At first I thought I could add a filter to everything that Doctrine issues to the database that would add a where clause to say that the realm_id or client_company_id has to be the same as the one of the currently logged in person. If I could hook that up in the entity manager, then I’d never have to deal with this issue ever again.

Once again, the filter wouldn’t have to be super complex because data from one realm only can have association to data in the same realm. So even on complex queries with multiple join, only filtering the main component of the request is enough.

From the Doctrine Jira, it seems that it is in their plans to do so. You can also find a few other places on the web where they talk about doing so. However this is not available yet and the reason is that Doctrine doesn’t have one single way to query the database but at least two.

  • Using the default repository class’s methods (findAll, findOneBy etc…), there is no DQL or no Query class constructed.
  • Using DQL and / or a Query class.

So what we can do is find two different ways to deal with this issue. In total we have 4 things to do:

  1. Ensure when data is saved, it is saved within a given realm
  2. Deal with repository classes so they only return data from the current realm
  3. Deal with DQL queries in the same way
  4. Only grant access to an object for users that are in the same realm

Putting data into realms

The first thing we need is to put data into realms when it’s saved to the database. This is fairly easy as Doctrine has many events that can be used for this. We just need to listen to the prePersist event of Doctrine and add the correct data.

Notice we inject the service_container in our listener even though we only need the security context from there. However injecting the security context creates some circular dependency injection that breaks everything.

Now, whenever some entity is persisted in the database, if it supports being in a realm, we add the realm before persisting it. Done for this part!

Repository classes

This one can be relatively easy. You can create a base repository class for you application that overrides all the default repository methods and adds the required request parts for you. So in findAll you’d actually call findBy(array(‘realm’ => $realm)).

Once this is done, you need all your entities to use custom repository classes and all those custom repositories to inherit from your base repository. It would help here to be able to force Doctrine to use a given base repository class, but I’ve read from one of the devs that this is not one of the entry points they want to add in extending Doctrine. So no luck there, you still have a bit of work to do!

Queries

If you look at this Doctrine cookbook entry about extending DQL, it quite seems like something we could use for our own purpose. With a custom TreeWalker here, we could add a specific where clause to any query that has been hinted to use it.

I didn’t find much documentation on how to create those walkers, how to go through a select statement’s where clause and add my own thing there. But going through the code for Doctrine extensions, I was able to reuse some of this code and adapt it to my case, creating a RealmWalker. Adding this walker to any query, as well as a hint with the name of the realm field and the expected value will effectively add the required filter to the query. This is added just before the query is transformed from DQL to SQL. So everything that you would do before that stays the exact same.

Now however we need to add 3 lines of code before creating each query to set the 3 query hints: the RealmWalker, the field name and expected value. We can send all this into a QueryFactory that we’ll make available as a service from the controller.

And here’s how to make it a service:

Then from the controller you can just call $this->get(‘khepin.realm.query_factory’)->addRealm($query), this will add all you need to your query!

A bit of security now!

Now we need to ensure that a user trying to access a given object will be refused the right to do so if he and the object are in different realms. Symfony security component comes with something handy for that called the voters.

When access to something is required, all the voters are called to cast their votes for or against granting access. You can use different strategies so that one voter refusing access will automatically refuse all access, or that if just one gives access then access is granted.

There’s a good symfony cookbook entry on voters and how to implement them. Here is a sample in the case of my realm voter:

We define the voter class, make it private as we probably won’t need to call it ourselves ever, give a list of classes on which it should act, and a list of attributes on which it should cast its vote as well.

All it does is that if the attribute or class is not supported, it doesn’t have an opinion and therefore doesn’t vote. If the object’s realm is the same as the current user’s realm, then it grants access. If it’s not, then it denies the access.

Voters are called to vote everytime you check permissions through “isGranted”. In your controller, you can now use

In the templates you have {% if is_granted(‘EDIT’, object) %}

Conclusion

I can only hope that the Doctrine query filters make it to a soon to come version as it would make all of this much more simple. However for now, all my data is separated into different spaces and client A will never know about client B!