Frosty CMS: User Roles

Intro

Update 7/7/2020 The content covered in this post ended up being the tip of the iceberg. User roles ended up being the most labor intensive part of this whole project. This is just to say that the code in the repo that covers this is much more expansive and complete, I did not take the time to document every aspect of this process.

One of the things that a content management system needs is the ability to put users in different tiers, and have different abilities for each of them. We are going to separate our users into Administrators, Editors, Writers and Readers. Administrators will have control over everything, including changing users roles, and Readers will be able to do nothing except read blogs and leave comments.

Here are some matrix’s which show the visibility/authority that we will give to the different roles.

user permissions matrix

Restrict Editing Blog Posts

There are a couple of ways to restrict the editing of blog posts. In a previous post we went over how to accomplish this by creating a custom middleware function:

FrostyCMS: Authorizing Users to Edit Content

However that won’t work when we also want Administrators and Editors to be able to do this as well. We could write another middleware function, but in my opinion a better way would be to just modify the template Settings>Blogs with some conditionals that only make the option to edit or delete visible based on your role and user ID (edit 7/7/2020: This was lazy and bad practice and I fixed it later). Because our isLoggedIn middleware is checking to make sure that all HTTP requests to the settings menu, and editing/deleting routes are being created by logged in users, simply hiding the option should be adequate to protect an unauthorized user from making a malicious request (edit 7/7/2020: Wrong).

Here is a sample of some conditionals placed on the edit/delete buttons for posts.

<% if((currentUser && blog.author.id.equals(currentUser._id)) || (currentUser.role === "Administrator" || currentUser.role === "Editor")){ %>
    <a href="/blogs/<%=blog._id%>/edit" class="btn btn-outline-primary">Edit</a>
    </td>
    <td>
        <form action="/blogs/<%= blog._id %>?_method=DELETE" method="POST">
        <button type="submit" class="btn btn-outline-danger">Delete</button>
        </form>
    </td>
    <% } else { %>
    <td></td>
    <td></td>
    <% } %>

We can follow this basic pattern to restrict the options available in the settings menu. Here are some screenshots from different user roles.

Administrator View administrator view

Editor View editor view

Writer View writer view

Administrator Considerations

When it comes to editing user profiles, especially editing user roles it gets a bit tricky.

Cannot Remove Last Administrator

One of the tricky things that we need to consider here is that the Administrators are the only users that can create other Administrators. So if there is only one Administrator and they remove themselves… we will be in a pickle. Therefore we need to write a script for the user profile pages that will check if the user is the last remaining Administrator, and prevent them from changing their role or deleting themselves if that is the case.

Here is some pseudo code for what we are trying to accomplish.

// Is user logged in?
//  If no redirect to homepage 
//  If yes NEXT
// Is user an administrator? 
//  If no continue as normal 
//  If yes NEXT
// Get count of administrators. 
// Is count <=1 ?
//  If yes prevent user from making changes
//  If no continue as normal

And taking a first pass at that would give us this.

// if user is logged in go to next, if not load index
function checkLogin(req, res, next){
    //  If user is logged in
    if(req.isAuthenticated()){
        return next();
    }
    //  If not logged in, redirect to index
    Blog.find({},(err, blogs) => {
        if(err){
            console.log("Error: Unable to retreive blog data.");
        } else {
            res.render("index.ejs", {blogs:blogs});
        }
    });
}

// If user is administrator check if there are more than one administrator
// and prevent user role change if there is only one
function checkAdministratorCount() {
        
        router.get("/",checkLogin,(req, res) => {
        // Is user logged in? in middleware
        //  Find Current User
        User.findById(res.locals.currentUser.id, (err, foundUser) => {
            if(err){
                console.log(err);
            } else {
                    console.log(foundUser);
                    //  Check if current user is Administrator
                    if(foundUser.role == "Administrator"){
                        console.log("User is an Administrator");
                        // Get count of administrators.
                        User.countDocuments({ role: 'Administrator' },(err, count) => {
                            if(err){
                                console.log(err);
                            } 
                                console.log('There are: ' + count + ' Administrators' );
                                if(count <= 1){
                                    res.send("There is only one administrator");
                                    
                                } else {
                                    res.send("There is more than one administrator");
                                  
                                }
                            });
                        
                    } else {
                    //  If no load index.
                        Blog.find({},(err, blogs) => {
                        if(err){
                            console.log("Error: Unable to retreive blog data.");
                        } else {
                            res.render("index.ejs", {blogs:blogs});
                        }
                    });
                }  
            }
        });
    });
};
checkAdministratorCount();

That is pretty horrific. Let’s refactor that with Promises and Async.

