I’ve implemented my own auth logic (using JWT as well), however, I have also tried out using ABAC and I realized a caveat of using it than implementing my own REST endpoints, that is this:
Create(Role("user"), {
membership: {
resource: Collection("users")
},
privileges: [
{
resource: Collection("users"),
actions: {
read: Query(
Lambda(
"ref",
And(
Equals(
CurrentIdentity(),
Var("ref")
),
Call(Function("isUserNotDeleted"), CurrentIdentity())
)
)
),
write: Query(
Lambda(
["oldData", "newData", "ref"],
And(
Call(Function("isUserNotDeleted"), CurrentIdentity()),
Equals(CurrentIdentity(), Var("ref")),
Equals(
Select(["data", "email"], Var("oldData")),
Select(["data", "email"], Var("newData"))
)
)
)
)
}
}
]
});
The key there is this:
And(
Call(Function("isUserNotDeleted"), CurrentIdentity()),
Equals(CurrentIdentity(), Var("ref")),
Equals(
Select(["data", "email"], Var("oldData")),
Select(["data", "email"], Var("newData"))
)
)
Basically, this condition here ensures that the following are true:
- The user associated with the secret used hasn’t deleted the account yet.
- The user is editing his OWN account details.
- The user did NOT change his email address.
On the other hand, if you have like an HTTP endpoint, you’d do something like this:
import { query } from 'faunadb';
type User = {
ref: {
id: string;
};
data: {
firstName: string;
middleName: string;
lastName: string;
deletedAt?: string;
};
};
type payload = {
user: User;
formBody: {
firstName: string;
middleName: string;
lastName: string;
};
};
export async function handler({
user,
formBody: { firstName, lastName, middleName }
}: payload) {
if (user.data.deletedAt) return { statusCode: 418 };
// update user data
await faunadb.query(query.Update(user.ref, {
data: {
firstName,
lastName,
middleName
}
}));
return { statusCode: 200 };
}
You can even add yup in there and it will make security easier, and then you can use spread because yup handles removing excess fields for you.
import { query } from 'faunadb';
import * as yup from 'yup';
type User = {
ref: {
id: string;
};
data: {
firstName: string;
middleName: string;
lastName: string;
deletedAt?: string;
};
};
type payload = {
user: User;
formBody: {
firstName: string;
middleName: string;
lastName: string;
};
};
const validationSchema = yup.object({
firstName: yup
.string()
.required()
.max(50),
middleName: yup
.string()
.max(50),
lastName: yup
.string()
.required()
.max(50)
});
export async function handler({
user,
formBody
}: payload) {
if (user.data.deletedAt) return { statusCode: 418 };
const validForm = await validationSchema.validate(formBody, {
abortEarly: true,
stripUnknown: true,
strict: true
});
// update user data
await faunadb.query(
query.Update(user.ref, {
data: validForm
})
);
return { statusCode: 200 };
}
What happened here?
HTTP API approach
The approach that uses an HTTP endpoint is so much better from a security standpoint, why? Because it uses whitelist over blacklist, basically it will only update fields that your form body accepts, anything else will be discarded, so if the user were you pass something like below as the form body:
{
email: 'myNewEmail@gmail.com',
firstName: 'April',
middleName: 'Mintac',
lastName: 'Pineda'
}
Only the firstName, middleName, lastName
will be updated and the email
will be discarded.
On the other hand, we have:
ABAC
ABAC uses the Blacklist approach, that is all fields are allowed to be changed unless you implicitly check for it, hence, the need to do 3 checks in the example, which are (1) check that the user is updating his own document, (2) check that the user account hasn’t been deleted, (3) Check that the user did not change the email
field.
And that’s just one field, there could be 5 or even more fields that you define on the schema.graphql
(or whichever) that you don’t want the user to be able to change, and you have to make sure that you put a check for every field, forgetting to do it will cause a critical bug in the backend, especially if you happen to deploy it in production. So this:
And(
Call(Function("isUserNotDeleted"), CurrentIdentity()),
Equals(CurrentIdentity(), Var("ref")),
Equals(
Select(["data", "email"], Var("oldData")),
Select(["data", "email"], Var("newData"))
)
)
can get really really really long, and you have to do that for every collection that you allow user to modify.
And that’s just the write
, you also need to add the create
, delete
, and read
permissions on top of this caveat, you would usually end up writing a lot of FQL custom resolvers and that render the autogenerated mutations/queries kind-of useless and more of a risky thing than a beneficial thing.
So if your condition was only:
And(
Call(Function("isUserNotDeleted"), CurrentIdentity()),
Equals(CurrentIdentity(), Var("ref"))
)
and the user-submitted form below to an autogenerated update mutation
{
email: 'myNewEmail@gmail.com',
firstName: 'April',
middleName: 'Mintac',
lastName: 'Pineda'
}
The user will be able to change his email. Even on custom resolvers, there is still a tendency to forget to add these kinds of checks, on the other hand, the HTTP API approach doesn’t even require you to think about this, it’s baked right into the pattern.
I’m looking for insights, opinions, counter arguments on this.