Skip to content

Instantly share code, notes, and snippets.

@psenger
Last active August 14, 2024 00:52
Show Gist options
  • Save psenger/fd5d08c75f659db4fa015e1ac7885df7 to your computer and use it in GitHub Desktop.
Save psenger/fd5d08c75f659db4fa015e1ac7885df7 to your computer and use it in GitHub Desktop.
[Design Pattern: Role-Based Access Control (RBAC)] #DesignPattern #JavaScript #Security

Role-Based Access Control (RBAC)

RBAC involves defining roles and permissions, then enforcing these permissions in your API endpoints.

Roles and Permissions

Roles: Examples include Admin, Editor, and User.

Permissions: Common actions like Create, Read, Update, and Delete.

Users are assigned roles, and roles determine their permissions.

Conflict Resolution

If there is a conflict between roles, the presence of a permission takes precedence.

Example: If a user has both the Editor and Viewer roles, where the Editor role has the Update permission but the Viewer role does not, the user will be granted the ability to update content because the Update permission from the Editor role takes precedence.

Middleware Implementation

Two middleware functions are used in this example:

  1. Authenticate: Adds the user to the context, along with their roles and permissions.
  2. Authorize: Ensures the user has the necessary permissions to access a resource.

Declarative Middleware Usage in Express

In Express, middleware is used declaratively. The first value is the endpoint, followed by the authenticate function, and then the authorize function with the specified resource and action.

'/api/posts',
authenticate,
authorize('posts', 'read'),
(req, res) => {}

Calling next() in middleware controls the flow. If next() is not called, subsequent functions are blocked, similar to how yield works with generators.

SQL Suggested.

CREATE TABLE roles (
    id SERIAL PRIMARY KEY,
    name VARCHAR(50) UNIQUE NOT NULL
);

CREATE TABLE permissions (
    id SERIAL PRIMARY KEY,
    action VARCHAR(50) NOT NULL, // maybe one char is better.
    resource VARCHAR(50) NOT NULL,
    UNIQUE(action, resource)
);

CREATE TABLE role_permissions (
    role_id INTEGER REFERENCES roles(id),
    permission_id INTEGER REFERENCES permissions(id),
    PRIMARY KEY (role_id, permission_id)
);

CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    username VARCHAR(50) UNIQUE NOT NULL,
    role_id INTEGER REFERENCES roles(id)
);

-- Insert roles
INSERT INTO roles (name) VALUES ('admin'), ('editor'), ('user'), ('guest');

-- Insert permissions
INSERT INTO permissions (action, resource) VALUES 
('c', 'posts'), 
('r', 'posts'), 
('u', 'posts'), 
('d', 'posts'), 
('c', 'comments'), 
('r', 'comments'), 
('u', 'comments'), 
('d', 'comments');

