Securing Node.js objects with property level access rules
In this post, I will be discussing how to secure JavaScript objects in Node on a per property basis. For each property, we want to be able to concisely describe the rules for reading and writing based on the user or “requestor” performing the action. We also want to be able to easily enforce those rules in our application code.
When is this useful? Let’s come up with a use case for illustration in order to see how quickly complexity piles on. Say we’re building an applicant tracking system which takes data about the applicant, their interview score, and a hiring decision. We have an Applicant object with the following properties:
{
id,
name,
birthday,
gender,
salaryRequirement,
interviewScore,
hiringDecision,
visibleProperties,
}
We’ll make up some business rules for our hypothetical app. Assume there are three different users for our system: the applicant, interviewer, and a super admin.
Let’s look at the rules for reading properties. The applicant should be able to see their personal information, but not the interviewScore or hiringDecision. We have properties for birthday and gender, but an applicant can choose which fields to show to the interviewer, and those properties would be added to an array called visibleProperties. The interviewer should not be able to see the salaryRequirement, but can see the hiringDecision. Finally, the super admin should be able to see everything. Everyone can see the applicant’s name and id.
Who can write to each property? Applicants should be able to edit their personal information, and set the properties they want to make visible. They should not be able to modify interviewScores. Interviewers can modify the interviewScore, but not modify anything else. Super admins can write to anything.
Kind of hard to follow, right? You can see that even in this toy example, it’s already getting quite complex. Imagine trying to sprinkle this logic across our application. If we try to secure our data with if-statements, we will be writing a lot of code. That code is error prone, because we have to consistently enforce our logic everywhere. And that code is not malleable. If a business rule changes on a property, we will have to find every instance of that property being accessed, and change the logic. No thanks!
Access Schema
The solution is a centralized access schema, of course! There are a myriad of data validation schemas, and we can use them for inspiration, and apply it to data access. I have a few criteria for the schema:
I drew inspiration from a popular data schema tool called yup (https://github.com/jquense/yup), because it satisfies the first two conditions. A similar tool is called Joi (https://github.com/sideway/joi). Other tools I looked at were too verbose. Yup supports chaining rules like this to validate a number between 18 and 42, inclusive:
const schema = yup.number().min(18).max(42)
You can even use yup to describe objects with embedded sub-objects, and arrays of objects like this:
const schema = yup.object().shape({
nested: yup.object().shape({
arr: yup.array().of(
yup.object().shape({
num: number().max(4),
}),
),
}),
});
Let’s translate this paradigm to an access schema. Instead of yup, we will call the base object access. In our schema, we want to express four sets of rules:
In some cases, you don’t need field level overrides, so the defaults can act as the access policy for the full object. Let's assume all objects have a shape, so instead of yup's object().shape({ … }), we will use object({ … }) to instantiate a schema.
For a schema, we can express the four rules in four separate properties, like this:
const schema = access.object({
defaultRead,
propertyRead,
defaultWrite,
propertyWrite,
});
Specific Rules
yup has a set of predefined rules, like yup.number() to validate a number. We’ll do the same with access rules. Where yup takes a union of all rules, we are taking an intersection. Assume we have two rules called condition1() and condition2() that we chain together:
access.condition1().condition2()
As long as either condition is satisfied, we grant access. Okay, here are some rules to get us started.
role(roleArray)
The user performing the read or write is the “requestor” and in our system users have roles. role(roleArray) grants access to a property if the user belongs to any role in the array. In our example, we have 3 roles: applicant, interviewer, super admin, which we'll represent by an enum Role with three values: { Applicant, Interviewer, Admin }, respectively.
any()
Some fields are visible to all. any() simply grants access to anyone.
none()
This is the inverse, and grants no access.
identity()
A common rule is that a user can edit their own information, but not the information of others. identity() is a rule where if the id of the requestor matches the id of the object being acted on, access is granted. In our example let's assume the Applicant object’s id property would be the same id if the applicant was also the requestor.
custom(customRule)
Our applicant can choose to make certain properties visible which are found in the array visibleProperties. This rule is specific to our application, so there’s little value to generalize it. custom(customRule) lets us define arbitrary rules for cases like this. customRule is a function that takes in contextual information as arguments and returns true or false. The first argument is requestor, a user object. The second is the object we're testing authorization for, in this case the applicant. The last is propName, the specific property we’re validating access for on the applicant. Let's implement isVisibleProperty as a custom rule.
Recommended by LinkedIn
const isVisibleProperty = (requestor, applicant, propName) => {
const { visibleProperties = [] } = applicant;
const isVisible = Array.isArray(visibleProperties)
&& visibleProperties.indexOf(propName) !== -1;
return !!isVisible;
}
In this example, if the property is in the visibleProperties array, we grant access!
Building the Schema
With our schema rules ready to go, let’s see if we can model the original use case. Let’s start with the read rules for our Applicant object.
{
id, // anyone can read
name, // anyone can read
birthday, // anyone can read if in visibleProperties, otherwise applicant or super admin only
gender, // same rule as birthday
salaryRequirement, // applicant and super admin can read
interviewScore, // interviewer and super admin can read
hiringDecision, // interviewer and super admin can read
visibleProperties, // anyone can read
}
Most fields are readable, so we can make a default read rule of any(). With an open default, there is, of course, a danger here that a developer could add a new sensitive field and forget to secure it in the schema, which exposes it to everyone. There’s more than one way to write the same access rules, so think about your use case.
propertyRead are read rules for specific properties, and completely overrides the default. Using our annotations above, we can translate them to rules when a property deviates from the default.
{
defaultRead: access.any(),
propertyRead: {
birthday: access.identity().roles([Role.Admin]).custom(isVisibleProperty),
gender: access.identity().roles([Role.Admin]).custom(isVisibleProperty),
salaryRequirement: access.identity().roles([Role.Admin]),
interviewScore: access.roles([Role.Interviewer, Role.Admin]),
hiringDecision: access.roles([Role.Interviewer, Role.Admin]),
},
}
Next, let’s look at the write rules.
{
id, // immutable
name, // applicant or super admin can write
birthday, // applicant or super admin can write
gender, // applicant or super admin can write
salaryRequirement, // applicant or super admin can write
interviewScore, // interviewer can write
hiringDecision, // super admin can write
visibleProperties, // applicant can write
}
A requestor may write some properties, but not others. For a write to succeed, the requestor must send a partial object where all the properties pass the write rules. The id is a special case. Although it is immutable, I want it to be exempt from write restrictions, because I’m using a document store, and any write operation we issue will need to have the id in the object. I’m therefore making id permissible for writing by using the any() rule, even though I'm not actually writing to it. Most other fields are writable by the applicant for themselves and by super admins, so we can set that as our default write rule.
{
defaultWrite: access.identity().roles([Role.Admin])
propertyWrite: {
id: access.any(),
interviewScore: access.roles([Role.Interviewer]),
hiringDecision: access.roles([Role.Admin]),
visibleProperties: access.identity(),
},
}
Putting it all together, the full schema looks like this:
const applicantAccessSchema = access.object({
defaultRead: access.any(),
propertyRead: {
birthday: access.identity().roles([Role.Admin]).custom(isVisibleProperty),
gender: access.identity().roles([Role.Admin]).custom(isVisibleProperty),
salaryRequirement: access.identity().roles([Role.Admin]),
interviewScore: access.roles([Role.Interviewer, Role.Admin]),
hiringDecision: access.roles([Role.Interviewer, Role.Admin]),
},
defaultWrite: access.identity().roles([Role.Admin])
propertyWrite: {
id: access.any(),
interviewScore: access.roles([Role.Interviewer]),
hiringDecision: access.roles([Role.Admin]),
visibleProperties: access.identity(),
},
});
Using the Schema
Now we have a schema describing our rules, we need to be able to use it in our application. I typically use GraphQL for my API, and lots of different requestors share the same queries, but based on these rules can only access some of the data. For my application, if a user is not allowed to read a property, I just send back a null value. This may or may not be the right behavior, depending on your application.
filterRead
On my schemas, I implement a function called filterRead, which takes in an object and a requestor and returns a filtered version of the object after testing each access rule. If the requestor does not have access to a property, the property is assigned a null value. Usage looks like this:
const obj = await getObjFromDb();
const filteredObj = mySchema.filterRead(obj, requestor);
I can simply return the filteredObj in my API response. My API does not make the distinction between a value being null because no value is assigned versus it being null because the requestor doesn’t have sufficient access. Depending on your use case, you may want different behavior.
filterRead() loops through each property on obj, and looks up the rule for that property first in propertyRead, then in defaultRead if there’s no property specific rule.
authorizeWrite
I want to be stricter about my write rules. If a developer is writing code to send a particular property to be updated, they need to know at implementation time that what they’re trying to do is not permissible. To achieve this, I implement a function on my schema called authorizeWrite() that takes a partial object to be updated and the requestor attempting to make that update. If access to any properties on the object are not permissible, the function throws an exception. The reason I throw an exception instead of returning an error object is because I want to discourage developers from attempting to make writes that are not authorized from being part of the normal application flow.
If needed, I can always wrap my authorizeWrite() in a try-catch block.
try {
mySchema.authorizeWrite(partialObj, requestor);
partialUpdateToDb(partialObj);
} catch(err) {
// handle failure
}
authorizeWrite() loops through each property on the partial object, and on the first rule failure, throws an exception.
Final Thoughts
With data breaches common in news headlines, we need to go the extra yard to protect our users’ data. Making sure your application is enforcing the correct business rules is a very good start. It’s important to make access rules easy to reason with by developers. A concise but precise centralized schema used to secure all of your business objects makes this a more tractable problem.
Do you think this will be a useful tool for you? If so, I’d be happy to spend a little time to open source my implementation. Please let me know in the comments!
Happy coding!
About the Author
Marvin Li is CEO of Apollo 350, a software agency focused on video solutions for major publishers and startups alike. We can help you grow your products with video!
Very slick. I wish I had something like this to use on past projects. You should open source it! 👍