// user profile page
router.get("/:id/profile",isLoggedIn, (req, res) => {
    
    function getUserData(IDNumber){
        return new Promise((resolve,reject)=>{
            console.log("Requesting user data of: "+ IDNumber);
            resolve(User.findById(IDNumber));
        });
    }

    // if user is not administrator render profile page
    function getAdminStatus(userData){
        return new Promise((resolve,reject)=>{
           console.log("Retreiving user role of: " + userData.username);
           if(userData.role === "Administrator"){
               resolve(true);
           } else if(userData.role != "Administrator") {
                User.findById(req.params.id, (err, foundUser) => {
                    if(err) {
                        console.log(err);
                    } else {
                    let adminDanger = false;
                    console.log('No Admin Danger');
                    res.render("userProfile.ejs", {user: foundUser, admindanger: adminDanger}); 
                    }
                });   
           } else {   
               console.log("Error retreiving status of user role.");
               reject("Error retreiving status of user role.");
           }
        });
    }

    function getAdminCount(role){
        return new Promise((resolve,reject)=>{
            User.countDocuments({ role: role },(err, count) => {
                                if(err){
                                    console.log(err);
                                } else {
                                    resolve(count);
                                }
                            });
        });
    }
    
    // if user is the last administrator and is editing the profile
    // of the last administrator, prevent them from changing roles
    async function checkAdminDanger(){
        try{
            // login check handled by middleware
            const currentUserData = await getUserData(res.locals.currentUser.id);
            console.log("Current User: " + currentUserData.username);
            const isAdmin = await getAdminStatus(currentUserData);
            console.log("User is Administrator: " + isAdmin);
            const numberOfAdmins = await getAdminCount('Administrator');
            console.log("Current Number of Administrators: " + numberOfAdmins);
            
            User.findById(req.params.id, (err, foundUser) => {
                // get role of current profile
                let profileRole = foundUser.role;
                console.log(profileRole);
                if(err){
                    console.log(err);
                } else {
                    if(isAdmin === true && numberOfAdmins <= 1 && profileRole === "Administrator"){
                        let adminDanger = true;
                        console.log('ADMIN DANGER: There is only 1 administrator');
                        res.render("userProfile.ejs", {user: foundUser, admindanger: adminDanger});
                    } else {
                        let adminDanger = false;
                        console.log('No Admin Danger');
                        res.render("userProfile.ejs", {user: foundUser, admindanger: adminDanger});
                    }   
                }
            });
            
        } catch(err) {
            console.log(err);
        }
    }
    checkAdminDanger();
});

This is the final working code (edit 7/7/2020: negative, see repo). The end result is that we are passing in a variable called adminDanger to the template that is either true or false. If it’s true then we prevent the user from changing the role of the last administrator, and if not then they are not prevented from doing so. For adminDanger to be true the current user must be an Administrator, the current number of Administrators must be 1 or less (in reality there should never be less than 1), and also the role of the user profile that they are currently looking at should be Administrator (otherwise they will be prevented from changing the role of ANY user if there is only 1 Administrator.)

Once we have that variable being passed into the template we can do the following.

<% if(admindanger === true){ %>
  <div class="alert alert-info" role="alert">
    You cannot remove the last Administrator.
  </div>
  <select class="form-control" name="user[role]" id="role-select" value="<%= user.role %>" disabled>
<% } else { %>
  <select class="form-control" name="user[role]" id="role-select" value="<%= user.role %>">
    <% } %>

    <option value="<%= user.role %>"><%= user.role %></options>
    <option value="Reader">Reader</option>
    <option value="Writer">Writer</option>
    <option value="Editor">Editor</option>

    <!--only admin can make other admin -->
    <% if(currentUser.role != "Administrator"){ %>
    <option value="Administrator" disabled>Administrator</option>
    <% } else { %>
    <option value="Administrator">Administrator</option>
<% } %>
  </select>

In short, if the adminDanger variable is true, the role selection form is disabled and an alert is visible informing the user why.

Conveniently, we don’t actually have to repeat these actions on the PUT route, because we have our isLoggedIn middleware working on that route, so nobody would be able to maliciously submit an update using Postman or a similar program, forcing all PUT requests to go through our GUI that we have created (edit 7/7/2020: again, incorrect).

you cannot remove last administrator

Remove Delete Button for Administrators

While the final Administrator cannot have his role edited. We still have the delete profile buttons available in the users settings table. We need to add a condition on this loop so that if the user role is Administrator the delete button is removed.

<!-- Remove Delete Button if user is Administrator -->
<% if(user.role === "Administrator"){ %>
  <td>
  </td>
<% } else { %>
  <td>
    <form action="/users/<%= user._id %>?_method=DELETE" method="POST">
    <button type="submit" class="btn btn-outline-danger">Delete</button>
    </form>
  </td>
<% } %>

no delete button for administrators

First Registered User Is Automatically Administrator

Typically in a CMS the first registered user is automatically an Administrator. Let’s implement that logic here.

router.post("/register", (req, res) => {

// new user registration: save user to database and authenticate them
// count current users    
// if current user count is 0 new user role is Administrator
// otherwise new user role is Reader    

    function constructUser(userCount){
        return new Promise((resolve,reject)=>{
            if(userCount === 0){
                let newUser = new User({
                firstname: req.body.firstname,
                lastname: req.body.lastname,
                // first user is administrator
                role: "Administrator",
                email: req.body.email,
                username: req.body.username
                });
                resolve(newUser);
            } else {
                let newUser = new User({
                firstname: req.body.firstname,
                lastname: req.body.lastname,
                // first user is administrator
                role: "Reader",
                email: req.body.email,
                username: req.body.username
                });
                resolve(newUser);
            }
        });
    }

    async function firstUserIsAdmin(){
        try{
            const userCount = await User.countDocuments({});
            console.log("Current number of users is: " + userCount);
            const newUser = await constructUser(userCount); 
            console.log("New User Information: " + newUser);
            
            User.register(newUser, req.body.password, (err, user) => {
                console.log("attempting user registration");
                if (err) {
                    console.log(err);
                    return res.render("register.ejs");
                }
                passport.authenticate("local")(req, res, () => {
                    res.redirect("/");
                });
                console.log("user registration successful: " + newUser.username);
            });
            
        } catch(err) {
            console.log(err);
        }
    }
    firstUserIsAdmin();
});

We start by counting the number of users. Then we use that number to determine what the role of the new user will be. Then we push that new user data into the User.register function, just like we had it before. And it works!

GitHub Repo

Ncoughlin: Frosty