-- Assign permissions to roles
-- Example: Assigning all permissions to the admin role
INSERT INTO role_permissions (role_id, permission_id)
SELECT 1, id FROM permissions; -- Assuming role_id 1 is 'admin'
const express = require('express');
const {
authenticate,
authorize,
} = require('./security-middleware');
const app = express();
// --------------------------------------------
// Note:
// --------------------------------------------
// Here for demo purposes
// --------------------------------------------
app.use((req, res, next) => {
let authHeader = req.headers.authorization;
if (authHeader) {
const [ , authData] = authHeader.split(" ");
if (authData) {
const base64Decoded = Buffer.from(authData, 'base64').toString('utf8');
const [username, password] = base64Decoded.split(":");
req.user = req.user ?? {}
req.user.username = username;
}
}
next();
});
app.get('/api/posts',
authenticate,
authorize('posts', 'r'),
(req, res) => {
res.json({message: 'This is a post.'});
});
app.post('/api/posts',
authenticate,
authorize('posts', 'c'),
(req, res) => {
res.json({message: 'Post created successfully.'});
});
app.put('/api/posts',
authenticate,
authorize('posts', 'u'),
(req, res) => {
res.json({message: 'Post updated successfully.'});
});
app.delete('/api/posts',
authenticate,
authorize('posts', 'd'),
(req, res) => {
res.json({message: 'Post deleted successfully.'});
});
app.get('/api/comments',
authenticate,
authorize('comments', 'r'),
(req, res) => {
res.json({message: 'This is a comment.'});
});
app.post('/api/comments',
authenticate,
authorize('comments', 'c'),
(req, res) => {
res.json({message: 'Comment created successfully.'});
});
app.listen(3000, () => {
console.log('Server is running on http://localhost:3000');
});
{
"name": "rbac",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "Philip A. Senger",
"license": "ISC",
"description": "Role Based Access Control",
"keywords": [
"RBAC",
"Role-Based-Access-Control"
],
"dependencies": {
"express": "^4.19.2"
}
}
#!/usr/bin/env bash
if [ $# -ne 2 ]; then
echo "Error: Incorrect number of arguments."
echo "Usage: $0 <username> <password>"
echo "Example: $0 100 password"
exit 1
fi
# Encode a username and password
authValue=$(echo -n "$1":"$2" | base64)
echo ""
echo ""
# GET /api/posts
echo "Fetching posts..."
curl -X GET -H "Authorization: Basic $authValue" http://localhost:3000/api/posts
echo ""
echo ""
# POST /api/posts
echo "Creating a new post..."
curl -X POST -H "Authorization: Basic $authValue" -H "Content-Type: application/json" -d '{"json_data":"value"}' http://localhost:3000/api/posts
echo ""
echo ""
# PUT /api/posts
echo "Updating a post..."
curl -X PUT -H "Authorization: Basic $authValue" -H "Content-Type: application/json" -d '{"json_data":"value"}' http://localhost:3000/api/posts
echo ""
echo ""
# DELETE /api/posts
echo "Deleting a post..."
curl -X DELETE -H "Authorization: Basic $authValue" http://localhost:3000/api/posts
echo ""
echo ""
# GET /api/comments
echo "Fetching comments..."
curl -X GET -H "Authorization: Basic $authValue" http://localhost:3000/api/comments
echo ""
echo ""
# POST /api/comments
echo "Creating a new comment..."
curl -X POST -H "Authorization: Basic $authValue" -H "Content-Type: application/json" -d '{"json_data":"value"}' http://localhost:3000/api/comments
echo ""
echo ""
echo "done"
// --------------------------------------------
// Example:
// --------------------------------------------
// const roles = {
// admin: {
// can: ['posts:c', 'posts:r', 'posts:u', 'posts:d', 'comments:c', 'comments:r', 'comments:u', 'comments:d'],
// },
// editor: {
// can: ['posts:r', 'posts:u', 'comments:r', 'comments:u'],
// },
// user: {
// can: ['posts:r', 'comments:r'],
// },
// guest: {
// can: ['posts:r'],
// },
// };
const assert = require("assert");
/**
* Normalize function converts a values parameter into a normalized string.
* @example
* normalize( 'ud' ) = " ud"
* normalize( ['u','d'] ) = " ud"
* normalize( 'c' ) = "c "
* normalize( ['c'] ) = "c "
* normalize( 'dlaad' ) = " d"
* normalize( ['d','l','a','a','d'] ) = " d"
* normalize( 'crud' ) = "crud"
* normalize( ['c','r','u','d'] ) = "crud"
* normalize( 'durc' ) = "crud"
* normalize( ['d','u','r','c'] ) = "crud"
* normalize( 'nope' ) = " "
* normalize( ['n','o','p','e'] ) = " "
*
* @param {String|Array} values - The values to be normalized. Can be a string or an array of strings.
* @returns {String} - A normalized string consisting of 'crud' characters.
* @throws {Error} - Throws an error if the values parameter is neither a string nor an array of strings.
*/
const normalize = (values) => {
let arrValues = [];
if (Array.isArray(values)) {
arrValues = values;
} else if (typeof values === 'string') {
arrValues = values.split('');
} else {
throw new Error('normalize only supports a String, or an Array of Strings')
}
const clean = [...new Set(arrValues.map(s => s && s.toLowerCase()))];
const result = [' ', ' ', ' ', ' '];
const order = ['c', 'r', 'u', 'd'];
order.forEach((val, i) => {
if(clean.includes(val)) {
result[i] = val;
}
});
// results in a string 'crud', ' ', 'c d', or some mix of crud
return result.join('');
}
async function authenticate(req, res, next) {
// --------------------------------------------
// Note:
// --------------------------------------------
// This is where you would extract the identifier,
// which could be a JWT, Basic Auth token, or
// something similar. For this example, I've just
// placed a user ID in the request object.
// --------------------------------------------
try {
req.context = req.context ?? {};
// --------------------------------------------
// Note:
// --------------------------------------------
// Users can have multiple Roles as needed.
//
// Permissions are attached to Roles, not
// directly to the user.
//
// Permissions are additive in nature, meaning
// if a user has two roles—one with a permission
// and the other without—the user is granted
// access because the permission is added to
// the user.
//
// This approach to handling permissions is
// commonly referred to as
// "role-based access control" (RBAC).
//
// Avoid storing the user's roles or permissions
// in the session if possible. The downside
// of doing this is that if roles or permissions
// change, the user would need to log out
// and log back in to see the updates.
//
// Storing values as arrays, in JavaScript,
// allows us to use Array.prototype functions
// such as includes, every, includes, and some
// --------------------------------------------
switch (req.user?.username) {
case '10':
req.context.user = {
id: 10,
username: 'Rick Shaw',
roles: Array.from(new Set(['admin',])),
// ["posts:crud", "comments:crud"]
permissions: Array.from(new Set([`posts:${normalize(['c', 'r', 'u', 'd'])}`, `comments:${normalize(['c', 'r', 'u', 'd'])}`])),
}
break;
case '100':
req.context.user = {
id: 100,
username: 'X. Benedict',
roles: Array.from(new Set(['editor',])),
// ["posts:_ru_", "comments:_ru_"]
permissions: Array.from(new Set([`posts:${normalize(['r', 'u'])}`, `comments:${normalize(['r', 'u'])}`])),
}
break;
default:
console.error(`User not found for ${req.user?.id}`);
return res.status(403).json({message: 'Forbidden'});
}
next();
} catch (err) {
console.error(err);
res.status(500).json({message: 'Internal Server Error'});
}
}
/**
* Middleware to guard route access based on user permissions.
*
* @param {string} resource - The resource being accessed.
* @param {string} action - The action to be performed on the resource.
* @returns {function} - Returns express middleware function.
*/
function authorize(resource, action) {
/**
* Middleware function to guard routes based on user permissions.
*
* @param {object} req - The request object.
* @param {object} res - The response object.
* @param {function} next - The next middleware function.
* @returns {undefined} - Returns undefined.
*/
return (req, res, next) => {
assert(req.context?.user, 'Context is required')
const user = req.context.user;
if (user.permissions.some((userPermission)=>{
const [userResource,userActions] = userPermission.split(':')
if ( userResource === resource ) {
return userActions.split('').includes(action)
}
return false
})) {
return next()
} else {
return res.status(403).json({message: 'Forbidden'});
}
};
}
module.exports = {authorize, authenticate}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment