When writing your APIs for a microservice, special care should be taken to properly plan out auth and permissions since parts of your user system will be handled by other services. Previously, we introduced Yeti Threads as a case study for writing microservices in Node.js. This time, we’ll be diving into the Auth strategy in this case study.
“Auth” is really short for two things, authentication and authorization. Microservices don’t provide their own authentication, but they’re in charge of at least enforcing their own authorization, if not tracking the permissions as well. In this case, OpenID Connect gives us a JWT token encoded with a user identity and scopes of authorization (perhaps from Auth0, or one that we at &yet helped you customize and deploy). The microservice can trust these values because they’re signed by a private key that only the services themselves know, and can validate the signature. The scopes are a list of tags, typically indicating which sets of actions and endpoints are allowed.
In Yeti Threads, you might notice that we don’t have a users table in the database. Since we trust the identity and scopes in the signed JWT, we can simply treat the user id as an opaque string; it doesn’t matter what it is, as long as it uniquely identifies a user. I also decided that I didn’t need a scope for most actions, except for modifying permissions, in which case we need them to have 'forum_admin' in their scopes. Look at the access.js controller and you’ll see that the handlers specify an auth.scope
(which is a handy hapi handler feature).
auth: {
strategy: 'token',
scope: 'forum_admin'
},
You may want to have a scope that is required to use any of the API endpoints per microservice, but in this case, I decided that most people can use a commenting system.
Most of a user’s permissions are tracked by forum in the database. Specifically, we need to know what a user can do on a forum level. Since the forum is specific to this microservice, and spans many ids, it makes sense to track it in the database rather than managing scopes for this. Can you imaging having a scope for every forum id?
CREATE TABLE forums_access (
user_id TEXT,
forum_id INTEGER REFERENCES forum(id),
read BOOLEAN DEFAULT FALSE,
write BOOLEAN DEFAULT FALSE,
post BOOLEAN DEFAULT FALSE,
PRIMARY KEY (user_id, forum_id)
);
Read permissions are handled by joining to the forum_access table, which filters out rows that don’t have forum_access.read
set to True
.
Post.registerFactorySQL({
name: "get",
sql: [
"SELECT posts.id, posts.author, posts.body, posts.parent_id, posts.thread_id, posts.path, posts.created, posts.updated FROM posts",
"JOIN threads ON posts.thread_id=threads.id",
"JOIN forums ON threads.forum_id=forums.id",
"JOIN forums_access ON forums_access.forum_id=forums.id",
"WHERE posts.id=$post_id AND ((forums_access.user_id=$user_id AND forums_access.read=True) OR forums.owner=$user_id)"
].join(' '),
oneResult: true
});
Write actions are checked explicitly in their database function.
CREATE OR REPLACE FUNCTION check_write_access(userid TEXT, f_id INTEGER) RETURNS void AS $$
BEGIN
IF NOT EXISTS (SELECT * FROM forums_access WHERE user_id=userid AND forum_id=f_id AND write=TRUE) THEN
IF NOT EXISTS (SELECT id FROM forums WHERE owner=userid AND id=f_id) THEN
RAISE 'User lacks permission to write to forum: %', f_id USING ERRCODE = 'insufficient_privilege';
END IF;
END IF;
END;
$$ language 'plpgsql';
PERFORM check_write_access(user_id, (forum->>'parent_id')::integer);
Any time a forum is created or updated, a database trigger is called which gives them access to their own forum. Otherwise, the only way to change permissions is to have the "forum_admin" scope assigned by the authentication service to use the "access" routes as mentioned above.
CREATE OR REPLACE FUNCTION log_forums_change() RETURNS trigger AS $$
DECLARE
id bigint;
BEGIN
IF TG_OP = 'INSERT' OR TG_OP = 'UPDATE' THEN
id = NEW.id;
IF TG_OP = 'INSERT' AND NEW.owner IS NOT NULL THEN
INSERT INTO forums_access (user_id, forum_id, read, write, post) VALUES (NEW.owner, NEW.id, True, True, True);
END IF;
ELSE
id = OLD.id;
END IF;
INSERT INTO forums_log (forum_id, tbl, op) VALUES (id, TG_TABLE_NAME, TG_OP);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
This function also adds to a log, which we’ll get into for live events in a future post.
Note: Some companies have a permissions microservice for managing and getting user permission. If you’re doing row or object-level permissions on reads, separating it out that way could create a lot of overhead in interservice API requests. Generally, scopes provide a good way for dealing with permissions to features and local permission tracking within a service better handles that service contextually for individual objects.
For actually handling the JWT tokens, we’re using hapi-auth-jwt and a short validate function.
server.auth.strategy('token', 'jwt', {
key: options.jwtKey,
validateFunc: function (decoded, cb) {
var error, credentials = decoded;
if (!credentials) {
return cb(error, false, credentials);
}
return cb(error, true, credentials);
}
});
Note: After I wrote this, I discovered hapi-auth-jwt2, which provides some extra functionality which I haven’t yet needed. I may update Yeti Threads to use it since it’s had more recent commits.
Since JSON Web Tokens are stateless, there’s no need to check against the database -- they either pass validation or they don’t. For tests, we can generate our own JWT using the jsonwebtoken package to test against the API directly
// test/index.js
config.jwtKey = require('crypto').randomBytes(48).toString('base64');
var token = jwt.sign({user: 'tester-user', scope: ['forum_admin']}, config.jwtKey, {expiresInMinutes: 15});
var authorization = 'Bearer ' + token;
Next time, we’ll go into managing database migrations using knex.js.
&yet provides consulting for APIs: architecting, coding, testing, and deploying -- whichever services you need. Feel free to ask me any questions via <nathan@andyet.com> or @fritzy.